diff --git a/.gitignore b/.gitignore index 1fa77596..fce9be9d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,17 @@ dist/ # Generated plugin commands (compiled from shared/recipes/*.mds at build time) plugins/devflow-dynamic/commands/ +# Generated plugin commands (compiled from shared/knowledge/*.mds at build time) +plugins/devflow-implement/commands/implement.md +plugins/devflow-plan/commands/plan.md +plugins/devflow-resolve/commands/resolve.md +plugins/devflow-code-review/commands/code-review.md +plugins/devflow-self-review/commands/self-review.md +plugins/devflow-research/commands/research.md +plugins/devflow-bug-analysis/commands/bug-analysis.md +plugins/devflow-explore/commands/explore.md +plugins/devflow-debug/commands/debug.md + # Generated plugin skills (copied from shared/skills/ at build time) plugins/*/skills/ diff --git a/CLAUDE.md b/CLAUDE.md index 8888647e..2e20390f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ Plugin marketplace with 22 plugins (12 core + 9 optional language/ecosystem + 1 **LLM-vs-plumbing principle**: The LLM does all detection, semantic matching, materialization, and curation. Deterministic code is plumbing only: hooks, locks, throttles, file I/O, id-keyed JSONL records, `assign-anchor`/`retire-anchor` ledger numbering, `render-decisions` rendering, and `merge-observation` writes. No detection or judgment logic lives in shell or TypeScript. -**Working Memory**: Three shell-script hooks (`scripts/hooks/`) replace the old 8-hook system with a background-maintenance (Dream) architecture. Toggleable via `devflow memory --enable/--disable/--status` or `devflow init --memory/--no-memory`. Feature state is stored in `.devflow/dream/config.json` (config-only; dream config is the sole source of truth per ADR-001). `dream-capture` (Stop hook) — captures user/assistant turns to `.devflow/memory/.pending-turns.jsonl` queue; after the 120s throttle (keyed by `.working-memory-last-trigger` mtime), spawns `background-memory-update` as a detached `nohup` worker (`claude -p --model haiku`); touches `.working-memory-last-trigger` BEFORE spawning to prevent double-spawn; uses mkdir-based locking for queue overflow truncation across concurrent sessions. `background-memory-update` (Stop-hook worker, not a hook itself) — drains `.pending-turns.jsonl`, calls `claude -p` (prompt on stdin, never argv), rewrites `WORKING-MEMORY.md` with `` on line 1, touches `.last-refresh-ok` on success; holds a 300s-stale worker lock; user-only queue truncated without LLM run. `dream-dispatch` (UserPromptSubmit hook) — **capture-only**: appends the user turn to `.pending-turns.jsonl`; it does NOT emit any directive. `dream-evaluate` (SessionEnd hook) — orchestrator that sources `eval-helpers` + 3 feature modules (`eval-decisions`, `eval-knowledge`, `eval-curation`) after shared setup; each module uses `${VAR:?}` fail-fast guards and `_MODULENAME_` variable prefixes for namespace isolation; evaluates whether to write decisions, knowledge, or curation dream markers; writes per-session marker files using atomic temp+mv; uses mkdir-based locking (`dream-lock`) to serialize operations across concurrent sessions. Always-on SessionStart hook (`session-start-context`) — recovers stale `.processing` markers (via `dream-recover` → `dream_recover_stale`), collects pending markers (via `dream-collect-tasks`), and emits a **DREAM MAINTENANCE** directive instructing the main model to spawn background `Dream` agents; directive emission is throttled to 120s. `dream-collect-tasks` unconditionally deletes orphaned `learning.*` AND `memory.*` markers (both pipelines removed from Dream subagent). The Dream agent processes decisions/knowledge/curation only — memory is NOT a Dream task. SessionStart hook (`session-start-memory`) → injects previous memory with git-reconciled header (3-state: A in-sync / B drifted / C refresh-failing) + optional pre-compact snapshot as `additionalContext`; stamp `` on line 1 drives drift detection; no raw-turns dump. PreCompact hook → saves git state + WORKING-MEMORY.md snapshot. Memory sections: `## Now`, `## Progress`, `## Decisions`, `## Context`, `## Session Log`. The background-memory-update worker uses rename-to-claim for queue consumption (atomically renames `.pending-turns.jsonl` → `.pending-turns.processing`). Disabling memory writes `memory: false` to dream config — hooks remain registered (shared across features). `removeMemoryHooks` (used by `devflow init --no-memory`) also removes pre-dream legacy hooks. Use `devflow memory --clear` to clean up pending queue files across projects. Zero-ceremony context preservation. +**Working Memory**: Three shell-script hooks (`scripts/hooks/`) replace the old 8-hook system with a background-maintenance (Dream) architecture. Toggleable via `devflow memory --enable/--disable/--status` or `devflow init --memory/--no-memory`. Feature state is stored in `.devflow/dream/config.json` (config-only; dream config is the sole source of truth per ADR-001). `dream-capture` (Stop hook) — captures user/assistant turns to `.devflow/memory/.pending-turns.jsonl` queue; after the 120s throttle (keyed by `.working-memory-last-trigger` mtime), spawns `background-memory-update` as a detached `nohup` worker (`claude -p --model haiku`); touches `.working-memory-last-trigger` BEFORE spawning to prevent double-spawn; uses mkdir-based locking for queue overflow truncation across concurrent sessions. `background-memory-update` (Stop-hook worker, not a hook itself) — drains `.pending-turns.jsonl`, calls `claude -p` (prompt on stdin, never argv), rewrites `WORKING-MEMORY.md` with `` on line 1, touches `.last-refresh-ok` on success; holds a 300s-stale worker lock; user-only queue truncated without LLM run. `dream-dispatch` (UserPromptSubmit hook) — **capture-only**: appends the user turn to `.pending-turns.jsonl`; it does NOT emit any directive. `dream-evaluate` (SessionEnd hook) — orchestrator that sources `eval-helpers` + 2 feature modules (`eval-decisions`, `eval-curation`) after shared setup; each module uses `${VAR:?}` fail-fast guards and `_MODULENAME_` variable prefixes for namespace isolation; evaluates whether to write decisions or curation dream markers; writes per-session marker files using atomic temp+mv; uses mkdir-based locking (`dream-lock`) to serialize operations across concurrent sessions. Always-on SessionStart hook (`session-start-context`) — recovers stale `.processing` markers (via `dream-recover` → `dream_recover_stale`), collects pending markers (via `dream-collect-tasks`), and emits a **DREAM MAINTENANCE** directive instructing the main model to spawn background `Dream` agents; directive emission is throttled to 120s. `dream-collect-tasks` unconditionally deletes orphaned `learning.*`, `memory.*`, AND `knowledge.*` markers (all three pipelines removed from Dream subagent). The Dream agent processes decisions and curation only — memory and knowledge are NOT Dream tasks. SessionStart hook (`session-start-memory`) → injects previous memory with git-reconciled header (3-state: A in-sync / B drifted / C refresh-failing) + optional pre-compact snapshot as `additionalContext`; stamp `` on line 1 drives drift detection; no raw-turns dump. PreCompact hook → saves git state + WORKING-MEMORY.md snapshot. Memory sections: `## Now`, `## Progress`, `## Decisions`, `## Context`, `## Session Log`. The background-memory-update worker uses rename-to-claim for queue consumption (atomically renames `.pending-turns.jsonl` → `.pending-turns.processing`). Disabling memory writes `memory: false` to dream config — hooks remain registered (shared across features). `removeMemoryHooks` (used by `devflow init --no-memory`) also removes pre-dream legacy hooks. Use `devflow memory --clear` to clean up pending queue files across projects. Zero-ceremony context preservation. **Ambient Mode**: Single-component system for zero-overhead session enhancement. UserPromptSubmit hook (`preamble`) uses two coexisting detection paths, both controlled by the same single toggle (`devflow ambient --enable/--disable/--status` or `devflow init`). **First-word keyword detection** — when a prompt's first word (case-insensitive) is one of `implement`, `explore`, `research`, `debug`, or `plan`, followed by at least one additional word, the hook outputs a directive instructing the model to briefly announce the workflow then invoke the matching `devflow:` skill via the Skill tool. **3-marker plan detection** — when a prompt contains `## Goal`, `## Steps`, and `## Files` markers (and the keyword path did not fire), it outputs a directive to invoke `devflow:implement`. Zero overhead for normal prompts — hook outputs nothing. Any legacy `commands.md` rule left by prior installs is auto-removed on every `devflow ambient --enable/--disable` or `devflow init`. @@ -55,17 +55,18 @@ Debug logs stored at `~/.devflow/logs/{project-slug}/`. **Claude Code Flags**: Typed registry (`src/cli/utils/flags.ts`) for managing Claude Code feature flags (env vars and top-level settings). Pure functions `applyFlags`/`stripFlags`/`getDefaultFlags` follow the `applyViewMode`/`stripViewMode` pattern. Flags (18 total): default ON — `tui`, `tool-search`, `lsp`, `prompt-caching-1h`, `show-turn-duration`, `clear-context-on-plan`; default OFF — `brief`, `thinking-summaries`, `subprocess-env-scrub`, `disable-nonessential-traffic`, `forked-subagents`, `disable-adaptive-thinking`, `always-thinking`, `disable-git-instructions`, `disable-compact`, `disable-1m-context`, `disable-autoupdater`, `agent-teams`. Manageable via `devflow flags --enable/--disable/--status/--list`. Stored in manifest `features.flags: string[]`. View mode (`default`/`verbose`/`focus`) stored in manifest `features.viewMode?: string` and applied to `settings.json` as the `viewMode` key; `applyViewMode`/`stripViewMode` utilities colocated in `flags.ts`. -**Feature Knowledge Bases**: Per-feature `.devflow/features/` directory containing KNOWLEDGE.md files that capture area-specific patterns, conventions, architecture, and gotchas. Knowledge bases are created as side-effects of implementation (`/implement` Phase 12), loaded automatically across all workflows via `FEATURE_KNOWLEDGE` variable (companion to `DECISIONS_CONTEXT`), and use staleness detection via git log against `referencedFiles`. Index at `.devflow/features/index.json` (object keyed by slug). Managed via `devflow knowledge list|create|check|refresh|remove`. Knowledge agent (sonnet) structures exploration outputs into KNOWLEDGE.md. `apply-feature-knowledge` skill provides consumption algorithm for agents. `.devflow/features/.knowledge.lock` is gitignored (transient lock directory for concurrent index writes, added automatically by `devflow init`). `devflow knowledge list` — List all feature knowledge bases with staleness status. `devflow knowledge create ` — Create a new knowledge base via claude -p exploration. `devflow knowledge check` — Check all knowledge bases for staleness. `devflow knowledge refresh [slug]` — Refresh stale knowledge base(s). `devflow knowledge remove ` — Remove a knowledge base and its index entry. Note: `/debug` keeps FEATURE_KNOWLEDGE orchestrator-local (investigation workers examine code without pre-loaded context). Toggleable via `devflow knowledge --enable/--disable/--status` or `devflow init --knowledge/--no-knowledge`. SessionEnd hook auto-refreshes stale knowledge bases (throttled to once per 2 hours, max 3 per run). `.devflow/features/.disabled` sentinel gates Phase 12 generation and refresh hook. +**Feature Knowledge Bases**: Per-feature `.devflow/features/` directory containing KNOWLEDGE.md files that capture area-specific patterns, conventions, architecture, and gotchas. Uses a **write-through** model: load = direct file-I/O reading `.devflow/features/index.md` (regenerable cache) with frontmatter-glob fallback over `features/*/KNOWLEDGE.md` (source of truth) + verify-against-code on read; save = in-command write-through via a simplified Knowledge agent that writes `KNOWLEDGE.md` + the `index.md` line directly (no `.create-result.json`, no external scripts, no lock). Freshness = write-through + verify-on-read (NO git-staleness, NO SessionEnd eval, NO Dream task). `index.md` line format: `- **{slug}** — {areas} — {Use-when description}`; frontmatter is authoritative if the line is lost. MDS module: `shared/knowledge/` contains `_knowledge.mds` (defines/exports `knowledge_load` and `knowledge_writeback` partials) + 9 host `.mds` sources compiled to plugin commands at build time by `scripts/build-knowledge.ts` (`npm run build:knowledge`). `knowledge_load` is used up-front by: implement, plan, resolve, code-review, self-review, research, bug-analysis. `knowledge_writeback` is used at workflow end by: implement, resolve, self-review, explore, debug. explore/debug do NOT load up-front (intentional asymmetry). Config gate: single `knowledge: true|false` in dream config (default true) — gates write-back only; load is ungated. CLI: `devflow knowledge list` (read index.md / frontmatter glob), `devflow knowledge --enable/--disable/--status` (flip config). Note: `/debug` keeps FEATURE_KNOWLEDGE orchestrator-local (investigation workers examine code without pre-loaded context). Toggleable via `devflow knowledge --enable/--disable/--status` or `devflow init --knowledge/--no-knowledge`. **Rules**: Ultra-concise, always-on engineering principle files (~10-15 lines each) installed to `~/.claude/rules/devflow/` as flat `.md` files. Claude Code loads them automatically on every prompt — no hooks required — filling the guidance gap for quick edits that don't trigger a full skill pipeline. Rules flow through the same four-stage pipeline as skills: authored in `shared/rules/`, distributed to `plugins/*/rules/` at build time, installed (or shadowed) at runtime, and activated automatically. Unlike skills (which install universally from all plugins), rules are **plugin-scoped**: only rules belonging to selected plugins are installed. This keeps core rules (`security`, `engineering`, `quality`, `reliability` from `devflow-core-skills`) always present, and language/ecosystem rules (`typescript`, `react`, `go`, etc.) present only when the user has that plugin installed. Shadow overrides: `~/.devflow/rules/{name}.md` overrides the Devflow source. Toggleable via `devflow rules --enable/--disable/--status/--list` or `devflow init --rules/--no-rules`. Stored in manifest `features.rules: boolean` (self-heals to `true` on old manifests). Currently 12 rules: 4 core + 8 language/UI. `paths: []` YAML frontmatter must remain — it signals Claude Code to apply the rule globally. -**Two independent background pipelines** (both toggleable separately): +**One background pipeline** (toggleable): - `devflow decisions --enable/--disable` — Decisions pipeline (decision + pitfall detection, materialized by Dream agent from DIALOG_PAIRS) -- `devflow knowledge --enable/--disable` — Feature knowledge bases (codebase area exploration) + +Knowledge write-back is in-command (not a background pipeline): gated by `devflow knowledge --enable/--disable` (flips `knowledge` in dream config); Knowledge agent writes directly at workflow end. **Two-Mode Init**: `devflow init` offers Recommended (sensible defaults, quick setup) or Advanced (full interactive flow) after plugin selection. `--recommended` / `--advanced` CLI flags for non-interactive use. Recommended applies: ambient ON, memory ON, decisions ON, rules ON, HUD ON, default-ON flags, .claudeignore ON, auto-install safe-delete if trash CLI detected, user-mode security deny list, viewMode preserved from existing settings.json. Advanced path adds a view mode selector (default/verbose/focus) after Claude Code flags. Use `--decisions/--no-decisions` to toggle the decisions agent independently. Use `--rules/--no-rules` to toggle rules independently. -**Migrations**: Run-once migrations execute automatically on `devflow init`, tracked at `~/.devflow/migrations.json` (scope-independent; single file regardless of user-scope vs local-scope installs). Registry: append an entry to `MIGRATIONS` in `src/cli/utils/migrations.ts`. Scopes: `global` (runs once per machine, no project context) vs `per-project` (sweeps all discovered Claude-enabled projects in parallel). Failures are non-fatal — migrations retry on next init. Currently registered per-project migrations include `purge-legacy-knowledge-v2` (removes 4 hardcoded pre-v2 ADR/PF IDs and orphan `PROJECT-PATTERNS.md`), `purge-legacy-knowledge-v3` (v3: sweeps all remaining pre-v2 seeded entries using the `- **Source**: self-learning:` format discriminator — any ADR/PF section lacking this marker is removed; entries the user edited to include the marker survive), `purge-orphaned-sidecar-judgment-state` (per-project; removes orphaned `.learning-manifest.json`, `.decisions-manifest.json`, `.decisions-notifications.json` — judgment-state files written by the now-removed deterministic render/reconcile layer), `purge-learning-pipeline-v1` (per-project; removes `.devflow/learning/` directory, learning dream markers, `learning` key from dream/sidecar config, `.claude/commands/self-learning/`, and auto-generated skills), `purge-stale-memory-markers-v1` (per-project; removes stale `dream/memory.*` markers left by the old Dream-subagent memory pipeline now that `background-memory-update` handles memory refresh — ENOENT-idempotent, rethrows non-ENOENT errors), `purge-dead-working-memory-sentinel-v1` (per-project; removes the stale `.devflow/memory/.working-memory-disabled` sentinel now that the memory gate is config-only per ADR-001 — ENOENT-tolerant, rethrows non-ENOENT errors). Global migrations: `purge-learning-global-v1` removes `~/.devflow/learning.json`; `purge-orphaned-dream-commit-hook-v1` removes the orphaned `~/.devflow/scripts/hooks/dream-commit` (the `dream-commit` helper was deleted when `.devflow/` became gitignored-by-default per ADR-021, but the installer copies `scripts/` additively — `copyDirectory` never deletes — so the stale file would otherwise linger; ENOENT-idempotent). **D37 edge case**: a project cloned *after* migrations have run won't be swept (the marker is global, not per-project). Recovery: `rm ~/.devflow/migrations.json` forces a re-sweep on next `devflow init`. +**Migrations**: Run-once migrations execute automatically on `devflow init`, tracked at `~/.devflow/migrations.json` (scope-independent; single file regardless of user-scope vs local-scope installs). Registry: append an entry to `MIGRATIONS` in `src/cli/utils/migrations.ts`. Scopes: `global` (runs once per machine, no project context) vs `per-project` (sweeps all discovered Claude-enabled projects in parallel). Failures are non-fatal — migrations retry on next init. Currently registered per-project migrations include `purge-legacy-knowledge-v2` (removes 4 hardcoded pre-v2 ADR/PF IDs and orphan `PROJECT-PATTERNS.md`), `purge-legacy-knowledge-v3` (v3: sweeps all remaining pre-v2 seeded entries using the `- **Source**: self-learning:` format discriminator — any ADR/PF section lacking this marker is removed; entries the user edited to include the marker survive), `purge-orphaned-sidecar-judgment-state` (per-project; removes orphaned `.learning-manifest.json`, `.decisions-manifest.json`, `.decisions-notifications.json` — judgment-state files written by the now-removed deterministic render/reconcile layer), `purge-learning-pipeline-v1` (per-project; removes `.devflow/learning/` directory, learning dream markers, `learning` key from dream/sidecar config, `.claude/commands/self-learning/`, and auto-generated skills), `purge-stale-memory-markers-v1` (per-project; removes stale `dream/memory.*` markers left by the old Dream-subagent memory pipeline now that `background-memory-update` handles memory refresh — ENOENT-idempotent, rethrows non-ENOENT errors), `purge-dead-working-memory-sentinel-v1` (per-project; removes the stale `.devflow/memory/.working-memory-disabled` sentinel now that the memory gate is config-only per ADR-001 — ENOENT-tolerant, rethrows non-ENOENT errors). Global migrations: `purge-learning-global-v1` removes `~/.devflow/learning.json`; `purge-orphaned-dream-commit-hook-v1` removes the orphaned `~/.devflow/scripts/hooks/dream-commit` (the `dream-commit` helper was deleted when `.devflow/` became gitignored-by-default per ADR-021, but the installer copies `scripts/` additively — `copyDirectory` never deletes — so the stale file would otherwise linger; ENOENT-idempotent); `purge-knowledge-hooks-global-v1` removes the orphaned `~/.devflow/scripts/hooks/eval-knowledge` and `~/.devflow/scripts/hooks/lib/feature-knowledge.cjs` (deleted from source in Phase 2 of the write-through migration; installer copies additively so they linger; ENOENT-idempotent). Per-project: `purge-feature-knowledge-pipeline-v1` removes `.devflow/dream/knowledge.*` markers, `.devflow/features/.knowledge.lock/` (recursive), `.devflow/features/.knowledge-last-refresh`, `.devflow/features/.knowledge-refresh.lock`, `.devflow/features/.disabled`; renames `.devflow/features/index.json` → `index.json.deprecated` (if present; never recreated — write-through creates `index.md` lazily). Does NOT touch the `knowledge` key in dream config (write-back gate stays). Existing KNOWLEDGE.md files remain loadable via frontmatter-glob fallback. **D37 edge case**: a project cloned *after* migrations have run won't be swept (the marker is global, not per-project). Recovery: `rm ~/.devflow/migrations.json` forces a re-sweep on next `devflow init`. ## Project Structure @@ -78,7 +79,7 @@ devflow/ ├── plugins/devflow-*/ # 22 plugins (12 core + 9 optional language/ecosystem + 1 optional workflow) ├── docs/reference/ # Detailed reference documentation ├── scripts/ # Helper scripts (statusline, docs-helpers) -│ └── hooks/ # Dream + ambient + memory hooks (dream-capture, dream-dispatch [capture-only], background-memory-update [Stop-hook worker], dream-recover, dream-collect-tasks, dream-evaluate, dream-lock, session-start-memory, session-start-context, pre-compact-memory, preamble, get-mtime, hook-bootstrap, hook-log-init, eval-helpers, eval-decisions, eval-knowledge, eval-curation) +│ └── hooks/ # Dream + ambient + memory hooks (dream-capture, dream-dispatch [capture-only], background-memory-update [Stop-hook worker], dream-recover, dream-collect-tasks, dream-evaluate, dream-lock, session-start-memory, session-start-context, pre-compact-memory, preamble, get-mtime, hook-bootstrap, hook-log-init, eval-helpers, eval-decisions, eval-curation) ├── src/cli/ # TypeScript CLI (init, list, uninstall, ambient, decisions, flags, knowledge, rules, debug) ├── .claude-plugin/ # Marketplace registry ├── .devflow/ # All per-project runtime data — gitignored wholesale by default (ensure-devflow-init adds .devflow/ to the root .gitignore; sharing is opt-in) @@ -113,7 +114,7 @@ node dist/cli.js init --plugin=code-review # Single plugin /code-review ``` -**Build commands**: `npm run build` (full), `npm run build:cli` (TypeScript only), `npm run build:plugins` (skill/agent distribution only), `npm run build:recipes` (MDS recipe compilation only) +**Build commands**: `npm run build` (full), `npm run build:cli` (TypeScript only), `npm run build:plugins` (skill/agent distribution only), `npm run build:recipes` (MDS recipe compilation only), `npm run build:knowledge` (MDS knowledge command compilation only) ## Documentation Artifacts @@ -160,7 +161,7 @@ Per-project runtime files live under `.devflow/`: │ ├── .working-memory-last-trigger # Mtime = last worker spawn time (120s throttle key, transient) │ ├── .last-refresh-ok # Mtime = last successful WORKING-MEMORY.md write (transient) │ └── .working-memory.lock/ # Worker lock dir — 300s stale-break (transient, never tracked) -├── dream/ # Dream state: config.json (feature toggles), per-session markers (decisions.{session}.json, knowledge.{session}.json, curation.{session}.json), .processor-spawned-at (120s spawn throttle), .curation-last (7-day curation throttle) +├── dream/ # Dream state: config.json (feature toggles), per-session markers (decisions.{session}.json, curation.{session}.json), .processor-spawned-at (120s spawn throttle), .curation-last (7-day curation throttle) ├── decisions/ │ ├── decisions-ledger.jsonl # Anchored ledger (gitignored by default) — render source of truth; one row per ADR/PF incl. retired │ ├── decisions-log.jsonl # Raw decision/pitfall observations (JSONL, gitignored) @@ -174,10 +175,7 @@ Per-project runtime files live under `.devflow/`: │ └── .disabled # Runtime sentinel — decisions sections in session-start-context skip if present └── features/ # Per-feature knowledge bases (gitignored by default; opt-in to share) ├── {slug}/KNOWLEDGE.md - ├── index.json - ├── .disabled # Sentinel — gates Phase 12 generation and refresh hook - ├── .knowledge.lock # Transient lock directory for concurrent index writes (gitignored) - └── .knowledge-last-refresh # Epoch timestamp of last auto-refresh + └── index.md # Regenerable cache (line format: `- **{slug}** — {areas} — {Use-when}`); frontmatter is authoritative if absent ~/.devflow/logs/{project-slug}/ ├── .dream-capture.log # dream-capture (Stop hook) log @@ -194,7 +192,7 @@ Per-project runtime files live under `.devflow/`: **Universal Skill Installation**: All skills from all plugins are always installed, regardless of plugin selection. Skills are tiny markdown files installed as `~/.claude/skills/devflow:{name}/` (namespaced to avoid collisions with other plugin ecosystems). Source directories in `shared/skills/` stay unprefixed — the `devflow:` prefix is applied at install-time only. Shadow overrides live at `~/.devflow/skills/{name}/` (unprefixed); when shadowed, the installer copies the user's version to the prefixed install target. Only commands and agents remain plugin-specific. -**Model Strategy**: Explicit model assignments in agent frontmatter override the user's session model. Opus for analysis agents (reviewer, scrutinizer, evaluator, designer, researcher, bug-analyzer), Sonnet for execution agents (coder, simplifier, resolver, skimmer, tester), Haiku for I/O agents (git, synthesizer, validator). Dream uses per-task overrides via `session-start-context`: sonnet for knowledge, opus for decisions/curation (the latter two share one combined opus spawn). Memory is no longer a Dream task — `WORKING-MEMORY.md` is refreshed by the detached `background-memory-update` worker (`claude -p --model haiku`) spawned by `dream-capture`. +**Model Strategy**: Explicit model assignments in agent frontmatter override the user's session model. Opus for analysis agents (reviewer, scrutinizer, evaluator, designer, researcher, bug-analyzer), Sonnet for execution agents (coder, simplifier, resolver, skimmer, tester, knowledge), Haiku for I/O agents (git, synthesizer, validator). Dream uses per-task overrides via `session-start-context`: opus for decisions/curation (share one combined opus spawn). Memory is not a Dream task — `WORKING-MEMORY.md` is refreshed by the detached `background-memory-update` worker (`claude -p --model haiku`) spawned by `dream-capture`. Knowledge is not a Dream task — the Knowledge agent (sonnet) is spawned in-command by `knowledge_writeback()` at workflow end. ## Agent & Command Roster diff --git a/package.json b/package.json index 88b240fd..e1ab3dac 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,11 @@ "CHANGELOG.md" ], "scripts": { - "build": "npm run build:cli && npm run build:plugins && npm run build:recipes && npm run build:hud", + "build": "npm run build:cli && npm run build:plugins && npm run build:recipes && npm run build:knowledge && npm run build:hud", "build:cli": "tsc", "build:plugins": "npx tsx scripts/build-plugins.ts", "build:recipes": "npx tsx scripts/build-recipes.ts", + "build:knowledge": "npx tsx scripts/build-knowledge.ts", "build:hud": "node scripts/build-hud.js", "dev": "tsc --watch", "cli": "node dist/cli.js", diff --git a/plugins/devflow-core-skills/.claude-plugin/plugin.json b/plugins/devflow-core-skills/.claude-plugin/plugin.json index 8003d6b9..51fda8f3 100644 --- a/plugins/devflow-core-skills/.claude-plugin/plugin.json +++ b/plugins/devflow-core-skills/.claude-plugin/plugin.json @@ -30,7 +30,6 @@ "testing", "dependency-research", "dream-decisions", - "dream-knowledge", "dream-curation" ], "rules": [ diff --git a/plugins/devflow-debug/.claude-plugin/plugin.json b/plugins/devflow-debug/.claude-plugin/plugin.json index ff3caf49..c2c9fcd5 100644 --- a/plugins/devflow-debug/.claude-plugin/plugin.json +++ b/plugins/devflow-debug/.claude-plugin/plugin.json @@ -20,6 +20,7 @@ "skills": [ "git", "worktree-support", + "feature-knowledge", "apply-feature-knowledge" ] } diff --git a/plugins/devflow-implement/.claude-plugin/plugin.json b/plugins/devflow-implement/.claude-plugin/plugin.json index 014536c9..24f4e76d 100644 --- a/plugins/devflow-implement/.claude-plugin/plugin.json +++ b/plugins/devflow-implement/.claude-plugin/plugin.json @@ -30,6 +30,7 @@ "qa", "quality-gates", "worktree-support", + "feature-knowledge", "apply-feature-knowledge" ] } diff --git a/plugins/devflow-release/commands/release.md b/plugins/devflow-release/commands/release.md index 9687e0ec..a2a94e05 100644 --- a/plugins/devflow-release/commands/release.md +++ b/plugins/devflow-release/commands/release.md @@ -53,7 +53,7 @@ Load the decisions index: DECISIONS_CONTEXT=$(node ~/.devflow/scripts/hooks/lib/decisions-index.cjs index "." 2>/dev/null || echo "(none)") ``` -Load feature knowledge: Read `.devflow/features/index.json`, match release-relevant files, read relevant KNOWLEDGE.md entries. Set `FEATURE_KNOWLEDGE` (or `(none)`). +Load feature knowledge: Attempt to read `.devflow/features/index.md` (the regenerable cache). If absent or empty, glob `.devflow/features/*/KNOWLEDGE.md` and read each file's YAML frontmatter (`name`, `description`, `directories`) as the relevance surface. Pick release-relevant KBs by matching their documented area against the release context. For each selected KB, read the full `KNOWLEDGE.md` — trust current code over KB content on any mismatch. Concatenate under slug headers and set `FEATURE_KNOWLEDGE` (or `(none)` if no KBs exist or none are relevant). No `index.json`, no subprocess, no `.cjs` script. Pass both to all subsequent agents via their input contracts. diff --git a/plugins/devflow-resolve/.claude-plugin/plugin.json b/plugins/devflow-resolve/.claude-plugin/plugin.json index 0acec961..0e2f92b9 100644 --- a/plugins/devflow-resolve/.claude-plugin/plugin.json +++ b/plugins/devflow-resolve/.claude-plugin/plugin.json @@ -24,6 +24,7 @@ "patterns", "security", "worktree-support", + "feature-knowledge", "apply-feature-knowledge" ] } diff --git a/plugins/devflow-self-review/.claude-plugin/plugin.json b/plugins/devflow-self-review/.claude-plugin/plugin.json index f302b9ef..d424f5ba 100644 --- a/plugins/devflow-self-review/.claude-plugin/plugin.json +++ b/plugins/devflow-self-review/.claude-plugin/plugin.json @@ -23,6 +23,7 @@ "quality-gates", "software-design", "worktree-support", + "feature-knowledge", "apply-feature-knowledge" ] } diff --git a/scripts/build-knowledge.ts b/scripts/build-knowledge.ts new file mode 100644 index 00000000..985e5687 --- /dev/null +++ b/scripts/build-knowledge.ts @@ -0,0 +1,180 @@ +#!/usr/bin/env npx tsx +/** + * Build-time knowledge command compilation script + * + * Compiles `.mds` command files from shared/knowledge/ into Markdown command files + * in the appropriate plugin commands/ directories. Uses an explicit source→plugin map + * (not a glob) — a missing source file or unknown/missing plugin destination exits + * non-zero with a clear message. + * + * Partials (basename starts with `_`) are never outputs; they are imported by host files. + * + * Per-file clean: before compiling each file, removes only the mapped destination .md — + * never wipes a whole directory. + * + * Hard-fails the entire build on any compile error, ensuring a broken or stale command + * never ships. Errors are reported with the mds::* code, message, and source span. + * + * Usage: npm run build:knowledge + */ + +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; +import { init, compileFile, isMdsError } from "@mdscript/mds"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const KNOWLEDGE_DIR = path.join(ROOT, "shared", "knowledge"); + +/** + * Explicit source→plugin destination map. + * Key: basename of .mds file in shared/knowledge/ (without extension) + * Value: relative path from ROOT to the destination plugin commands/ directory + */ +const SOURCE_TO_PLUGIN_MAP: Record = { + "implement": "plugins/devflow-implement/commands", + "plan": "plugins/devflow-plan/commands", + "resolve": "plugins/devflow-resolve/commands", + "code-review": "plugins/devflow-code-review/commands", + "self-review": "plugins/devflow-self-review/commands", + "research": "plugins/devflow-research/commands", + "bug-analysis": "plugins/devflow-bug-analysis/commands", + "explore": "plugins/devflow-explore/commands", + "debug": "plugins/devflow-debug/commands", +}; + +interface CompileOutcome { + source: string; + dest: string; + warnings: string[]; +} + +function formatMdsError(err: unknown, sourcePath: string): string { + if (isMdsError(err)) { + const span = err.span + ? ` [line ${err.span.line ?? "?"}:${err.span.column ?? "?"}]` + : ""; + const help = err.help ? `\n help: ${err.help}` : ""; + return `${err.code}${span}: ${err.message}${help}\n file: ${path.relative(ROOT, sourcePath)}`; + } + return String(err); +} + +async function compileKnowledgeFile( + sourcePath: string, + destDir: string +): Promise { + const basename = path.basename(sourcePath, ".mds"); + const dest = path.join(destDir, `${basename}.md`); + + // Per-file clean: remove only the mapped destination — never wipe the whole directory + if (fs.existsSync(dest)) { + fs.rmSync(dest); + } + + const result = await compileFile(sourcePath); + fs.writeFileSync(dest, result.output, "utf-8"); + + return { + source: path.relative(ROOT, sourcePath), + dest: path.relative(ROOT, dest), + warnings: result.warnings, + }; +} + +async function main(): Promise { + console.log("Building knowledge commands...\n"); + + // Validate shared/knowledge/ exists + if (!fs.existsSync(KNOWLEDGE_DIR)) { + console.error(`ERROR: shared/knowledge/ directory not found at ${KNOWLEDGE_DIR}`); + process.exit(1); + } + + // Initialize the MDS compiler (required before any compile/check call) + await init(); + + // Validate each source file exists and each destination directory exists + const validationErrors: string[] = []; + + for (const [basename, destRelDir] of Object.entries(SOURCE_TO_PLUGIN_MAP)) { + const sourcePath = path.join(KNOWLEDGE_DIR, `${basename}.mds`); + if (!fs.existsSync(sourcePath)) { + validationErrors.push( + `Missing source file: shared/knowledge/${basename}.mds` + ); + } + + const destDir = path.join(ROOT, destRelDir); + if (!fs.existsSync(destDir)) { + validationErrors.push( + `Missing plugin destination directory: ${destRelDir} (plugin not installed or commands/ dir absent)` + ); + } + } + + if (validationErrors.length > 0) { + for (const err of validationErrors) { + console.error(` ERROR: ${err}`); + } + console.error( + `\n${validationErrors.length} validation error(s) — build FAILED. Ensure all source files exist and plugin directories are present.` + ); + process.exit(1); + } + + const entries = Object.entries(SOURCE_TO_PLUGIN_MAP); + console.log(` ${entries.length} command(s) to compile:\n`); + + // Compile each command — hard-fail on any error + const outcomes: CompileOutcome[] = []; + const errors: string[] = []; + + for (const [basename, destRelDir] of entries) { + const sourcePath = path.join(KNOWLEDGE_DIR, `${basename}.mds`); + const destDir = path.join(ROOT, destRelDir); + + try { + const outcome = await compileKnowledgeFile(sourcePath, destDir); + outcomes.push(outcome); + + const warnNote = + outcome.warnings.length > 0 + ? ` (${outcome.warnings.length} warning(s))` + : ""; + console.log(` compiled: ${outcome.source} → ${outcome.dest}${warnNote}`); + + for (const w of outcome.warnings) { + console.warn(` WARNING: ${w}`); + } + } catch (err) { + const formatted = formatMdsError(err, sourcePath); + errors.push(formatted); + console.error(` FAILED: ${basename}.mds`); + console.error(` ${formatted}`); + } + } + + const totalWarnings = outcomes.reduce((n, o) => n + o.warnings.length, 0); + console.log( + `\nKnowledge: ${outcomes.length} compiled, ${errors.length} error(s), ${totalWarnings} warning(s)` + ); + + if (errors.length > 0) { + console.error( + `\n${errors.length} compile error(s) — build FAILED. Fix the mds::* errors above before shipping.` + ); + process.exit(1); + } + + console.log("\nKnowledge commands build complete!"); +} + +main().catch((err) => { + // Hard-fail on any error escaping main() (e.g. init() or readdir failure) — + // a broken or stale command must never ship. + console.error( + `\nFATAL: knowledge build aborted — ${err instanceof Error ? err.message : String(err)}` + ); + process.exit(1); +}); diff --git a/scripts/hooks/dream-collect-tasks b/scripts/hooks/dream-collect-tasks index 085054ac..691a861a 100644 --- a/scripts/hooks/dream-collect-tasks +++ b/scripts/hooks/dream-collect-tasks @@ -4,23 +4,24 @@ # # Source this file to get the dream_collect_tasks and dream_build_spawn_directive functions. # -# Function: dream_collect_tasks DREAM_DIR DECISIONS_ENABLED KNOWLEDGE_ENABLED +# Function: dream_collect_tasks DREAM_DIR DECISIONS_ENABLED # # Inputs (positional args): # $1 DREAM_DIR — path to .devflow/dream/ # $2 DECISIONS_ENABLED — "true"/"false" -# $3 KNOWLEDGE_ENABLED — "true"/"false" # # Outputs (exported variable): # _DREAM_TASKS — comma-separated, deduped, sorted list of pending task types. # Empty string if no pending tasks. # NOTE: "memory" never appears — memory markers are unconditionally swept # (background-memory-update worker handles refresh from dream-capture). +# NOTE: "knowledge" never appears — knowledge is handled in-command via +# write-through (knowledge_writeback MDS partial). # # Behaviour: # - Skips config.json (reserved). -# - Pass 1: unconditional sweep — deletes learning.* and memory.* markers always -# (both pipelines removed from Dream subagent); deletes disabled decisions/knowledge +# - Pass 1: unconditional sweep — deletes learning.*, memory.*, and knowledge.* markers +# always (all three pipelines removed from Dream subagent); deletes disabled decisions # markers. When decisions is disabled, curation markers are also swept (curation # depends on decisions data and should not run when decisions is disabled). # Unknown types pass through unchanged. @@ -52,7 +53,6 @@ _derive_marker_type() { dream_collect_tasks() { local dream_dir="${1:?dream_collect_tasks: dream_dir required}" local dec_enabled="${2:-true}" - local know_enabled="${3:-true}" _DREAM_TASKS="" @@ -110,6 +110,14 @@ dream_collect_tasks() { dbg "dream_collect_tasks: deleted stale memory marker: $_base" continue ;; + knowledge) + # Knowledge is no longer a Dream subagent task — write-through (knowledge_writeback + # MDS partial) handles it in-command. Delete any stale knowledge.* markers + # unconditionally (same treatment as learning.* and memory.*). + rm -f "$_f" 2>/dev/null || true + dbg "dream_collect_tasks: deleted stale knowledge marker: $_base" + continue + ;; decisions) if [ "$dec_enabled" != "true" ]; then rm -f "$_f" 2>/dev/null || true @@ -117,13 +125,6 @@ dream_collect_tasks() { continue fi ;; - knowledge) - if [ "$know_enabled" != "true" ]; then - rm -f "$_f" 2>/dev/null || true - dbg "dream_collect_tasks: deleted disabled-feature marker: $_base" - continue - fi - ;; curation) # Curation depends on decisions data — sweep when decisions is disabled # so stray curation markers don't trigger Dream agent spawns when disabled. @@ -230,8 +231,9 @@ _DREAM_DIRECTIVE="" # exact directive bytes (including trailing newlines) survive intact; command # substitution would strip them. # -# Hardcoded task→model map: memory=haiku, knowledge=sonnet, decisions=opus, -# curation=opus. decisions+curation co-pending → exactly ONE opus spawn that +# Hardcoded task→model map: decisions=opus, curation=opus. +# memory and knowledge are no longer Dream tasks — swept unconditionally by collect. +# decisions+curation co-pending → exactly ONE opus spawn that # runs decisions THEN curation sequentially (prevents .decisions.lock contention). # Unknown task types are skipped (belt-and-suspenders — dream_collect_tasks # should never emit them). @@ -239,18 +241,13 @@ dream_build_spawn_directive() { local _dbsd_tasks="$1" _DREAM_DIRECTIVE="" - # memory is no longer a Dream subagent task (handled by background-memory-update worker). - # Map: knowledge=sonnet, decisions=opus, curation=opus. memory entries swept by collect. - local _dbsd_know="false" _dbsd_dec="false" _dbsd_cur="false" - case ",$_dbsd_tasks," in *,knowledge,*) _dbsd_know="true" ;; esac - case ",$_dbsd_tasks," in *,decisions,*) _dbsd_dec="true" ;; esac - case ",$_dbsd_tasks," in *,curation,*) _dbsd_cur="true" ;; esac + # memory, knowledge: no longer Dream subagent tasks (handled by other mechanisms). + # Map: decisions=opus, curation=opus. memory and knowledge entries swept by collect. + local _dbsd_dec="false" _dbsd_cur="false" + case ",$_dbsd_tasks," in *,decisions,*) _dbsd_dec="true" ;; esac + case ",$_dbsd_tasks," in *,curation,*) _dbsd_cur="true" ;; esac local _dbsd_lines="" - if [ "$_dbsd_know" = "true" ]; then - _dbsd_lines="${_dbsd_lines}Agent(subagent_type=\"Dream\", model=\"sonnet\", run_in_background: true, prompt: \"Process pending 'knowledge' marker(s): claim per your plumbing, then load devflow:dream-knowledge and follow it.\") -" - fi if [ "$_dbsd_dec" = "true" ] && [ "$_dbsd_cur" = "true" ]; then _dbsd_lines="${_dbsd_lines}Agent(subagent_type=\"Dream\", model=\"opus\", run_in_background: true, prompt: \"Process pending decisions and curation marker(s): claim each per your plumbing, then load devflow:dream-decisions and follow it, THEN load devflow:dream-curation and follow it (sequentially, never concurrently).\") " diff --git a/scripts/hooks/dream-evaluate b/scripts/hooks/dream-evaluate index 0ff08300..76578c0b 100755 --- a/scripts/hooks/dream-evaluate +++ b/scripts/hooks/dream-evaluate @@ -1,10 +1,11 @@ #!/bin/bash # Dream System: dream-evaluate (SessionEnd Hook) -# Evaluates session-level features: decisions detection, knowledge refresh. +# Evaluates session-level features: decisions detection and curation. # Writes marker files to .devflow/dream/ for dream-dispatch to pick up. # -# Orchestrator: sources eval-helpers + 3 feature modules after shared setup. +# Orchestrator: sources eval-helpers + 2 feature modules after shared setup. +# Knowledge is no longer evaluated here — write-through handles it in-command. # Safe no-op fallback: must exist before set -e and before hook-bootstrap is sourced. dbg() { :; } @@ -43,18 +44,15 @@ DEVFLOW_DIR="$PROJECT_ROOT/.devflow" [ ! -d "$DEVFLOW_DIR" ] && exit 0 MEMORY_DIR="$DEVFLOW_DIR/memory" -FEATURES_DIR="$DEVFLOW_DIR/features" DREAM_DIR="$DEVFLOW_DIR/dream" DECISIONS_DIR_DATA="$DEVFLOW_DIR/decisions" # Read dream config DREAM_CONFIG="$DREAM_DIR/config.json" DECISIONS_ENABLED="true" -KNOWLEDGE_ENABLED="true" if [ -f "$DREAM_CONFIG" ]; then DECISIONS_ENABLED=$(json_field_file "$DREAM_CONFIG" "decisions" "true") - KNOWLEDGE_ENABLED=$(json_field_file "$DREAM_CONFIG" "knowledge" "true") fi # Dual-signal gate: config OR sentinel disables decisions (and transitively curation). @@ -67,7 +65,7 @@ source "$SCRIPT_DIR/hook-log-init" "dream-evaluate" source "$SCRIPT_DIR/dream-lock" || { log "failed to source dream-lock"; exit 1; } log "SessionEnd hook triggered" -dbg "DECISIONS=$DECISIONS_ENABLED KNOWLEDGE=$KNOWLEDGE_ENABLED" +dbg "DECISIONS=$DECISIONS_ENABLED" # --- Find transcript --- ENCODED_CWD=$(echo "$CWD" | sed 's|^/||' | tr '/' '-') @@ -133,13 +131,14 @@ MARKER_SUFFIX="${SESSION_ID:-$$}" # MARKER_SUFFIX — suffix for per-session marker filenames (SESSION_ID or $$) # DREAM_DIR — absolute path to .devflow/dream/ # DECISIONS_DIR_DATA — absolute path to .devflow/decisions/ -# FEATURES_DIR — absolute path to .devflow/features/ -# DECISIONS_ENABLED / KNOWLEDGE_ENABLED — "true"/"false" +# DECISIONS_ENABLED — "true"/"false" # _HAS_JQ — "true" if jq is available on PATH # +# Note: FEATURES_DIR and KNOWLEDGE_ENABLED are no longer set here. +# Knowledge write-through is handled in-command via the knowledge_writeback() MDS partial. +# source "$SCRIPT_DIR/eval-helpers" source "$SCRIPT_DIR/eval-decisions" -source "$SCRIPT_DIR/eval-knowledge" source "$SCRIPT_DIR/eval-curation" dbg "=== HOOK COMPLETE ===" diff --git a/scripts/hooks/dream-recover b/scripts/hooks/dream-recover index 3d71ad71..6cc6adf8 100644 --- a/scripts/hooks/dream-recover +++ b/scripts/hooks/dream-recover @@ -23,7 +23,8 @@ # - Per-type stale thresholds: # memory = 300s (5 minutes) # decisions = 1800s (30 minutes) -# knowledge = 1800s (30 minutes) +# knowledge = 1800s (legacy; knowledge is no longer a Dream task — stale markers are + # recovered here then deleted by dream-collect-tasks on sight) # unknown = 1800s (default) # - Also recovers orphaned .pending-turns.processing: # If MEMORY_DIR/.pending-turns.processing exists and is older than 300s, @@ -36,9 +37,10 @@ # D56a: Per-type stale thresholds. # memory = 300s (5 min) because the memory agent is fast — if it is still # running after 5 minutes the session almost certainly crashed. All other -# types (learning, decisions, knowledge, curation) run the LLM and may take -# up to 30 minutes on large logs or slow models; 1800s avoids yanking an -# actively-running Dream agent. +# types (decisions, curation) run the LLM and may take up to 30 minutes on +# large logs or slow models; 1800s avoids yanking an actively-running Dream agent. +# Learning and knowledge are no longer Dream tasks; stale markers are recovered +# here and then deleted by dream-collect-tasks on sight. # # D56b: JUST_RECOVERED guard. # When dream_recover_stale renames a .processing back to .json, it records diff --git a/scripts/hooks/ensure-devflow-init b/scripts/hooks/ensure-devflow-init index 158d2c5c..2b172d34 100755 --- a/scripts/hooks/ensure-devflow-init +++ b/scripts/hooks/ensure-devflow-init @@ -1,8 +1,10 @@ #!/bin/bash -# Ensures .devflow/ and all subdirectories exist, the project root .gitignore -# ignores .devflow/, and .devflow/features/index.json is bootstrapped. -# Merges functionality of the former ensure-memory-gitignore and ensure-features-init. +# Ensures .devflow/ and all subdirectories exist and the project root .gitignore +# ignores .devflow/. Merges functionality of the former ensure-memory-gitignore +# and ensure-features-init. # Called from dream-capture, dream-dispatch, and pre-compact-memory. Idempotent. +# Note: features/index.json is no longer bootstrapped here — features/index.md is +# created lazily by write-through (knowledge_writeback MDS partial). # Usage: source ensure-devflow-init "$CWD" [ -z "$1" ] && return 1 @@ -34,12 +36,6 @@ mkdir -p \ "$_DEVFLOW_DIR/docs" \ 2>/dev/null || return 1 -# Bootstrap features/index.json if missing -if [ ! -f "$_DEVFLOW_DIR/features/index.json" ]; then - printf '{"version":1,"features":{}}' > "$_DEVFLOW_DIR/features/index.json.tmp" && \ - mv "$_DEVFLOW_DIR/features/index.json.tmp" "$_DEVFLOW_DIR/features/index.json" -fi - # One-time root .gitignore setup — delegated to the sibling ensure-root-gitignore # helper (single source of truth) so the always-on, memory-independent # session-start-context hook applies the identical rule. _EDI_DIR and _EDI_ROOT diff --git a/scripts/hooks/eval-knowledge b/scripts/hooks/eval-knowledge deleted file mode 100644 index c43438cb..00000000 --- a/scripts/hooks/eval-knowledge +++ /dev/null @@ -1,73 +0,0 @@ -#!/bin/bash -# dream-evaluate: Knowledge evaluation module. -# Checks for stale feature knowledge bases and writes a knowledge refresh marker. -# Source from dream-evaluate orchestrator after eval-helpers. -# -# Requires (from orchestrator namespace): -# KNOWLEDGE_ENABLED, FEATURES_DIR, CWD, DREAM_DIR, NOW, MARKER_SUFFIX, -# SCRIPT_DIR, _HAS_JQ, log(), dbg() - -: "${FEATURES_DIR:?eval-knowledge: FEATURES_DIR must be set by orchestrator}" -: "${CWD:?eval-knowledge: CWD must be set by orchestrator}" -: "${DREAM_DIR:?eval-knowledge: DREAM_DIR must be set by orchestrator}" -: "${NOW:?eval-knowledge: NOW must be set by orchestrator}" -: "${MARKER_SUFFIX:?eval-knowledge: MARKER_SUFFIX must be set by orchestrator}" -: "${SCRIPT_DIR:?eval-knowledge: SCRIPT_DIR must be set by orchestrator}" -: "${_HAS_JQ:?eval-knowledge: _HAS_JQ must be set by orchestrator}" - -if [ "$KNOWLEDGE_ENABLED" = "true" ]; then - log "Evaluating knowledge..." - - # Check .devflow/features/.disabled sentinel - if [ -f "$FEATURES_DIR/.disabled" ]; then - log "Knowledge disabled by sentinel" - else - # Throttle: skip if refreshed within last 2 hours - _KNOW_MARKER_FILE="$FEATURES_DIR/.knowledge-last-refresh" - _KNOW_LAST_REFRESH=$(tr -dc '0-9' < "$_KNOW_MARKER_FILE" 2>/dev/null || true) - _KNOW_LAST_REFRESH="${_KNOW_LAST_REFRESH:-0}" - _KNOW_AGE=$(( NOW - _KNOW_LAST_REFRESH )) - - dbg "Knowledge: AGE=${_KNOW_AGE}s (throttle=7200s)" - if [ "$_KNOW_AGE" -lt 7200 ]; then - log "Knowledge throttled: refreshed ${_KNOW_AGE}s ago" - dbg "Knowledge throttled: AGE=${_KNOW_AGE}s" - else - # Query stale slugs - _KNOW_FEATURE_LIB="$SCRIPT_DIR/lib/feature-knowledge.cjs" - _KNOW_STALE_SLUGS="" - if [ -f "$_KNOW_FEATURE_LIB" ]; then - _KNOW_STALE_SLUGS=$(node "$_KNOW_FEATURE_LIB" stale-slugs "$CWD" 2>/dev/null || true) - fi - - if [ "${DEVFLOW_HOOK_DEBUG:-}" = "1" ]; then dbg "Knowledge stale_slugs=$(echo "$_KNOW_STALE_SLUGS" | wc -l | tr -d ' ')"; fi - if [ -n "$_KNOW_STALE_SLUGS" ]; then - # Write knowledge marker (per-session name for concurrency safety) - _KNOW_MARKER="$DREAM_DIR/knowledge.${MARKER_SUFFIX}.json" - _KNOW_TMP="${_KNOW_MARKER}.tmp.$$" - if [ "$_HAS_JQ" = "true" ]; then - _KNOW_STALE_ARRAY=$(echo "$_KNOW_STALE_SLUGS" | jq -R . | jq -s .) - jq -n \ - --argjson staleSlugs "$_KNOW_STALE_ARRAY" \ - --arg worktreePath "$CWD" \ - --argjson timestamp "$NOW" \ - '{staleSlugs: $staleSlugs, worktreePath: $worktreePath, timestamp: $timestamp}' \ - > "$_KNOW_TMP" && mv "$_KNOW_TMP" "$_KNOW_MARKER" || rm -f "$_KNOW_TMP" - else - node -e " - const slugs = process.argv[1].trim().split('\n').filter(Boolean); - process.stdout.write(JSON.stringify({staleSlugs: slugs, worktreePath: process.argv[2], timestamp: parseInt(process.argv[3])}) + '\n') - " -- "$_KNOW_STALE_SLUGS" "$CWD" "$NOW" > "$_KNOW_TMP" && mv "$_KNOW_TMP" "$_KNOW_MARKER" || rm -f "$_KNOW_TMP" - fi - # Optimistically update throttle marker so the stale-slug query is skipped - # on the next session end — the dream agent will do the actual refresh. - echo "$NOW" > "$_KNOW_MARKER_FILE" - log "Wrote knowledge marker: $(echo "$_KNOW_STALE_SLUGS" | wc -l | tr -d ' ') stale slug(s)" - dbg "Wrote knowledge marker: $_KNOW_MARKER" - else - log "Knowledge: no stale slugs found" - dbg "Knowledge: no stale slugs found" - fi - fi - fi -fi diff --git a/scripts/hooks/lib/feature-knowledge.cjs b/scripts/hooks/lib/feature-knowledge.cjs deleted file mode 100644 index efe9ab77..00000000 --- a/scripts/hooks/lib/feature-knowledge.cjs +++ /dev/null @@ -1,652 +0,0 @@ -// scripts/hooks/lib/feature-knowledge.cjs -// Runtime module for per-feature knowledge base management. -// -// DESIGN: Feature knowledge bases live under .devflow/features/{slug}/KNOWLEDGE.md with a central -// index at .devflow/features/index.json (keyed by slug). This module is the single -// source of truth for all knowledge base operations — loading, staleness detection, index -// mutation, and listing. A mkdir-based lock guards concurrent index writes. -// -// ARCHITECTURE EXCEPTION: This is a developer-facing CLI tool invoked exclusively -// by trusted orchestration scripts within Claude Code. The worktree path argument -// is controlled by the Claude Code session, not by end users. Path traversal -// analysis (CWE-23) flags the worktreePath→fs I/O flow as a risk, but this is -// inherent to a tool whose sole purpose is to manage files in a git worktree. -// The same pattern exists in scripts/hooks/lib/decisions-index.cjs (accepted). -// For command execution, we use execFileSync with array args (not shell strings) -// to prevent injection attacks from index content. -// -// CLI interface (see if-require.main block at bottom): -// node feature-knowledge.cjs list -// node feature-knowledge.cjs stale [slug] -// node feature-knowledge.cjs update-index --slug=X --name=Y ... -// node feature-knowledge.cjs find-overlapping [file2...] -// node feature-knowledge.cjs remove - -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const { execFileSync } = require('child_process'); -const { getFeaturesDir, getFeaturesIndexPath, getFeaturesLockDir, getKnowledgePath } = require('./project-paths.cjs'); - -/** Sentinel returned whenever a knowledge entry is confirmed non-stale or a fallback is needed. */ -const NOT_STALE = Object.freeze({ stale: false, changedFiles: [] }); - -/** - * Parse git log output into a deduplicated list of changed file paths. - * @param {string} output - raw stdout from `git log --name-only` - * @returns {string[]} - */ -function parseGitChangedFiles(output) { - return [...new Set(output.split('\n').map(l => l.trim()).filter(Boolean))]; -} - -/** - * Parse git log output with dates into a map of file → latest change timestamp. - * Expects `--pretty=format:%aI` output: ISO date, blank line, file names, blank, repeat. - * Reverse chronological order means first occurrence = latest change. - * @param {string} output - * @returns {Map} - */ -function parseGitLogWithDates(output) { - const fileLatestChange = new Map(); - let currentDate = null; - for (const line of output.split('\n')) { - const trimmed = line.trim(); - if (!trimmed) continue; - if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) { - currentDate = new Date(trimmed).getTime(); - continue; - } - if (currentDate !== null && !fileLatestChange.has(trimmed)) { - fileLatestChange.set(trimmed, currentDate); - } - } - return fileLatestChange; -} - -/** - * Validate that a slug is safe for use as a directory name. - * Rejects path traversal attempts (e.g., '../etc'), absolute paths, - * and characters unsafe for filesystem use. - * - * D52: Defense-in-depth — even though callers are trusted orchestration - * scripts, validate at the boundary closest to the filesystem operation. - * - * @param {string} slug - * @returns {void} - * @throws {Error} if slug is invalid - */ -function validateSlug(slug) { - if (!slug || typeof slug !== 'string') { - throw new Error('Slug must be a non-empty string'); - } - if (slug.includes('..') || slug.includes('/') || slug.includes('\\')) { - throw new Error(`Invalid slug '${slug}': must not contain '..', '/', or '\\'`); - } - if (slug.startsWith('.')) { - throw new Error(`Invalid slug '${slug}': must not start with '.'`); - } - // Only allow kebab-case identifiers: lowercase letters, digits, hyphens - if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) { - throw new Error(`Invalid slug '${slug}': must be kebab-case (lowercase letters, digits, hyphens)`); - } -} - -/** - * @typedef {{ - * name: string, - * description: string, - * directories: string[], - * referencedFiles: string[], - * lastUpdated: string, - * createdBy: string - * }} FeatureEntry - */ - -/** - * Load and parse .devflow/features/index.json from a worktree path. - * Returns null when the file is absent or contains invalid JSON. - * - * @param {string} worktreePath - * @returns {{ version: number, features: Record } | null} - */ -function loadIndex(worktreePath) { - const indexPath = getFeaturesIndexPath(worktreePath); - try { - const raw = fs.readFileSync(indexPath, 'utf8'); - return JSON.parse(raw); - } catch { - return null; - } -} - -/** - * Load knowledge base content for a given slug. - * Returns null when the KNOWLEDGE.md file is absent. - * - * @param {string} worktreePath - * @param {string} slug - * @returns {string | null} - */ -function loadKnowledgeContent(worktreePath, slug) { - validateSlug(slug); - const kbPath = getKnowledgePath(worktreePath, slug); - try { - return fs.readFileSync(kbPath, 'utf8'); - } catch { - return null; - } -} - -/** - * Check staleness for a single feature entry by running git log against its referencedFiles. - * Callers are responsible for the git-dir check — this helper assumes the repo exists. - * - * @param {string} worktreePath - * @param {FeatureEntry} entry - * @returns {{ stale: boolean, changedFiles: string[] }} - */ -function checkEntryFiles(worktreePath, entry) { - const files = entry.referencedFiles || []; - if (files.length === 0) return NOT_STALE; - - try { - // Use execFileSync with array args to prevent command injection. - // lastUpdated comes from the index (a controlled ISO timestamp), but we - // avoid string interpolation into a shell command as a defense-in-depth measure. - const result = execFileSync( - 'git', - ['log', `--after=${entry.lastUpdated}`, '--name-only', '--pretty=format:', '--', ...files], - { cwd: worktreePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] } - ); - const changedFiles = parseGitChangedFiles(result); - return { stale: changedFiles.length > 0, changedFiles }; - } catch { - return NOT_STALE; - } -} - -/** - * Check if a knowledge entry is stale by comparing lastUpdated against git log of referencedFiles. - * Returns { stale: false } for non-git repos or when the entry is not found. - * - * @param {string} worktreePath - * @param {string} slug - * @returns {{ stale: boolean, changedFiles: string[] }} - */ -function checkStaleness(worktreePath, slug) { - validateSlug(slug); - const index = loadIndex(worktreePath); - if (!index || !index.features[slug]) return NOT_STALE; - - try { - // Check if in git repo — use execFileSync to avoid shell injection - execFileSync('git', ['rev-parse', '--git-dir'], { cwd: worktreePath, stdio: 'pipe' }); - } catch { - return NOT_STALE; // Non-git fallback - } - - return checkEntryFiles(worktreePath, index.features[slug]); -} - -/** - * Check staleness for all knowledge entries in the index. - * Loads the index once, checks git-dir once, and runs a single git log call - * to detect all changed files since the oldest lastUpdated timestamp. - * Uses the oldest timestamp as the --after cutoff to minimize git calls, - * then compares each file's latest change date against each entry's own - * lastUpdated to avoid false-positive staleness. - * - * @param {string} worktreePath - * @param {{ version: number; features: Record } | null} [cachedIndex] - Optional pre-loaded index to avoid double reads - * @returns {Record} - */ -function checkAllStaleness(worktreePath, cachedIndex) { - const index = cachedIndex !== undefined ? cachedIndex : loadIndex(worktreePath); - if (!index) return {}; - - const slugs = Object.keys(index.features); - if (slugs.length === 0) return {}; - - // Check git-dir once for the whole batch - try { - execFileSync('git', ['rev-parse', '--git-dir'], { cwd: worktreePath, stdio: 'pipe' }); - } catch { - // Non-git repo — all entries non-stale - return Object.fromEntries(slugs.map(slug => [slug, NOT_STALE])); - } - - // Collect all referenced files and find the oldest lastUpdated timestamp - const allFilesSet = new Set(); - let oldestTimestamp = null; - for (const slug of slugs) { - const entry = index.features[slug]; - const files = entry.referencedFiles || []; - for (const f of files) { - allFilesSet.add(f); - } - if (entry.lastUpdated) { - const ts = new Date(entry.lastUpdated).getTime(); - if (!isNaN(ts) && (oldestTimestamp === null || ts < oldestTimestamp)) { - oldestTimestamp = ts; - } - } - } - - // If no files or no timestamp, fall back to per-entry checks - if (allFilesSet.size === 0 || oldestTimestamp === null) { - const results = {}; - for (const slug of slugs) { - results[slug] = checkEntryFiles(worktreePath, index.features[slug]); - } - return results; - } - - // Single git log call for all files since oldest timestamp, with author dates - let fileLatestChange; - try { - const gitOutput = execFileSync( - 'git', - ['log', `--after=${new Date(oldestTimestamp).toISOString()}`, '--name-only', '--pretty=format:%aI', '--', ...allFilesSet], - { cwd: worktreePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] } - ); - fileLatestChange = parseGitLogWithDates(gitOutput); - } catch { - // Git call failed — fall back to per-entry checks - const results = {}; - for (const slug of slugs) { - results[slug] = checkEntryFiles(worktreePath, index.features[slug]); - } - return results; - } - - // For each entry, compare its files' latest change dates against its own lastUpdated - const results = {}; - for (const slug of slugs) { - const entry = index.features[slug]; - const files = entry.referencedFiles || []; - if (files.length === 0) { - results[slug] = NOT_STALE; - continue; - } - const entryTimestamp = entry.lastUpdated ? new Date(entry.lastUpdated).getTime() : 0; - const changedForEntry = files.filter(f => { - const changeTs = fileLatestChange.get(f); - return changeTs !== undefined && changeTs > entryTimestamp; - }); - results[slug] = { stale: changedForEntry.length > 0, changedFiles: changedForEntry }; - } - return results; -} - -/** - * Attempt to break a stale mkdir-based lock. - * Returns true when the lock is gone (either removed or already absent), - * false when the lock is still live (within staleMs). - * - * @param {string} lockPath - * @param {number} staleMs - * @returns {boolean} - */ -function tryBreakStaleLock(lockPath, staleMs) { - try { - const stat = fs.statSync(lockPath); - if (Date.now() - stat.mtimeMs > staleMs) { - try { fs.rmdirSync(lockPath); } catch { /* ignore */ } - return true; - } - } catch { - return true; // lock disappeared - } - return false; -} - -/** - * Acquire a mkdir-based lock. Follows the same pattern as .memory/.decisions.lock. - * Returns true when the lock is acquired within timeoutMs, false otherwise. - * - * @param {string} lockPath - * @param {number} [timeoutMs=30000] - * @param {number} [staleMs=60000] - * @returns {boolean} - */ -function acquireLock(lockPath, timeoutMs = 30000, staleMs = 60000) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - try { - fs.mkdirSync(lockPath); - return true; - } catch { - if (!tryBreakStaleLock(lockPath, staleMs)) { - // Wait 100ms before retrying (Atomics.wait avoids shell dependency) - try { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); - } catch { /* Node < 16 fallback: busy-wait */ } - } - } - } - return false; -} - -/** - * Release a mkdir-based lock. - * - * @param {string} lockPath - */ -function releaseLock(lockPath) { - try { fs.rmdirSync(lockPath); } catch { /* ignore */ } -} - -/** - * Create or update an entry in index.json with the mkdir-based lock protocol. - * - * @param {string} worktreePath - * @param {{ - * slug: string, - * name: string, - * description?: string, - * directories: string[], - * referencedFiles: string[], - * createdBy?: string - * }} entry - * @param {number} [lockTimeoutMs=30000] optional lock timeout for testability - */ -function updateIndex(worktreePath, entry, lockTimeoutMs = 30000) { - validateSlug(entry.slug); - const featuresDir = getFeaturesDir(worktreePath); - fs.mkdirSync(featuresDir, { recursive: true }); - const lockPath = getFeaturesLockDir(worktreePath); - const indexPath = getFeaturesIndexPath(worktreePath); - - if (!acquireLock(lockPath, lockTimeoutMs)) { - throw new Error('Failed to acquire .devflow/features/.knowledge.lock within timeout'); - } - - try { - let index = { version: 1, features: {} }; - try { - index = JSON.parse(fs.readFileSync(indexPath, 'utf8')); - } catch { /* start fresh */ } - - const existing = index.features[entry.slug] || {}; - index.features[entry.slug] = { - name: entry.name, - description: entry.description ?? existing.description ?? '', - directories: entry.directories, - referencedFiles: entry.referencedFiles, - lastUpdated: new Date().toISOString(), - createdBy: entry.createdBy || existing.createdBy || 'manual', - }; - - fs.writeFileSync(indexPath, JSON.stringify(index, null, 2) + '\n'); - } finally { - releaseLock(lockPath); - } -} - -/** - * Find knowledge entries whose referencedFiles overlap with the given changed file list. - * Uses directory-boundary matching to avoid false positives (e.g., `src/foo` - * matching `src/foobar`). Returns the list of slugs with overlapping files. - * - * @param {string} worktreePath - * @param {string[]} changedFiles - * @returns {string[]} slugs that have overlapping referenced files - */ -function findOverlapping(worktreePath, changedFiles) { - const index = loadIndex(worktreePath); - if (!index) return []; - - const overlappingSlugs = []; - for (const [slug, entry] of Object.entries(index.features)) { - const refs = entry.referencedFiles || []; - const overlap = refs.some(ref => - changedFiles.some(f => f === ref || f.startsWith(ref + '/') || ref.startsWith(f + '/')) - ); - if (overlap) overlappingSlugs.push(slug); - } - return overlappingSlugs; -} - -/** - * Remove a knowledge entry from index.json and delete its directory. - * No-op if the slug does not exist in the index or if .devflow/features/ is absent. - * - * @param {string} worktreePath - * @param {string} slug - * @param {number} [lockTimeoutMs=30000] optional lock timeout for testability - */ -function removeEntry(worktreePath, slug, lockTimeoutMs = 30000) { - validateSlug(slug); - const featuresDir = getFeaturesDir(worktreePath); - if (!fs.existsSync(featuresDir)) return; - const lockPath = getFeaturesLockDir(worktreePath); - const indexPath = getFeaturesIndexPath(worktreePath); - - if (!acquireLock(lockPath, lockTimeoutMs)) { - throw new Error('Failed to acquire .devflow/features/.knowledge.lock within timeout'); - } - - try { - let index; - try { - index = JSON.parse(fs.readFileSync(indexPath, 'utf8')); - } catch { - return; // nothing to remove — preserve existing (possibly corrupt) file - } - - delete index.features[slug]; - fs.writeFileSync(indexPath, JSON.stringify(index, null, 2) + '\n'); - - const kbDir = path.join(featuresDir, slug); - try { - fs.rmSync(kbDir, { recursive: true, force: true }); - } catch { /* ignore */ } - } finally { - releaseLock(lockPath); - } -} - -/** - * List all knowledge entries with their metadata (slug + FeatureEntry fields). - * - * @param {string} worktreePath - * @param {{ version: number; features: Record } | null} [cachedIndex] - Optional pre-loaded index to avoid double reads - * @returns {Array<{ slug: string } & FeatureEntry>} - */ -function listEntries(worktreePath, cachedIndex) { - const index = cachedIndex !== undefined ? cachedIndex : loadIndex(worktreePath); - if (!index) return []; - return Object.entries(index.features).map(([slug, entry]) => ({ slug, ...entry })); -} - -// --------------------------------------------------------------------------- -// CLI interface -// -// Usage: -// node feature-knowledge.cjs list -// node feature-knowledge.cjs stale [slug] -// node feature-knowledge.cjs update-index --slug=X --name=Y --directories='[...]' --referencedFiles='[...]' [--description=Y] [--createdBy=Z] -// node feature-knowledge.cjs find-overlapping [file2...] -// node feature-knowledge.cjs remove -// node feature-knowledge.cjs stale-slugs -// node feature-knowledge.cjs refresh-context -// --------------------------------------------------------------------------- - -if (require.main === module) { - const argv = process.argv.slice(2); - const subcommand = argv[0]; - - /** - * Parse --key=value style arguments from an argv array. - * @param {string[]} args - * @returns {Record} - */ - function parseKeyValue(args) { - const result = {}; - for (const arg of args) { - const m = arg.match(/^--([^=]+)=(.*)$/s); - if (m) result[m[1]] = m[2]; - } - return result; - } - - const USAGE = [ - 'Usage:', - ' node feature-knowledge.cjs list ', - ' node feature-knowledge.cjs stale [slug]', - ' node feature-knowledge.cjs update-index --slug=X --name=Y --directories=\'[...]\' --referencedFiles=\'[...]\' [--description=Y] [--createdBy=Z]', - ' node feature-knowledge.cjs find-overlapping [file2...]', - ' node feature-knowledge.cjs remove ', - ' node feature-knowledge.cjs stale-slugs ', - ' node feature-knowledge.cjs refresh-context ', - ].join('\n'); - - /** - * Resolve and validate a worktree path argument. - * Exits with an error message if missing or not a valid directory. - * @param {string[]} cliArgv - * @returns {string} - */ - function requireWorktree(cliArgv) { - const p = cliArgv[1] ? path.resolve(cliArgv[1]) : null; - if (!p) { - process.stderr.write('Error: missing worktree argument\n' + USAGE + '\n'); - process.exit(1); - } - if (!fs.existsSync(p) || !fs.statSync(p).isDirectory()) { - process.stderr.write(`Error: '${p}' is not a valid directory\n`); - process.exit(1); - } - return p; - } - - /** @type {Record void>} */ - const dispatch = { - list() { - const worktreePath = requireWorktree(argv); - const entries = listEntries(worktreePath); - process.stderr.write(`[feature-knowledge] mode=list worktree=${worktreePath} count=${entries.length}\n`); - process.stdout.write(JSON.stringify(entries, null, 2) + '\n'); - process.exit(0); - }, - - stale() { - const worktreePath = requireWorktree(argv); - const slug = argv[2]; - if (slug) { - const result = checkStaleness(worktreePath, slug); - process.stderr.write(`[feature-knowledge] mode=stale worktree=${worktreePath} slug=${slug} stale=${result.stale}\n`); - process.stdout.write(JSON.stringify(result, null, 2) + '\n'); - } else { - const result = checkAllStaleness(worktreePath); - process.stderr.write(`[feature-knowledge] mode=stale worktree=${worktreePath} all=true\n`); - process.stdout.write(JSON.stringify(result, null, 2) + '\n'); - } - process.exit(0); - }, - - 'update-index'() { - const worktreePath = requireWorktree(argv); - const kv = parseKeyValue(argv.slice(2)); - if (!kv.slug || !kv.name || !kv.directories || !kv.referencedFiles) { - process.stderr.write('Error: missing required flags (slug, name, directories, referencedFiles)\n' + USAGE + '\n'); - process.exit(1); - } - let directories; - let referencedFiles; - try { - directories = JSON.parse(kv.directories); - referencedFiles = JSON.parse(kv.referencedFiles); - } catch (e) { - process.stderr.write(`Error: --directories and --referencedFiles must be valid JSON arrays: ${e.message}\n`); - process.exit(1); - } - updateIndex(worktreePath, { - slug: kv.slug, - name: kv.name, - description: kv.description, - directories, - referencedFiles, - createdBy: kv.createdBy, - }); - process.stderr.write(`[feature-knowledge] mode=update-index worktree=${worktreePath} slug=${kv.slug}\n`); - process.stdout.write(JSON.stringify({ ok: true, slug: kv.slug }) + '\n'); - process.exit(0); - }, - - 'find-overlapping'() { - const worktreePath = requireWorktree(argv); - const changedFiles = argv.slice(2); - const overlapping = findOverlapping(worktreePath, changedFiles); - process.stderr.write(`[feature-knowledge] mode=find-overlapping worktree=${worktreePath} overlappingCount=${overlapping.length}\n`); - process.stdout.write(JSON.stringify(overlapping, null, 2) + '\n'); - process.exit(0); - }, - - remove() { - const worktreePath = requireWorktree(argv); - const slug = argv[2]; - if (!slug) { - process.stderr.write('Error: missing slug argument\n' + USAGE + '\n'); - process.exit(1); - } - removeEntry(worktreePath, slug); - process.stderr.write(`[feature-knowledge] mode=remove worktree=${worktreePath} slug=${slug}\n`); - process.stdout.write(JSON.stringify({ ok: true, slug }) + '\n'); - process.exit(0); - }, - - 'stale-slugs'() { - const worktreePath = requireWorktree(argv); - const staleness = checkAllStaleness(worktreePath); - for (const [slug, info] of Object.entries(staleness)) { - if (info.stale) { - process.stdout.write(slug + '\n'); - } - } - process.exit(0); - }, - - 'refresh-context'() { - const worktreePath = requireWorktree(argv); - const slug = argv[2]; - if (!slug) { - process.stderr.write('Error: missing slug argument\n' + USAGE + '\n'); - process.exit(1); - } - validateSlug(slug); - const index = loadIndex(worktreePath); - if (!index || !index.features[slug]) { - process.stderr.write(`Error: knowledge entry '${slug}' not found in index\n`); - process.exit(1); - } - const entry = index.features[slug]; - const staleness = checkStaleness(worktreePath, slug); - // Tab-separated: name, directories JSON, changed files JSON - process.stdout.write([ - entry.name, - JSON.stringify(entry.directories), - JSON.stringify(staleness.changedFiles), - ].join('\t') + '\n'); - process.exit(0); - }, - }; - - if (!subcommand) { - process.stderr.write(USAGE + '\n'); - process.exit(1); - } - - const handler = dispatch[subcommand]; - if (!handler) { - process.stderr.write(`Error: unknown subcommand '${subcommand}'\n` + USAGE + '\n'); - process.exit(1); - } - handler(); -} - -module.exports = { loadIndex, loadKnowledgeContent, checkStaleness, checkAllStaleness, updateIndex, findOverlapping, removeEntry, listEntries, validateSlug }; -// Note: loadIndex is already exported above, enabling callers to read the index once -// and pass it to listEntries/checkAllStaleness via their optional cachedIndex parameter. diff --git a/scripts/hooks/lib/project-paths.cjs b/scripts/hooks/lib/project-paths.cjs index cc230dae..86677dc6 100644 --- a/scripts/hooks/lib/project-paths.cjs +++ b/scripts/hooks/lib/project-paths.cjs @@ -167,31 +167,11 @@ function getPendingTurnsLockDir(projectRoot) { // Features / knowledge files // --------------------------------------------------------------------------- -/** .devflow/features/index.json */ -function getFeaturesIndexPath(projectRoot) { - return path.join(projectRoot, '.devflow', 'features', 'index.json'); -} - /** .devflow/features/{slug}/KNOWLEDGE.md */ function getKnowledgePath(projectRoot, slug) { return path.join(projectRoot, '.devflow', 'features', slug, 'KNOWLEDGE.md'); } -/** .devflow/features/.disabled — sentinel that gates knowledge phase/refresh */ -function getFeaturesDisabledSentinel(projectRoot) { - return path.join(projectRoot, '.devflow', 'features', '.disabled'); -} - -/** .devflow/features/.knowledge.lock — transient lock directory for concurrent index writes */ -function getFeaturesLockDir(projectRoot) { - return path.join(projectRoot, '.devflow', 'features', '.knowledge.lock'); -} - -/** .devflow/features/.knowledge-last-refresh — timestamp of last auto-refresh */ -function getFeaturesLastRefreshPath(projectRoot) { - return path.join(projectRoot, '.devflow', 'features', '.knowledge-last-refresh'); -} - // --------------------------------------------------------------------------- // Docs files // --------------------------------------------------------------------------- @@ -266,11 +246,7 @@ module.exports = { getPendingTurnsProcessingPath, getPendingTurnsLockDir, // Features files - getFeaturesIndexPath, getKnowledgePath, - getFeaturesDisabledSentinel, - getFeaturesLockDir, - getFeaturesLastRefreshPath, // Docs files getReviewsDir, getDesignDir, diff --git a/scripts/hooks/session-start-context b/scripts/hooks/session-start-context index 55ae1af0..70207559 100755 --- a/scripts/hooks/session-start-context +++ b/scripts/hooks/session-start-context @@ -129,22 +129,20 @@ fi if [ "$_SC2_HELPERS_OK" = "true" ]; then # Determine feature enabled-state. - # Primary: dream/config.json fields; secondary: runtime sentinels (defense-in-depth). + # Primary: dream/config.json fields; secondary: runtime sentinel for decisions. # Note: memory is NOT a Dream task (handled by background-memory-update worker) — # mem_enabled was removed from dream_collect_tasks (applies ADR-016; avoids PF-009). + # Note: knowledge is no longer a Dream task — write-through handles it in-command. _SC2_DEC_EN="true" - _SC2_KNOW_EN="true" _SC2_DREAM_CONFIG="$DREAM_DIR/config.json" if [ -f "$_SC2_DREAM_CONFIG" ]; then - _SC2_DEC_EN=$(json_field_file "$_SC2_DREAM_CONFIG" "decisions" "true") - _SC2_KNOW_EN=$(json_field_file "$_SC2_DREAM_CONFIG" "knowledge" "true") + _SC2_DEC_EN=$(json_field_file "$_SC2_DREAM_CONFIG" "decisions" "true") fi - # Sentinel overrides - [ -f "$DEVFLOW_DIR/decisions/.disabled" ] && _SC2_DEC_EN="false" - [ -f "$DEVFLOW_DIR/features/.disabled" ] && _SC2_KNOW_EN="false" + # Sentinel override for decisions + [ -f "$DEVFLOW_DIR/decisions/.disabled" ] && _SC2_DEC_EN="false" - dbg "Section2: dec=$_SC2_DEC_EN know=$_SC2_KNOW_EN" + dbg "Section2: dec=$_SC2_DEC_EN" # Step 1: recover stale .processing markers so they become collectable in step 2 if [ -d "$DREAM_DIR" ]; then @@ -152,7 +150,7 @@ if [ "$_SC2_HELPERS_OK" = "true" ]; then fi # Step 2: collect pending marker task types - dream_collect_tasks "$DREAM_DIR" "$_SC2_DEC_EN" "$_SC2_KNOW_EN" || true + dream_collect_tasks "$DREAM_DIR" "$_SC2_DEC_EN" || true # Emit directive if tasks pending, subject to processor-spawn throttle. if [ -n "$_DREAM_TASKS" ]; then diff --git a/shared/agents/dream.md b/shared/agents/dream.md index 3faa6870..4c069d3e 100644 --- a/shared/agents/dream.md +++ b/shared/agents/dream.md @@ -1,6 +1,6 @@ --- name: Dream -description: Background maintenance agent — processes ONE pending task type named in its prompt (decisions, knowledge, curation). Spawned per-task by session-start-context; loads the matching per-task skill via the Skill tool. Memory is NOT a Dream task — it is handled by the background-memory-update worker spawned from dream-capture. +description: Background maintenance agent — processes ONE pending task type named in its prompt (decisions, curation). Spawned per-task by session-start-context; loads the matching per-task skill via the Skill tool. Memory is NOT a Dream task — it is handled by the background-memory-update worker spawned from dream-capture. Knowledge is NOT a Dream task — it is handled in-command via write-through. model: sonnet tools: - Read @@ -13,7 +13,6 @@ skills: - devflow:apply-decisions - devflow:apply-feature-knowledge - devflow:dream-decisions - - devflow:dream-knowledge - devflow:dream-curation --- @@ -24,8 +23,8 @@ combined Opus spawn). Your role: claim markers atomically, do real LLM work via per-task skill, write results through plumbing ops, clean up. > **Model note**: The `model: sonnet` frontmatter is the fallback default. In practice -> `session-start-context` overrides the model per spawn: sonnet for `knowledge`, opus for -> the combined `decisions then curation` spawn. When you see "Opus spawn" in this document, +> `session-start-context` overrides the model per spawn: opus for the combined +> `decisions then curation` spawn. When you see "Opus spawn" in this document, > that refers to the model assigned by the orchestrator at spawn time. ## Environment @@ -33,17 +32,18 @@ per-task skill, write results through plumbing ops, clean up. Installed scripts live at `$HOME/.devflow/scripts/hooks/`. All node invocations use these paths: - `node "$HOME/.devflow/scripts/hooks/json-helper.cjs" ...` -- `node "$HOME/.devflow/scripts/hooks/lib/feature-knowledge.cjs" ...` - `node "$HOME/.devflow/scripts/hooks/lib/decisions-index.cjs" ...` Project root is your current working directory (`.`). All `.devflow/` paths are relative to it. ## Step 0 — Identify your task -Your prompt names the task type(s) to process: `decisions`, `knowledge`, `curation`, +Your prompt names the task type(s) to process: `decisions`, `curation`, or `decisions then curation` (the combined Opus spawn). Process ONLY the task(s) named. **Memory is NOT a Dream task** — the background-memory-update worker (spawned by dream-capture) handles WORKING-MEMORY.md writes. If your prompt mentions `memory`, skip it. +**Knowledge is NOT a Dream task** — write-through (knowledge_writeback MDS partial, invoked +in-command) handles knowledge base updates. If your prompt mentions `knowledge`, skip it. ## Step 1 — Claim markers atomically @@ -71,7 +71,6 @@ the plumbing ops in the per-task skills — they hold locks internally for one a **Multi-marker merge**: When multiple `{type}.{session}.processing` files exist for one type, read them all, then union/concat their payloads before processing: - `decisions`: concatenate `dialogPairs` strings; union `existingObservationIds` arrays -- `knowledge`: union `staleSlugs` arrays; use any `worktreePath` - `curation`: single marker only **Input cap**: Process only the last **30** dialog-pairs (truncate oldest if more). @@ -82,7 +81,6 @@ This bounds token cost and keeps each run predictable. For each task type you claimed markers for, load the matching skill and follow its procedure: - **decisions** → load `devflow:dream-decisions` via the Skill tool and follow its procedure exactly. -- **knowledge** → load `devflow:dream-knowledge` via the Skill tool and follow its procedure exactly. - **curation** → load `devflow:dream-curation` via the Skill tool and follow its procedure exactly. For the combined "decisions then curation" spawn: run decisions fully (claim + process + cleanup) diff --git a/shared/agents/knowledge.md b/shared/agents/knowledge.md index fc143f90..0bcdfd18 100644 --- a/shared/agents/knowledge.md +++ b/shared/agents/knowledge.md @@ -1,6 +1,6 @@ --- name: Knowledge -description: Structures codebase exploration into a feature knowledge base +description: Structures codebase exploration into a feature knowledge base and registers it in the index cache model: sonnet skills: - devflow:feature-knowledge @@ -21,32 +21,46 @@ tools: - **FEATURE_SLUG** (required): Kebab-case identifier for the feature area (e.g., `cli-commands`) - **FEATURE_NAME** (required): Human-readable name (e.g., "CLI Command System") - **DIRECTORIES** (required): Directory prefixes defining the feature area scope -- **EXPLORATION_OUTPUTS** (optional): Pre-computed findings from Skimmer + Explore agents. When provided (e.g., from /plan), synthesize these instead of exploring from scratch. When absent, perform your own exploration in Phase 1 (Scan) and Phase 2 (Extract) using the tools available. +- **FILES_CHANGED** (optional): Files changed in the workflow session that triggered write-back - **DECISIONS_CONTEXT** (optional): Compact ADR/PF index for cross-referencing in the Related section. When `(none)`, skip citing decisions in the Related section. -- **EXISTING_KB** (optional): Current KNOWLEDGE.md content when refreshing stale feature knowledge -- **CHANGED_FILES** (optional): Files that changed since last feature knowledge update (for refresh) +- **EXISTING_KB** (optional): Current KNOWLEDGE.md content when refreshing existing feature knowledge - **WORKTREE_PATH** (optional): Worktree root for path resolution +- **EXPLORATION_OUTPUTS** (optional): Pre-computed findings from Skimmer + Explore agents. When provided, synthesize these instead of exploring from scratch. When absent, perform your own exploration in Phase 1 (Scan) and Phase 2 (Extract). ## Responsibilities -1. **Resolve worktree path**: Use `devflow:worktree-support` to determine the working directory -2. **Orient on feature area**: Read EXPLORATION_OUTPUTS to understand the feature's architecture, patterns, and boundaries +1. **Resolve worktree path**: Use `devflow:worktree-support` to determine the working directory (WORKTREE_PATH or cwd) +2. **Orient on feature area**: Read EXPLORATION_OUTPUTS or EXISTING_KB to understand the feature's architecture, patterns, and boundaries 3. **Follow the feature-knowledge skill**: Execute the 4-phase process (Scan → Extract → Distill → Forge) from `devflow:feature-knowledge` 4. **Cross-reference decisions**: If DECISIONS_CONTEXT is provided, reference relevant ADR/PF entries in the feature knowledge's "Related" section -5. **Handle refresh**: If EXISTING_KB is provided, update stale sections based on CHANGED_FILES while preserving any manually added content (user edits). Don't regenerate from scratch. -6. **Write KNOWLEDGE.md**: Write to `.devflow/features/{FEATURE_SLUG}/KNOWLEDGE.md` (create directory if needed) -7. **Write result file**: Write result JSON file (`.create-result.json` or `.refresh-result.json`) with `referencedFiles` and `description` so the host process can update the index -8. **Report**: Output what was created/updated +5. **Handle refresh**: If EXISTING_KB is provided, update stale sections based on FILES_CHANGED while preserving any manually added content. Don't regenerate from scratch. +6. **Write KNOWLEDGE.md directly**: Write to `{worktree}/.devflow/features/{FEATURE_SLUG}/KNOWLEDGE.md` (create directory if needed) +7. **Update index.md directly**: Read-modify-write `{worktree}/.devflow/features/index.md` + - If slug already present: replace that line in-place + - If absent or file missing: append (or create file) + - Line format: `- **{slug}** — {areas} — {Use-when description}` +8. **Report**: Output KB_PATH and KB_SLUG (see Output section) + +## Direct Write Protocol + +Write BOTH files atomically — no intermediate result files, no external scripts: + +1. Ensure `{worktree}/.devflow/features/{slug}/` directory exists +2. Write `KNOWLEDGE.md` to that directory +3. Read `{worktree}/.devflow/features/index.md` (tolerate ENOENT) +4. Replace the `- **{slug}**` line if found; else append the new line +5. Write `index.md` back + +The frontmatter in KNOWLEDGE.md is always the authority. The index.md line is a discoverable cache. ## Output ``` KB_STATUS: created | refreshed -KB_PATH: .devflow/features/{slug}/KNOWLEDGE.md +KB_PATH: {worktree}/.devflow/features/{slug}/KNOWLEDGE.md KB_SLUG: {slug} KB_NAME: {name} SECTIONS: [list of sections written] -REFERENCED_FILES: [files selected for staleness tracking] CROSS_REFERENCES: [ADR/PF entries referenced, if any] ``` @@ -54,5 +68,6 @@ CROSS_REFERENCES: [ADR/PF entries referenced, if any] - **Only writes to `.devflow/features/` directory** — never modify source code - **Never delete existing feature knowledge** — only create new or refresh existing -- **500-line cap** — if the knowledge base exceeds 500 lines, split into focused sub-knowledge bases (each gets own index entry) +- **500-line cap** — if the knowledge base exceeds 500 lines, split into focused sub-knowledge bases (each gets its own index entry) - **No push, no external API calls** — local filesystem operations only +- **No `.create-result.json` or `.refresh-result.json`** — write directly; the host workflow reads KB_PATH/KB_SLUG from this agent's output text diff --git a/shared/knowledge/_knowledge.mds b/shared/knowledge/_knowledge.mds new file mode 100644 index 00000000..cb3a24a3 --- /dev/null +++ b/shared/knowledge/_knowledge.mds @@ -0,0 +1,93 @@ +@define knowledge_load(): +### Load Feature Knowledge + +Resolve the worktree root using the `devflow:worktree-support` algorithm (use WORKTREE_PATH if provided, otherwise cwd). All paths below are relative to `\{worktree\}`. + +**Step 1 — Read the index cache:** + +Attempt to read `\{worktree\}/.devflow/features/index.md`. Each line follows the format: + +``` +- **{slug}** — {areas} — {Use-when description} +``` + +If `index.md` exists and contains at least one entry line, use it for relevance matching. + +**Step 2 — Fallback: glob frontmatter (if `index.md` is absent or empty):** + +Glob `\{worktree\}/.devflow/features/*/KNOWLEDGE.md`. For each file found, read only its YAML frontmatter block (between the opening and closing `---` delimiters). The frontmatter fields `name`, `description`, and `directories` are the authoritative relevance surface — `index.md` is only a cache. + +**Step 3 — Pick relevant KBs:** + +Match the current task area and description against each index line (or frontmatter `description` + `directories` on fallback). Select entries whose documented area overlaps the current task. This is a relevance judgment — prefer specificity over breadth. + +**Step 4 — Read selected KBs:** + +For each selected entry, read `\{worktree\}/.devflow/features/\{slug\}/KNOWLEDGE.md` in full. When the KB content contradicts the current code you observe, **trust the code** — the code is the freshness mechanism; the KB may lag behind. + +**Step 5 — Set FEATURE_KNOWLEDGE:** + +Concatenate the selected KNOWLEDGE.md files under slug headers: + +``` +--- Feature knowledge: {slug} --- +{full KNOWLEDGE.md content} +``` + +If no KBs exist, no KBs are relevant, or `.devflow/features/` is absent, set `FEATURE_KNOWLEDGE` to `(none)`. + +**No subprocess, no git calls, no `.cjs` script.** This entire step is direct file reads — 1 index read (or N frontmatter reads on fallback), bounded by KB count. +@end + +@export knowledge_load + +@define knowledge_writeback(): +### Feature Knowledge Write-Back (Conditional) + +Resolve the worktree root using the `devflow:worktree-support` algorithm (use WORKTREE_PATH if provided, otherwise cwd). All paths below are relative to `\{worktree\}`. + +**Step 1 — Check the opt-out gate:** + +Read `\{worktree\}/.devflow/dream/config.json`. If the `knowledge` field is `false`, skip write-back entirely — the user has disabled it. + +If `.devflow/dream/config.json` does not exist, proceed (default is enabled). + +**Step 2 — Evaluate whether write-back is warranted:** + +Only proceed if **at least one** of these is true: +- This workflow changed files in a directory that is documented by an existing feature knowledge base (a documented area changed). +- This workflow surfaced durable, cross-cutting knowledge about a codebase area that would help future agents working in the same area — patterns, anti-patterns, integration points, gotchas not visible from a single file read. + +**Never spawn unconditionally.** If neither condition is met, skip write-back silently. + +**Step 3 — Spawn the Knowledge agent:** + +Spawn `Agent(subagent_type="Knowledge")` with the following context: + +``` +"WORKTREE_PATH: {worktree root} +FEATURE_SLUG: {slug derived from primary changed directory, kebab-case} +FEATURE_NAME: {human-readable name} +DIRECTORIES: {list of primary directories touched by this workflow} +FILES_CHANGED: {list of files changed} +DECISIONS_CONTEXT: {DECISIONS_CONTEXT if available, else (none)} + +Load the devflow:feature-knowledge skill and follow its authoring process. + +Write the knowledge base to: + {worktree}/.devflow/features/{slug}/KNOWLEDGE.md + +Then update the index cache by performing a read-modify-write on: + {worktree}/.devflow/features/index.md + +Index line format: `- **{slug}** — {areas} — {Use-when description}` + +If the line for this slug already exists in index.md, replace it. If it does not exist, append it. If index.md does not exist, create it with just this line. + +The frontmatter in KNOWLEDGE.md is the source of truth — index.md is only a cache. Write the two files directly — no intermediate result JSON files, no external scripts." +``` + +**Failure handling**: Non-blocking. If the Knowledge agent fails, log the failure and continue — the workflow outcome is not affected by write-back success. +@end + +@export knowledge_writeback diff --git a/plugins/devflow-bug-analysis/commands/bug-analysis.md b/shared/knowledge/bug-analysis.mds similarity index 92% rename from plugins/devflow-bug-analysis/commands/bug-analysis.md rename to shared/knowledge/bug-analysis.mds index 1dbef2e1..bf32517a 100644 --- a/plugins/devflow-bug-analysis/commands/bug-analysis.md +++ b/shared/knowledge/bug-analysis.mds @@ -1,6 +1,7 @@ --- description: Proactive bug finding with static and semantic analysis — hunts real bugs in changed code before merge --- +@import { knowledge_load } from "./_knowledge.mds" # Bug Analysis Command @@ -46,16 +47,16 @@ If `pr_number` is absent or the command fails, set `PR_DESCRIPTION` to `(none)`. **Produces:** DIFF_RANGE, ANALYSIS_DIR **Requires:** BRANCH_INFO -1. Check `.devflow/docs/bug-analysis/{branch-slug}/.last-analysis-head`: +1. Check `.devflow/docs/bug-analysis/\{branch-slug\}/.last-analysis-head`: - **If exists AND `--full` NOT set:** - Read the SHA from the file - - Verify reachable: `git cat-file -t {sha}` — if exit code non-zero (rebase invalidated SHA), fall through to full + - Verify reachable: `git cat-file -t \{sha\}` — if exit code non-zero (rebase invalidated SHA), fall through to full - If SHA == current HEAD → "No new commits since last analysis. Use --full for a full re-analysis." Stop. - - Set `DIFF_RANGE` to `{sha}...HEAD` + - Set `DIFF_RANGE` to `\{sha\}...HEAD` - **If not exists, unreachable SHA, or `--full`:** - - Set `DIFF_RANGE` to `{base_branch}...HEAD` + - Set `DIFF_RANGE` to `\{base_branch\}...HEAD` 2. Generate timestamp: `YYYY-MM-DD_HHMM`. If directory already exists (same-minute collision), append seconds (`YYYY-MM-DD_HHMMSS`). -3. Create timestamped analysis directory: `mkdir -p .devflow/docs/bug-analysis/{branch-slug}/{timestamp}/` +3. Create timestamped analysis directory: `mkdir -p .devflow/docs/bug-analysis/\{branch-slug\}/\{timestamp\}/` 4. Set `ANALYSIS_DIR` to that path. #### Step 2b: Check Changed Files @@ -140,9 +141,9 @@ Parse `CODEQL_SARIF` → extract findings. If database creation fails, `CODEQL_E | Tool | File:Line | CWE | Severity | Title | Description | |------|-----------|-----|----------|-------|-------------| -| {tool} | {file}:{line} | {CWE or —} | {CRITICAL/HIGH/MEDIUM/LOW} | {title} | {description truncated to 200 chars} | +| \{tool\} | \{file\}:\{line\} | \{CWE or —\} | \{CRITICAL/HIGH/MEDIUM/LOW\} | \{title\} | \{description truncated to 200 chars\} | -Write ALL raw findings to `{ANALYSIS_DIR}/static-findings.md`. Set `STATIC_FINDINGS` to the top-50 table. +Write ALL raw findings to `\{ANALYSIS_DIR\}/static-findings.md`. Set `STATIC_FINDINGS` to the top-50 table. If no tool produced findings: set `STATIC_FINDINGS` to `(none)`. @@ -158,10 +159,7 @@ DECISIONS_CONTEXT=$(node ~/.devflow/scripts/hooks/lib/decisions-index.cjs index #### Feature Knowledge -1. Read `.devflow/features/index.json` if it exists -2. Match changed files from Phase 2 against feature knowledge entries (`directories` and `referencedFiles`) -3. For each match: check staleness via `node ~/.devflow/scripts/hooks/lib/feature-knowledge.cjs stale "." {slug} 2>/dev/null`, read `.devflow/features/{slug}/KNOWLEDGE.md` -4. Concatenate as `FEATURE_KNOWLEDGE` (or `(none)`) +{knowledge_load()} #### Plan Artifact @@ -235,7 +233,7 @@ Output: {ANALYSIS_DIR}/bug-analysis-summary.md" **Requires:** BRANCH_INFO, ANALYSIS_DIR -1. Write current HEAD SHA to `.devflow/docs/bug-analysis/{branch-slug}/.last-analysis-head` +1. Write current HEAD SHA to `.devflow/docs/bug-analysis/\{branch-slug\}/.last-analysis-head` 2. Report to user: ``` @@ -285,7 +283,7 @@ Run `/resolve` to process and fix these findings. │ ├─ Phase 3: Context Loading │ ├─ decisions-index.cjs → DECISIONS_CONTEXT -│ ├─ feature-knowledge.cjs → FEATURE_KNOWLEDGE +│ ├─ Feature knowledge load → FEATURE_KNOWLEDGE │ └─ .devflow/docs/design/*.md → PLAN_CONTEXT + ACCEPTANCE_RULES │ ├─ Phase 4: File Analysis @@ -344,7 +342,7 @@ Before reporting results, verify every phase was executed: - [ ] Phase 3: Context Loading → DECISIONS_CONTEXT captured, FEATURE_KNOWLEDGE loaded (or `(none)`), PLAN_CONTEXT and ACCEPTANCE_RULES captured (or `(none)`) - [ ] Phase 4: File Analysis → ACTIVE_FOCUSES determined (security + functional always present) - [ ] Phase 5: Parallel Bug Analysis → ANALYZER_OUTPUTS captured per active focus; all reports written to ANALYSIS_DIR -- [ ] Phase 6: Synthesis → BUG_ANALYSIS_SUMMARY written to `{ANALYSIS_DIR}/bug-analysis-summary.md` +- [ ] Phase 6: Synthesis → BUG_ANALYSIS_SUMMARY written to `\{ANALYSIS_DIR\}/bug-analysis-summary.md` - [ ] Phase 7: Finalize → `.last-analysis-head` updated; results displayed to user If any phase is unchecked, execute it before proceeding. diff --git a/plugins/devflow-code-review/commands/code-review.md b/shared/knowledge/code-review.mds similarity index 86% rename from plugins/devflow-code-review/commands/code-review.md rename to shared/knowledge/code-review.mds index 64faa46a..60f28f98 100644 --- a/plugins/devflow-code-review/commands/code-review.md +++ b/shared/knowledge/code-review.mds @@ -1,6 +1,7 @@ --- description: Comprehensive branch review using specialized sub-agents for PR readiness --- +@import { knowledge_load } from "./_knowledge.mds" # Code Review Command @@ -29,7 +30,7 @@ Run a comprehensive code review of the current branch by spawning parallel revie 2. **If `--path` flag provided:** use only that worktree, skip discovery **`--path` validation**: Before proceeding, verify the path exists as a directory and appears in `git worktree list` output. If not: report error and stop. 3. **If only 1 reviewable worktree** (the common case): proceed as single-worktree flow — zero behavior change -4. **If multiple reviewable worktrees:** report "Found N worktrees with reviewable branches: {list with paths and branches}" and proceed with multi-worktree flow +4. **If multiple reviewable worktrees:** report "Found N worktrees with reviewable branches: \{list with paths and branches\}" and proceed with multi-worktree flow #### Step 0b: Per-Worktree Pre-Flight (Git Agent) @@ -37,7 +38,7 @@ Run a comprehensive code review of the current branch by spawning parallel revie **Requires:** WORKTREES Discover PR description guidance from plan artifact (per worktree): -1. List `{worktree}/.devflow/docs/design/*.md` files +1. List `\{worktree\}/.devflow/docs/design/*.md` files 2. Sort by timestamp in filename (descending -- timestamps are YYYY-MM-DD_HHMM, naturally sortable) 3. Read the most recent file, extract `## PR Description Guidance` section 4. If no plan files exist or section not found, set `PR_DESCRIPTION_GUIDANCE` to `(none)` @@ -73,15 +74,15 @@ If `pr_number` is absent or the command fails, set `PR_DESCRIPTION` to `(none)`. For each worktree: 1. Generate timestamp: `YYYY-MM-DD_HHMM`. If directory already exists (same-minute collision), append seconds (`YYYY-MM-DD_HHMMSS`). -2. Create timestamped review directory: `mkdir -p {worktree}/.devflow/docs/reviews/{branch-slug}/{timestamp}/` -3. Check if `{worktree}/.devflow/docs/reviews/{branch-slug}/.last-review-head` exists: +2. Create timestamped review directory: `mkdir -p \{worktree\}/.devflow/docs/reviews/\{branch-slug\}/\{timestamp\}/` +3. Check if `\{worktree\}/.devflow/docs/reviews/\{branch-slug\}/.last-review-head` exists: - **If yes AND `--full` NOT set:** - Read the SHA from the file - - Verify reachable: `git -C {worktree} cat-file -t {sha}` (handles rebases — if unreachable, fallback to full) + - Verify reachable: `git -C \{worktree\} cat-file -t \{sha\}` (handles rebases — if unreachable, fallback to full) - Check if SHA == current HEAD → if so, skip review: "No new commits since last review. Use --full for a full re-review." - - Set `DIFF_RANGE` to `{last-review-sha}...HEAD` + - Set `DIFF_RANGE` to `\{last-review-sha\}...HEAD` - **If no (first review), or `--full`:** - - Set `DIFF_RANGE` to `{base_branch}...HEAD` + - Set `DIFF_RANGE` to `\{base_branch\}...HEAD` #### Step 0d-i: Load Prior Resolution and Count Cycles @@ -89,10 +90,10 @@ For each worktree: **Requires:** BRANCH_INFO For each worktree, perform a single pass over timestamped directories: -1. List timestamped directories in `{worktree}/.devflow/docs/reviews/{branch-slug}/` sorted descending: `ls -1d {worktree}/.devflow/docs/reviews/{branch-slug}/20* 2>/dev/null | sort -r` +1. List timestamped directories in `\{worktree\}/.devflow/docs/reviews/\{branch-slug\}/` sorted descending: `ls -1d \{worktree\}/.devflow/docs/reviews/\{branch-slug\}/20* 2>/dev/null | sort -r` 2. Iterate once: accumulate CYCLE_NUMBER count for each directory containing `resolution-summary.md`; capture the first (most-recent) such directory as PRIOR_DIR. 3. If CYCLE_NUMBER = 0: set PRIOR_RESOLUTIONS=(none), CYCLE_NUMBER=1, proceed. -4. Otherwise: set CYCLE_NUMBER = count + 1. Read `{PRIOR_DIR}/resolution-summary.md` as PRIOR_RESOLUTIONS. +4. Otherwise: set CYCLE_NUMBER = count + 1. Read `\{PRIOR_DIR\}/resolution-summary.md` as PRIOR_RESOLUTIONS. 5. If `--full`: still load PRIOR_RESOLUTIONS (valuable for reviewer cross-cycle awareness). #### Step 0d-ii: Convergence Assessment @@ -103,7 +104,7 @@ For each worktree, perform a single pass over timestamped directories: MAX_REVIEW_CYCLES = 10 1. If CYCLE_NUMBER > MAX_REVIEW_CYCLES: - Warn in output: "⚠️ Review pipeline has run {CYCLE_NUMBER-1} cycles (exceeds MAX_REVIEW_CYCLES=10). Consider merging or manual inspection." + Warn in output: "⚠️ Review pipeline has run \{CYCLE_NUMBER-1\} cycles (exceeds MAX_REVIEW_CYCLES=10). Consider merging or manual inspection." Continue with review. 2. Parse Statistics table from PRIOR_RESOLUTIONS: - Extract False Positive, Fixed, Deferred counts @@ -111,7 +112,7 @@ MAX_REVIEW_CYCLES = 10 - If denominator = 0: fp_ratio = 0, skip warning - If parsing fails: fp_ratio = 0, skip warning; note in output: "Warning: Could not parse Statistics table from prior resolution. FP ratio unavailable — convergence tracking degraded." 3. If fp_ratio > 0.7 AND CYCLE_NUMBER >= 3: - Warn in output: "⚠️ Convergence: {ratio}% false positives in cycle {N-1}. Consider merging or manual inspection." + Warn in output: "⚠️ Convergence: \{ratio\}% false positives in cycle \{N-1\}. Consider merging or manual inspection." Continue with review. **Decision table — Step 0d-ii paths:** @@ -143,7 +144,7 @@ Per worktree, detect file types in diff using `DIFF_RANGE` to determine conditio | Dependency files changed | dependencies | | Docs or significant code | documentation | -**Skill availability check**: Language/ecosystem reviews (typescript, react, accessibility, ui-design, go, java, python, rust) require their optional skill plugin to be installed. Before spawning a conditional Reviewer for these focuses, use Read to check if `~/.claude/skills/devflow:{focus}/SKILL.md` exists. If Read returns an error (file not found), **skip that review** — the language plugin isn't installed. Non-language reviews (database, dependencies, documentation) use skills bundled with this plugin and are always available. +**Skill availability check**: Language/ecosystem reviews (typescript, react, accessibility, ui-design, go, java, python, rust) require their optional skill plugin to be installed. Before spawning a conditional Reviewer for these focuses, use Read to check if `~/.claude/skills/devflow:\{focus\}/SKILL.md` exists. If Read returns an error (file not found), **skip that review** — the language plugin isn't installed. Non-language reviews (database, dependencies, documentation) use skills bundled with this plugin and are always available. ### Phase 1b: Load Decisions Index @@ -159,11 +160,7 @@ DECISIONS_CONTEXT=$(node ~/.devflow/scripts/hooks/lib/decisions-index.cjs index This produces a compact index of active ADR/PF entries. Pass `DECISIONS_CONTEXT` to all Reviewer agents. Reviewers use `devflow:apply-decisions` to Read full entry bodies on demand. -**Load Feature Knowledge:** -1. Read `.devflow/features/index.json` if it exists -2. Based on changed files from Phase 1 analysis, identify relevant feature knowledge (match file paths against each feature knowledge entry's `directories` and `referencedFiles`) -3. For each match: check staleness via `node ~/.devflow/scripts/hooks/lib/feature-knowledge.cjs stale "{worktree}" {slug} 2>/dev/null`, read `.devflow/features/{slug}/KNOWLEDGE.md` -4. Set `FEATURE_KNOWLEDGE` (or `(none)` if no feature knowledge exists or none is relevant) +{knowledge_load()} Pass `FEATURE_KNOWLEDGE` to all Reviewer agents alongside `DECISIONS_CONTEXT`. @@ -254,7 +251,7 @@ Output: {worktree_path}/.devflow/docs/reviews/{branch-slug}/{timestamp}/review-s **Requires:** BRANCH_INFO, REVIEW_DIR Per worktree, after successful completion: -1. Write current HEAD SHA to `{worktree_path}/.devflow/docs/reviews/{branch-slug}/.last-review-head` +1. Write current HEAD SHA to `\{worktree_path\}/.devflow/docs/reviews/\{branch-slug\}/.last-review-head` 2. Display results from all agents: - Merge recommendation (from Synthesizer) - Issue counts by category (🔴 blocking / ⚠️ should-fix / ℹ️ pre-existing) @@ -317,7 +314,7 @@ In multi-worktree mode, report results per worktree. ## Backwards Compatibility - **Single worktree**: Auto-discovery finds only one worktree → proceeds exactly as before. Zero behavior change. -- **Legacy flat layout**: If `.devflow/docs/reviews/{branch-slug}/` contains flat `*.md` files (no timestamped subdirectories), new runs create timestamped subdirectories. Old flat files remain untouched. +- **Legacy flat layout**: If `.devflow/docs/reviews/\{branch-slug\}/` contains flat `*.md` files (no timestamped subdirectories), new runs create timestamped subdirectories. Old flat files remain untouched. ## Principles diff --git a/plugins/devflow-debug/commands/debug.md b/shared/knowledge/debug.mds similarity index 89% rename from plugins/devflow-debug/commands/debug.md rename to shared/knowledge/debug.mds index a50eb994..a83bd16d 100644 --- a/plugins/devflow-debug/commands/debug.md +++ b/shared/knowledge/debug.mds @@ -1,6 +1,7 @@ --- description: Debug issues using competing hypothesis investigation with parallel agents --- +@import { knowledge_writeback } from "./_knowledge.mds" # Debug Command @@ -25,7 +26,7 @@ Investigate bugs by spawning parallel agents, each pursuing a different hypothes ### Phase 1: Load Decisions Index (Orchestrator-Local) -**Produces:** DECISIONS_CONTEXT, FEATURE_KNOWLEDGE +**Produces:** DECISIONS_CONTEXT **Load Companion Skills** — Load via Skill tool: `devflow:test-driven-development`, `devflow:software-design`, `devflow:testing`. If a skill fails to load, continue without it. @@ -37,11 +38,7 @@ DECISIONS_CONTEXT=$(node ~/.devflow/scripts/hooks/lib/decisions-index.cjs index The orchestrator uses `DECISIONS_CONTEXT` locally when generating hypotheses (Phase 2) — prior pitfalls and decisions can suggest specific root causes to investigate. Follow `devflow:apply-decisions` to Read full entry bodies on demand. **Do NOT pass `DECISIONS_CONTEXT` to Explore investigators** — decisions context stays in the orchestrator; investigators examine code directly. -**Load Feature Knowledge:** -1. Read `.devflow/features/index.json` if it exists -2. Based on the bug description, identify relevant feature knowledge -3. For each match: check staleness via `node ~/.devflow/scripts/hooks/lib/feature-knowledge.cjs stale "{worktree}" {slug} 2>/dev/null`, read `.devflow/features/{slug}/KNOWLEDGE.md` -4. Use `FEATURE_KNOWLEDGE` **locally only** for hypothesis generation — feature-specific gotchas and anti-patterns suggest root causes. **Do NOT pass to Explore investigators.** +**No up-front feature knowledge load** — debug investigation workers read code directly to avoid confirmation bias. Feature knowledge is only written back at the end if the investigation surfaced durable, cross-cutting knowledge. ### Phase 2: Context Gathering @@ -180,12 +177,15 @@ Ask user via AskUserQuestion: "Want me to implement this fix?" - **YES** → Load `devflow:patterns` and `devflow:test-driven-development` skills, implement the fix, then spawn `Agent(subagent_type="Simplifier")` on changed files. - **NO** → Done. Report stands as documentation. +{knowledge_writeback()} + ## Architecture ``` /debug (orchestrator) │ ├─ Phase 1: Load Decisions Index (Orchestrator-Local) +│ └─ No up-front feature knowledge load (avoids confirmation bias in investigators) │ ├─ Phase 2: Context gathering │ └─ Git agent (fetch issue, if #N provided) @@ -201,8 +201,11 @@ Ask user via AskUserQuestion: "Want me to implement this fix?" │ ├─ Phase 6: Root cause report with confidence level │ -└─ Phase 7: Offer Fix - └─ AskUserQuestion → implement fix + Simplifier, or done +├─ Phase 7: Offer Fix +│ └─ AskUserQuestion → implement fix + Simplifier, or done +│ +└─ Feature Knowledge Write-Back (Conditional) + └─ Knowledge agent (if investigation surfaced durable cross-cutting knowledge) ``` ## Principles @@ -212,6 +215,7 @@ Ask user via AskUserQuestion: "Want me to implement this fix?" 3. **Evidence-based** - Every claim requires file:line references 4. **Honest uncertainty** - If no hypothesis survives, report that clearly 5. **Convergence validation** - Confirmed hypotheses get additional validation to prevent confirmation bias +6. **No pre-loaded knowledge in sub-agents** - Investigators read code fresh to avoid confirmation bias; feature knowledge write-back happens only after investigation completes ## Error Handling diff --git a/plugins/devflow-explore/commands/explore.md b/shared/knowledge/explore.mds similarity index 62% rename from plugins/devflow-explore/commands/explore.md rename to shared/knowledge/explore.mds index 03fbcaa1..83a6d631 100644 --- a/plugins/devflow-explore/commands/explore.md +++ b/shared/knowledge/explore.mds @@ -1,6 +1,7 @@ --- description: Explore codebase with structured analysis and optional feature knowledge creation --- +@import { knowledge_writeback } from "./_knowledge.mds" # Explore Command @@ -25,7 +26,7 @@ Explore a codebase area by spawning parallel agents for flow tracing, dependency ### Phase 1: Load Decisions (Orchestrator-Local) -**Produces:** DECISIONS_CONTEXT, FEATURE_KNOWLEDGE +**Produces:** DECISIONS_CONTEXT Before exploring, load the decisions index: @@ -35,14 +36,7 @@ DECISIONS_CONTEXT=$(node ~/.devflow/scripts/hooks/lib/decisions-index.cjs index The orchestrator uses `DECISIONS_CONTEXT` locally when framing exploration — prior decisions and pitfalls suggest specific areas to investigate. Follow `devflow:apply-decisions` to Read full entry bodies on demand. **Do NOT pass `DECISIONS_CONTEXT` to Explore sub-agents** — decisions context stays in the orchestrator, not in the investigation workers. -Also load feature knowledge: -1. Read `.devflow/features/index.json` if it exists. If not, set `FEATURE_KNOWLEDGE = (none)`. -2. Identify relevant feature knowledge entries (match task intent against each entry's descriptions and directories). -3. For each match: check staleness via `node ~/.devflow/scripts/hooks/lib/feature-knowledge.cjs stale "{worktree}" {slug} 2>/dev/null`, read `.devflow/features/{slug}/KNOWLEDGE.md`. -4. Use `FEATURE_KNOWLEDGE` **locally** for exploration framing — feature-specific patterns and integration points guide where to focus. -5. **Do NOT pass to Explore sub-agents** (same asymmetric pattern as DECISIONS_CONTEXT). - -**Explore agent framing**: "The feature knowledge is a baseline — your job is to VALIDATE, EXTEND, and CORRECT it, not repeat it. Focus on areas the feature knowledge doesn't cover and things that may have changed." +**No up-front feature knowledge load** — explore investigation workers read code directly to avoid confirmation bias. Feature knowledge is only created as a write-back at the end, after exploration is complete. ### Phase 2: Orient @@ -95,39 +89,14 @@ Present findings to user. Use AskUserQuestion to offer focused follow-up explora **Requires:** MERGED_FINDINGS, DECISIONS_CONTEXT **Produces:** FEATURE_KNOWLEDGE_STATUS (created | skipped) -1. If `.devflow/features/.disabled` exists → skip, set FEATURE_KNOWLEDGE_STATUS = skipped -2. Read `.devflow/features/index.json` (if it exists) -3. Based on the explored area (user's question + MERGED_FINDINGS scope), check if matching feature knowledge - already exists (match against each entry's `directories` and `description`). If covered → skip -4. Use AskUserQuestion: "No feature knowledge exists for {explored area}. Create one to capture these patterns?" -5. If user declines → set FEATURE_KNOWLEDGE_STATUS = skipped -6. If user accepts: - a. Derive FEATURE_SLUG from explored area (kebab-case from primary directory, strip src/lib - prefixes, must match `^[a-z0-9][a-z0-9-]*$`) - b. Derive FEATURE_NAME (human-readable) - c. Identify DIRECTORIES from explored scope - d. Spawn Agent(subagent_type="Knowledge"): - ``` - "FEATURE_SLUG: {slug} - FEATURE_NAME: {name} - DIRECTORIES: {directories} - EXPLORATION_OUTPUTS: {MERGED_FINDINGS from Phase 4} - DECISIONS_CONTEXT: {from Phase 1} - WORKTREE_PATH: {worktree path, if in a worktree} - Load the devflow:feature-knowledge skill. EXPLORATION_OUTPUTS are pre-computed — synthesize instead of - exploring from scratch. Read .devflow/features/index.json for cross-referencing." - ``` - e. Read result file (`.devflow/features/{slug}/.create-result.json`), then run: - ```bash - node ~/.devflow/scripts/hooks/lib/feature-knowledge.cjs update-index "{worktree}" \ - --slug="{slug}" --name="{name}" --directories='[...]' \ - --referencedFiles='{from_result}' --description="{from_result}" \ - --createdBy="explore" 2>/dev/null - ``` - Clean up: `rm -f .devflow/features/{slug}/.create-result.json` - If result file missing (agent failed), use empty defaults: `referencedFiles='[]'`, `description=""`. - f. Report: "Created feature knowledge: {slug}" - g. Set FEATURE_KNOWLEDGE_STATUS = created +1. Check if matching feature knowledge already exists by reading `\{worktree\}/.devflow/features/index.md` (or globbing frontmatter if absent). If covered → skip +2. Use AskUserQuestion: "No feature knowledge exists for \{explored area\}. Create one to capture these patterns?" +3. If user declines → set FEATURE_KNOWLEDGE_STATUS = skipped +4. If user accepts: proceed with write-back below. + +{knowledge_writeback()} + +Set FEATURE_KNOWLEDGE_STATUS = created (if agent spawned) or skipped. **Failure handling**: Non-blocking. If Knowledge agent fails, log and continue. @@ -165,14 +134,14 @@ Structured exploration findings with concrete code references: ├─ Phase 5: Present findings with drill-down offer │ └─ Phase 6: Suggest feature knowledge creation (conditional) - └─ Knowledge agent (if user accepts and no existing feature knowledge) + └─ Knowledge agent write-back (if user accepts and no existing feature knowledge) ``` ## Principles 1. **Structure over browsing** - Every claim must cite file:line references 2. **Parallel execution** - All explorers run simultaneously for speed -3. **Knowledge-informed** - Prior decisions and feature knowledge guide where to look +3. **Knowledge-informed** - Prior decisions guide where to look; NO pre-loaded feature knowledge in sub-agents (avoids confirmation bias) 4. **User-driven depth** - Present findings, then offer drill-down into specific areas ## Error Handling diff --git a/plugins/devflow-implement/commands/implement.md b/shared/knowledge/implement.mds similarity index 78% rename from plugins/devflow-implement/commands/implement.md rename to shared/knowledge/implement.mds index 94e6aee8..f9c91cc1 100644 --- a/plugins/devflow-implement/commands/implement.md +++ b/shared/knowledge/implement.mds @@ -1,6 +1,7 @@ --- description: Execute a single task through implementation, quality gates, and PR creation - accepts plan documents, issues, or task descriptions --- +@import { knowledge_load, knowledge_writeback } from "./_knowledge.mds" # Implement Command @@ -33,7 +34,7 @@ When the user explicitly asks to re-validate, re-check, or re-run quality gates 1. **Branch safety check**: If on a protected branch (main, master, develop, etc.), run Phase 1 to create/switch to a work branch. If already on a work branch, skip Phase 1. 2. **Skip Phase 2** — no Coder needed, user already made changes. -3. **Detect FILES_CHANGED**: `git diff --name-only {base_branch}...HEAD` +3. **Detect FILES_CHANGED**: `git diff --name-only \{base_branch\}...HEAD` 4. **Run Phases 3-8** — full quality gate pipeline on detected changes. 5. **Proceed to Phase 10** (Create PR) / **Phase 11** (Report). @@ -41,7 +42,7 @@ If the user prompt does NOT match re-validation, proceed with the full pipeline ### Phase 1: Setup -**Produces:** TASK_ID, BASE_BRANCH, EXECUTION_PLAN, DECISIONS_CONTEXT, FEATURE_KNOWLEDGE, STALE_FEATURE_KNOWLEDGE_SLUGS, PR_DESCRIPTION_GUIDANCE +**Produces:** TASK_ID, BASE_BRANCH, EXECUTION_PLAN, DECISIONS_CONTEXT, FEATURE_KNOWLEDGE, PR_DESCRIPTION_GUIDANCE **Load Companion Skills** — Load via Skill tool: `devflow:test-driven-development`, `devflow:patterns`, `devflow:dependency-research`. If a skill fails to load, continue without it. @@ -83,12 +84,7 @@ DECISIONS_CONTEXT=$(node ~/.devflow/scripts/hooks/lib/decisions-index.cjs index ``` Pass to Coder (Phase 2) and Scrutinizer (Phase 5). -**Load Feature Knowledge:** -1. Read `.devflow/features/index.json` if it exists -2. Based on task description and file targets, identify relevant feature knowledge -3. For each match: check staleness via `node ~/.devflow/scripts/hooks/lib/feature-knowledge.cjs stale "{worktree}" {slug} 2>/dev/null`, read `.devflow/features/{slug}/KNOWLEDGE.md` -4. Set `FEATURE_KNOWLEDGE` (or `(none)` if no feature knowledge exists or none is relevant) -5. Collect slugs where staleness check returned stale → `STALE_FEATURE_KNOWLEDGE_SLUGS`. +{knowledge_load()} ### Phase 2: Implement @@ -167,7 +163,7 @@ HANDOFF_REQUIRED: {true if not last phase} HANDOFF_FILE: .devflow/docs/handoff-{branch_slug}.md" ``` -**Handoff Protocol**: Each sequential Coder receives the prior Coder's implementation summary via PRIOR_PHASE_SUMMARY and FILES_FROM_PRIOR_PHASE. The Coder's built-in branch orientation step handles git log scanning, file reading, and pattern discovery automatically. After each Coder with HANDOFF_REQUIRED=true completes, write its phase summary to `.devflow/docs/handoff-{branch_slug}.md` using the Write tool (survives context compaction). Delete `.devflow/docs/handoff-{branch_slug}.md` after the final Coder completes (cleanup). +**Handoff Protocol**: Each sequential Coder receives the prior Coder's implementation summary via PRIOR_PHASE_SUMMARY and FILES_FROM_PRIOR_PHASE. The Coder's built-in branch orientation step handles git log scanning, file reading, and pattern discovery automatically. After each Coder with HANDOFF_REQUIRED=true completes, write its phase summary to `.devflow/docs/handoff-\{branch_slug\}.md` using the Write tool (survives context compaction). Delete `.devflow/docs/handoff-\{branch_slug\}.md` after the final Coder completes (cleanup). --- @@ -228,10 +224,10 @@ Run build, typecheck, lint, test. Report pass/fail with failure details." - Spawn Coder with fix context: ``` Agent(subagent_type="Coder"): - "TASK_ID: {task-id} + "TASK_ID: \{task-id\} TASK_DESCRIPTION: Fix validation failures OPERATION: validation-fix - VALIDATION_FAILURES: {parsed failures from Validator} + VALIDATION_FAILURES: \{parsed failures from Validator\} SCOPE: Fix only the listed failures, no other changes CREATE_PR: false" ``` @@ -317,17 +313,17 @@ Validate alignment with request and plan. Report ALIGNED or MISALIGNED with deta - Spawn Coder to fix misalignments: ``` Agent(subagent_type="Coder"): - "TASK_ID: {task-id} + "TASK_ID: \{task-id\} TASK_DESCRIPTION: Fix alignment issues OPERATION: alignment-fix - MISALIGNMENTS: {structured misalignments from Evaluator} + MISALIGNMENTS: \{structured misalignments from Evaluator\} SCOPE: Fix only the listed misalignments, no other changes CREATE_PR: false" ``` - Spawn Validator to verify fix didn't break tests: ``` Agent(subagent_type="Validator", model="haiku"): - "FILES_CHANGED: {files modified by fix Coder} + "FILES_CHANGED: \{files modified by fix Coder\} VALIDATION_SCOPE: changed-only" ``` - If Validator FAIL: Report to user @@ -359,17 +355,17 @@ Design and execute scenario-based acceptance tests. Report PASS or FAIL with evi - Spawn Coder to fix QA failures: ``` Agent(subagent_type="Coder"): - "TASK_ID: {task-id} + "TASK_ID: \{task-id\} TASK_DESCRIPTION: Fix QA test failures OPERATION: qa-fix - QA_FAILURES: {structured failures from Tester} + QA_FAILURES: \{structured failures from Tester\} SCOPE: Fix only the listed failures, no other changes CREATE_PR: false" ``` - Spawn Validator to verify fix didn't break tests: ``` Agent(subagent_type="Validator", model="haiku"): - "FILES_CHANGED: {files modified by fix Coder} + "FILES_CHANGED: \{files modified by fix Coder\} VALIDATION_SCOPE: changed-only" ``` - If Validator FAIL: Report to user @@ -407,75 +403,9 @@ If `PR_DESCRIPTION_GUIDANCE` is not `(none)`, use it to compose the PR body (see **Requires:** VALIDATION_RESULT, ALIGNMENT_RESULT, QA_RESULT, PR_URL -Compute overlapping feature knowledge: -```bash -OVERLAPPING_SLUGS=$(node ~/.devflow/scripts/hooks/lib/feature-knowledge.cjs find-overlapping "{worktree}" {files_changed...} 2>/dev/null) -``` -Parse the JSON array output. Pass `OVERLAPPING_SLUGS` to Phase 12. - Display completion summary with phase status, PR info, and next steps. -### Phase 12: Feature Knowledge Generation (Conditional) - -**Requires:** FILES_CHANGED, STALE_FEATURE_KNOWLEDGE_SLUGS, OVERLAPPING_SLUGS, DECISIONS_CONTEXT -**Produces:** Updated `.devflow/features/index.json` (or skipped) - -If `.devflow/features/.disabled` exists, skip entirely. - -**New feature knowledge creation**: If FILES_CHANGED touch a feature area that does NOT have matching feature knowledge in `.devflow/features/index.json`: - -**Slug derivation**: Derive the slug from the primary directory name using kebab-case. Examples: `src/cli/commands/` → `cli-commands`, `src/payments/stripe/` → `payments-stripe`, `scripts/hooks/` → `hooks`. Strip common prefixes like `src/` and `lib/`. The slug must match `^[a-z0-9][a-z0-9-]*$`. - -1. Identify the feature area slug and human-readable name from the implemented directories -2. Spawn Agent(subagent_type="Knowledge"): - ``` - "FEATURE_SLUG: {slug} - FEATURE_NAME: {name} - FILES_CHANGED: {files_changed list} - DIRECTORIES: {directory prefixes from FILES_CHANGED} - DECISIONS_CONTEXT: {from Phase 1} - - Load the devflow:feature-knowledge skill and follow its 4-phase process exactly. - Read the FILES_CHANGED to understand the implemented code. - Read .devflow/features/index.json to see existing feature knowledge for cross-referencing." - ``` -3. Read result file (`.devflow/features/{slug}/.create-result.json`), then run: - ```bash - node ~/.devflow/scripts/hooks/lib/feature-knowledge.cjs update-index "{worktree}" \ - --slug="{slug}" --name="{name}" \ - --directories='["{dir1}", "{dir2}"]' \ - --referencedFiles='{referencedFiles_json_from_result}' \ - --description="{description_from_result}" \ - --createdBy="implement" 2>/dev/null - ``` - Clean up: `rm -f .devflow/features/{slug}/.create-result.json` - If the result file does not exist (agent failed to write it), use empty defaults: - `referencedFiles='[]'`, `description=""`. -4. Report: "Created feature knowledge: {slug}" - -Skip if all touched areas already have matching feature knowledge. - -**Refresh stale feature knowledge**: Combine STALE_FEATURE_KNOWLEDGE_SLUGS (from Phase 1) and OVERLAPPING_SLUGS (from Phase 11), deduplicate. For each slug, refresh: - -1. Read `.devflow/features/{slug}/KNOWLEDGE.md` and index entry -2. Spawn Agent(subagent_type="Knowledge"): - ``` - "FEATURE_SLUG: {slug} - FEATURE_NAME: {name from index} - DIRECTORIES: {directories from index} - EXISTING_FEATURE_KNOWLEDGE: {content of .devflow/features/{slug}/KNOWLEDGE.md} - CHANGED_FILES: {FILES_CHANGED that overlap this feature knowledge} - DECISIONS_CONTEXT: {from Phase 1} - - Load the devflow:feature-knowledge skill. This is a REFRESH, not a new creation. - Read the CHANGED_FILES to understand what changed, then update the EXISTING_FEATURE_KNOWLEDGE. - Maintain quality standards from the skill. Do NOT regenerate from scratch. - Write updated feature knowledge to .devflow/features/{slug}/KNOWLEDGE.md - Write .devflow/features/{slug}/.refresh-result.json with referencedFiles and description." - ``` -3. Read result file, update index (same CLI call as step 3 above), clean up the result file. - -**Failure handling**: Non-blocking. If Knowledge agent crashes, log failure and report results normally. +{knowledge_writeback()} ## Architecture @@ -525,8 +455,8 @@ Skip if all touched areas already have matching feature knowledge. │ ├─ Phase 11: Report │ -└─ Phase 12: Feature Knowledge Generation (Conditional) - └─ Knowledge agent (if new/stale feature area) +└─ Feature Knowledge Write-Back (Conditional) + └─ Knowledge agent (if documented area changed AND knowledge enabled) ``` ## Principles diff --git a/plugins/devflow-plan/commands/plan.md b/shared/knowledge/plan.mds similarity index 95% rename from plugins/devflow-plan/commands/plan.md rename to shared/knowledge/plan.mds index 93b7c3a9..5d6f73b4 100644 --- a/plugins/devflow-plan/commands/plan.md +++ b/shared/knowledge/plan.mds @@ -1,6 +1,7 @@ --- description: Unified design planning - combines requirements discovery, gap analysis, implementation planning, and design review into a single workflow --- +@import { knowledge_load } from "./_knowledge.mds" # Plan Command @@ -96,11 +97,7 @@ DECISIONS_CONTEXT=$(node ~/.devflow/scripts/hooks/lib/decisions-index.cjs index This produces a compact index of active ADR/PF entries. Pass Skimmer context and `DECISIONS_CONTEXT` to all subsequent agents — prior decisions constrain design, known pitfalls inform gap analysis. Agents use `devflow:apply-decisions` to Read full entry bodies on demand. -**Load Feature Knowledge:** -1. Read `.devflow/features/index.json` if it exists -2. Based on the planning task description, identify relevant feature knowledge -3. For each match: check staleness via `node ~/.devflow/scripts/hooks/lib/feature-knowledge.cjs stale "{worktree}" {slug} 2>/dev/null`, read `.devflow/features/{slug}/KNOWLEDGE.md` -4. Concatenate as `FEATURE_KNOWLEDGE` (or `(none)` if no feature knowledge exists or none are relevant) +{knowledge_load()} Pass `FEATURE_KNOWLEDGE` alongside `DECISIONS_CONTEXT` to Explorer and Designer agents. @@ -348,9 +345,9 @@ User can: **Store design artifact:** Write design artifact to disk: -- If issue number: `.devflow/docs/design/{issue-number}-{topic-slug}.{YYYY-MM-DD_HHMM}.md` -- If multi-issue: `.devflow/docs/design/{first-issue-number}-multi.{YYYY-MM-DD_HHMM}.md` -- If no issue: `.devflow/docs/design/{topic-slug}.{YYYY-MM-DD_HHMM}.md` +- If issue number: `.devflow/docs/design/\{issue-number\}-\{topic-slug\}.\{YYYY-MM-DD_HHMM\}.md` +- If multi-issue: `.devflow/docs/design/\{first-issue-number\}-multi.\{YYYY-MM-DD_HHMM\}.md` +- If no issue: `.devflow/docs/design/\{topic-slug\}.\{YYYY-MM-DD_HHMM\}.md` Create parent directory if needed. @@ -419,7 +416,7 @@ Display completion summary: - Issue URL (if created or if pre-existing) - Gap analysis summary (N blocking, M should-address) - Design review summary (N anti-patterns found, M mitigated in plan) -- Suggested next step: `/implement {artifact-path}` or `/implement #{issue-number}` +- Suggested next step: `/implement \{artifact-path\}` or `/implement #\{issue-number\}` --- diff --git a/plugins/devflow-research/commands/research.md b/shared/knowledge/research.mds similarity index 80% rename from plugins/devflow-research/commands/research.md rename to shared/knowledge/research.mds index 871bfbd3..1baa9796 100644 --- a/plugins/devflow-research/commands/research.md +++ b/shared/knowledge/research.mds @@ -1,6 +1,7 @@ --- description: Research a topic using parallel multi-type researchers with trust-aware synthesis --- +@import { knowledge_load } from "./_knowledge.mds" # Research Command @@ -36,11 +37,9 @@ DECISIONS_CONTEXT=$(node ~/.devflow/scripts/hooks/lib/decisions-index.cjs index Use `DECISIONS_CONTEXT` locally when framing research — prior decisions and pitfalls suggest areas to investigate. Follow `devflow:apply-decisions` to Read full entry bodies on demand. Pass `DECISIONS_CONTEXT` to each Researcher agent in Phase 4 so they can cite relevant decisions in findings. -Also load feature knowledge: -1. Read `.devflow/features/index.json` if it exists. If not, set `FEATURE_KNOWLEDGE = (none)`. -2. Identify relevant feature knowledge entries (match research question against each entry's descriptions and directories). -3. For each match: check staleness via `node ~/.devflow/scripts/hooks/lib/feature-knowledge.cjs stale "{worktree}" {slug} 2>/dev/null`, read `.devflow/features/{slug}/KNOWLEDGE.md`. -4. Use `FEATURE_KNOWLEDGE` **locally** for research framing. Pass to each Researcher agent in Phase 4. +{knowledge_load()} + +Use `FEATURE_KNOWLEDGE` **locally** for research framing. Pass to each Researcher agent in Phase 4. ### Phase 2: Requirements @@ -49,7 +48,7 @@ Also load feature knowledge: Analyze the research question to infer research types needed (min 2, max 5). For each type: - `RESEARCH_TYPE`: `codebase | external | market | competitor | technology` - `RESEARCH_QUESTION`: Focused sub-question for this type -- `OUTPUT_PATH`: `.devflow/docs/research/{topic-slug}/{YYYY-MM-DD_HHMM}/{type}.md` +- `OUTPUT_PATH`: `.devflow/docs/research/\{topic-slug\}/\{YYYY-MM-DD_HHMM\}/\{type\}.md` **Tool availability check**: If WebSearch/WebFetch are unavailable, restrict to `codebase` type only. @@ -91,7 +90,7 @@ Spawn `Agent(subagent_type="Synthesizer")` in `research` mode: - Merges findings with trust-aware aggregation - Writes `research-summary.md` to the same timestamped directory -Output path: `.devflow/docs/research/{topic-slug}/{timestamp}/research-summary.md` +Output path: `.devflow/docs/research/\{topic-slug\}/\{timestamp\}/research-summary.md` ### Phase 6: Present @@ -109,13 +108,11 @@ If external research was skipped due to tool unavailability: inform user. **Requires:** RESEARCH_SUMMARY, DECISIONS_CONTEXT **Produces:** FEATURE_KNOWLEDGE_STATUS (created | skipped) -1. If `.devflow/features/.disabled` exists → skip -2. If `codebase` type was not in RESEARCH_PLAN → skip -3. Read `.devflow/features/index.json` (if it exists) -4. Check if matching feature knowledge already exists. If covered → skip -5. Use AskUserQuestion: "No feature knowledge exists for {researched area}. Create one?" -6. If user accepts: spawn Knowledge agent, update index -7. Set FEATURE_KNOWLEDGE_STATUS = created or skipped +1. If `codebase` type was not in RESEARCH_PLAN → skip +2. Check if matching feature knowledge already exists by reading `\{worktree\}/.devflow/features/index.md` (or globbing frontmatter if absent). If covered → skip +3. Use AskUserQuestion: "No feature knowledge exists for \{researched area\}. Create one?" +4. If user accepts: spawn `Agent(subagent_type="Knowledge")` with researched area context + worktree root, instructing it to load `devflow:feature-knowledge`, write `KNOWLEDGE.md`, and update `index.md` directly (no `.create-result.json`) +5. Set FEATURE_KNOWLEDGE_STATUS = created or skipped **Failure handling**: Non-blocking. If Knowledge agent fails, log and continue. @@ -125,8 +122,8 @@ If the orchestrator receives a `WORKTREE_PATH` context (e.g., from multi-worktre ## Output -Research findings saved to `.devflow/docs/research/{topic-slug}/{YYYY-MM-DD_HHMM}/`: -- `{type}.md` per research type (codebase.md, external.md, etc.) +Research findings saved to `.devflow/docs/research/\{topic-slug\}/\{YYYY-MM-DD_HHMM\}/`: +- `\{type\}.md` per research type (codebase.md, external.md, etc.) - `research-summary.md` — synthesized findings with trust annotations ## Architecture diff --git a/plugins/devflow-resolve/commands/resolve.md b/shared/knowledge/resolve.mds similarity index 89% rename from plugins/devflow-resolve/commands/resolve.md rename to shared/knowledge/resolve.mds index cf2a3d4c..9f6178a4 100644 --- a/plugins/devflow-resolve/commands/resolve.md +++ b/shared/knowledge/resolve.mds @@ -1,6 +1,7 @@ --- description: Process review issues - validate, assess risk, fix low-risk issues, defer high-risk to tech debt --- +@import { knowledge_load, knowledge_writeback } from "./_knowledge.mds" # Resolve Command @@ -30,7 +31,7 @@ Process issues from code review reports: validate them (false positive check), a 2. **If `--path` flag provided:** use only that worktree, skip discovery **`--path` validation**: Before proceeding, verify the path exists as a directory and appears in `git worktree list` output. If not: report error and stop. 3. **If only 1 resolvable worktree** (the common case): proceed as single-worktree flow — zero behavior change -4. **If multiple resolvable worktrees:** report "Found N worktrees with unresolved reviews: {list}" and proceed with multi-worktree flow +4. **If multiple resolvable worktrees:** report "Found N worktrees with unresolved reviews: \{list\}" and proceed with multi-worktree flow #### Step 0b: Per-Worktree Pre-Flight (Git Agent) @@ -66,14 +67,14 @@ If `pr_number` is absent or the command fails, set `PR_DESCRIPTION` to `(none)`. For each worktree: -1. List directories in `{worktree}/.devflow/docs/reviews/{branch-slug}/` -2. **If `--review {timestamp}` provided:** use that specific directory (not supported in multi-worktree mode) +1. List directories in `\{worktree\}/.devflow/docs/reviews/\{branch-slug\}/` +2. **If `--review \{timestamp\}` provided:** use that specific directory (not supported in multi-worktree mode) 3. **Otherwise:** sort directories by name descending (timestamps are naturally sortable), scan the 10 most recent directories only. Select the first that contains `review-summary.md` (complete review) 4. **If latest directory already has `resolution-summary.md`:** the review is resolved — check bug-analysis fallback (step 5b). -5. **Legacy fallback:** if no timestamped subdirectories exist but flat `*.md` files do in `{worktree}/.devflow/docs/reviews/{branch-slug}/`, read them directly (backwards compatible). +5. **Legacy fallback:** if no timestamped subdirectories exist but flat `*.md` files do in `\{worktree\}/.devflow/docs/reviews/\{branch-slug\}/`, read them directly (backwards compatible). **5b. Bug analysis fallback** — if no qualifying review directory found (no reviews exist, or all resolved): -- List directories in `{worktree}/.devflow/docs/bug-analysis/{branch-slug}/` +- List directories in `\{worktree\}/.devflow/docs/bug-analysis/\{branch-slug\}/` - Sort by name descending (timestamps are naturally sortable), scan the 10 most recent directories only. Select the latest that: - Contains at least one focus report (`security.md`, `functional.md`, `integration.md`, or `usability.md`) - Does NOT contain a `resolution-summary.md` @@ -94,11 +95,7 @@ DECISIONS_CONTEXT=$(node ~/.devflow/scripts/hooks/lib/decisions-index.cjs index This produces a compact index of active ADR/PF entries from `decisions.md` and `pitfalls.md`, with Deprecated/Superseded entries already stripped. Falls back to `(none)` when both files are absent or all entries are filtered. Pass `DECISIONS_CONTEXT` to every Resolver agent in Phase 4. Resolver agents use `devflow:apply-decisions` to Read full entry bodies on demand — no fan-out of the full corpus. -**Load Feature Knowledge:** -1. Read `.devflow/features/index.json` if it exists -2. Based on file paths from review report issue entries, identify relevant feature knowledge -3. For each match: check staleness via `node ~/.devflow/scripts/hooks/lib/feature-knowledge.cjs stale "{worktree}" {slug} 2>/dev/null`, read `.devflow/features/{slug}/KNOWLEDGE.md` -4. Set `FEATURE_KNOWLEDGE` (or `(none)` if no feature knowledge exists or none are relevant) +{knowledge_load()} Pass `FEATURE_KNOWLEDGE` to every Resolver agent in Phase 4. @@ -107,7 +104,7 @@ Pass `FEATURE_KNOWLEDGE` to every Resolver agent in Phase 4. **Produces:** ISSUES **Requires:** TARGET_DIR -Read review reports from `{TARGET_DIR}/*.md` and extract: +Read review reports from `\{TARGET_DIR\}/*.md` and extract: **Exclude from issue extraction:** - `review-summary.md` (synthesizer output, not individual findings) @@ -117,7 +114,7 @@ Read review reports from `{TARGET_DIR}/*.md` and extract: **Include:** ALL issues from all categories and severities, including Suggestions. -Issues are extracted from `{TARGET_DIR}` only — never cross-reference reviews from other worktrees. +Issues are extracted from `\{TARGET_DIR\}` only — never cross-reference reviews from other worktrees. **Extract per issue:** - `id`: Generated from file:line:type @@ -191,7 +188,7 @@ Aggregate from all Resolvers: Extract all decisions citations from Resolver Reasoning columns. Collect unique `applies ADR-NNN` and `avoids PF-NNN` references across all batches. -**Immediately write `resolution-summary.md`** to `{TARGET_DIR}` using the Write tool. Do this now — not in Phase 9 — while the aggregated results are fresh in context. Use the template from the Output Artifact section below. This ensures the resolution record is persisted even if later phases (Simplify, Tech Debt) trigger context compaction. +**Immediately write `resolution-summary.md`** to `\{TARGET_DIR\}` using the Write tool. Do this now — not in Phase 9 — while the aggregated results are fresh in context. Use the template from the Output Artifact section below. This ensures the resolution record is persisted even if later phases (Simplify, Tech Debt) trigger context compaction. ### Phase 6: Simplify @@ -248,7 +245,7 @@ Note: Deferred issues from resolution are already in resolution-summary.md" **Requires:** TARGET_DIR -The resolution summary was already written to `{TARGET_DIR}/resolution-summary.md` in Phase 5. Display results to the user: +The resolution summary was already written to `\{TARGET_DIR\}/resolution-summary.md` in Phase 5. Display results to the user: ``` ## Resolution Summary @@ -277,6 +274,8 @@ The resolution summary was already written to `{TARGET_DIR}/resolution-summary.m In multi-worktree mode, report results per worktree with aggregate summary. +{knowledge_writeback()} + ## Architecture ``` @@ -329,7 +328,7 @@ In multi-worktree mode, report results per worktree with aggregate summary. | Incomplete review directory (no review-summary.md) | Skip — resolve only targets complete reviews | | Latest review already resolved | Check bug-analysis fallback (Step 0c-5b); if also absent, skip worktree and suggest `/code-review` or `/bug-analysis` | | Legacy flat layout (no subdirectories) | Read flat *.md files directly (backwards compatible) | -| `--review {timestamp}` in multi-worktree mode | Not supported — use `--path` + `--review` to target specific worktree + review | +| `--review \{timestamp\}` in multi-worktree mode | Not supported — use `--path` + `--review` to target specific worktree + review | | Worktree pre-flight fails | Report failure, continue with other worktrees | ## Principles @@ -345,7 +344,7 @@ In multi-worktree mode, report results per worktree with aggregate summary. ## Output Artifact -Written in Phase 5 (Collect Results) to `{TARGET_DIR}/resolution-summary.md`: +Written in Phase 5 (Collect Results) to `\{TARGET_DIR\}/resolution-summary.md`: ```markdown # Resolution Summary diff --git a/plugins/devflow-self-review/commands/self-review.md b/shared/knowledge/self-review.mds similarity index 76% rename from plugins/devflow-self-review/commands/self-review.md rename to shared/knowledge/self-review.mds index 240e0049..1347b7fa 100644 --- a/plugins/devflow-self-review/commands/self-review.md +++ b/shared/knowledge/self-review.mds @@ -1,6 +1,7 @@ --- description: Self-review workflow - Simplifier (code clarity) then Scrutinizer (9-pillar quality gate) --- +@import { knowledge_load, knowledge_writeback } from "./_knowledge.mds" # Self-Review Command @@ -24,16 +25,16 @@ Detect changed files and build context: 3. If no changes found, report "No changes to review" and exit 4. Build TASK_DESCRIPTION from recent commit messages or branch name 5. Load decisions index: - ```bash - DECISIONS_CONTEXT=$(node ~/.devflow/scripts/hooks/lib/decisions-index.cjs index "{worktree}" 2>/dev/null || echo "(none)") - ``` + +```bash +DECISIONS_CONTEXT=$(node ~/.devflow/scripts/hooks/lib/decisions-index.cjs index "{worktree}" 2>/dev/null || echo "(none)") +``` + Pass `DECISIONS_CONTEXT` to Scrutinizer — the compact index lists active ADR/PF entries; Scrutinizer uses `devflow:apply-decisions` to Read full entry bodies on demand. Known pitfalls help identify reintroduced issues, prior decisions help validate architectural consistency. (Simplifier does not consume decisions — it operates at code-shape level and Scrutinizer runs after to catch any architectural drift.) -6. Load feature knowledge: - - Read `.devflow/features/index.json` if it exists - - Based on FILES_CHANGED, identify relevant feature knowledge (match file paths against feature knowledge `directories` and `referencedFiles`) - - For each match: check staleness via `node ~/.devflow/scripts/hooks/lib/feature-knowledge.cjs stale "{worktree}" {slug} 2>/dev/null`, read `.devflow/features/{slug}/KNOWLEDGE.md` - - Set `FEATURE_KNOWLEDGE` (or `(none)` if no feature knowledge exists or none are relevant) - - Pass `FEATURE_KNOWLEDGE` to Scrutinizer + +{knowledge_load()} + +Pass `FEATURE_KNOWLEDGE` to Scrutinizer. **Extract:** FILES_CHANGED (list), TASK_DESCRIPTION (string), DECISIONS_CONTEXT (string, optional), FEATURE_KNOWLEDGE (string, optional) @@ -45,8 +46,8 @@ Detect changed files and build context: Spawn Simplifier agent to refine code for clarity and consistency: Agent(subagent_type="Simplifier", run_in_background=false): -"TASK_DESCRIPTION: {task_description} -FILES_CHANGED: {files_changed} +"TASK_DESCRIPTION: \{task_description\} +FILES_CHANGED: \{files_changed\} Simplify and refine the code for clarity and consistency while preserving functionality." **Wait for completion.** Simplifier commits changes directly. @@ -59,10 +60,10 @@ Simplify and refine the code for clarity and consistency while preserving functi Spawn Scrutinizer agent for quality evaluation and fixing: Agent(subagent_type="Scrutinizer", run_in_background=false): -"TASK_DESCRIPTION: {task_description} -FILES_CHANGED: {files_changed} -DECISIONS_CONTEXT: {decisions_context} -FEATURE_KNOWLEDGE: {feature_knowledge} +"TASK_DESCRIPTION: \{task_description\} +FILES_CHANGED: \{files_changed\} +DECISIONS_CONTEXT: \{decisions_context\} +FEATURE_KNOWLEDGE: \{feature_knowledge\} Evaluate against 9-pillar framework. Fix P0/P1 issues. Return structured report. Follow devflow:apply-decisions to scan DECISIONS_CONTEXT and Read full ADR/PF bodies on demand. Skip if (none). Follow devflow:apply-feature-knowledge for FEATURE_KNOWLEDGE. Skip if (none)." @@ -77,7 +78,7 @@ Follow devflow:apply-feature-knowledge for FEATURE_KNOWLEDGE. Skip if (none)." If Scrutinizer made changes (STATUS == FIXED): Agent(subagent_type="Validator", run_in_background=false): -"FILES_CHANGED: {scrutinizer_modified_files} +"FILES_CHANGED: \{scrutinizer_modified_files\} VALIDATION_SCOPE: changed-only Run build, typecheck, lint, test on modified files" @@ -92,29 +93,31 @@ Display summary: ## Self-Review Complete -**Files Reviewed**: {n} -**Status**: {PASS|FIXED|BLOCKED} +**Files Reviewed**: \{n\} +**Status**: \{PASS|FIXED|BLOCKED\} ### Simplifier -- {n} files refined for clarity +- \{n\} files refined for clarity ### Scrutinizer (9-Pillar Evaluation) | Pillar | Status | |--------|--------| -| Design | {status} | -| Functionality | {status} | -| Security | {status} | -| Complexity | {status} | -| Error Handling | {status} | -| Tests | {status} | -| Naming | {status} | -| Consistency | {status} | -| Documentation | {status} | +| Design | \{status\} | +| Functionality | \{status\} | +| Security | \{status\} | +| Complexity | \{status\} | +| Error Handling | \{status\} | +| Tests | \{status\} | +| Naming | \{status\} | +| Consistency | \{status\} | +| Documentation | \{status\} | ### Commits Created -- {sha} {message} +- \{sha\} \{message\} + +\{If BLOCKED: ### Blocking Issue\n\{description\}\} -{If BLOCKED: ### Blocking Issue\n{description}} +{knowledge_writeback()} ## Architecture diff --git a/shared/skills/apply-feature-knowledge/SKILL.md b/shared/skills/apply-feature-knowledge/SKILL.md index 40ad6270..9e888883 100644 --- a/shared/skills/apply-feature-knowledge/SKILL.md +++ b/shared/skills/apply-feature-knowledge/SKILL.md @@ -9,10 +9,11 @@ allowed-tools: Read ## Iron Law -> **Pre-computed context, not a cage. Verify against current code when assumptions seem outdated.** +> **Pre-computed context, not a cage. Verify against current code — always.** > -> A feature knowledge captures patterns AS THEY WERE when last updated. Code evolves. -> Use the feature knowledge as a starting point, not gospel truth. +> A feature knowledge captures patterns AS THEY WERE when last written. Code evolves. +> Use the feature knowledge as a starting point, not gospel truth. When something feels +> off, Read the actual files. Code is authoritative; feature knowledge is supplementary. --- @@ -34,12 +35,13 @@ When `FEATURE_KNOWLEDGE` is provided and is not `(none)`: 4. **Integration points**: Ensure your changes respect documented boundaries 5. **Key files**: Use as starting points for exploration -### Step 3: Supplement as Needed +### Step 3: Verify Against Current Code -The feature knowledge may not cover everything: +The feature knowledge may not reflect recent changes: - If the feature knowledge doesn't address your specific area, explore further -- If the feature knowledge seems outdated (marked `[STALE]`), verify against current code -- If you discover new patterns, note them — they may become feature knowledge updates +- **When an assertion seems outdated**: Read the relevant source files to confirm — code wins +- When you find a contradiction between the KB and actual code, trust the code +- Note discrepancies in your output when they matter for the task --- @@ -48,13 +50,12 @@ The feature knowledge may not cover everything: When `FEATURE_KNOWLEDGE` is `(none)`, empty, or not provided — skip this skill entirely. Do not mention feature knowledge or its absence in your output. -## Staleness Handling +## Freshness Model -Feature knowledge entries marked with `[STALE — referenced files changed since last update. Verify against current code.]`: -- Treat as **lower-confidence** context -- Verify key assertions against current code before relying on them -- Don't assume anti-patterns or gotchas are still valid -- Still use as a starting point — stale context is better than no context +Feature knowledge uses **write-through + verify-on-read** for freshness: +- KBs are written at the point a documented area changes (not on a background schedule) +- Readers verify key assertions against current code rather than relying on staleness markers +- When in doubt, Read the file — that resolves any uncertainty immediately ## Concatenation Format diff --git a/shared/skills/dream-knowledge/SKILL.md b/shared/skills/dream-knowledge/SKILL.md deleted file mode 100644 index 2b731708..00000000 --- a/shared/skills/dream-knowledge/SKILL.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -name: dream-knowledge -description: "Dream agent per-task procedure for the 'knowledge' task. Loaded EXPLICITLY by the Dream agent via the Skill tool when the agent is spawned for a knowledge task — not auto-activated. Handles stale feature knowledge base refresh and index updates." -allowed-tools: Read, Bash, Write, Edit, Glob, Grep ---- - -# Dream Task: knowledge - -## Iron Law - -> **REFRESH FROM THE LIVE CODEBASE — NEVER FROM MEMORY OR STALE CONTEXT** -> -> Every KNOWLEDGE.md update must be grounded in the current files listed under -> `directories` and `referencedFiles`. Do not carry forward assertions from the -> existing KNOWLEDGE.md without re-verifying them against the current source. - -This skill is loaded by the Dream agent after it has claimed the knowledge marker(s). -The agent has already done: claim (mv .json → .processing) and multi-marker merge -(union staleSlugs arrays; use any worktreePath). - -## Procedure - -Touch all claimed `.devflow/dream/knowledge.{session}.processing` files. - -Read the merged `staleSlugs` and `worktreePath`. - -For each stale slug: -1. Read `.devflow/features/index.json` for the entry's `directories` and `referencedFiles`. -2. Read `.devflow/features/{slug}/KNOWLEDGE.md` (existing content). -3. Read the referenced files and directories to understand current state. -4. **Author an updated KNOWLEDGE.md** (LLM writes real content — no canned filler): - Cover: architecture, key patterns, anti-patterns, gotchas, integration points, key files. -5. Write the updated file. -6. Update the index entry: - ```bash - node "$HOME/.devflow/scripts/hooks/lib/feature-knowledge.cjs" update-index \ - "{worktreePath}" \ - --slug="{slug}" \ - --name="{name}" \ - --directories='["{dir1}","{dir2}"]' \ - --referencedFiles='["{file1}"]' \ - --description="{description}" - ``` - -After all slugs, write the refresh timestamp: -```bash -date +%s > .devflow/features/.knowledge-last-refresh -``` - -`.devflow/` is gitignored by default (ADR-021) — refreshed knowledge bases stay local. -Delete all claimed `.processing` markers on success. - -**On any failure**: leave `.processing` files in place (dream-recover will retry them). diff --git a/shared/skills/feature-knowledge/SKILL.md b/shared/skills/feature-knowledge/SKILL.md index aa4ec1b9..b6eab94f 100644 --- a/shared/skills/feature-knowledge/SKILL.md +++ b/shared/skills/feature-knowledge/SKILL.md @@ -96,7 +96,6 @@ name: {human-readable name} description: "Use when [specific scenarios]. Keywords: [relevant terms]." category: [architecture | conventions | component-patterns | domain-knowledge | lessons-learned] directories: [{dir prefixes}] -referencedFiles: [{5-10 key files for staleness tracking}] created: {ISO date} updated: {ISO date} --- @@ -229,7 +228,6 @@ Run through this before writing. If any check fails, go back and fix it. - [ ] Category is correct and main sections follow the matching template - [ ] Description field starts with "Use when" and includes keywords - [ ] File stays under 500 lines (split if necessary) -- [ ] 5-10 referenced files selected for staleness tracking **Connections:** - [ ] Cross-references to related feature knowledge entries and ADR/PF entries in Related section @@ -238,21 +236,26 @@ Run through this before writing. If any check fails, go back and fix it. --- -## Result Output +## Index Registration -After writing KNOWLEDGE.md, write a result JSON for the host process: +After writing KNOWLEDGE.md, update the index cache directly: -**Filename**: `.create-result.json` (new feature knowledge entries) or `.refresh-result.json` (refreshes) -**Location**: Same directory as KNOWLEDGE.md (`.devflow/features/{slug}/`) +**File**: `{worktree}/.devflow/features/index.md` +**Operation**: Read-modify-write — replace the existing line for this slug if present, or append. -```json -{ - "referencedFiles": ["src/path/to/key-file.ts", "src/path/to/other.ts"], - "description": "Use when working on {feature area}. Keywords: {terms}." -} +**Line format**: ``` +- **{slug}** — {areas} — {Use-when description} +``` + +Where: +- `{slug}` matches the `feature:` frontmatter field +- `{areas}` is a comma-separated summary of the `directories:` frontmatter field +- `{Use-when description}` is the `description:` frontmatter field value (the full "Use when..." sentence) + +If `index.md` does not exist, create it with just this line. If the file already has an entry for this slug, replace that line in-place. This is the discoverable cache read by `knowledge_load()` — the KNOWLEDGE.md frontmatter is always authoritative. -The host process reads this result file and updates `.devflow/features/index.json` — do NOT update the index directly. +Do NOT write `.create-result.json` or `.refresh-result.json`. Do NOT call any external script to update an index. Write directly. --- @@ -267,7 +270,6 @@ name: Third-Party Integrations description: "Use when adding a new vendor integration, implementing API clients, or connecting to external services. Keywords: integration, vendor, API client, webhook, spawn, config." category: component-patterns directories: [src/lib/] -referencedFiles: [src/lib/config.ts, src/lib/claude.ts, src/lib/skill-installer.ts, src/lib/deploy.ts] created: 2026-04-30 updated: 2026-04-30 --- diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index b7cda164..3d2b0156 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -41,7 +41,7 @@ import { addContextHook, removeContextHook, hasContextHook } from './context.js' import { manageSentinel } from '../utils/sentinel.js'; import { writeFileAtomicExclusive } from '../utils/fs-atomic.js'; import { writeConfig as writeDreamConfig } from '../utils/dream-config.js'; -import { getFeaturesDir, getFeaturesIndexPath, getFeaturesDisabledSentinel, getDecisionsDisabledSentinel, getPendingTurnsPath, getPendingTurnsProcessingPath } from '../utils/project-paths.js'; +import { getDecisionsDisabledSentinel, getPendingTurnsPath, getPendingTurnsProcessingPath } from '../utils/project-paths.js'; import * as os from 'os'; // Re-export pure functions for tests (canonical source is post-install.ts) @@ -594,8 +594,8 @@ export const initCommand = new Command('init') } else { p.note( 'Per-feature knowledge bases capture cross-cutting patterns,\n' + - 'conventions, and gotchas. Auto-refreshed when files change.\n' + - 'Consumes a background agent session on staleness detection.', + 'conventions, and gotchas. Created and updated automatically\n' + + 'when workflows touch a documented area (write-through model).', 'Feature Knowledge Bases', ); const knowledgeChoice = await p.confirm({ @@ -1100,7 +1100,7 @@ export const initCommand = new Command('init') // kb → knowledge rename: hook scripts replaced by session-end-knowledge-refresh / background-knowledge-refresh 'session-end-kb-refresh', 'background-kb-refresh', - // kb → knowledge rename: CJS module replaced by feature-knowledge.cjs + // kb → knowledge rename: CJS module (now removed — knowledge pipeline is write-through) 'lib/feature-kb.cjs', // decisions agent decoupling: background-learning replaced by TypeScript CLI (devflow learn --run-background) 'background-learning', @@ -1140,7 +1140,8 @@ export const initCommand = new Command('init') // Memory hooks — always remove-then-add to upgrade hook format (e.g., .sh → run-hook) // Memory hooks include the unified dream hooks (dream-dispatch, dream-capture, - // dream-evaluate) which handle memory, decisions, and knowledge in the background. + // dream-evaluate) which handle memory and decisions in the background. + // Knowledge is handled in-command via write-through (knowledge_writeback MDS partial). const cleaned = removeMemoryHooks(content); content = memoryEnabled ? addMemoryHooks(cleaned, devflowDir) : cleaned; @@ -1183,25 +1184,11 @@ export const initCommand = new Command('init') } catch { /* settings.json may not exist yet */ } - // Create .devflow/features/ directory with empty index (feature knowledge bases) - // .devflow/features/ is gitignored by default (under .devflow/); opt-in to share. - if (gitRoot && knowledgeEnabled) { - const featuresDir = getFeaturesDir(gitRoot); - await fs.mkdir(featuresDir, { recursive: true }); - const featuresIndexPath = getFeaturesIndexPath(gitRoot); - try { - await fs.access(featuresIndexPath); - } catch { - await fs.writeFile(featuresIndexPath, JSON.stringify({ version: 1, features: {} }, null, 2) + '\n'); - if (verbose) { - p.log.success('.devflow/features/index.json created'); - } - } - } - - // Manage runtime-disable sentinels for session-start-context gating + // Manage runtime-disable sentinel for decisions gating. + // Note: features/.disabled sentinel for knowledge is no longer managed here; + // write-through (knowledge_writeback MDS partial) creates features/ lazily, + // and the config gate (dream config `knowledge` field) is the sole opt-out. if (gitRoot) { - await manageSentinel(getFeaturesDisabledSentinel(gitRoot), knowledgeEnabled); await manageSentinel(getDecisionsDisabledSentinel(gitRoot), decisionsEnabled); } diff --git a/src/cli/commands/knowledge/check.ts b/src/cli/commands/knowledge/check.ts deleted file mode 100644 index 5da4aabe..00000000 --- a/src/cli/commands/knowledge/check.ts +++ /dev/null @@ -1,44 +0,0 @@ -import * as p from '@clack/prompts'; -import color from 'picocolors'; -import { getFeatureKnowledge, getWorktreePath } from './shared.js'; - -export async function handleCheck(): Promise { - p.intro(color.cyan('Knowledge Base Staleness Check')); - - const worktreePath = await getWorktreePath(); - // Load index once and pass to both functions to avoid double file reads - const index = getFeatureKnowledge().loadIndex(worktreePath); - const kbs = getFeatureKnowledge().listEntries(worktreePath, index); - const staleness = getFeatureKnowledge().checkAllStaleness(worktreePath, index); - - if (kbs.length === 0) { - p.log.info('No feature knowledge bases found.'); - p.outro(''); - return; - } - - let staleCount = 0; - - for (const kb of kbs) { - const staleInfo = staleness[kb.slug]; - const isStale = staleInfo?.stale ?? false; - if (isStale) { - staleCount++; - p.log.warn(`${kb.name} (${kb.slug}) is stale`); - for (const f of staleInfo.changedFiles.slice(0, 5)) { - console.log(` ${color.yellow('•')} ${f}`); - } - if (staleInfo.changedFiles.length > 5) { - console.log(` ${color.yellow('•')} ...and ${staleInfo.changedFiles.length - 5} more`); - } - } else { - p.log.success(`${kb.name} (${kb.slug}) is current`); - } - } - - if (staleCount > 0) { - p.outro(`${staleCount} knowledge base${staleCount === 1 ? '' : 's'} need refresh. Run: ${color.cyan('devflow knowledge refresh')}`); - } else { - p.outro('All knowledge bases are current'); - } -} diff --git a/src/cli/commands/knowledge/create.ts b/src/cli/commands/knowledge/create.ts deleted file mode 100644 index 230a5149..00000000 --- a/src/cli/commands/knowledge/create.ts +++ /dev/null @@ -1,85 +0,0 @@ -import * as p from '@clack/prompts'; -import color from 'picocolors'; -import { isClaudeCliAvailable } from '../../utils/cli.js'; -import { runKnowledgeAgent, loadDecisionsContext } from '../../utils/knowledge-agent.js'; -import { getFeatureKnowledge, exitOnInvalidSlug, getWorktreePath } from './shared.js'; - -export async function handleCreate(slug: string): Promise { - exitOnInvalidSlug(slug); - p.intro(color.cyan(`Create Feature Knowledge Base: ${slug}`)); - - if (!isClaudeCliAvailable()) { - p.log.error('claude CLI not found on PATH. Install Claude Code first.'); - process.exit(1); - } - - const worktreePath = await getWorktreePath(); - - const name = await p.text({ - message: 'Feature name (human-readable)', - placeholder: 'e.g., CLI Command System', - validate: (v) => (v.trim().length < 3 ? 'Name must be at least 3 characters' : undefined), - }); - if (p.isCancel(name)) { p.cancel('Cancelled.'); return; } - - const directoriesRaw = await p.text({ - message: 'Directories (comma-separated, e.g., src/cli/commands/,src/cli/utils/)', - placeholder: 'src/feature/', - validate: (v) => (v.trim().length === 0 ? 'Enter at least one directory' : undefined), - }); - if (p.isCancel(directoriesRaw)) { p.cancel('Cancelled.'); return; } - - const directories = (directoriesRaw as string).split(',').map((d) => d.trim()).filter(Boolean); - const dirList = directories.map((d) => `"${d}"`).join(', '); - - const s = p.spinner(); - s.start('Creating knowledge base...'); - - const decisionsContext = loadDecisionsContext(worktreePath); - - const prompt = [ - `You are the Knowledge agent. Create a feature knowledge base for the following area:`, - ``, - `FEATURE_SLUG: ${slug}`, - `FEATURE_NAME: ${name as string}`, - `DIRECTORIES: [${dirList}]`, - `WORKTREE_PATH: ${worktreePath}`, - `DECISIONS_CONTEXT: ${decisionsContext}`, - ``, - `STEP 1: Load the devflow:feature-knowledge skill using the Skill tool (skill: "devflow:feature-knowledge").`, - `STEP 2: Read .devflow/features/index.json (if it exists) to see what other knowledge bases exist for cross-referencing.`, - `STEP 3: Execute the skill's 4-phase process (Scan → Extract → Distill → Forge) exactly as specified.`, - ` - You have no pre-computed exploration outputs — perform your own exploration in Phase 1 (Scan) and Phase 2 (Extract).`, - ` - Use DECISIONS_CONTEXT to cross-reference ADR/PF entries in the Related section.`, - ``, - `After writing KNOWLEDGE.md, write .devflow/features/${slug}/.create-result.json with:`, - `{`, - ` "referencedFiles": [<5-10 key files from the explored directories for staleness tracking>],`, - ` "description": ""`, - `}`, - ``, - `Create the directory if needed. Report KB_STATUS when done.`, - ].join('\n'); - - try { - const { result } = await runKnowledgeAgent({ worktreePath, slug, prompt, resultFileName: '.create-result.json' }); - - getFeatureKnowledge().updateIndex(worktreePath, { - slug, - name: name as string, - directories, - referencedFiles: result.referencedFiles ?? [], - description: result.description, - createdBy: 'devflow-knowledge', - }); - - s.stop('Knowledge base created successfully'); - p.log.success(`Knowledge base written to .devflow/features/${slug}/KNOWLEDGE.md`); - } catch (err) { - s.stop('Knowledge base creation failed'); - p.log.error(`claude exited with error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - - p.outro(`Run ${color.cyan(`devflow knowledge list`)} to see all knowledge bases`); -} diff --git a/src/cli/commands/knowledge/index.ts b/src/cli/commands/knowledge/index.ts index c2c13a60..b76e9d15 100644 --- a/src/cli/commands/knowledge/index.ts +++ b/src/cli/commands/knowledge/index.ts @@ -1,19 +1,14 @@ /** * devflow knowledge — per-feature knowledge base management. - * Thin router that delegates each subcommand to its own module. + * Thin router that delegates to list (read-only) and toggle (enable/disable/status). + * + * The heavy CRUD subcommands (create/check/refresh/remove) are deleted — knowledge + * bases are created automatically via write-through (knowledge_writeback MDS partial). */ import { Command } from 'commander'; import { handleToggle } from './toggle.js'; import { handleList } from './list.js'; -import { handleCheck } from './check.js'; -import { handleCreate } from './create.js'; -import { handleRefresh } from './refresh.js'; -import { handleRemove } from './remove.js'; - -// Re-export agent result helpers for callers that import from knowledge/index.js -export type { AgentResult } from '../../utils/agent-result.js'; -export { readAgentResult } from '../../utils/agent-result.js'; export const knowledgeCommand = new Command('knowledge') .description('Manage per-feature knowledge bases') @@ -26,35 +21,7 @@ export const knowledgeCommand = new Command('knowledge') knowledgeCommand .command('list') - .description('List all feature knowledge bases with staleness status') + .description('List all feature knowledge bases') .action(async () => { await handleList(); }); - -knowledgeCommand - .command('check') - .description('Check all knowledge bases for staleness') - .action(async () => { - await handleCheck(); - }); - -knowledgeCommand - .command('create ') - .description('Create a new knowledge base via claude -p exploration') - .action(async (slug: string) => { - await handleCreate(slug); - }); - -knowledgeCommand - .command('refresh [slug]') - .description('Refresh stale knowledge base(s). Omit slug to refresh all stale knowledge bases.') - .action(async (slug?: string) => { - await handleRefresh(slug); - }); - -knowledgeCommand - .command('remove ') - .description('Remove a knowledge base and its index entry') - .action(async (slug: string) => { - await handleRemove(slug); - }); diff --git a/src/cli/commands/knowledge/list.ts b/src/cli/commands/knowledge/list.ts index 092f7282..9c91c9da 100644 --- a/src/cli/commands/knowledge/list.ts +++ b/src/cli/commands/knowledge/list.ts @@ -1,44 +1,131 @@ +/** + * devflow knowledge list — list feature knowledge bases. + * + * Reads .devflow/features/index.md directly (write-through cache), or falls back + * to globbing KNOWLEDGE.md frontmatter when index.md is absent/empty. No .cjs + * engine, no index.json, no staleness detection (verify-against-code is the freshness + * mechanism per the new write-through model). + */ +import { promises as fs } from 'fs'; +import * as path from 'path'; import * as p from '@clack/prompts'; import color from 'picocolors'; -import { getFeatureKnowledge, getWorktreePath } from './shared.js'; +import { getGitRoot } from '../../utils/git.js'; +import { getFeaturesDir } from '../../utils/project-paths.js'; + +/** + * A lightweight entry parsed from either index.md lines or KNOWLEDGE.md frontmatter. + */ +interface KbEntry { + slug: string; + areas: string; + description: string; +} + +/** + * Parse entries from index.md lines. + * Line format: `- **{slug}** — {areas} — {Use-when description}` + */ +function parseIndexMd(content: string): KbEntry[] { + const entries: KbEntry[] = []; + for (const line of content.split('\n')) { + const m = line.match(/^-\s+\*\*([^*]+)\*\*\s+—\s+([^—]+)\s+—\s+(.+)$/); + if (m) { + entries.push({ slug: m[1].trim(), areas: m[2].trim(), description: m[3].trim() }); + } + } + return entries; +} + +/** + * Parse the slug and description from a KNOWLEDGE.md file's YAML frontmatter. + * Returns null when frontmatter is absent or unparseable. + */ +function parseFrontmatter(content: string): { slug: string; name: string; description: string; directories: string[] } | null { + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!fmMatch) return null; + const fm = fmMatch[1]; + const get = (key: string): string => { + const m = fm.match(new RegExp(`^${key}:\\s*(.+)$`, 'm')); + return m ? m[1].trim().replace(/^"(.*)"$/, '$1') : ''; + }; + const getArr = (key: string): string[] => { + const m = fm.match(new RegExp(`^${key}:\\s*\\[([^\\]]+)\\]`, 'm')); + if (!m) return []; + return m[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')); + }; + const slug = get('feature'); + const name = get('name'); + const description = get('description'); + const directories = getArr('directories'); + if (!slug) return null; + return { slug, name, description, directories }; +} + +/** + * Resolve the git root for the current directory, falling back to cwd. + */ +async function getWorktreePath(): Promise { + return (await getGitRoot()) ?? process.cwd(); +} export async function handleList(): Promise { p.intro(color.cyan('Feature Knowledge Bases')); const worktreePath = await getWorktreePath(); - // Load index once and pass to both functions to avoid double file reads - const index = getFeatureKnowledge().loadIndex(worktreePath); - const kbs = getFeatureKnowledge().listEntries(worktreePath, index); - const staleness = getFeatureKnowledge().checkAllStaleness(worktreePath, index); + const featuresDir = getFeaturesDir(worktreePath); + const indexMdPath = path.join(featuresDir, 'index.md'); + + let entries: KbEntry[] = []; + + // Try index.md cache first + try { + const indexContent = await fs.readFile(indexMdPath, 'utf-8'); + entries = parseIndexMd(indexContent); + } catch { + /* index.md absent — fall through to frontmatter glob */ + } + + // Fallback: glob features/*/KNOWLEDGE.md and parse frontmatter + if (entries.length === 0) { + try { + const slugDirs = await fs.readdir(featuresDir, { withFileTypes: true }); + for (const dirent of slugDirs) { + if (!dirent.isDirectory() || dirent.name.startsWith('.')) continue; + const kbPath = path.join(featuresDir, dirent.name, 'KNOWLEDGE.md'); + try { + const kbContent = await fs.readFile(kbPath, 'utf-8'); + const fm = parseFrontmatter(kbContent); + if (fm) { + entries.push({ + slug: fm.slug, + areas: fm.directories.join(', ') || dirent.name, + description: fm.description || fm.name, + }); + } + } catch { /* KNOWLEDGE.md absent or unreadable — skip */ } + } + } catch { /* featuresDir absent — no KBs */ } + } - if (kbs.length === 0) { + if (entries.length === 0) { p.log.info( - 'No feature knowledge bases found. Knowledge bases are created automatically during planning, or manually via ' + - color.cyan('devflow knowledge create ') + '.' + 'No feature knowledge bases found. Knowledge bases are created automatically ' + + 'when workflows (implement, resolve, self-review, explore, debug) detect documented area changes.', ); p.outro(''); return; } - p.log.info(`Found ${kbs.length} feature knowledge base${kbs.length === 1 ? '' : 's'} in ${color.dim(worktreePath)}`); + p.log.info(`Found ${entries.length} feature knowledge base${entries.length === 1 ? '' : 's'} in ${color.dim(worktreePath)}`); console.log(''); - for (const kb of kbs) { - const staleInfo = staleness[kb.slug]; - const isStale = staleInfo?.stale ?? false; - const statusBadge = isStale ? color.yellow('[STALE]') : color.green('[current]'); - - console.log(` ${color.bold(kb.name)} ${statusBadge}`); - console.log(` slug: ${color.dim(kb.slug)}`); - console.log(` updated: ${color.dim(kb.lastUpdated)}`); - console.log(` dirs: ${color.dim(kb.directories.join(', '))}`); - if (isStale && staleInfo.changedFiles.length > 0) { - const shown = staleInfo.changedFiles.slice(0, 3).join(', '); - const overflow = staleInfo.changedFiles.length > 3 ? ` +${staleInfo.changedFiles.length - 3} more` : ''; - console.log(` changed: ${color.yellow(shown)}${overflow}`); - } + for (const entry of entries) { + console.log(` ${color.bold(entry.slug)}`); + console.log(` areas: ${color.dim(entry.areas)}`); + console.log(` use when: ${color.dim(entry.description)}`); console.log(''); } - p.outro(`Run ${color.cyan('devflow knowledge check')} to see staleness details`); + p.outro(''); } diff --git a/src/cli/commands/knowledge/refresh.ts b/src/cli/commands/knowledge/refresh.ts deleted file mode 100644 index b902d953..00000000 --- a/src/cli/commands/knowledge/refresh.ts +++ /dev/null @@ -1,101 +0,0 @@ -import * as p from '@clack/prompts'; -import color from 'picocolors'; -import { isClaudeCliAvailable } from '../../utils/cli.js'; -import { runKnowledgeAgent, loadDecisionsContext } from '../../utils/knowledge-agent.js'; -import { getFeatureKnowledge, exitOnInvalidSlug, getWorktreePath } from './shared.js'; - -export async function handleRefresh(targetSlug?: string): Promise { - p.intro(color.cyan(targetSlug ? `Refresh Knowledge Base: ${targetSlug}` : 'Refresh Stale Knowledge Bases')); - - if (targetSlug) exitOnInvalidSlug(targetSlug); - - if (!isClaudeCliAvailable()) { - p.log.error('claude CLI not found on PATH. Install Claude Code first.'); - process.exit(1); - } - - const worktreePath = await getWorktreePath(); - - // Determine which slugs to refresh - // Load index once and reuse across staleness check + listEntries to avoid double reads - const index = getFeatureKnowledge().loadIndex(worktreePath); - const kbs = getFeatureKnowledge().listEntries(worktreePath, index); - - let slugsToRefresh: string[]; - let stalenessMap: Record | undefined; - if (targetSlug) { - slugsToRefresh = [targetSlug]; - } else { - stalenessMap = getFeatureKnowledge().checkAllStaleness(worktreePath, index); - slugsToRefresh = Object.entries(stalenessMap) - .filter(([, info]) => info.stale) - .map(([s]) => s); - } - - if (slugsToRefresh.length === 0) { - p.log.success('No stale knowledge bases found — everything is current.'); - p.outro(''); - return; - } - - p.log.info(`Refreshing ${slugsToRefresh.length} knowledge base${slugsToRefresh.length === 1 ? '' : 's'}: ${slugsToRefresh.join(', ')}`); - - const decisionsContext = loadDecisionsContext(worktreePath); - - for (const slug of slugsToRefresh) { - const s = p.spinner(); - s.start(`Refreshing ${slug}...`); - - const staleInfo = stalenessMap?.[slug] ?? getFeatureKnowledge().checkStaleness(worktreePath, slug); - const entry = kbs.find((k: { slug: string }) => k.slug === slug); - const featureName = entry?.name ?? slug; - const directories = entry?.directories ?? []; - - const prompt = [ - `You are the Knowledge agent refreshing a stale feature knowledge base.`, - ``, - `FEATURE_SLUG: ${slug}`, - `FEATURE_NAME: ${featureName}`, - `DIRECTORIES: ${JSON.stringify(directories)}`, - `WORKTREE_PATH: ${worktreePath}`, - `CHANGED_FILES: ${JSON.stringify(staleInfo.changedFiles)}`, - `DECISIONS_CONTEXT: ${decisionsContext}`, - ``, - `STEP 1: Load the devflow:feature-knowledge skill using the Skill tool (skill: "devflow:feature-knowledge").`, - `STEP 2: Read .devflow/features/${slug}/KNOWLEDGE.md to understand the existing knowledge base content and structure.`, - `STEP 3: Read the CHANGED_FILES to understand what changed in the codebase.`, - `STEP 4: Update the knowledge base following the skill's quality standards:`, - ` - Maintain the correct category template structure`, - ` - Ensure code examples follow the 3-part rule (description, inline comments, takeaways)`, - ` - Update cross-references in the Related section using DECISIONS_CONTEXT`, - ` - Preserve any manually added content the user edited in`, - ` - Do NOT regenerate from scratch — update only what changed`, - `STEP 5: Write the updated knowledge base to .devflow/features/${slug}/KNOWLEDGE.md`, - `STEP 6: Write .devflow/features/${slug}/.refresh-result.json with:`, - `{`, - ` "referencedFiles": [<5-10 key files from explored directories for staleness tracking>],`, - ` "description": ""`, - `}`, - ].join('\n'); - - try { - const { result } = await runKnowledgeAgent({ worktreePath, slug, prompt, resultFileName: '.refresh-result.json' }); - - getFeatureKnowledge().updateIndex(worktreePath, { - slug, - name: featureName, - directories, - referencedFiles: result.referencedFiles ?? entry?.referencedFiles ?? [], - description: result.description, - createdBy: 'devflow-knowledge', - }); - - s.stop(`${slug} refreshed`); - } catch (err) { - s.stop(`${slug} refresh failed`); - p.log.error(`Error: ${err instanceof Error ? err.message : String(err)}`); - } - } - - p.outro('Refresh complete'); -} diff --git a/src/cli/commands/knowledge/remove.ts b/src/cli/commands/knowledge/remove.ts deleted file mode 100644 index fd183171..00000000 --- a/src/cli/commands/knowledge/remove.ts +++ /dev/null @@ -1,29 +0,0 @@ -import * as p from '@clack/prompts'; -import color from 'picocolors'; -import { getFeatureKnowledge, exitOnInvalidSlug, getWorktreePath } from './shared.js'; - -export async function handleRemove(slug: string): Promise { - exitOnInvalidSlug(slug); - p.intro(color.cyan(`Remove Knowledge Base: ${slug}`)); - - const confirmed = await p.confirm({ - message: `Remove knowledge base '${slug}' and its KNOWLEDGE.md? This cannot be undone.`, - initialValue: false, - }); - if (p.isCancel(confirmed) || !confirmed) { - p.cancel('Removal cancelled.'); - return; - } - - const worktreePath = await getWorktreePath(); - - try { - getFeatureKnowledge().removeEntry(worktreePath, slug); - p.log.success(`Knowledge base '${slug}' removed`); - } catch (err) { - p.log.error(`Failed to remove knowledge base: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - - p.outro('Done'); -} diff --git a/src/cli/commands/knowledge/shared.ts b/src/cli/commands/knowledge/shared.ts deleted file mode 100644 index a5b33135..00000000 --- a/src/cli/commands/knowledge/shared.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Shared utilities and the feature-knowledge module reference for knowledge subcommands. - * All subcommand files import from here to avoid duplication. - */ -import * as path from 'path'; -import { createRequire } from 'module'; -import * as p from '@clack/prompts'; -import { getGitRoot } from '../../utils/git.js'; -import { getDevFlowDirectory } from '../../utils/paths.js'; - -const _require = createRequire(import.meta.url); - -export type KnowledgeIndex = { version: number; features: Record } | null; -export type KnowledgeEntry = { slug: string; name: string; directories: string[]; lastUpdated: string; referencedFiles?: string[]; description?: string; createdBy?: string }; - -export interface FeatureKnowledgeModule { - loadIndex: (worktreePath: string) => KnowledgeIndex; - listEntries: (worktreePath: string, cachedIndex?: KnowledgeIndex) => KnowledgeEntry[]; - checkAllStaleness: (worktreePath: string, cachedIndex?: KnowledgeIndex) => Record; - checkStaleness: (worktreePath: string, slug: string) => { stale: boolean; changedFiles: string[] }; - findOverlapping: (worktreePath: string, changedFiles: string[]) => string[]; - removeEntry: (worktreePath: string, slug: string) => void; - validateSlug: (slug: string) => void; - updateIndex: (worktreePath: string, entry: { slug: string; name: string; description?: string; directories: string[]; referencedFiles: string[]; createdBy?: string }, lockTimeoutMs?: number) => void; -} - -let _featureKnowledge: FeatureKnowledgeModule | undefined; - -export function getFeatureKnowledge(): FeatureKnowledgeModule { - if (!_featureKnowledge) { - _featureKnowledge = _require( - path.join(getDevFlowDirectory(), 'scripts', 'hooks', 'lib', 'feature-knowledge.cjs'), - ); - } - return _featureKnowledge!; -} - -/** - * Validate a knowledge base slug and exit with an error message if invalid. - */ -export function exitOnInvalidSlug(slug: string): void { - try { - getFeatureKnowledge().validateSlug(slug); - } catch (err) { - p.log.error(err instanceof Error ? err.message : String(err)); - process.exit(1); - } -} - -/** - * Get the git root for the current directory, or cwd if not in a git repo. - */ -export async function getWorktreePath(): Promise { - return (await getGitRoot()) ?? process.cwd(); -} diff --git a/src/cli/commands/knowledge/toggle.ts b/src/cli/commands/knowledge/toggle.ts index 3ddfafea..7d29bc96 100644 --- a/src/cli/commands/knowledge/toggle.ts +++ b/src/cli/commands/knowledge/toggle.ts @@ -1,15 +1,43 @@ +/** + * Handle the enable/disable/status toggle actions for `devflow knowledge`. + * + * The sole opt-out mechanism is the dream config `knowledge` field (config-only gate per ADR-001). + * The features/.disabled sentinel and index.json bootstrap are no longer managed here — + * write-through creates features/ lazily, and the sentinel machinery is deleted. + */ import { promises as fs } from 'fs'; +import * as path from 'path'; import * as p from '@clack/prompts'; import color from 'picocolors'; +import { getGitRoot } from '../../utils/git.js'; import { getDevFlowDirectory } from '../../utils/paths.js'; import { readManifest, writeManifest } from '../../utils/manifest.js'; -import { getFeatureKnowledge, getWorktreePath } from './shared.js'; import { updateFeature, isFeatureEnabled } from '../../utils/dream-config.js'; -import { getFeaturesDir, getFeaturesIndexPath, getFeaturesDisabledSentinel } from '../../utils/project-paths.js'; +import { getFeaturesDir } from '../../utils/project-paths.js'; + +async function getWorktreePath(): Promise { + return (await getGitRoot()) ?? process.cwd(); +} + +/** Count KNOWLEDGE.md files present in features/ */ +async function countKnowledgeBases(worktreePath: string): Promise { + const featuresDir = getFeaturesDir(worktreePath); + try { + const entries = await fs.readdir(featuresDir, { withFileTypes: true }); + let count = 0; + for (const dirent of entries) { + if (!dirent.isDirectory() || dirent.name.startsWith('.')) continue; + try { + await fs.access(path.join(featuresDir, dirent.name, 'KNOWLEDGE.md')); + count++; + } catch { /* KNOWLEDGE.md absent */ } + } + return count; + } catch { + return 0; + } +} -/** - * Handle the enable/disable/status toggle actions for `devflow knowledge`. - */ export async function handleToggle(options: { enable?: boolean; disable?: boolean; status?: boolean }): Promise { if (!options.enable && !options.disable && !options.status) return; @@ -19,20 +47,7 @@ export async function handleToggle(options: { enable?: boolean; disable?: boolea if (options.enable) { p.intro(color.cyan('Enable Feature Knowledge Bases')); - // Create .devflow/features/index.json if missing - const featuresDir = getFeaturesDir(worktreePath); - await fs.mkdir(featuresDir, { recursive: true }); - const indexPath = getFeaturesIndexPath(worktreePath); - try { - await fs.access(indexPath); - } catch { - await fs.writeFile(indexPath, JSON.stringify({ version: 1, features: {} }, null, 2) + '\n'); - } - - // Remove .disabled sentinel - try { await fs.unlink(getFeaturesDisabledSentinel(worktreePath)); } catch { /* doesn't exist */ } - - // Update dream config + // Update dream config (the sole gate — config-only per ADR-001) await updateFeature(worktreePath, 'knowledge', true); // Update manifest @@ -44,17 +59,13 @@ export async function handleToggle(options: { enable?: boolean; disable?: boolea } p.log.success('Feature knowledge bases enabled'); - p.outro(`Run ${color.cyan('devflow knowledge create ')} to create a knowledge base.`); + p.log.info('Knowledge bases are created automatically when workflows detect documented area changes.'); + p.outro(''); } else if (options.disable) { p.intro(color.cyan('Disable Feature Knowledge Bases')); - // Create .disabled sentinel - const featuresDir = getFeaturesDir(worktreePath); - await fs.mkdir(featuresDir, { recursive: true }); - await fs.writeFile(getFeaturesDisabledSentinel(worktreePath), '', 'utf-8'); - - // Update dream config + // Update dream config (the sole gate — config-only per ADR-001) await updateFeature(worktreePath, 'knowledge', false); // Update manifest @@ -66,32 +77,18 @@ export async function handleToggle(options: { enable?: boolean; disable?: boolea } p.log.success('Feature knowledge bases disabled'); - p.log.info('Existing knowledge bases preserved. Manual commands (create/refresh) still work.'); + p.log.info('Existing knowledge bases preserved. Write-back skipped while disabled.'); p.outro(''); } else { // options.status p.intro(color.cyan('Feature Knowledge Status')); - // Check dream config enabled state const enabled = await isFeatureEnabled(worktreePath, 'knowledge'); + const kbCount = await countKnowledgeBases(worktreePath); - // Check sentinel - let disabled = false; - try { - await fs.access(getFeaturesDisabledSentinel(worktreePath)); - disabled = true; - } catch { /* not disabled */ } - - // Count knowledge bases - const kbs = getFeatureKnowledge().listEntries(worktreePath); - - p.log.info(`Status: ${(enabled && !disabled) ? color.green('enabled') : color.yellow('disabled')}`); - p.log.info(`Config: ${enabled ? color.green('enabled') : color.dim('disabled')}`); - p.log.info(`Knowledge bases: ${kbs.length}`); - if (disabled) { - p.log.info(`Sentinel: ${color.yellow('.devflow/features/.disabled present')}`); - } + p.log.info(`Status: ${enabled ? color.green('enabled') : color.yellow('disabled')}`); + p.log.info(`Knowledge bases: ${kbCount}`); p.outro(''); } } diff --git a/src/cli/plugins.ts b/src/cli/plugins.ts index 435a04e7..d60314da 100644 --- a/src/cli/plugins.ts +++ b/src/cli/plugins.ts @@ -56,7 +56,7 @@ export const DEVFLOW_PLUGINS: PluginDefinition[] = [ // plugin, unlike skills which install universally). Predecessor was the universally // installed `devflow:sidecar` skill; core-skills preserves that guarantee. agents: ['dream'], - skills: ['apply-decisions', 'apply-feature-knowledge', 'software-design', 'docs-framework', 'git', 'boundary-validation', 'test-driven-development', 'testing', 'dependency-research', 'dream-decisions', 'dream-knowledge', 'dream-curation'], + skills: ['apply-decisions', 'apply-feature-knowledge', 'software-design', 'docs-framework', 'git', 'boundary-validation', 'test-driven-development', 'testing', 'dependency-research', 'dream-decisions', 'dream-curation'], rules: ['security', 'engineering', 'quality', 'reliability'], }, { @@ -72,7 +72,7 @@ export const DEVFLOW_PLUGINS: PluginDefinition[] = [ description: 'Complete task implementation workflow - accepts plan documents, issues, or task descriptions', commands: ['/implement'], agents: ['git', 'coder', 'simplifier', 'scrutinizer', 'evaluator', 'tester', 'validator'], - skills: ['patterns', 'qa', 'quality-gates', 'worktree-support', 'apply-feature-knowledge'], + skills: ['patterns', 'qa', 'quality-gates', 'worktree-support', 'feature-knowledge', 'apply-feature-knowledge'], rules: [], }, { @@ -88,7 +88,7 @@ export const DEVFLOW_PLUGINS: PluginDefinition[] = [ description: 'Process and fix code review issues with risk assessment', commands: ['/resolve'], agents: ['git', 'resolver', 'simplifier'], - skills: ['patterns', 'security', 'worktree-support', 'apply-feature-knowledge'], + skills: ['patterns', 'security', 'worktree-support', 'feature-knowledge', 'apply-feature-knowledge'], rules: [], }, { @@ -96,7 +96,7 @@ export const DEVFLOW_PLUGINS: PluginDefinition[] = [ description: 'Debugging workflows with competing hypothesis investigation via parallel subagents', commands: ['/debug'], agents: ['git', 'synthesizer'], - skills: ['git', 'worktree-support', 'apply-feature-knowledge'], + skills: ['git', 'worktree-support', 'feature-knowledge', 'apply-feature-knowledge'], rules: [], }, { @@ -128,7 +128,7 @@ export const DEVFLOW_PLUGINS: PluginDefinition[] = [ description: 'Self-review workflow: Simplifier + Scrutinizer for code quality', commands: ['/self-review'], agents: ['simplifier', 'scrutinizer', 'validator'], - skills: ['quality-gates', 'software-design', 'worktree-support', 'apply-feature-knowledge'], + skills: ['quality-gates', 'software-design', 'worktree-support', 'feature-knowledge', 'apply-feature-knowledge'], rules: [], }, { @@ -536,8 +536,10 @@ const LEGACY_SKILLS_V2X: string[] = [ 'devflow:research:orch', 'devflow:release:orch', // v3.x dream per-task skills: bare names for pre-namespace installs. - // NOTE: dream-decisions, dream-knowledge, and dream-curation are STILL-ACTIVE skills - // (declared in DEVFLOW_PLUGINS, installed at the namespaced path devflow:dream-*). + // NOTE: dream-decisions and dream-curation are STILL-ACTIVE skills (declared in + // DEVFLOW_PLUGINS, installed at the namespaced path devflow:dream-*). + // dream-knowledge was REMOVED in this release — knowledge is now handled in-command + // via write-through (knowledge_writeback MDS partial). // These bare entries exist solely to clean up pre-namespace V2.x installs where // skills were written without the devflow: prefix. On current installs the post-install // fs.rm targets a bare path (e.g. ~/.claude/skills/dream-decisions) that does not exist @@ -551,6 +553,9 @@ const LEGACY_SKILLS_V2X: string[] = [ 'devflow:dream-memory', // v3.x agent-teams removal: namespaced name for cleanup of installed devflow:agent-teams skill 'devflow:agent-teams', + // v3.x dream-knowledge removal: namespaced name for cleanup of installed devflow:dream-knowledge skill + // (dream-knowledge was removed when knowledge write-through replaced the Dream background pipeline) + 'devflow:dream-knowledge', // v2.x ambient refinements: devflow:-prefixed triage/guided/router names for cleanup 'devflow:router', 'devflow:implement:triage', diff --git a/src/cli/utils/agent-result.ts b/src/cli/utils/agent-result.ts deleted file mode 100644 index 639621a4..00000000 --- a/src/cli/utils/agent-result.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { promises as fs } from 'fs'; - -export interface AgentResult { - referencedFiles?: string[]; - description?: string; -} - -/** - * Read the result JSON written by the Knowledge agent. - * Returns an empty object when the file is missing, corrupt, or non-object JSON. - */ -export async function readAgentResult(resultPath: string): Promise { - let raw: unknown; - try { - raw = JSON.parse(await fs.readFile(resultPath, 'utf8')); - } catch { - return {}; - } - if (typeof raw !== 'object' || raw === null) return {}; - const data = raw as Record; - const result: AgentResult = {}; - if (Array.isArray(data.referencedFiles)) { - result.referencedFiles = data.referencedFiles.filter( - (f): f is string => typeof f === 'string' - ); - } - if (typeof data.description === 'string') { - result.description = data.description; - } - return result; -} diff --git a/src/cli/utils/knowledge-agent.ts b/src/cli/utils/knowledge-agent.ts deleted file mode 100644 index cdf5f593..00000000 --- a/src/cli/utils/knowledge-agent.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { execFile, execFileSync } from 'child_process'; -import { promisify } from 'util'; -import { existsSync, promises as fs } from 'fs'; -import * as path from 'path'; -import { readAgentResult, type AgentResult } from './agent-result.js'; -import { getDevFlowDirectory } from './paths.js'; -import { getKnowledgePath } from './project-paths.js'; - -const execFileAsync = promisify(execFile); - -/** Tools passed to `claude -p` when spawning the Knowledge agent. */ -const KNOWLEDGE_AGENT_TOOLS = 'Read,Grep,Glob,Write,Skill'; - -/** - * Load the compact DECISIONS_CONTEXT index (ADR/PF entries) for cross-referencing. - * Returns '(none)' when no decisions files exist or the script is not installed. - */ -export function loadDecisionsContext(worktreePath: string): string { - const scriptPath = path.join( - getDevFlowDirectory(), 'scripts', 'hooks', 'lib', 'decisions-index.cjs', - ); - - if (!existsSync(scriptPath)) return '(none)'; - - try { - return execFileSync('node', [scriptPath, 'index', worktreePath], { - encoding: 'utf8', - timeout: 10_000, - }).trim(); - } catch { - return '(none)'; - } -} - -export interface RunKnowledgeAgentOptions { - worktreePath: string; - slug: string; - /** Prompt to pass to the Knowledge agent. */ - prompt: string; - /** Result filename: '.create-result.json' or '.refresh-result.json' */ - resultFileName: string; -} - -export interface RunKnowledgeAgentResult { - result: AgentResult; -} - -/** - * Spawn the Knowledge agent via `claude -p`, then read and clean up the result file. - * - * The agent is expected to write a JSON file at - * `.devflow/features/{slug}/{resultFileName}` with `referencedFiles` and optionally `description`. - * If the file is absent (agent failure), an empty AgentResult is returned. - * - * Using async execFile keeps the event loop free so the clack spinner can - * animate while the agent runs. - * - * Uses `--dangerously-skip-permissions` because `claude -p` is non-interactive - * and cannot prompt for approval; tool access is restricted via `--allowedTools`. - * - * @throws When `claude` exits with a non-zero status (propagates execFile error). - */ -export async function runKnowledgeAgent(opts: RunKnowledgeAgentOptions): Promise { - const { worktreePath, slug, prompt, resultFileName } = opts; - // Build result path in .devflow/features/{slug}/ (same directory as KNOWLEDGE.md) - const resultPath = path.join(path.dirname(getKnowledgePath(worktreePath, slug)), resultFileName); - - // Pre-clean any leftover file from a previous run - try { await fs.unlink(resultPath); } catch { /* doesn't exist — that's fine */ } - - // Spawn Knowledge agent (async — keeps event loop free for spinner animation) - await execFileAsync('claude', [ - '-p', prompt, - '--model', 'sonnet', - '--allowedTools', KNOWLEDGE_AGENT_TOOLS, - '--dangerously-skip-permissions', - ], { - cwd: worktreePath, - timeout: 300_000, - }); - - // Read result file written by the agent (returns {} if missing/invalid) - const result = await readAgentResult(resultPath); - - // Post-clean — best effort; callers should not rely on the file persisting - try { await fs.unlink(resultPath); } catch { /* already cleaned or never written */ } - - return { result }; -} diff --git a/src/cli/utils/migrations.ts b/src/cli/utils/migrations.ts index ebb6dfe5..df9a93be 100644 --- a/src/cli/utils/migrations.ts +++ b/src/cli/utils/migrations.ts @@ -958,6 +958,155 @@ const MIGRATION_PURGE_DEAD_WORKING_MEMORY_SENTINEL: Migration<'per-project'> = { }, }; +/** + * Global: remove the orphaned eval-knowledge hook and feature-knowledge.cjs + * library left in prior installs at ~/.devflow/scripts/hooks/. + * + * Phase 2 deleted these files from source, but the installer copies scripts/ + * additively (copyDirectory never deletes), so stale copies linger on every + * existing machine. Same rationale as purge-orphaned-dream-commit-hook-v1. + * + * Removes (ENOENT-idempotent; rethrows non-ENOENT errors): + * - ~/.devflow/scripts/hooks/eval-knowledge + * - ~/.devflow/scripts/hooks/lib/feature-knowledge.cjs + */ +const MIGRATION_PURGE_KNOWLEDGE_HOOKS_GLOBAL: Migration<'global'> = { + id: 'purge-knowledge-hooks-global-v1', + description: 'Remove orphaned eval-knowledge hook + feature-knowledge.cjs library from ~/.devflow/scripts/hooks/', + scope: 'global', + async run(ctx: GlobalMigrationContext): Promise { + const hooksDir = path.join(ctx.devflowDir, 'scripts', 'hooks'); + const toRemove = [ + path.join(hooksDir, 'eval-knowledge'), + path.join(hooksDir, 'lib', 'feature-knowledge.cjs'), + ]; + + let removed = 0; + for (const filePath of toRemove) { + try { + await fs.unlink(filePath); + removed++; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') continue; // already absent — idempotent skip + throw err; // unexpected — surface to runner + } + } + + const infos = removed > 0 + ? [`Removed ${removed} orphaned knowledge hook file(s) from ~/.devflow/scripts/hooks/`] + : []; + return { infos, warnings: [] }; + }, +}; + +/** + * Per-project: remove the feature-knowledge pipeline runtime artifacts left + * by the old SessionEnd hook / Dream refresh task model. + * + * Write-through (Phase 1–2) replaced the entire pipeline. Knowledge is now + * authored in-command; the Dream refresh path is gone. Per ADR-001, the + * config gate (knowledge key in dream/config.json) is intentionally preserved + * — it still gates write-back in the new model. + * + * Removes (ENOENT-idempotent; rethrows non-ENOENT errors): + * - .devflow/dream/knowledge.*.json markers (glob) + * - .devflow/dream/knowledge.*.processing markers (glob) + * - .devflow/features/.knowledge.lock/ directory (recursive) + * - .devflow/features/.knowledge-last-refresh (file) + * - .devflow/features/.knowledge-refresh.lock (file or dir) + * - .devflow/features/.disabled (sentinel — config-only gate now) + * - renames .devflow/features/index.json → index.json.deprecated (if present) + * + * Does NOT touch the `knowledge` key in dream/config.json (write-back gate stays). + * Does NOT remove KNOWLEDGE.md files — existing KBs remain loadable via frontmatter. + */ +const MIGRATION_PURGE_FEATURE_KNOWLEDGE_PIPELINE: Migration<'per-project'> = { + id: 'purge-feature-knowledge-pipeline-v1', + description: 'Remove feature-knowledge pipeline artifacts (dream markers, lock/sentinel/refresh files, deprecate index.json)', + scope: 'per-project', + async run(ctx: PerProjectMigrationContext): Promise { + const devflowDir = path.join(ctx.projectRoot, '.devflow'); + const dreamDir = path.join(devflowDir, 'dream'); + const featuresDir = path.join(devflowDir, 'features'); + let removed = 0; + + // 1. Remove .devflow/dream/knowledge.*.json and *.processing markers + try { + const dreamEntries = await fs.readdir(dreamDir); + for (const entry of dreamEntries) { + if (entry.startsWith('knowledge.') && + (entry.endsWith('.json') || entry.endsWith('.processing'))) { + try { + await fs.unlink(path.join(dreamDir, entry)); + removed++; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw err; + } + } + } + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw err; // unexpected — surface to runner + } + + // 2. Remove transient artifacts from .devflow/features/ + const featureFilesToRemove = [ + path.join(featuresDir, '.knowledge-last-refresh'), + path.join(featuresDir, '.disabled'), + ]; + for (const filePath of featureFilesToRemove) { + try { + await fs.unlink(filePath); + removed++; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw err; + } + } + + // 3. Remove .knowledge.lock dir (recursive) and .knowledge-refresh.lock (file or dir) + const featureDirsToRemove = [ + path.join(featuresDir, '.knowledge.lock'), + path.join(featuresDir, '.knowledge-refresh.lock'), + ]; + for (const dirPath of featureDirsToRemove) { + try { + await fs.rm(dirPath, { recursive: true, force: true }); + removed++; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw err; + } + } + + // 4. Rename index.json → index.json.deprecated (if present; never recreate index.json) + const indexJsonPath = path.join(featuresDir, 'index.json'); + const indexDeprecatedPath = path.join(featuresDir, 'index.json.deprecated'); + try { + // Skip if deprecated already exists (idempotent) + try { + await fs.lstat(indexDeprecatedPath); + // dest already present — idempotent skip + } catch { + // dest absent — proceed with rename + await fs.rename(indexJsonPath, indexDeprecatedPath); + removed++; + } + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw err; // unexpected — surface to runner + // ENOENT on indexJsonPath means nothing to rename — idempotent no-op + } + + const infos = removed > 0 + ? [`Removed ${removed} feature-knowledge pipeline artifact(s)`] + : []; + return { infos, warnings: [] }; + }, +}; + export const MIGRATIONS: readonly Migration[] = [ MIGRATION_SHADOW_OVERRIDES, MIGRATION_PURGE_LEGACY_KNOWLEDGE, @@ -975,6 +1124,8 @@ export const MIGRATIONS: readonly Migration[] = [ MIGRATION_PURGE_TEAMMATE_MODE_PER_PROJECT, MIGRATION_DECISIONS_LEDGER_UNIFY, MIGRATION_PURGE_DEAD_WORKING_MEMORY_SENTINEL, + MIGRATION_PURGE_KNOWLEDGE_HOOKS_GLOBAL, + MIGRATION_PURGE_FEATURE_KNOWLEDGE_PIPELINE, ]; const MIGRATIONS_FILE = 'migrations.json'; diff --git a/src/cli/utils/project-paths.ts b/src/cli/utils/project-paths.ts index 2b489a26..b62eb315 100644 --- a/src/cli/utils/project-paths.ts +++ b/src/cli/utils/project-paths.ts @@ -168,31 +168,11 @@ export function getPendingTurnsLockDir(projectRoot: string): string { // Features / knowledge files // --------------------------------------------------------------------------- -/** .devflow/features/index.json */ -export function getFeaturesIndexPath(projectRoot: string): string { - return path.join(projectRoot, '.devflow', 'features', 'index.json'); -} - /** .devflow/features/{slug}/KNOWLEDGE.md */ export function getKnowledgePath(projectRoot: string, slug: string): string { return path.join(projectRoot, '.devflow', 'features', slug, 'KNOWLEDGE.md'); } -/** .devflow/features/.disabled — sentinel that gates knowledge phase/refresh */ -export function getFeaturesDisabledSentinel(projectRoot: string): string { - return path.join(projectRoot, '.devflow', 'features', '.disabled'); -} - -/** .devflow/features/.knowledge.lock — transient lock directory for concurrent index writes */ -export function getFeaturesLockDir(projectRoot: string): string { - return path.join(projectRoot, '.devflow', 'features', '.knowledge.lock'); -} - -/** .devflow/features/.knowledge-last-refresh — timestamp of last auto-refresh */ -export function getFeaturesLastRefreshPath(projectRoot: string): string { - return path.join(projectRoot, '.devflow', 'features', '.knowledge-last-refresh'); -} - // --------------------------------------------------------------------------- // Docs files // --------------------------------------------------------------------------- diff --git a/tests/build-knowledge.test.ts b/tests/build-knowledge.test.ts new file mode 100644 index 00000000..40c40f08 --- /dev/null +++ b/tests/build-knowledge.test.ts @@ -0,0 +1,218 @@ +/** + * Smoke tests for scripts/build-knowledge.ts + * + * Validates the four key guarantees of the build-knowledge script (AC C4): + * + * 1. Explicit SOURCE_TO_PLUGIN_MAP — the 9 source basenames and their plugin + * destination directories match the canonical mapping in build-knowledge.ts. + * 2. MDS compiler happy path — a valid .mds source compiles to a non-empty + * Markdown string (same MDS mechanism the script relies on). + * 3. Script happy-path exit — spawning the real build-knowledge.ts script against + * the real shared/knowledge/ sources exits 0 and produces at least one .md file. + * 4. Missing source exits non-zero — verifies the hard-fail contract when a source + * or plugin destination is absent (tested via the script's validation logic). + * + * NOTE: The error-path subprocess test (missing source / unknown plugin) is validated + * structurally via the SOURCE_TO_PLUGIN_MAP assertion (test 1) and the MDS error-path + * in build-recipes.test.ts (shared mechanism). Test 3 covers the happy-path subprocess + * contract. Together they lock both ends without mutating committed source files. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; +import { init, compile, isMdsError } from '@mdscript/mds'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const KNOWLEDGE_DIR = path.join(ROOT, 'shared', 'knowledge'); + +/** + * Canonical SOURCE_TO_PLUGIN_MAP — must stay in sync with scripts/build-knowledge.ts. + * This test locks the 9-entry explicit mapping (AC C4). + */ +const EXPECTED_SOURCE_TO_PLUGIN_MAP: Record = { + 'implement': 'plugins/devflow-implement/commands', + 'plan': 'plugins/devflow-plan/commands', + 'resolve': 'plugins/devflow-resolve/commands', + 'code-review': 'plugins/devflow-code-review/commands', + 'self-review': 'plugins/devflow-self-review/commands', + 'research': 'plugins/devflow-research/commands', + 'bug-analysis': 'plugins/devflow-bug-analysis/commands', + 'explore': 'plugins/devflow-explore/commands', + 'debug': 'plugins/devflow-debug/commands', +}; + +// --------------------------------------------------------------------------- +// Shared MDS initialisation — required before compile calls +// --------------------------------------------------------------------------- + +let mdsInitialised = false; + +async function ensureInit(): Promise { + if (!mdsInitialised) { + await init(); + mdsInitialised = true; + } +} + +// --------------------------------------------------------------------------- +// 1. SOURCE_TO_PLUGIN_MAP — lock the 9-entry explicit mapping (AC C4) +// --------------------------------------------------------------------------- + +describe('SOURCE_TO_PLUGIN_MAP — 9-entry explicit mapping', () => { + it('has exactly 9 entries', () => { + expect(Object.keys(EXPECTED_SOURCE_TO_PLUGIN_MAP)).toHaveLength(9); + }); + + it('each source .mds file exists in shared/knowledge/', async () => { + for (const basename of Object.keys(EXPECTED_SOURCE_TO_PLUGIN_MAP)) { + const sourcePath = path.join(KNOWLEDGE_DIR, `${basename}.mds`); + await expect( + fs.access(sourcePath), + `Missing source: shared/knowledge/${basename}.mds`, + ).resolves.toBeUndefined(); + } + }); + + it('each destination plugin commands/ directory exists', async () => { + for (const [basename, destRelDir] of Object.entries(EXPECTED_SOURCE_TO_PLUGIN_MAP)) { + const destDir = path.join(ROOT, destRelDir); + await expect( + fs.access(destDir), + `Missing plugin commands/ dir for ${basename}: ${destRelDir}`, + ).resolves.toBeUndefined(); + } + }); + + it('output filename derivation matches {basename}.md pattern', () => { + for (const basename of Object.keys(EXPECTED_SOURCE_TO_PLUGIN_MAP)) { + const derived = path.basename(`${basename}.mds`, '.mds') + '.md'; + expect(derived).toBe(`${basename}.md`); + } + }); + + it('_knowledge.mds partial exists in shared/knowledge/', async () => { + const partialPath = path.join(KNOWLEDGE_DIR, '_knowledge.mds'); + await expect(fs.access(partialPath)).resolves.toBeUndefined(); + }); + + it('_knowledge.mds is a partial (starts with _) and is not in the output map', () => { + expect(Object.keys(EXPECTED_SOURCE_TO_PLUGIN_MAP)).not.toContain('_knowledge'); + }); +}); + +// --------------------------------------------------------------------------- +// 2. MDS compiler happy path (shared with build-recipes.test.ts mechanism) +// --------------------------------------------------------------------------- + +describe('MDS compiler happy path (build-knowledge hard-fail mechanism)', () => { + it('compiles a minimal valid .mds source to a non-empty Markdown string', async () => { + await ensureInit(); + const validSource = '# My Command\n\nThis is a valid MDS template.\n'; + const result = compile(validSource); + expect(typeof result.output).toBe('string'); + expect(result.output.length).toBeGreaterThan(0); + expect(result.output).toContain('My Command'); + expect(Array.isArray(result.warnings)).toBe(true); + }); + + it('isMdsError correctly identifies MDS errors', () => { + const generic = new Error('generic'); + expect(isMdsError(generic)).toBe(false); + expect(isMdsError(null)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Script happy-path exit (AC C4 — subprocess contract) +// --------------------------------------------------------------------------- + +describe('build-knowledge.ts script subprocess contract', () => { + it('exits 0 when real knowledge sources compile cleanly (CI path)', () => { + const result = spawnSync( + 'npx', + ['tsx', path.join(ROOT, 'scripts', 'build-knowledge.ts')], + { + cwd: ROOT, + encoding: 'utf-8', + timeout: 60_000, // 60s generous timeout for WASM init + file I/O + }, + ); + + if (result.error) { + throw result.error; + } + + expect( + result.status, + `build-knowledge.ts should exit 0 but exited ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + ).toBe(0); + }); + + it('produces at least one .md command file after the script runs', async () => { + // After the subprocess test above runs the script, each mapped plugin + // commands/ dir should contain the compiled .md file for that source. + let foundAtLeastOne = false; + for (const [basename, destRelDir] of Object.entries(EXPECTED_SOURCE_TO_PLUGIN_MAP)) { + const outputPath = path.join(ROOT, destRelDir, `${basename}.md`); + try { + await fs.access(outputPath); + foundAtLeastOne = true; + break; + } catch { + // Not found for this entry — continue + } + } + expect(foundAtLeastOne, 'at least one compiled .md command file should exist after build').toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Compiled output assertions — no stale call sites (AC F1) +// --------------------------------------------------------------------------- + +describe('compiled knowledge commands — no stale call-site references', () => { + beforeAll(async () => { + // Ensure fresh compilation + const result = spawnSync( + 'npx', + ['tsx', path.join(ROOT, 'scripts', 'build-knowledge.ts')], + { cwd: ROOT, encoding: 'utf-8', timeout: 60_000 }, + ); + if (result.error) throw result.error; + }); + + it('no compiled command contains a literal knowledge_load() or knowledge_writeback() call site', async () => { + const callSitePattern = /\{knowledge_(?:load|writeback)\(\)\}/; + for (const [basename, destRelDir] of Object.entries(EXPECTED_SOURCE_TO_PLUGIN_MAP)) { + const outputPath = path.join(ROOT, destRelDir, `${basename}.md`); + let content: string; + try { + content = await fs.readFile(outputPath, 'utf-8'); + } catch { + continue; // file not present — skip (covered by subprocess test above) + } + expect( + callSitePattern.test(content), + `${destRelDir}/${basename}.md must not contain un-expanded MDS call sites`, + ).toBe(false); + } + }); + + it('no compiled command references feature-knowledge.cjs', async () => { + for (const [basename, destRelDir] of Object.entries(EXPECTED_SOURCE_TO_PLUGIN_MAP)) { + const outputPath = path.join(ROOT, destRelDir, `${basename}.md`); + let content: string; + try { + content = await fs.readFile(outputPath, 'utf-8'); + } catch { + continue; + } + expect( + content, + `${destRelDir}/${basename}.md must not reference feature-knowledge.cjs`, + ).not.toContain('feature-knowledge.cjs'); + } + }); +}); diff --git a/tests/feature-knowledge/apply-feature-knowledge-skill.test.ts b/tests/feature-knowledge/apply-feature-knowledge-skill.test.ts deleted file mode 100644 index 1a30cc59..00000000 --- a/tests/feature-knowledge/apply-feature-knowledge-skill.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; -import * as path from 'path'; - -const ROOT = path.resolve(import.meta.dirname, '../..'); - -describe('feature-knowledge skill', () => { - const content = readFileSync(path.join(ROOT, 'shared/skills/feature-knowledge/SKILL.md'), 'utf8'); - - it('has iron law', () => { expect(content).toContain('## Iron Law'); }); - it('has 4-phase process', () => { - expect(content).toContain('### Phase 1: Scan'); - expect(content).toContain('### Phase 2: Extract'); - expect(content).toContain('### Phase 3: Distill'); - expect(content).toContain('### Phase 4: Forge'); - }); - it('has quality self-checks', () => { expect(content).toContain('## Quality Self-Checks'); }); - it('has feature knowledge format template with required sections', () => { - expect(content).toContain('## Overview'); - expect(content).toContain('## Anti-Patterns'); - expect(content).toContain('## Gotchas'); - expect(content).toContain('## Key Files'); - expect(content).toContain('## Related'); - }); - it('has category templates', () => { - expect(content).toContain('**Architecture:**'); - expect(content).toContain('**Conventions:**'); - expect(content).toContain('**Component Patterns:**'); - expect(content).toContain('**Domain Knowledge:**'); - expect(content).toContain('**Lessons Learned:**'); - }); - it('has code example rules', () => { - expect(content).toContain('Rules for Code Examples'); - }); - it('has worked example', () => { - expect(content).toContain('## Worked Example'); - }); -}); - -describe('apply-feature-knowledge skill', () => { - const content = readFileSync(path.join(ROOT, 'shared/skills/apply-feature-knowledge/SKILL.md'), 'utf8'); - - it('has iron law', () => { expect(content).toContain('## Iron Law'); }); - it('has 3-step algorithm', () => { - expect(content).toContain('### Step 1: Read the Feature Knowledge'); - expect(content).toContain('### Step 2: Apply to Current Task'); - expect(content).toContain('### Step 3: Supplement as Needed'); - }); - it('has skip guard', () => { expect(content).toContain('## Skip Guard'); }); - it('has staleness handling', () => { expect(content).toContain('## Staleness Handling'); }); - it('references (none) skip', () => { expect(content).toContain('(none)'); }); -}); diff --git a/tests/feature-knowledge/feature-knowledge.test.ts b/tests/feature-knowledge/feature-knowledge.test.ts deleted file mode 100644 index 8a4d45a7..00000000 --- a/tests/feature-knowledge/feature-knowledge.test.ts +++ /dev/null @@ -1,810 +0,0 @@ -import { describe, it, expect, afterAll, afterEach } from 'vitest'; -import * as path from 'path'; -import * as os from 'os'; -import { createRequire } from 'module'; -import { writeFileSync, mkdirSync, readFileSync, existsSync, rmSync, rmdirSync, realpathSync } from 'fs'; -import { execSync, execFileSync } from 'child_process'; -import { - SAMPLE_INDEX, - SAMPLE_FEATURE_KNOWLEDGE_CONTENT, - makeTmpFeatureWorktree, - cleanupTmpFeatureWorktrees, -} from './fixtures'; - -afterAll(() => cleanupTmpFeatureWorktrees()); - -const ROOT = path.resolve(import.meta.dirname, '../..'); -const require = createRequire(import.meta.url); - -const { - loadIndex, - loadKnowledgeContent, - checkStaleness, - checkAllStaleness, - updateIndex, - findOverlapping, - removeEntry, - listEntries, - validateSlug, -} = require(path.join(ROOT, 'scripts/hooks/lib/feature-knowledge.cjs')) as { - loadIndex: (worktreePath: string) => { version: number; features: Record } | null; - loadKnowledgeContent: (worktreePath: string, slug: string) => string | null; - checkStaleness: (worktreePath: string, slug: string) => { stale: boolean; changedFiles: string[] }; - checkAllStaleness: (worktreePath: string, cachedIndex?: { version: number; features: Record } | null) => Record; - updateIndex: (worktreePath: string, entry: Record, lockTimeoutMs?: number) => void; - findOverlapping: (worktreePath: string, changedFiles: string[]) => string[]; - removeEntry: (worktreePath: string, slug: string, lockTimeoutMs?: number) => void; - listEntries: (worktreePath: string, cachedIndex?: { version: number; features: Record } | null) => Array<{ slug: string } & Record>; - validateSlug: (slug: string) => void; -}; - -// --------------------------------------------------------------------------- -// loadIndex -// --------------------------------------------------------------------------- - -describe('loadIndex', () => { - it('returns parsed object for valid JSON', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX); - const result = loadIndex(tmp); - expect(result).not.toBeNull(); - expect(result!.version).toBe(1); - expect(result!.features['cli-commands']).toBeDefined(); - }); - - it('returns null for missing directory', () => { - const tmp = makeTmpFeatureWorktree(); // no index written - rmSync(path.join(tmp, '.devflow', 'features'), { recursive: true, force: true }); - expect(loadIndex(tmp)).toBeNull(); - }); - - it('returns null for invalid JSON', () => { - const tmp = makeTmpFeatureWorktree(); - writeFileSync(path.join(tmp, '.devflow', 'features', 'index.json'), 'not-json'); - expect(loadIndex(tmp)).toBeNull(); - }); -}); - -// --------------------------------------------------------------------------- -// loadKnowledgeContent -// --------------------------------------------------------------------------- - -describe('loadKnowledgeContent', () => { - it('returns content string when KNOWLEDGE.md exists', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX, { 'cli-commands': SAMPLE_FEATURE_KNOWLEDGE_CONTENT }); - const content = loadKnowledgeContent(tmp, 'cli-commands'); - expect(content).not.toBeNull(); - expect(content).toContain('# CLI Command System'); - }); - - it('returns null for missing knowledge base', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX); - expect(loadKnowledgeContent(tmp, 'cli-commands')).toBeNull(); - }); -}); - -// --------------------------------------------------------------------------- -// checkStaleness -// --------------------------------------------------------------------------- - -describe('checkStaleness', () => { - it('returns stale: false when entry is not found in index', () => { - const tmp = makeTmpFeatureWorktree({ version: 1, features: {} }); - const result = checkStaleness(tmp, 'nonexistent'); - expect(result.stale).toBe(false); - expect(result.changedFiles).toEqual([]); - }); - - it('returns stale: false for non-git repos', () => { - // tmp dir has no git init, so it is a non-git directory - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX); - const result = checkStaleness(tmp, 'cli-commands'); - expect(result.stale).toBe(false); - expect(result.changedFiles).toEqual([]); - }); -}); - -// --------------------------------------------------------------------------- -// checkStaleness (positive — git repo) -// --------------------------------------------------------------------------- - -// T2: Positive staleness detection in a real git repo -describe('checkStaleness (positive — git repo)', () => { - it('detects stale feature knowledge when referenced file changed after lastUpdated', () => { - const tmp = makeTmpFeatureWorktree(); - // Remove auto-created .features dir — we'll set it up after git init - rmSync(path.join(tmp, '.devflow', 'features'), { recursive: true, force: true }); - - // Initialize git repo with initial commit - execSync('git init', { cwd: tmp, stdio: 'pipe' }); - execSync('git config user.email "test@test.com"', { cwd: tmp, stdio: 'pipe' }); - execSync('git config user.name "Test"', { cwd: tmp, stdio: 'pipe' }); - - // Create a tracked file and commit it - const srcDir = path.join(tmp, 'src', 'cli'); - mkdirSync(srcDir, { recursive: true }); - writeFileSync(path.join(srcDir, 'cli.ts'), 'export const v = 1;'); - execSync('git add .', { cwd: tmp, stdio: 'pipe' }); - execSync('git commit -m "initial"', { cwd: tmp, stdio: 'pipe' }); - - // Set lastUpdated to just before now - const lastUpdated = new Date(Date.now() - 5000).toISOString(); - - // Create the index with a feature knowledge entry that references src/cli/cli.ts - const featuresDir = path.join(tmp, '.devflow', 'features'); - mkdirSync(featuresDir, { recursive: true }); - const index = { - version: 1, - features: { - 'my-feature': { - name: 'My Feature', - description: '', - directories: ['src/cli/'], - referencedFiles: ['src/cli/cli.ts'], - lastUpdated, - createdBy: 'test', - }, - }, - }; - writeFileSync(path.join(featuresDir, 'index.json'), JSON.stringify(index, null, 2)); - - // Modify the file and commit - writeFileSync(path.join(srcDir, 'cli.ts'), 'export const v = 2;'); - execSync('git add .', { cwd: tmp, stdio: 'pipe' }); - execSync('git commit -m "update cli.ts"', { cwd: tmp, stdio: 'pipe' }); - - const result = checkStaleness(tmp, 'my-feature'); - expect(result.stale).toBe(true); - expect(result.changedFiles).toContain('src/cli/cli.ts'); - }); -}); - -// --------------------------------------------------------------------------- -// updateIndex -// --------------------------------------------------------------------------- - -describe('updateIndex', () => { - it('creates a new entry in an empty index', () => { - const tmp = makeTmpFeatureWorktree({ version: 1, features: {} }); - updateIndex(tmp, { - slug: 'payments', - name: 'Payment Processing', - directories: ['src/payments/'], - referencedFiles: ['src/payments/checkout.ts'], - createdBy: 'test', - }); - const index = loadIndex(tmp); - expect(index).not.toBeNull(); - expect(index!.features['payments']).toBeDefined(); - const entry = index!.features['payments'] as Record; - expect(entry.name).toBe('Payment Processing'); - }); - - it('upserts an existing entry, preserving createdBy', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX); - updateIndex(tmp, { - slug: 'cli-commands', - name: 'CLI Command System Updated', - directories: ['src/cli/'], - referencedFiles: ['src/cli/cli.ts'], - }); - const index = loadIndex(tmp); - expect(index).not.toBeNull(); - const entry = index!.features['cli-commands'] as Record; - expect(entry.name).toBe('CLI Command System Updated'); - // createdBy should be preserved from original - expect(entry.createdBy).toBe('implement'); - }); - - it('sets lastUpdated to a current ISO timestamp', () => { - const before = new Date().toISOString(); - const tmp = makeTmpFeatureWorktree({ version: 1, features: {} }); - updateIndex(tmp, { - slug: 'test-slug', - name: 'Test', - directories: [], - referencedFiles: [], - }); - const after = new Date().toISOString(); - const index = loadIndex(tmp); - expect(index).not.toBeNull(); - const entry = index!.features['test-slug'] as Record; - const updated = entry.lastUpdated as string; - expect(updated >= before).toBe(true); - expect(updated <= after).toBe(true); - }); - - // T1: Lock failure - it('throws when lock cannot be acquired within timeout', () => { - const tmp = makeTmpFeatureWorktree({ version: 1, features: {} }); - const lockPath = path.join(tmp, '.devflow', 'features', '.knowledge.lock'); - // Pre-create lock directory to simulate a held lock - mkdirSync(lockPath); - - expect(() => updateIndex(tmp, { - slug: 'test-lock', - name: 'Test', - directories: [], - referencedFiles: [], - }, 200)).toThrow(/lock/i); - - // Lock dir should still exist (not cleaned up by our failed attempt) - expect(existsSync(lockPath)).toBe(true); - // Clean up - rmdirSync(lockPath); - }); - - // T4: Creates missing .features/ directory - it('creates .features/ directory if missing', () => { - const tmp = makeTmpFeatureWorktree(); - // Remove the .features dir - rmSync(path.join(tmp, '.devflow', 'features'), { recursive: true, force: true }); - expect(existsSync(path.join(tmp, '.devflow', 'features'))).toBe(false); - - updateIndex(tmp, { - slug: 'new-feature', - name: 'New Feature', - directories: ['src/new/'], - referencedFiles: ['src/new/index.ts'], - }); - - expect(existsSync(path.join(tmp, '.devflow', 'features'))).toBe(true); - const index = loadIndex(tmp); - expect(index).not.toBeNull(); - expect(index!.features['new-feature']).toBeDefined(); - }); -}); - -// --------------------------------------------------------------------------- -// removeEntry -// --------------------------------------------------------------------------- - -describe('removeEntry', () => { - it('removes entry from index and deletes its directory', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX, { 'cli-commands': SAMPLE_FEATURE_KNOWLEDGE_CONTENT }); - const knowledgeDir = path.join(tmp, '.devflow', 'features', 'cli-commands'); - expect(existsSync(knowledgeDir)).toBe(true); - - removeEntry(tmp, 'cli-commands'); - - const index = loadIndex(tmp); - expect(index).not.toBeNull(); - expect(index!.features['cli-commands']).toBeUndefined(); - expect(existsSync(knowledgeDir)).toBe(false); - }); - - it('is a no-op for a non-existent slug', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX); - // Should not throw - expect(() => removeEntry(tmp, 'nonexistent')).not.toThrow(); - // Original entry should still exist - const index = loadIndex(tmp); - expect(index).not.toBeNull(); - expect(index!.features['cli-commands']).toBeDefined(); - }); - - // T5: No-op when .features/ directory is missing - it('is a no-op when .features/ directory does not exist', () => { - const tmp = makeTmpFeatureWorktree(); - rmSync(path.join(tmp, '.devflow', 'features'), { recursive: true, force: true }); - expect(existsSync(path.join(tmp, '.devflow', 'features'))).toBe(false); - - // Should not throw - expect(() => removeEntry(tmp, 'nonexistent')).not.toThrow(); - }); - - it('preserves corrupt index.json on remove instead of overwriting', () => { - const tmp = makeTmpFeatureWorktree(); - writeFileSync(path.join(tmp, '.devflow', 'features', 'index.json'), 'not-valid-json'); - removeEntry(tmp, 'nonexistent'); - const raw = readFileSync(path.join(tmp, '.devflow', 'features', 'index.json'), 'utf8'); - expect(raw).toBe('not-valid-json'); - }); -}); - -// --------------------------------------------------------------------------- -// findOverlapping -// --------------------------------------------------------------------------- - -describe('findOverlapping', () => { - it('identifies feature knowledge whose referencedFiles overlap with changed files', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX); - const overlapping = findOverlapping(tmp, ['src/cli/cli.ts', 'some/other/file.ts']); - expect(overlapping).toContain('cli-commands'); - }); - - it('returns empty array when no overlap', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX); - const overlapping = findOverlapping(tmp, ['src/payments/checkout.ts', 'src/unrelated.ts']); - expect(overlapping).toEqual([]); - }); - - it('returns empty array for missing index', () => { - const tmp = makeTmpFeatureWorktree(); - const overlapping = findOverlapping(tmp, ['src/cli/cli.ts']); - expect(overlapping).toEqual([]); - }); - - it('does not match on common prefix without directory boundary', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX); - // 'src/cli' should NOT match 'src/clitools/foo.ts' (no dir boundary) - const overlapping = findOverlapping(tmp, ['src/clitools/foo.ts']); - expect(overlapping).not.toContain('cli-commands'); - }); - - // T3: Directory boundary matching - // referencedFiles uses no trailing slash so the startsWith(ref + '/') logic - // in findOverlapping correctly matches nested files while rejecting - // files that merely share a prefix (e.g. src/client vs src/cli). - it('matches files under a referenced directory prefix', () => { - const index = { - version: 1, - features: { - 'cli-feature': { - name: 'CLI', - description: '', - directories: ['src/cli/'], - referencedFiles: ['src/cli'], - lastUpdated: new Date().toISOString(), - createdBy: 'test', - }, - }, - }; - const tmp = makeTmpFeatureWorktree(index); - - // File under the directory prefix — should match (src/cli is a prefix of src/cli/deep/file.ts) - expect(findOverlapping(tmp, ['src/cli/deep/file.ts'])).toContain('cli-feature'); - - // File NOT under the directory but sharing prefix — should NOT match - // (src/cli is NOT a prefix of src/client.ts since there's no / after cli) - expect(findOverlapping(tmp, ['src/client.ts'])).toEqual([]); - }); -}); - -// --------------------------------------------------------------------------- -// listEntries -// --------------------------------------------------------------------------- - -describe('listEntries', () => { - it('returns all entries with their slugs', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX); - const entries = listEntries(tmp); - expect(entries).toHaveLength(1); - expect(entries[0].slug).toBe('cli-commands'); - expect(entries[0].name).toBe('CLI Command System'); - }); - - it('returns empty array for missing index', () => { - const tmp = makeTmpFeatureWorktree(); - expect(listEntries(tmp)).toEqual([]); - }); -}); - -// --------------------------------------------------------------------------- -// checkAllStaleness -// --------------------------------------------------------------------------- - -describe('checkAllStaleness', () => { - it('returns empty object for missing index', () => { - const tmp = makeTmpFeatureWorktree(); - expect(checkAllStaleness(tmp)).toEqual({}); - }); - - it('returns an entry per slug', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX); - const result = checkAllStaleness(tmp); - expect(result['cli-commands']).toBeDefined(); - expect(result['cli-commands']).toHaveProperty('stale'); - expect(result['cli-commands']).toHaveProperty('changedFiles'); - }); - - it('does not false-positive newer feature knowledge when shared file changed between timestamps', () => { - const tmp = makeTmpFeatureWorktree(); - rmSync(path.join(tmp, '.devflow', 'features'), { recursive: true, force: true }); - - execSync('git init', { cwd: tmp, stdio: 'pipe' }); - execSync('git config user.email "test@test.com"', { cwd: tmp, stdio: 'pipe' }); - execSync('git config user.name "Test"', { cwd: tmp, stdio: 'pipe' }); - - // Initial commit with shared.ts - const srcDir = path.join(tmp, 'src'); - mkdirSync(srcDir, { recursive: true }); - writeFileSync(path.join(srcDir, 'shared.ts'), 'export const v = 1;'); - execSync('git add .', { cwd: tmp, stdio: 'pipe' }); - execSync('git commit -m "initial"', { cwd: tmp, stdio: 'pipe' }); - - // knowledge-A: lastUpdated BEFORE the upcoming change → should be stale - const knowledgeATimestamp = new Date(Date.now() - 10000).toISOString(); - - // Modify shared.ts and commit (the change happens "now") - writeFileSync(path.join(srcDir, 'shared.ts'), 'export const v = 2;'); - execSync('git add .', { cwd: tmp, stdio: 'pipe' }); - execSync('git commit -m "update shared.ts"', { cwd: tmp, stdio: 'pipe' }); - - // knowledge-B: lastUpdated AFTER the change → should NOT be stale - const knowledgeBTimestamp = new Date(Date.now() + 10000).toISOString(); - - const featuresDir = path.join(tmp, '.devflow', 'features'); - mkdirSync(featuresDir, { recursive: true }); - writeFileSync(path.join(featuresDir, 'index.json'), JSON.stringify({ - version: 1, - features: { - 'knowledge-old': { - name: 'Old Feature Knowledge', - description: '', - directories: ['src/'], - referencedFiles: ['src/shared.ts'], - lastUpdated: knowledgeATimestamp, - createdBy: 'test', - }, - 'knowledge-new': { - name: 'New Feature Knowledge', - description: '', - directories: ['src/'], - referencedFiles: ['src/shared.ts'], - lastUpdated: knowledgeBTimestamp, - createdBy: 'test', - }, - }, - }, null, 2)); - - const result = checkAllStaleness(tmp); - expect(result['knowledge-old'].stale).toBe(true); - expect(result['knowledge-old'].changedFiles).toContain('src/shared.ts'); - expect(result['knowledge-new'].stale).toBe(false); - expect(result['knowledge-new'].changedFiles).toEqual([]); - }); -}); - -// --------------------------------------------------------------------------- -// validateSlug -// --------------------------------------------------------------------------- - -describe('validateSlug', () => { - it('accepts valid kebab-case slugs', () => { - expect(() => validateSlug('cli-commands')).not.toThrow(); - expect(() => validateSlug('payments')).not.toThrow(); - expect(() => validateSlug('my-feature-123')).not.toThrow(); - expect(() => validateSlug('a')).not.toThrow(); - }); - - it('rejects path traversal attempts', () => { - expect(() => validateSlug('../etc')).toThrow(/must not contain/); - expect(() => validateSlug('../../dangerous')).toThrow(/must not contain/); - expect(() => validateSlug('foo/../bar')).toThrow(/must not contain/); - }); - - it('rejects slugs with slashes', () => { - expect(() => validateSlug('foo/bar')).toThrow(/must not contain/); - expect(() => validateSlug('foo\\bar')).toThrow(/must not contain/); - }); - - it('rejects slugs starting with a dot', () => { - expect(() => validateSlug('.hidden')).toThrow(/must not start with/); - }); - - it('rejects non-kebab-case slugs', () => { - expect(() => validateSlug('MyFeature')).toThrow(/kebab-case/); - expect(() => validateSlug('my_feature')).toThrow(/kebab-case/); - expect(() => validateSlug('MY-FEATURE')).toThrow(/kebab-case/); - expect(() => validateSlug('')).toThrow(/non-empty/); - }); - - it('rejects empty and non-string values', () => { - expect(() => validateSlug('')).toThrow(); - // @ts-expect-error testing runtime behavior - expect(() => validateSlug(null)).toThrow(); - // @ts-expect-error testing runtime behavior - expect(() => validateSlug(undefined)).toThrow(); - }); -}); - -// --------------------------------------------------------------------------- -// CLI: stale-slugs subcommand -// --------------------------------------------------------------------------- - -const FEATURE_KNOWLEDGE_CJS = path.join(ROOT, 'scripts/hooks/lib/feature-knowledge.cjs'); - -describe('CLI stale-slugs', () => { - it('outputs nothing for non-stale index (non-git repo)', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX); - // Non-git repo → checkAllStaleness returns stale: false for everything - const output = execFileSync('node', [FEATURE_KNOWLEDGE_CJS, 'stale-slugs', tmp], { encoding: 'utf8' }); - expect(output.trim()).toBe(''); - }); - - it('outputs stale slugs one per line for a git repo with changes', () => { - const tmp = makeTmpFeatureWorktree(); - // Remove auto-created .features dir — we'll set it up after git init - rmSync(path.join(tmp, '.devflow', 'features'), { recursive: true, force: true }); - - execSync('git init', { cwd: tmp, stdio: 'pipe' }); - execSync('git config user.email "test@test.com"', { cwd: tmp, stdio: 'pipe' }); - execSync('git config user.name "Test"', { cwd: tmp, stdio: 'pipe' }); - - const srcDir = path.join(tmp, 'src', 'cli'); - mkdirSync(srcDir, { recursive: true }); - writeFileSync(path.join(srcDir, 'cli.ts'), 'export const v = 1;'); - execSync('git add .', { cwd: tmp, stdio: 'pipe' }); - execSync('git commit -m "initial"', { cwd: tmp, stdio: 'pipe' }); - - const lastUpdated = new Date(Date.now() - 5000).toISOString(); - const featuresDir = path.join(tmp, '.devflow', 'features'); - mkdirSync(featuresDir, { recursive: true }); - writeFileSync(path.join(featuresDir, 'index.json'), JSON.stringify({ - version: 1, - features: { - 'stale-feature': { - name: 'Stale Feature', - description: '', - directories: ['src/cli/'], - referencedFiles: ['src/cli/cli.ts'], - lastUpdated, - createdBy: 'test', - }, - }, - }, null, 2)); - - // Modify the referenced file and commit after lastUpdated - writeFileSync(path.join(srcDir, 'cli.ts'), 'export const v = 2;'); - execSync('git add .', { cwd: tmp, stdio: 'pipe' }); - execSync('git commit -m "update cli.ts"', { cwd: tmp, stdio: 'pipe' }); - - const output = execFileSync('node', [FEATURE_KNOWLEDGE_CJS, 'stale-slugs', tmp], { encoding: 'utf8' }); - expect(output.trim().split('\n')).toContain('stale-feature'); - }); - - it('exits non-zero and prints usage when worktree argument is missing', () => { - expect(() => - execFileSync('node', [FEATURE_KNOWLEDGE_CJS, 'stale-slugs'], { encoding: 'utf8', stdio: 'pipe' }) - ).toThrow(expect.objectContaining({ status: 1 })); - }); -}); - -// --------------------------------------------------------------------------- -// CLI: refresh-context subcommand -// --------------------------------------------------------------------------- - -describe('CLI refresh-context', () => { - it('outputs tab-separated metadata for an existing feature knowledge entry', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX); - const output = execFileSync('node', [FEATURE_KNOWLEDGE_CJS, 'refresh-context', tmp, 'cli-commands'], { encoding: 'utf8' }); - const parts = output.trim().split('\t'); - expect(parts).toHaveLength(3); - expect(parts[0]).toBe('CLI Command System'); // name - expect(JSON.parse(parts[1])).toEqual(['src/cli/commands/', 'src/cli/utils/']); // directories JSON - expect(() => JSON.parse(parts[2])).not.toThrow(); // changed files JSON - }); - - it('exits non-zero when slug is missing', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX); - expect(() => - execFileSync('node', [FEATURE_KNOWLEDGE_CJS, 'refresh-context', tmp], { encoding: 'utf8', stdio: 'pipe' }) - ).toThrow(expect.objectContaining({ status: 1 })); - }); - - it('exits non-zero when slug is not found in index', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX); - expect(() => - execFileSync('node', [FEATURE_KNOWLEDGE_CJS, 'refresh-context', tmp, 'nonexistent'], { encoding: 'utf8', stdio: 'pipe' }) - ).toThrow(expect.objectContaining({ status: 1 })); - }); - - it('exits non-zero for invalid slug (path traversal)', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX); - expect(() => - execFileSync('node', [FEATURE_KNOWLEDGE_CJS, 'refresh-context', tmp, '../etc'], { encoding: 'utf8', stdio: 'pipe' }) - ).toThrow(expect.objectContaining({ status: 1 })); - }); -}); - -// --------------------------------------------------------------------------- -// CLI stale-slugs: empty index -// --------------------------------------------------------------------------- - -describe('CLI stale-slugs (empty index)', () => { - it('outputs nothing for empty index', () => { - const tmp = makeTmpFeatureWorktree({ version: 1, features: {} }); - const output = execFileSync('node', [FEATURE_KNOWLEDGE_CJS, 'stale-slugs', tmp], { encoding: 'utf8' }); - expect(output.trim()).toBe(''); - }); -}); - -// --------------------------------------------------------------------------- -// json-helper.cjs read-dream -// --------------------------------------------------------------------------- - -const JSON_HELPER_CJS = path.join(ROOT, 'scripts/hooks/json-helper.cjs'); - -describe('json-helper read-dream', () => { - it('returns parsed JSON array for valid result with array field', () => { - const realTmp = realpathSync(os.tmpdir()); - const result = path.join(realTmp, `test-result-${Date.now()}.json`); - writeFileSync(result, JSON.stringify({ referencedFiles: ['src/a.ts', 'src/b.ts'] })); - try { - const output = execFileSync('node', [JSON_HELPER_CJS, 'read-dream', result, 'referencedFiles'], - { encoding: 'utf8', cwd: realTmp }); - expect(JSON.parse(output.trim())).toEqual(['src/a.ts', 'src/b.ts']); - } finally { - try { rmSync(result); } catch { /* best-effort */ } - } - }); - - it('returns [] for missing file', () => { - const output = execFileSync('node', [JSON_HELPER_CJS, 'read-dream', '/nonexistent/path/file.json', 'referencedFiles'], { encoding: 'utf8' }); - expect(output.trim()).toBe('[]'); - }); - - it('returns [] for malformed JSON', () => { - const realTmp = realpathSync(os.tmpdir()); - const result = path.join(realTmp, `test-result-bad-${Date.now()}.json`); - writeFileSync(result, 'not-json'); - try { - const output = execFileSync('node', [JSON_HELPER_CJS, 'read-dream', result, 'referencedFiles'], - { encoding: 'utf8', cwd: realTmp }); - expect(output.trim()).toBe('[]'); - } finally { - try { rmSync(result); } catch { /* best-effort */ } - } - }); - - it('returns string value as-is for string fields', () => { - const realTmp = realpathSync(os.tmpdir()); - const result = path.join(realTmp, `test-result-string-${Date.now()}.json`); - writeFileSync(result, JSON.stringify({ description: 'Use when working on auth' })); - try { - const output = execFileSync('node', [JSON_HELPER_CJS, 'read-dream', result, 'description'], - { encoding: 'utf8', cwd: realTmp }); - expect(output.trim()).toBe('Use when working on auth'); - } finally { - try { rmSync(result); } catch { /* best-effort */ } - } - }); - - it('returns [] when field value is not an array or string', () => { - const realTmp = realpathSync(os.tmpdir()); - const result = path.join(realTmp, `test-result-noarray-${Date.now()}.json`); - writeFileSync(result, JSON.stringify({ referencedFiles: 42 })); - try { - const output = execFileSync('node', [JSON_HELPER_CJS, 'read-dream', result, 'referencedFiles'], - { encoding: 'utf8', cwd: realTmp }); - expect(output.trim()).toBe('[]'); - } finally { - try { rmSync(result); } catch { /* best-effort */ } - } - }); - - it('returns [] when args are missing', () => { - const output = execFileSync('node', [JSON_HELPER_CJS, 'read-dream'], { encoding: 'utf8' }); - expect(output.trim()).toBe('[]'); - }); - - it('returns [] for disallowed field name', () => { - const realTmp = realpathSync(os.tmpdir()); - const result = path.join(realTmp, `test-result-disallowed-${Date.now()}.json`); - writeFileSync(result, JSON.stringify({ secret: 'password123' })); - try { - const output = execFileSync('node', [JSON_HELPER_CJS, 'read-dream', result, 'secret'], - { encoding: 'utf8', cwd: realTmp }); - expect(output.trim()).toBe('[]'); - } finally { - try { rmSync(result); } catch {} - } - }); - - it('returns [] when result path is outside cwd', () => { - const realTmp = realpathSync(os.tmpdir()); - const result = path.join(realTmp, `test-result-outside-${Date.now()}.json`); - writeFileSync(result, JSON.stringify({ referencedFiles: ['a.ts'] })); - const otherDir = path.join(realTmp, `test-other-${Date.now()}`); - mkdirSync(otherDir, { recursive: true }); - try { - const output = execFileSync('node', [JSON_HELPER_CJS, 'read-dream', result, 'referencedFiles'], - { encoding: 'utf8', cwd: otherDir }); - expect(output.trim()).toBe('[]'); - } finally { - try { rmSync(result); } catch {} - try { rmdirSync(otherDir); } catch {} - } - }); -}); - -// --------------------------------------------------------------------------- -// safePath unit tests -// --------------------------------------------------------------------------- - -describe('safePath', () => { - const { safePath } = require(path.join(ROOT, 'scripts/hooks/lib/safe-path.cjs')); - - it('resolves relative path to absolute', () => { - const result = safePath('foo/bar.json'); - expect(path.isAbsolute(result)).toBe(true); - }); - - it('passes absolute path through', () => { - const realTmp = realpathSync(os.tmpdir()); - expect(safePath(path.join(realTmp, 'test.json'))).toBe(path.join(realTmp, 'test.json')); - }); - - it('allows path inside allowedRoot', () => { - const realTmp = realpathSync(os.tmpdir()); - const filePath = path.join(realTmp, 'sub', 'file.ts'); - expect(safePath(filePath, realTmp)).toBe(filePath); - }); - - it('throws for path outside allowedRoot', () => { - expect(() => safePath('/etc/passwd', '/project')).toThrow('Refused path outside'); - }); - - it('allows path with .. that resolves inside root', () => { - expect(safePath('/project/a/../b/file.ts', '/project')).toBe('/project/b/file.ts'); - }); - - it('skips validation when allowedRoot is undefined', () => { - expect(safePath('/anywhere/file.json')).toBe('/anywhere/file.json'); - }); -}); - -// --------------------------------------------------------------------------- -// readAgentResult helper (TypeScript) -// --------------------------------------------------------------------------- - -import { readAgentResult } from '../../src/cli/commands/knowledge/index.js'; - -describe('readAgentResult', () => { - const tmpFiles: string[] = []; - - afterEach(() => { - for (const f of tmpFiles) { - try { rmSync(f); } catch { /* best-effort */ } - } - tmpFiles.length = 0; - }); - - function writeTmp(content: string): string { - const f = path.join(os.tmpdir(), `test-read-agent-result-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); - writeFileSync(f, content); - tmpFiles.push(f); - return f; - } - - it('returns referencedFiles and description for valid result file', async () => { - const f = writeTmp(JSON.stringify({ referencedFiles: ['src/a.ts', 'src/b.ts'], description: 'Use when X' })); - const result = await readAgentResult(f); - expect(result.referencedFiles).toEqual(['src/a.ts', 'src/b.ts']); - expect(result.description).toBe('Use when X'); - }); - - it('returns {} for missing file', async () => { - const result = await readAgentResult('/nonexistent/path/file.json'); - expect(result).toEqual({}); - }); - - it('returns {} for invalid JSON', async () => { - const f = writeTmp('not-valid-json'); - const result = await readAgentResult(f); - expect(result).toEqual({}); - }); - - it('omits referencedFiles when value is a string not array', async () => { - const f = writeTmp(JSON.stringify({ referencedFiles: 'should-be-array' })); - const result = await readAgentResult(f); - expect(result.referencedFiles).toBeUndefined(); - }); - - it('filters mixed-type referencedFiles array to strings only', async () => { - const f = writeTmp(JSON.stringify({ referencedFiles: ['src/a.ts', 42, null, 'src/b.ts'] })); - const result = await readAgentResult(f); - expect(result.referencedFiles).toEqual(['src/a.ts', 'src/b.ts']); - }); - - it('returns {} when JSON parses to a non-object (primitive)', async () => { - const tmp = path.join(os.tmpdir(), `agent-result-primitive-${Date.now()}.json`); - writeFileSync(tmp, '42'); - try { - const result = await readAgentResult(tmp); - expect(result).toEqual({}); - } finally { - rmSync(tmp, { force: true }); - } - }); -}); diff --git a/tests/feature-knowledge/fixtures.ts b/tests/feature-knowledge/fixtures.ts deleted file mode 100644 index f36fd892..00000000 --- a/tests/feature-knowledge/fixtures.ts +++ /dev/null @@ -1,98 +0,0 @@ -// Shared test fixtures for feature-knowledge module tests. - -import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs'; -import * as path from 'path'; -import * as os from 'os'; - -export const SAMPLE_INDEX = { - version: 1, - features: { - 'cli-commands': { - name: 'CLI Command System', - description: 'Use when adding CLI subcommands, modifying plugin registration, or changing the init flow.', - directories: ['src/cli/commands/', 'src/cli/utils/'], - referencedFiles: ['src/cli/cli.ts', 'src/cli/plugins.ts'], - lastUpdated: '2026-04-20T14:30:00Z', - createdBy: 'implement', - }, - }, -}; - -export const SAMPLE_FEATURE_KNOWLEDGE_CONTENT = `--- -feature: cli-commands -name: CLI Command System -directories: - - src/cli/commands/ - - src/cli/utils/ -referencedFiles: - - src/cli/cli.ts - - src/cli/plugins.ts -created: 2026-04-20T14:30:00Z -updated: 2026-04-20T14:30:00Z ---- - -# CLI Command System - -## Overview -Commander.js-based CLI with @clack/prompts for interactive UX. - -## Architecture -Each command is a separate file in src/cli/commands/ exporting a Command instance. - -## Key Patterns -- Commander.js option chain -- @clack/prompts for TUI dialogs - -## Anti-Patterns -- Don't use inquirer (project uses @clack/prompts) - -## Gotchas -- Always register new commands in cli.ts - -## Key Files -- src/cli/cli.ts — command registration -- src/cli/plugins.ts — plugin registry -`; - -const createdTmpDirs: string[] = []; - -/** - * Create a temporary worktree directory with optional .features/ index and feature knowledge files. - * Returns the absolute path to the tmpdir root. - * Directories are tracked — call `cleanupTmpFeatureWorktrees()` in afterAll. - */ -export function makeTmpFeatureWorktree( - indexContent?: object, - featureKnowledge?: Record, -): string { - const tmp = mkdtempSync(path.join(os.tmpdir(), 'feature-knowledge-test-')); - createdTmpDirs.push(tmp); - - const featuresDir = path.join(tmp, '.devflow', 'features'); - mkdirSync(featuresDir, { recursive: true }); - - if (indexContent) { - writeFileSync(path.join(featuresDir, 'index.json'), JSON.stringify(indexContent, null, 2)); - } - - if (featureKnowledge) { - for (const [slug, content] of Object.entries(featureKnowledge)) { - const knowledgeDir = path.join(featuresDir, slug); - mkdirSync(knowledgeDir, { recursive: true }); - writeFileSync(path.join(knowledgeDir, 'KNOWLEDGE.md'), content); - } - } - - return tmp; -} - -/** - * Remove all temporary worktree directories created by `makeTmpFeatureWorktree`. - * Call in `afterAll(() => cleanupTmpFeatureWorktrees())`. - */ -export function cleanupTmpFeatureWorktrees(): void { - for (const dir of createdTmpDirs) { - try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } - } - createdTmpDirs.length = 0; -} diff --git a/tests/feature-knowledge/knowledge-agent.test.ts b/tests/feature-knowledge/knowledge-agent.test.ts deleted file mode 100644 index 89de3326..00000000 --- a/tests/feature-knowledge/knowledge-agent.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; -import * as path from 'path'; - -const ROOT = path.resolve(import.meta.dirname, '../..'); - -describe('knowledge agent', () => { - const content = readFileSync(path.join(ROOT, 'shared/agents/knowledge.md'), 'utf8'); - - it('has correct model', () => { expect(content).toContain('model: sonnet'); }); - it('has feature-knowledge skill', () => { expect(content).toContain('devflow:feature-knowledge'); }); - it('has worktree-support skill', () => { expect(content).toContain('devflow:worktree-support'); }); - it('has required tools', () => { - expect(content).toContain('Read'); - expect(content).toContain('Grep'); - expect(content).toContain('Glob'); - expect(content).toContain('Write'); - }); - it('documents input contract', () => { - expect(content).toContain('FEATURE_SLUG'); - expect(content).toContain('FEATURE_NAME'); - expect(content).toContain('EXPLORATION_OUTPUTS'); - expect(content).toContain('DIRECTORIES'); - expect(content).toContain('DECISIONS_CONTEXT'); - }); - it('constrains writes to .devflow/features/', () => { - expect(content).toContain('.devflow/features/'); - expect(content).toContain('Boundaries'); - }); -}); diff --git a/tests/feature-knowledge/knowledge-command.test.ts b/tests/feature-knowledge/knowledge-command.test.ts deleted file mode 100644 index 18b2c888..00000000 --- a/tests/feature-knowledge/knowledge-command.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, it, expect, afterAll } from 'vitest'; -import { execSync } from 'child_process'; -import * as path from 'path'; -import { readFileSync, rmSync } from 'fs'; -import { makeTmpFeatureWorktree, cleanupTmpFeatureWorktrees, SAMPLE_INDEX } from './fixtures'; - -afterAll(() => cleanupTmpFeatureWorktrees()); - -const ROOT = path.resolve(import.meta.dirname, '../..'); -const CJS_PATH = path.join(ROOT, 'scripts/hooks/lib/feature-knowledge.cjs'); - -describe('feature-knowledge.cjs CLI', () => { - it('list shows entries', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX); - const result = execSync(`node ${CJS_PATH} list ${tmp}`, { encoding: 'utf8' }); - const entries = JSON.parse(result); - expect(entries).toHaveLength(1); - expect(entries[0].slug).toBe('cli-commands'); - expect(entries[0].name).toBe('CLI Command System'); - }); - - it('list returns empty array for missing index', () => { - const tmp = makeTmpFeatureWorktree(); - // Remove the index file so index is missing - try { rmSync(path.join(tmp, '.devflow', 'features', 'index.json')); } catch { /* ignore */ } - const result = execSync(`node ${CJS_PATH} list ${tmp}`, { encoding: 'utf8' }); - expect(JSON.parse(result)).toEqual([]); - }); - - it('stale returns staleness for slug', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX); - const result = execSync(`node ${CJS_PATH} stale ${tmp} cli-commands`, { encoding: 'utf8' }); - const parsed = JSON.parse(result); - expect(parsed).toHaveProperty('stale'); - expect(parsed).toHaveProperty('changedFiles'); - }); - - it('exits 1 with usage error on no args', () => { - expect(() => execSync(`node ${CJS_PATH}`, { encoding: 'utf8', stdio: 'pipe' })).toThrow(); - }); - - it('update-index creates entry', () => { - const tmp = makeTmpFeatureWorktree({ version: 1, features: {} }); - execSync( - `node ${CJS_PATH} update-index ${tmp} --slug=payments --name="Payment Processing" --directories='["src/payments/"]' --referencedFiles='["src/payments/checkout.ts"]'`, - { encoding: 'utf8' } - ); - const index = JSON.parse(readFileSync(path.join(tmp, '.devflow', 'features', 'index.json'), 'utf8')); - expect(index.features.payments).toBeDefined(); - expect(index.features.payments.name).toBe('Payment Processing'); - }); - - it('remove deletes entry and directory', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX, { 'cli-commands': '# Test Feature Knowledge' }); - execSync(`node ${CJS_PATH} remove ${tmp} cli-commands`, { encoding: 'utf8' }); - const index = JSON.parse(readFileSync(path.join(tmp, '.devflow', 'features', 'index.json'), 'utf8')); - expect(index.features['cli-commands']).toBeUndefined(); - }); - - // T6: Unknown command and invalid worktree - it('exits 1 for unknown subcommand', () => { - expect(() => execSync(`node ${CJS_PATH} unknown-command /tmp`, { encoding: 'utf8', stdio: 'pipe' })).toThrow(); - }); - - it('exits 1 for invalid worktree path', () => { - expect(() => execSync(`node ${CJS_PATH} list /nonexistent/path/that/does/not/exist`, { encoding: 'utf8', stdio: 'pipe' })).toThrow(); - }); - - it('find-overlapping returns overlapping slugs', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX); - const result = execSync(`node ${CJS_PATH} find-overlapping ${tmp} src/cli/cli.ts`, { encoding: 'utf8' }); - const slugs = JSON.parse(result); - expect(slugs).toContain('cli-commands'); - }); - - it('find-overlapping returns empty for non-overlapping files', () => { - const tmp = makeTmpFeatureWorktree(SAMPLE_INDEX); - const result = execSync(`node ${CJS_PATH} find-overlapping ${tmp} src/payments/checkout.ts`, { encoding: 'utf8' }); - expect(JSON.parse(result)).toEqual([]); - }); - -}); diff --git a/tests/migrations.test.ts b/tests/migrations.test.ts index 60985dfe..b6764f90 100644 --- a/tests/migrations.test.ts +++ b/tests/migrations.test.ts @@ -163,6 +163,36 @@ describe('MIGRATIONS', () => { expect(renameIdx).toBeGreaterThanOrEqual(0); expect(consolidateIdx).toBeGreaterThan(renameIdx); }); + + it('contains purge-knowledge-hooks-global-v1 with global scope', () => { + const m = MIGRATIONS.find(m => m.id === 'purge-knowledge-hooks-global-v1'); + expect(m).toBeDefined(); + expect(m?.scope).toBe('global'); + expect(m?.description).toBeTruthy(); + expect(typeof m?.run).toBe('function'); + }); + + it('contains purge-feature-knowledge-pipeline-v1 with per-project scope', () => { + const m = MIGRATIONS.find(m => m.id === 'purge-feature-knowledge-pipeline-v1'); + expect(m).toBeDefined(); + expect(m?.scope).toBe('per-project'); + expect(m?.description).toBeTruthy(); + expect(typeof m?.run).toBe('function'); + }); + + it('purge-knowledge-hooks-global-v1 appears after purge-dead-working-memory-sentinel-v1', () => { + const sentinelIdx = MIGRATIONS.findIndex(m => m.id === 'purge-dead-working-memory-sentinel-v1'); + const hooksIdx = MIGRATIONS.findIndex(m => m.id === 'purge-knowledge-hooks-global-v1'); + expect(sentinelIdx).toBeGreaterThanOrEqual(0); + expect(hooksIdx).toBeGreaterThan(sentinelIdx); + }); + + it('purge-feature-knowledge-pipeline-v1 appears after purge-knowledge-hooks-global-v1', () => { + const hooksIdx = MIGRATIONS.findIndex(m => m.id === 'purge-knowledge-hooks-global-v1'); + const pipelineIdx = MIGRATIONS.findIndex(m => m.id === 'purge-feature-knowledge-pipeline-v1'); + expect(hooksIdx).toBeGreaterThanOrEqual(0); + expect(pipelineIdx).toBeGreaterThan(hooksIdx); + }); }); describe('runMigrations', () => { @@ -1898,3 +1928,325 @@ describe('purge-dead-working-memory-sentinel-v1 migration', () => { expect(deadIdx).toBeGreaterThan(staleIdx); }); }); + +// --------------------------------------------------------------------------- +// purge-knowledge-hooks-global-v1 migration +// --------------------------------------------------------------------------- + +describe('purge-knowledge-hooks-global-v1 migration', () => { + let tmpDir: string; + let fakeDevflow: string; + let originalHome: string | undefined; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devflow-knowledge-hooks-global-test-')); + fakeDevflow = path.join(tmpDir, 'devflow'); + await fs.mkdir(path.join(fakeDevflow, 'scripts', 'hooks', 'lib'), { recursive: true }); + originalHome = process.env.HOME; + process.env.HOME = path.join(tmpDir, 'home'); + await fs.mkdir(path.join(tmpDir, 'home', '.devflow'), { recursive: true }); + }); + + afterEach(async () => { + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + function getMigration(): Migration<'global'> { + const m = MIGRATIONS.find(m => m.id === 'purge-knowledge-hooks-global-v1'); + if (!m) throw new Error('purge-knowledge-hooks-global-v1 migration not found'); + return m as Migration<'global'>; + } + + function makeCtx(): import('../src/cli/utils/migrations.js').GlobalMigrationContext { + return { scope: 'global', devflowDir: fakeDevflow }; + } + + it('is registered in MIGRATIONS with global scope', () => { + const m = MIGRATIONS.find(m => m.id === 'purge-knowledge-hooks-global-v1'); + expect(m).toBeDefined(); + expect(m?.scope).toBe('global'); + expect(m?.description).toBeTruthy(); + expect(typeof m?.run).toBe('function'); + }); + + it('appears after purge-dead-working-memory-sentinel-v1 in MIGRATIONS array', () => { + const prevIdx = MIGRATIONS.findIndex(m => m.id === 'purge-dead-working-memory-sentinel-v1'); + const thisIdx = MIGRATIONS.findIndex(m => m.id === 'purge-knowledge-hooks-global-v1'); + expect(prevIdx).toBeGreaterThanOrEqual(0); + expect(thisIdx).toBeGreaterThan(prevIdx); + }); + + it('removes eval-knowledge hook when present', async () => { + const hookPath = path.join(fakeDevflow, 'scripts', 'hooks', 'eval-knowledge'); + await fs.writeFile(hookPath, '#!/bin/bash\necho eval-knowledge\n', 'utf-8'); + + const result = await getMigration().run(makeCtx()); + + await expect(fs.access(hookPath)).rejects.toThrow(); + expect(result?.infos?.length).toBeGreaterThan(0); + expect(result?.warnings ?? []).toEqual([]); + }); + + it('removes feature-knowledge.cjs when present', async () => { + const libPath = path.join(fakeDevflow, 'scripts', 'hooks', 'lib', 'feature-knowledge.cjs'); + await fs.writeFile(libPath, '// feature-knowledge lib\n', 'utf-8'); + + const result = await getMigration().run(makeCtx()); + + await expect(fs.access(libPath)).rejects.toThrow(); + expect(result?.infos?.length).toBeGreaterThan(0); + }); + + it('removes both files when both are present', async () => { + const hookPath = path.join(fakeDevflow, 'scripts', 'hooks', 'eval-knowledge'); + const libPath = path.join(fakeDevflow, 'scripts', 'hooks', 'lib', 'feature-knowledge.cjs'); + await fs.writeFile(hookPath, '#!/bin/bash\n', 'utf-8'); + await fs.writeFile(libPath, '// lib\n', 'utf-8'); + + const result = await getMigration().run(makeCtx()); + + await expect(fs.access(hookPath)).rejects.toThrow(); + await expect(fs.access(libPath)).rejects.toThrow(); + expect(result?.infos?.length).toBeGreaterThan(0); + }); + + it('is a no-op when neither file exists (ENOENT-idempotent)', async () => { + // No files seeded — migration should succeed without error + const result = await getMigration().run(makeCtx()); + expect(result?.infos ?? []).toEqual([]); + expect(result?.warnings ?? []).toEqual([]); + }); + + it('is idempotent — running twice produces no error and same result', async () => { + const hookPath = path.join(fakeDevflow, 'scripts', 'hooks', 'eval-knowledge'); + await fs.writeFile(hookPath, '#!/bin/bash\n', 'utf-8'); + + await getMigration().run(makeCtx()); + // Second run: file already gone — must not throw + await expect(getMigration().run(makeCtx())).resolves.not.toThrow(); + const result = await getMigration().run(makeCtx()); + expect(result?.infos ?? []).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// purge-feature-knowledge-pipeline-v1 migration +// --------------------------------------------------------------------------- + +describe('purge-feature-knowledge-pipeline-v1 migration', () => { + let tmpDir: string; + let projectRoot: string; + let devflowDir: string; + let dreamDir: string; + let featuresDir: string; + let fakeHome: string; + let originalHome: string | undefined; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devflow-purge-knowledge-pipeline-test-')); + projectRoot = path.join(tmpDir, 'project'); + devflowDir = path.join(projectRoot, '.devflow'); + dreamDir = path.join(devflowDir, 'dream'); + featuresDir = path.join(devflowDir, 'features'); + await fs.mkdir(dreamDir, { recursive: true }); + await fs.mkdir(featuresDir, { recursive: true }); + originalHome = process.env.HOME; + process.env.HOME = path.join(tmpDir, 'home'); + fakeHome = path.join(tmpDir, 'home', '.devflow'); + await fs.mkdir(fakeHome, { recursive: true }); + }); + + afterEach(async () => { + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + function getMigration(): Migration<'per-project'> { + const m = MIGRATIONS.find(m => m.id === 'purge-feature-knowledge-pipeline-v1'); + if (!m) throw new Error('purge-feature-knowledge-pipeline-v1 migration not found'); + return m as Migration<'per-project'>; + } + + function makeCtx(): import('../src/cli/utils/migrations.js').PerProjectMigrationContext { + return { + scope: 'per-project', + devflowDir: fakeHome, + memoryDir: path.join(devflowDir, 'memory'), + projectRoot, + }; + } + + it('is registered in MIGRATIONS with per-project scope', () => { + const m = MIGRATIONS.find(m => m.id === 'purge-feature-knowledge-pipeline-v1'); + expect(m).toBeDefined(); + expect(m?.scope).toBe('per-project'); + expect(m?.description).toBeTruthy(); + expect(typeof m?.run).toBe('function'); + }); + + it('appears after purge-knowledge-hooks-global-v1 in MIGRATIONS array', () => { + const globalIdx = MIGRATIONS.findIndex(m => m.id === 'purge-knowledge-hooks-global-v1'); + const thisIdx = MIGRATIONS.findIndex(m => m.id === 'purge-feature-knowledge-pipeline-v1'); + expect(globalIdx).toBeGreaterThanOrEqual(0); + expect(thisIdx).toBeGreaterThan(globalIdx); + }); + + it('removes knowledge.*.json dream markers', async () => { + const marker1 = path.join(dreamDir, 'knowledge.abc123.json'); + const marker2 = path.join(dreamDir, 'knowledge.def456.json'); + await fs.writeFile(marker1, '{}', 'utf-8'); + await fs.writeFile(marker2, '{}', 'utf-8'); + + await getMigration().run(makeCtx()); + + await expect(fs.access(marker1)).rejects.toThrow(); + await expect(fs.access(marker2)).rejects.toThrow(); + }); + + it('removes knowledge.*.processing dream markers', async () => { + const marker = path.join(dreamDir, 'knowledge.abc123.processing'); + await fs.writeFile(marker, '', 'utf-8'); + + await getMigration().run(makeCtx()); + + await expect(fs.access(marker)).rejects.toThrow(); + }); + + it('preserves non-knowledge dream files', async () => { + const decisionsMarker = path.join(dreamDir, 'decisions.abc123.json'); + const configFile = path.join(dreamDir, 'config.json'); + await fs.writeFile(decisionsMarker, '{}', 'utf-8'); + await fs.writeFile(configFile, '{"knowledge":true}', 'utf-8'); + + await getMigration().run(makeCtx()); + + // decisions and config must survive + await expect(fs.access(decisionsMarker)).resolves.toBeUndefined(); + await expect(fs.access(configFile)).resolves.toBeUndefined(); + }); + + it('removes .knowledge-last-refresh file', async () => { + const lastRefresh = path.join(featuresDir, '.knowledge-last-refresh'); + await fs.writeFile(lastRefresh, '1718000000', 'utf-8'); + + await getMigration().run(makeCtx()); + + await expect(fs.access(lastRefresh)).rejects.toThrow(); + }); + + it('removes .disabled sentinel file', async () => { + const disabled = path.join(featuresDir, '.disabled'); + await fs.writeFile(disabled, '', 'utf-8'); + + await getMigration().run(makeCtx()); + + await expect(fs.access(disabled)).rejects.toThrow(); + }); + + it('removes .knowledge.lock directory recursively', async () => { + const lockDir = path.join(featuresDir, '.knowledge.lock'); + await fs.mkdir(lockDir, { recursive: true }); + await fs.writeFile(path.join(lockDir, 'pid'), '12345', 'utf-8'); + + await getMigration().run(makeCtx()); + + await expect(fs.access(lockDir)).rejects.toThrow(); + }); + + it('removes .knowledge-refresh.lock directory recursively', async () => { + const lockDir = path.join(featuresDir, '.knowledge-refresh.lock'); + await fs.mkdir(lockDir, { recursive: true }); + + await getMigration().run(makeCtx()); + + await expect(fs.access(lockDir)).rejects.toThrow(); + }); + + it('renames index.json to index.json.deprecated when present', async () => { + const indexJson = path.join(featuresDir, 'index.json'); + const indexDeprecated = path.join(featuresDir, 'index.json.deprecated'); + const content = '{"feature-knowledge-system":{"name":"Feature Knowledge System"}}'; + await fs.writeFile(indexJson, content, 'utf-8'); + + await getMigration().run(makeCtx()); + + await expect(fs.access(indexJson)).rejects.toThrow(); + await expect(fs.access(indexDeprecated)).resolves.toBeUndefined(); + const renamed = await fs.readFile(indexDeprecated, 'utf-8'); + expect(renamed).toBe(content); + }); + + it('does NOT touch the knowledge key in dream config.json', async () => { + const configPath = path.join(dreamDir, 'config.json'); + const config = JSON.stringify({ knowledge: true, decisions: true }, null, 2); + await fs.writeFile(configPath, config, 'utf-8'); + + await getMigration().run(makeCtx()); + + const after = JSON.parse(await fs.readFile(configPath, 'utf-8')); + expect(after.knowledge).toBe(true); + expect(after.decisions).toBe(true); + }); + + it('does NOT remove KNOWLEDGE.md files from feature slugs', async () => { + const slugDir = path.join(featuresDir, 'my-feature'); + await fs.mkdir(slugDir, { recursive: true }); + const kbPath = path.join(slugDir, 'KNOWLEDGE.md'); + await fs.writeFile(kbPath, '# My Feature KB\n', 'utf-8'); + + await getMigration().run(makeCtx()); + + await expect(fs.access(kbPath)).resolves.toBeUndefined(); + const content = await fs.readFile(kbPath, 'utf-8'); + expect(content).toBe('# My Feature KB\n'); + }); + + it('is a no-op when no artifacts exist (ENOENT-idempotent)', async () => { + // No artifacts seeded — migration should succeed without error + const result = await getMigration().run(makeCtx()); + expect(result?.warnings ?? []).toEqual([]); + }); + + it('is idempotent — running twice produces no error and no further change', async () => { + // Seed all artifacts + await fs.writeFile(path.join(dreamDir, 'knowledge.abc123.json'), '{}', 'utf-8'); + await fs.writeFile(path.join(featuresDir, '.knowledge-last-refresh'), '1718000000', 'utf-8'); + await fs.writeFile(path.join(featuresDir, '.disabled'), '', 'utf-8'); + await fs.mkdir(path.join(featuresDir, '.knowledge.lock'), { recursive: true }); + await fs.writeFile(path.join(featuresDir, 'index.json'), '{}', 'utf-8'); + + // First run — performs cleanup + await getMigration().run(makeCtx()); + + // Second run — everything already gone, must not throw + await expect(getMigration().run(makeCtx())).resolves.not.toThrow(); + const result = await getMigration().run(makeCtx()); + expect(result?.warnings ?? []).toEqual([]); + + // index.json.deprecated must still be present (not removed on second run) + await expect( + fs.access(path.join(featuresDir, 'index.json.deprecated')), + ).resolves.toBeUndefined(); + }); + + it('does not rename index.json.deprecated a second time if already renamed', async () => { + // Pre-seed only the deprecated file (first run already ran) + const deprecatedPath = path.join(featuresDir, 'index.json.deprecated'); + await fs.writeFile(deprecatedPath, '{"existing":true}', 'utf-8'); + + await getMigration().run(makeCtx()); + + // Deprecated file must still contain original content + const content = await fs.readFile(deprecatedPath, 'utf-8'); + expect(content).toBe('{"existing":true}'); + }); +}); diff --git a/tests/project-paths.test.ts b/tests/project-paths.test.ts index 5dcb2451..f2b2dd73 100644 --- a/tests/project-paths.test.ts +++ b/tests/project-paths.test.ts @@ -41,11 +41,7 @@ import { getPendingTurnsPath, getPendingTurnsProcessingPath, getPendingTurnsLockDir, - getFeaturesIndexPath, getKnowledgePath, - getFeaturesDisabledSentinel, - getFeaturesLockDir, - getFeaturesLastRefreshPath, getReviewsDir, getDesignDir, getResearchDir, @@ -164,25 +160,9 @@ describe('project-paths TypeScript module', () => { }); describe('features / knowledge files', () => { - it('getFeaturesIndexPath returns .devflow/features/index.json', () => { - expect(getFeaturesIndexPath(ROOT)).toBe('/some/project/.devflow/features/index.json'); - }); - it('getKnowledgePath returns .devflow/features/{slug}/KNOWLEDGE.md', () => { expect(getKnowledgePath(ROOT, 'my-feature')).toBe('/some/project/.devflow/features/my-feature/KNOWLEDGE.md'); }); - - it('getFeaturesDisabledSentinel returns .devflow/features/.disabled', () => { - expect(getFeaturesDisabledSentinel(ROOT)).toBe('/some/project/.devflow/features/.disabled'); - }); - - it('getFeaturesLockDir returns .devflow/features/.knowledge.lock', () => { - expect(getFeaturesLockDir(ROOT)).toBe('/some/project/.devflow/features/.knowledge.lock'); - }); - - it('getFeaturesLastRefreshPath returns .devflow/features/.knowledge-last-refresh', () => { - expect(getFeaturesLastRefreshPath(ROOT)).toBe('/some/project/.devflow/features/.knowledge-last-refresh'); - }); }); describe('docs files', () => { @@ -276,10 +256,6 @@ describe('CJS project-paths parity', () => { { name: 'getPendingTurnsPath', ts: getPendingTurnsPath, cjs: cjsPaths.getPendingTurnsPath }, { name: 'getPendingTurnsProcessingPath', ts: getPendingTurnsProcessingPath, cjs: cjsPaths.getPendingTurnsProcessingPath }, { name: 'getPendingTurnsLockDir', ts: getPendingTurnsLockDir, cjs: cjsPaths.getPendingTurnsLockDir }, - { name: 'getFeaturesIndexPath', ts: getFeaturesIndexPath, cjs: cjsPaths.getFeaturesIndexPath }, - { name: 'getFeaturesDisabledSentinel', ts: getFeaturesDisabledSentinel, cjs: cjsPaths.getFeaturesDisabledSentinel }, - { name: 'getFeaturesLockDir', ts: getFeaturesLockDir, cjs: cjsPaths.getFeaturesLockDir }, - { name: 'getFeaturesLastRefreshPath', ts: getFeaturesLastRefreshPath, cjs: cjsPaths.getFeaturesLastRefreshPath }, { name: 'getReviewsDir', ts: getReviewsDir, cjs: cjsPaths.getReviewsDir }, { name: 'getDesignDir', ts: getDesignDir, cjs: cjsPaths.getDesignDir }, { name: 'getResearchDir', ts: getResearchDir, cjs: cjsPaths.getResearchDir }, diff --git a/tests/shell-hooks.test.ts b/tests/shell-hooks.test.ts index 0487755f..2621e0e7 100644 --- a/tests/shell-hooks.test.ts +++ b/tests/shell-hooks.test.ts @@ -31,7 +31,6 @@ const HOOK_SCRIPTS = [ 'dream-dispatch', 'eval-helpers', 'eval-decisions', - 'eval-knowledge', ]; describe('shell hook syntax checks', () => { @@ -1391,22 +1390,13 @@ describe('ensure-devflow-init behavioral', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it('creates .devflow/features/ and index.json when absent', () => { + it('creates .devflow/features/ directory when absent (no index.json — write-through model)', () => { execSync(`bash -c 'source "${ENSURE_DEVFLOW}" "${tmpDir}"'`, { stdio: 'pipe' }); - expect(fs.existsSync(path.join(tmpDir, '.devflow', 'features', 'index.json'))).toBe(true); - const content = fs.readFileSync(path.join(tmpDir, '.devflow', 'features', 'index.json'), 'utf-8'); - expect(content).toBe('{"version":1,"features":{}}'); - }); - - it('does not overwrite existing index.json', () => { - fs.mkdirSync(path.join(tmpDir, '.devflow', 'features'), { recursive: true }); - fs.writeFileSync(path.join(tmpDir, '.devflow', 'features', 'index.json'), '{"existing":"data"}'); - - execSync(`bash -c 'source "${ENSURE_DEVFLOW}" "${tmpDir}"'`, { stdio: 'pipe' }); - - const content = fs.readFileSync(path.join(tmpDir, '.devflow', 'features', 'index.json'), 'utf-8'); - expect(content).toBe('{"existing":"data"}'); + // Knowledge index is now write-through (written when a KB is created/refreshed in-command) + // ensure-devflow-init only creates the directory, not the index file + expect(fs.existsSync(path.join(tmpDir, '.devflow', 'features'))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, '.devflow', 'features', 'index.json'))).toBe(false); }); it('adds .devflow/ to the project root .gitignore (creates it when absent)', () => { @@ -1735,9 +1725,10 @@ describe('dream-evaluate business logic', () => { expect(fs.existsSync(logFile)).toBe(true); const log = fs.readFileSync(logFile, 'utf-8'); expect(log).toContain('Evaluating decisions'); - expect(log).toContain('Evaluating knowledge'); // learning pipeline removed — no 'Evaluating learning' should appear expect(log).not.toContain('Evaluating learning'); + // knowledge is now write-through (in-command) — no 'Evaluating knowledge' from dream-evaluate + expect(log).not.toContain('Evaluating knowledge'); }); it('shallow session (2 turns) skips decisions', () => { @@ -1786,51 +1777,6 @@ describe('dream-evaluate business logic', () => { expect(pairs.length).toBeGreaterThan(0); }); - it('knowledge throttle: recent refresh skips evaluation', () => { - // Write a recent timestamp to the throttle marker - const featuresDir = path.join(tmpDir, '.devflow', 'features'); - fs.mkdirSync(featuresDir, { recursive: true }); - fs.writeFileSync(path.join(featuresDir, 'index.json'), '{"version":1,"features":{}}'); - const now = Math.floor(Date.now() / 1000); - fs.writeFileSync(path.join(featuresDir, '.knowledge-last-refresh'), String(now)); - - createTranscript(homeDir, tmpDir, 5); - runHook(EVALUATE_HOOK, { - cwd: tmpDir, - session_id: 'test-session', - }, homeDir); - - const dreamDir = path.join(tmpDir, '.devflow', 'dream'); - expect(fs.existsSync(path.join(dreamDir, 'knowledge.json'))).toBe(false); - - const logDir = path.join(homeDir, '.devflow', 'logs', encodeCwd(tmpDir)); - const logFile = path.join(logDir, '.dream-evaluate.log'); - expect(fs.existsSync(logFile)).toBe(true); - const log = fs.readFileSync(logFile, 'utf-8'); - expect(log).toContain('Knowledge throttled'); - }); - - it('knowledge disabled sentinel skips evaluation', () => { - const featuresDir = path.join(tmpDir, '.devflow', 'features'); - fs.mkdirSync(featuresDir, { recursive: true }); - fs.writeFileSync(path.join(featuresDir, '.disabled'), ''); - - createTranscript(homeDir, tmpDir, 5); - runHook(EVALUATE_HOOK, { - cwd: tmpDir, - session_id: 'test-session', - }, homeDir); - - const dreamDir = path.join(tmpDir, '.devflow', 'dream'); - expect(fs.existsSync(path.join(dreamDir, 'knowledge.json'))).toBe(false); - - const logDir = path.join(homeDir, '.devflow', 'logs', encodeCwd(tmpDir)); - const logFile = path.join(logDir, '.dream-evaluate.log'); - expect(fs.existsSync(logFile)).toBe(true); - const log = fs.readFileSync(logFile, 'utf-8'); - expect(log).toContain('Knowledge disabled by sentinel'); - }); - it('session ID path traversal rejected, fallback used', () => { // Create a valid transcript with a normal name createTranscript(homeDir, tmpDir, 5, 5, 'valid-session'); @@ -1849,76 +1795,6 @@ describe('dream-evaluate business logic', () => { }); }); -// ============================================================================= -// dream-evaluate knowledge evaluation -// ============================================================================= - -describe('dream-evaluate knowledge evaluation', () => { - const EVALUATE_HOOK = path.join(HOOKS_DIR, 'dream-evaluate'); - - let tmpDir: string; - let homeDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-cap-test-')); - homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-cap-home-')); - fs.mkdirSync(path.join(tmpDir, '.devflow', 'dream'), { recursive: true }); - fs.mkdirSync(path.join(homeDir, '.devflow', 'logs'), { recursive: true }); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - fs.rmSync(homeDir, { recursive: true, force: true }); - }); - - it('LAST_REFRESH sanitization: non-numeric content in knowledge marker defaults to 0', () => { - // Write non-numeric content to .knowledge-last-refresh — should sanitize to 0 - // resulting in a stale age calculation (now - 0 > 7200) and triggering evaluation - const featuresDir = path.join(tmpDir, '.devflow', 'features'); - fs.mkdirSync(featuresDir, { recursive: true }); - // No stale slugs — so no marker will be written — but hook should not crash - fs.writeFileSync(path.join(featuresDir, 'index.json'), '{"version":1,"features":{}}'); - fs.writeFileSync(path.join(featuresDir, '.knowledge-last-refresh'), 'not-a-number\n'); - - createTranscript(homeDir, tmpDir, 5); - const { exitCode } = runHook(EVALUATE_HOOK, { cwd: tmpDir, session_id: 'test-session' }, homeDir); - expect(exitCode).toBe(0); - - // Hook should have attempted knowledge evaluation (no crash from bad timestamp) - const logDir = path.join(homeDir, '.devflow', 'logs', encodeCwd(tmpDir)); - const logFile = path.join(logDir, '.dream-evaluate.log'); - if (fs.existsSync(logFile)) { - const log = fs.readFileSync(logFile, 'utf-8'); - // Either throttled (with sanitized timestamp near 0 → stale) or "no stale slugs" - // Both are valid outcomes — the key is it didn't crash - expect(log).toContain('Evaluating knowledge'); - } - }); - - it('LAST_UPDATE get_mtime fallback: missing WORKING-MEMORY defaults to 0', () => { - // Without WORKING-MEMORY.md, get_mtime returns empty string; LAST_UPDATE should default to 0 - // so age computation doesn't error - createTranscript(homeDir, tmpDir, 5); - - // Deliberately do NOT create WORKING-MEMORY.md - // We call dream-capture (not dream-evaluate) for this one since it does the get_mtime check - const CAPTURE_HOOK = path.join(HOOKS_DIR, 'dream-capture'); - const exitCode = (() => { - try { - execSync(`bash "${CAPTURE_HOOK}"`, { - input: JSON.stringify({ cwd: tmpDir, session_id: 'test', last_assistant_message: 'response text here' }), - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, HOME: homeDir }, - }); - return 0; - } catch (e: unknown) { - return (e as { status: number }).status ?? 1; - } - })(); - expect(exitCode).toBe(0); - }); -}); - // ============================================================================= // dream-recover stale marker recovery // ============================================================================= @@ -2464,7 +2340,7 @@ exit 0 */ function runCollectTasks( dreamDir: string, - opts: { decEnabled?: boolean; knowEnabled?: boolean }, + opts: { decEnabled?: boolean }, ): { tasks: string; mtimeCount: number } { const hooksDir = path.resolve(__dirname, '..', 'scripts', 'hooks'); const counterFile = path.join(os.tmpdir(), `devflow-mtime-counter-${Date.now()}-${Math.random().toString(36).slice(2)}`); @@ -2478,7 +2354,6 @@ function runCollectTasks( // 4. Prints _DREAM_TASKS to stdout. // Note: memEnabled removed — memory is not a Dream task (applies ADR-016; avoids PF-009). const decEn = opts.decEnabled ?? true ? 'true' : 'false'; - const knowEn = opts.knowEnabled ?? true ? 'true' : 'false'; const script = `#!/bin/bash dbg() { :; } @@ -2494,7 +2369,7 @@ get_mtime() { printf '%s' "$c" } source "${hooksDir}/dream-collect-tasks" -dream_collect_tasks "${dreamDir}" "${decEn}" "${knowEn}" +dream_collect_tasks "${dreamDir}" "${decEn}" printf '%s' "\$_DREAM_TASKS" `; @@ -2555,17 +2430,18 @@ describe('dream-collect-tasks: single-pass scan', () => { expect(fs.existsSync(path.join(dreamDir, 'learning.sess2.json'))).toBe(false); }); - // AC-3: memory always swept (unconditional); disabled decisions/knowledge/curation deleted; type never in _DREAM_TASKS - // Curation is now also swept when decisions is disabled (curation depends on decisions data). + // AC-3: memory + knowledge always swept unconditionally; decisions/curation deleted when decisions disabled + // Curation is swept when decisions is disabled (curation depends on decisions data). + // Knowledge is no longer a Dream task — write-through handles it in-command; stale markers deleted on sight. it('AC-3: memory always swept; disabled decisions/knowledge/curation markers deleted when decisions disabled', () => { fs.writeFileSync(path.join(dreamDir, 'memory.sess1.json'), '{}'); // swept unconditionally fs.writeFileSync(path.join(dreamDir, 'decisions.sess1.json'), '{}'); - fs.writeFileSync(path.join(dreamDir, 'knowledge.sess1.json'), '{}'); + fs.writeFileSync(path.join(dreamDir, 'knowledge.sess1.json'), '{}'); // swept unconditionally (no longer Dream task) fs.writeFileSync(path.join(dreamDir, 'curation.sess1.json'), '{}'); - // Memory markers are always swept unconditionally (memory is no longer a Dream task). + // Memory + knowledge markers are always swept unconditionally (neither is a Dream task anymore). // Curation markers are also swept when decisions is disabled — curation inherits decisions state. - const { tasks } = runCollectTasks(dreamDir, { decEnabled: false, knowEnabled: false }); + const { tasks } = runCollectTasks(dreamDir, { decEnabled: false }); expect(tasks).toBe(''); expect(fs.existsSync(path.join(dreamDir, 'memory.sess1.json'))).toBe(false); expect(fs.existsSync(path.join(dreamDir, 'decisions.sess1.json'))).toBe(false);