From fe25a78d2a9a488c384913ec29a1d5dc786004e6 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 21 Jun 2026 10:59:56 +0300 Subject: [PATCH 1/3] feat!: ignore .devflow/ wholesale at repo root (clean break, drop nested allowlist) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the nested .devflow/.gitignore "ignore-everything-then-re-include decisions/features" policy with a single .devflow/ entry in the project's root .gitignore. The whole runtime folder is now ignored by default; sharing .devflow/ content (decisions, knowledge bases) becomes opt-in. The ensure-devflow-init hook is the live, every-project mechanism: it now appends .devflow/ to the root .gitignore (creating the file if absent, idempotent) and writes a new .root-gitignore-configured marker so projects set up under the old model are retrofitted lazily on the next session. No migration sweep — clean break for v2. - project-paths.ts/.cjs: getGitignoreEntries() now includes .devflow/; removed getDevflowGitignoreContent() - migrations.ts: removed sync-devflow-gitignore v1/v2/v3 migrations and the Step-5 nested-gitignore creator; stop stripping .devflow/ from the root .gitignore during consolidation - ensure-devflow-init: write the root .gitignore instead of the nested allowlist - tests: updated project-paths, migrations, shell-hooks, eager-memory-refresh (S9), and decisions-ledger to the wholesale-ignore model - repo: untracked .devflow/ (git rm --cached) and added .devflow/ to the root .gitignore - docs: CLAUDE.md reflects the new policy BREAKING CHANGE: .devflow/ is gitignored by default. Projects that previously committed decisions/feature-knowledge under .devflow/ keep those files tracked (git honors existing tracking), but new .devflow/ content will not be tracked. Remove the .devflow/ entry from .gitignore to opt back into sharing. --- .devflow/.gitignore | 28 -- .devflow/decisions/decisions-ledger.jsonl | 32 -- .devflow/decisions/decisions.md | 176 -------- .devflow/decisions/pitfalls.md | 94 ---- .devflow/features/.gitignore | 3 - .devflow/features/cli-rules/KNOWLEDGE.md | 402 ------------------ .devflow/features/decisions/KNOWLEDGE.md | 240 ----------- .devflow/features/hooks/KNOWLEDGE.md | 341 --------------- .devflow/features/index.json | 95 ----- .gitignore | 5 + CLAUDE.md | 10 +- scripts/hooks/ensure-devflow-init | 60 +-- scripts/hooks/lib/project-paths.cjs | 57 +-- src/cli/utils/migrations.ts | 144 +------ src/cli/utils/project-paths.ts | 58 +-- .../decisions-ledger-migration.test.ts | 121 ------ tests/eager-memory-refresh.test.ts | 22 +- tests/migrations.test.ts | 263 +----------- tests/project-paths.test.ts | 32 +- tests/shell-hooks.test.ts | 50 +-- 20 files changed, 99 insertions(+), 2134 deletions(-) delete mode 100644 .devflow/.gitignore delete mode 100644 .devflow/decisions/decisions-ledger.jsonl delete mode 100644 .devflow/decisions/decisions.md delete mode 100644 .devflow/decisions/pitfalls.md delete mode 100644 .devflow/features/.gitignore delete mode 100644 .devflow/features/cli-rules/KNOWLEDGE.md delete mode 100644 .devflow/features/decisions/KNOWLEDGE.md delete mode 100644 .devflow/features/hooks/KNOWLEDGE.md delete mode 100644 .devflow/features/index.json diff --git a/.devflow/.gitignore b/.devflow/.gitignore deleted file mode 100644 index 8b12e616..00000000 --- a/.devflow/.gitignore +++ /dev/null @@ -1,28 +0,0 @@ -# .devflow/ git-tracking policy -# --------------------------------------------------------------------------- -# Only curated, shared team knowledge is committed to git: -# - decisions/decisions.md, decisions/pitfalls.md (ADR / pitfall records) -# - features/index.json, features//KNOWLEDGE.md (feature knowledge bases) -# -# Everything else under .devflow/ is per-developer or transient (memory, dream, -# docs, locks, runtime state, manifest, scratch results) and is -# intentionally ignored. Model: ignore-by-default, then re-include the curated -# files. Any NEW file under .devflow/ is ignored unless explicitly listed below. - -# 1. Ignore everything under .devflow/ by default -* - -# 2. Keep this policy file -!.gitignore - -# 3. Track the decisions knowledge (not its log / config / locks / usage state) -!decisions/ -!decisions/decisions.md -!decisions/pitfalls.md -!decisions/decisions-ledger.jsonl - -# 4. Track the feature knowledge bases (not locks / sentinels / scratch results) -!features/ -!features/index.json -!features/*/ -!features/*/KNOWLEDGE.md diff --git a/.devflow/decisions/decisions-ledger.jsonl b/.devflow/decisions/decisions-ledger.jsonl deleted file mode 100644 index 8e19dfe2..00000000 --- a/.devflow/decisions/decisions-ledger.jsonl +++ /dev/null @@ -1,32 +0,0 @@ -{"id":"obs_c9d3m1","type":"decision","pattern":"No migration code for devflow refactors — clean break philosophy","status":"created","anchor_id":"ADR-001","decisions_status":"Accepted","raw_body":"\n## ADR-001: No migration code for devflow refactors — clean break philosophy\n\n- **Date**: 2026-05-06\n- **Status**: Accepted\n- **Context**: Phase 2 rename refactor (kb→knowledge) was implemented with a full backward-compat layer including a shim re-export, .alias('kb'), deprecated --kb/--no-kb flags, manifest fallback, and migration scripts\n- **Decision**: remove all compat code except one-time cleanup items (legacy hook file removal, manifest self-heal write-back)\n- **Consequences**: 'Don't want to start accumulating backward compatible code. And we don't really have that many users of devflow yet' — avoid polluting codebase with compat cruft when user base is small\n- **Source**: self-learning:obs_c9d3m1\n","date":"2026-05-06","details":"No migration code for devflow refactors — clean break philosophy"} -{"id":"obs_okp1fh","type":"decision","pattern":".devflow/.gitignore template must exclude transient per-developer artifacts that are not project-level committed data","evidence":["One thing to fix back here in devflow: the .devflow/.gitignore template should also exclude learning/debug/ and docs/WORKING-MEMORY.md — those are per-developer transient artifacts that slipped through","on Alefy, do we need to gitignore these? Am I right? And since the PR was already merged but over there, we are still in the same chore fix branch we were on, and now we have those files which are on track","Yes, please add these"],"details":"context: after Alefy PR merged, .devflow/docs/ and .devflow/learning/ appeared as untracked files; investigation showed learning/debug/ and docs/WORKING-MEMORY.md are per-session transient artifacts not meant to be committed; decision: .devflow/.gitignore template must explicitly exclude learning/debug/ and any transient per-developer files (runtime logs, in-progress state) while still tracking project-level artifacts (features/ knowledge bases, decisions/, sidecar/ markers); rationale: template is applied to all projects at init time — missing exclusions cause confusion in git status and risk accidental commits of ephemeral data","count":1,"confidence":0.95,"quality_ok":true,"status":"created","created":"2026-05-19T14:23:29.773Z","last_seen":"2026-05-19T14:23:29.773Z","artifact_path":"/Users/dean/Sandbox/devflow/.devflow/decisions/decisions.md#ADR-003","anchor_id":"ADR-003","decisions_status":"Accepted","raw_body":"\n## ADR-003: .devflow/.gitignore template must exclude transient per-developer artifacts\n\n- **Date**: 2026-05-19\n- **Status**: Accepted\n- **Context**: After migrating Alefy project to `.devflow/` layout, `learning/debug/` and `WORKING-MEMORY.md` appeared as untracked files in git status, causing confusion and risk of accidental commits of ephemeral session data\n- **Decision**: The `.devflow/.gitignore` template (applied at `devflow init` time) must explicitly exclude all transient per-developer artifacts (`learning/debug/`, runtime logs, in-progress state files) while still tracking project-level artifacts (`features/` knowledge bases, `decisions/`, sidecar markers).\n- **Consequences**: Clean git status after init across all projects. No accidental commits of session-transient data. Template is the single place to maintain this exclusion list.\n- **Source**: self-learning:obs_okp1fh\n","date":"2026-05-19"} -{"id":"obs_686xoq","type":"decision","pattern":"Bug analysis must be a separate workflow from the Evaluator — different timing, persistence, and circularity properties make them non-substitutable","evidence":["Okay, so I do want a completely separate workflow for the bug analysis.","What the Evaluator already does: Receives ORIGINAL_REQUEST, EXECUTION_PLAN, FILES_CHANGED, ACCEPTANCE_CRITERIA. Performs goal-backward verification — starts from user goals, traces backward through code.","What is actually different: (1) Timing — Evaluator runs mid-implement, before code is reviewed/resolved. A post-pipeline check would see the final code, not the pre-review version. (2) Persistence — Evaluator findings vanish (not written to disk). Nobody downstream can see them. (3) Circularity — Evaluator runs in the same session as the Coder, potentially same model."],"details":"context: the Evaluator already performs intent-vs-implementation comparison as part of the implement pipeline; decision: create a completely separate /bug-analysis workflow that runs post-review/post-resolve rather than integrating with the Evaluator; rationale: three non-substitutable properties distinguish them — timing (Evaluator sees pre-review code, bug-analysis sees final code), persistence (Evaluator findings are ephemeral, bug-analysis writes reports to disk), and circularity (Evaluator shares session/model with Coder, bug-analysis is independent)","count":1,"confidence":0.95,"quality_ok":true,"status":"created","created":"2026-05-23T21:17:01.106Z","last_seen":"2026-05-23T21:17:01.106Z","artifact_path":"/Users/dean/Sandbox/devflow/.devflow/decisions/decisions.md#ADR-004","anchor_id":"ADR-004","decisions_status":"Accepted","raw_body":"\n## ADR-004: /bug-analysis must be a completely separate workflow from the Evaluator\n\n- **Date**: 2026-05-23\n- **Status**: Accepted\n- **Context**: The Evaluator agent already performs intent-vs-implementation comparison inside the implement pipeline (receives ORIGINAL_REQUEST, EXECUTION_PLAN, FILES_CHANGED, ACCEPTANCE_CRITERIA and performs goal-backward verification). When designing /bug-analysis, the question arose whether to integrate with the Evaluator or build a separate workflow.\n- **Decision**: Create `/bug-analysis` as a completely independent post-pipeline workflow rather than extending the Evaluator.\n- **Consequences**: Three non-substitutable properties make them distinct — (1) Timing: Evaluator sees pre-review code, bug-analysis sees the final post-resolve code; (2) Persistence: Evaluator findings are ephemeral (not written to disk), bug-analysis writes reports to `.devflow/docs/bug-analysis/`; (3) Circularity: Evaluator shares session/model with the Coder, while bug-analysis is fully independent. Separation avoids conflating mid-pipeline quality checks with final-state bug detection.\n- **Source**: self-learning:obs_686xoq\n","date":"2026-05-23"} -{"id":"obs_3pp5sq","type":"decision","pattern":"Bug analysis scope must include business logic bugs by consuming upstream plan/PRD intent — not just security and syntax bugs","evidence":["Business logic bugs. Why can't we detect these with LLMs? I don't think I want to scope that out. We have a plan, an implementation plan that was derived from usually a PRD of sorts.","An LLM agent can literally compare the plan says X should happen when Y against the code does Z when Y — that's business logic bug detection. No static tool can do this, but an LLM with upstream context absolutely can. This is actually where devflow would have a unique advantage over every tool we researched.","I also want functional usability bugs and integration bugs. I wanted to also be able to analyze and surface these."],"details":"context: initial bug analysis research scoped out business logic bugs as undetectable by static tools; decision: bug analysis must include business logic bugs, functional usability bugs, and integration bugs by providing LLM agents with upstream plan/PRD intent alongside code — enabling plan-intent vs implementation comparison; rationale: when bug analysis runs post-plan→implement→review→resolve pipeline, the LLM has access to what was supposed to happen (plan) vs what the code actually does — this is a unique capability that no static tool can match and differentiates devflow from all surveyed tools","count":1,"confidence":0.95,"quality_ok":true,"status":"created","created":"2026-05-23T21:17:01.106Z","last_seen":"2026-05-23T21:17:01.106Z","artifact_path":"/Users/dean/Sandbox/devflow/.devflow/decisions/decisions.md#ADR-005","anchor_id":"ADR-005","decisions_status":"Accepted","raw_body":"\n## ADR-005: Bug analysis scope includes business logic bugs via upstream plan/PRD intent\n\n- **Date**: 2026-05-23\n- **Status**: Accepted\n- **Context**: Initial research scoped out business logic bugs as undetectable by static analysis tools. Devflow's post-pipeline position (after plan→implement→review→resolve) means bug-analysis agents can access plan documents and PRD intent — information no general-purpose static tool has.\n- **Decision**: Bug analysis must include business logic bugs, functional usability bugs, and integration bugs by providing LLM agents with upstream plan/PRD context alongside the code, enabling plan-intent vs implementation comparison.\n- **Consequences**: Devflow gains a unique capability not present in any surveyed tool — LLM agents compare \"the plan says X should happen when Y\" against \"the code does Z when Y\". This is only possible because bug-analysis runs post-pipeline with access to the full artifact chain. Bug categories: security, functional, integration, usability, and business logic.\n- **Source**: self-learning:obs_3pp5sq\n","date":"2026-05-23"} -{"id":"obs_dwm8fa","type":"decision","pattern":"Bug analysis architecture: hybrid static analysis (Semgrep + CodeQL) as candidate generators feeding LLM semantic reasoning agents as false-positive filters","evidence":["Three independent research streams converged on a hybrid static analysis + LLM semantic reasoning architecture: (1) Static tools as candidate generators (Semgrep for speed ~10s, CodeQL for depth) produce structured alerts; (2) LLM agents as semantic filters — reason about feasibility, context, and intent to eliminate false positives (94-98% FP reduction reported by Tencent's LLM4PFA deployment); (3) Multi-agent consensus for confidence scoring.","Why use Semgrep and not CodeQL upfront? What's the difference? Anyway, if you think both tools, as you know, you say Semgrep for fast scan deep analysis and CodeQL, I'm down with that.","yes go ahead and research that"],"details":"context: bug analysis workflow architecture selection; decision: use hybrid approach — Semgrep (~10s, single-file pattern matching) and CodeQL (minutes, cross-file data flow) as parallel static candidate generators, then feed structured alerts to LLM semantic reasoning agents that filter false positives and reason about business logic, feasibility, and intent; rationale: static tools provide speed and coverage breadth while LLM agents handle semantic reasoning neither tool can do alone; 94-98% false positive reduction observed in production (Tencent LLM4PFA); multi-agent consensus prevents model-specific error amplification","count":1,"confidence":0.9,"quality_ok":true,"status":"created","created":"2026-05-23T21:17:01.106Z","last_seen":"2026-05-23T21:17:01.106Z","artifact_path":"/Users/dean/Sandbox/devflow/.devflow/decisions/decisions.md#ADR-006","anchor_id":"ADR-006","decisions_status":"Accepted","raw_body":"\n## ADR-006: Bug analysis uses hybrid static analysis + LLM semantic reasoning architecture\n\n- **Date**: 2026-05-23\n- **Status**: Accepted\n- **Context**: Three independent research streams (codebase, external, academic) converged on the same architecture. Semgrep (~10s, single-file pattern matching) and CodeQL (minutes, cross-file data flow) each cover different threat classes. LLM semantic reasoning alone without static candidates has high false-negative rates.\n- **Decision**: Use a hybrid approach: Semgrep and CodeQL run in parallel as static candidate generators producing structured alerts, which are then fed to LLM semantic reasoning agents that filter false positives and reason about business logic, feasibility, and intent.\n- **Consequences**: Static tools provide speed and breadth of coverage while LLM agents handle semantic reasoning neither tool can do alone. Multi-agent consensus prevents model-specific error amplification. Production deployment (Tencent LLM4PFA) reports 94-98% false positive reduction using this pattern.\n- **Source**: self-learning:obs_dwm8fa\n","date":"2026-05-23"} -{"id":"obs_h9bw3c","type":"decision","pattern":"Debug tracing for hooks must be a single global toggle (devflow debug --enable/--disable) covering all hooks — not per-feature or per-hook","evidence":["I think it should only be toggleable. I'm not sure if per feature or just in general for devflow, when you are in the flow with debug","You're right — the debug tracing covers ALL hooks (memory, learning, decisions, knowledge). It should be devflow debug, not devflow memory --debug","Please plan and design the implementation of those debug traces for all hooks now. Let's do this as part of the work we have here on this branch. I'll already take care of everything. It would help us make sure that we have a consistent debugging implementation across all of our hooks"],"details":"context: adding debug tracing to sidecar-capture prompted a decision on how to expose the toggle; decision: implement a single global DEVFLOW_HOOK_DEBUG=1 env var toggle exposed as devflow debug --enable/--disable/--status, covering ALL hooks (sidecar-capture, sidecar-dispatch, sidecar-evaluate, session-start-memory, session-start-context, pre-compact-memory, preamble) via a shared debug-trace helper script; rationale: debug issues rarely occur in isolation — if one hook is misbehaving, you want to trace all hooks simultaneously to see the full picture; per-feature toggles would require enabling/disabling multiple flags and could miss cross-hook interactions; the single toggle is stored in ~/.claude/settings.json env block so it survives reinstalls","count":1,"confidence":0.95,"quality_ok":true,"status":"created","created":"2026-05-27T00:00:00.000Z","last_seen":"2026-05-27T00:00:00.000Z","artifact_path":"/Users/dean/Sandbox/devflow/.devflow/decisions/decisions.md#ADR-007","mayBeStale":true,"staleReason":"code-ref-missing:/.claude/settings.js","anchor_id":"ADR-007","decisions_status":"Accepted","raw_body":"\n## ADR-007: Hook debug tracing must be a single global toggle (devflow debug) covering all hooks\n\n- **Date**: 2026-05-27\n- **Status**: Accepted\n- **Context**: Adding debug tracing to `sidecar-capture` raised the question of whether the toggle should be per-feature (e.g., `devflow memory --debug`) or a single global flag. The system has 7 hooks across 4 feature areas (memory, learning, decisions, knowledge).\n- **Decision**: Implement a single global `DEVFLOW_HOOK_DEBUG=1` env var toggle exposed as `devflow debug --enable/--disable/--status`, covering ALL hooks via a shared `scripts/hooks/debug-trace` helper script. Stored in `~/.claude/settings.json` env block so it survives reinstalls.\n- **Consequences**: When debugging any hook issue, all hooks emit traces simultaneously — enabling cross-hook interaction visibility. Per-feature toggles would require enabling multiple flags and could miss interactions between hooks (e.g., sidecar-capture writing a queue entry that sidecar-dispatch reads). The shared helper means debug tracing is consistent across all hooks and can be updated in one place.\n- **Source**: self-learning:obs_h9bw3c\n","date":"2026-05-27"} -{"id":"obs_7xk9qm","type":"decision","pattern":"LLM-vs-plumbing principle: artifact content must be LLM-authored — deterministic scripts must not write memory, observations, ADR/PF bodies, or knowledge bases","confidence":0.95,"observations":2,"first_seen":"2026-06-01T10:33:15Z","last_seen":"2026-06-01T10:34:06Z","status":"created","evidence":["Writing decisions, okay, actually writing the file, deciding what to write: Learning, Pitfalls, Knowledge, Working memory — Those are things that cannot be deterministic. It must be some kind of an LLM writing those, because it is not something programmatic; it is something we need intelligence to do for us","I think that at some point there was a misunderstanding with the implementer, who did not fully understand what we are trying to do here and created scripts that update those. That is not good","If that is what you are talking about, then I think we should make sure that we do not confuse ourselves in the future. Document this understanding in our claude.md or anywhere else you think is relevant, and also maybe remove that dead code"],"details":"context: investigation found dead deterministic promotion code (process-observations, calculateConfidence, tryImmediatePromotion) that was writing artifact files via threshold calculations — contradicting the system design; decision: all artifact content (working memory, learning observations, ADR/PF bodies, knowledge bases) MUST be authored by an LLM agent; plumbing scripts are restricted to structural operations only — atomic file writes, JSONL log management, id-keyed reinforcement (merge-observation), append-only numbering (decisions-append), locks, and throttles; rationale: artifact quality requires semantic intelligence that deterministic thresholds cannot provide; misattributing LLM judgment to code creates false confidence in automation and produces artifacts that miss intent, context, and nuance","quality_ok":true,"anchor_id":"ADR-008","decisions_status":"Accepted","raw_body":"\n## ADR-008: LLM-vs-plumbing principle: artifact content must be LLM-authored — deterministic scripts must not write memory, observations, ADR/PF bodies, or knowledge bases\n\n- **Date**: 2026-06-01\n- **Status**: Accepted\n- **Context**: investigation found dead deterministic promotion code (process-observations, calculateConfidence, tryImmediatePromotion) that was writing artifact files via threshold calculations — contradicting the system design\n- **Decision**: all artifact content (working memory, learning observations, ADR/PF bodies, knowledge bases) MUST be authored by an LLM agent\n- **Consequences**: artifact quality requires semantic intelligence that deterministic thresholds cannot provide\n- **Source**: self-learning:obs_7xk9qm\n","date":"2026-06-01"} -{"id":"obs_p3r8wn","type":"decision","pattern":"Sidecar processor must be spawned at SessionStart — not via additionalContext injection — because soft context directives are unreliably acted upon when a user task is present","confidence":0.95,"observations":2,"first_seen":"2026-06-01T10:33:29Z","last_seen":"2026-06-01T10:34:16Z","status":"created","evidence":["The model ignoring the SIDECAR directive. The directive arrives as a system-reminder tag alongside the user message. The model is supposed to load devflow:sidecar skill, rename markers to .processing, and spawn background agents. But in practice, the model almost never does this because it prioritizes the user actual question","Markers are piling up across all your projects","We need to figure out a way to make this more reliable. I am thinking maybe trying to move everything to session start if we think that would be a more reliable consumption point. Now, what we are saying here goes for all of our sidecar/background functionality"],"details":"context: original sidecar design injected SIDECAR directives via additionalContext (UserPromptSubmit hook) and relied on the model to spawn a background processor; this failed in practice — markers accumulated across all projects because models deprioritize system-reminder content when the user has an active question; decision: move processor spawning entirely to SessionStart (session-start-context hook) — a clean hook event where no competing user task is present; the SessionStart hook emits the spawn directive as its primary output with a 120s spawn-throttle so rapid /clear or window-open events do not spawn duplicate processors; sidecar-dispatch (UserPromptSubmit) is now capture-only; rationale: SessionStart fires before any user turn is visible to the model — there is no competing user request, so the spawn directive receives full attention; this is the only reliable mechanism available given Claude Code hook constraints","quality_ok":true,"anchor_id":"ADR-009","decisions_status":"Accepted","raw_body":"\n## ADR-009: Sidecar processor must be spawned at SessionStart — not via additionalContext injection — because soft context directives are unreliably acted upon when a user task is present\n\n- **Date**: 2026-06-01\n- **Status**: Accepted\n- **Context**: original sidecar design injected SIDECAR directives via additionalContext (UserPromptSubmit hook) and relied on the model to spawn a background processor\n- **Decision**: move processor spawning entirely to SessionStart (session-start-context hook) — a clean hook event where no competing user task is present\n- **Consequences**: SessionStart fires before any user turn is visible to the model — there is no competing user request, so the spawn directive receives full attention\n- **Source**: self-learning:obs_p3r8wn\n","date":"2026-06-01"} -{"id":"obs_scopeu1","type":"decision","pattern":"Interactive devflow init always installs on user scope — the interactive local/project scope prompt is removed; --scope flag retained for scripted use","confidence":0.95,"observations":1,"first_seen":"2026-06-01T12:14:24Z","last_seen":"2026-06-01T12:14:24Z","status":"created","evidence":["Going forward, interactive init should always install on user scope — no project-scope prompt","The --scope CLI flag and non-TTY auto-detection (both already user by default) continue to work unchanged; --scope local still functions for scripted use","Deferred (explicitly later): Deeper scope removal — stripping the local branch from uninstall.ts and paths.ts, removing the --scope flag entirely. (User: later, we will go into a deeper removal.)"],"details":"context: devflow init interactively asked user vs local/project install scope, adding unwanted friction since user scope is the intended default; decision: remove the interactive Installation scope prompt entirely and hardcode interactive scope to user; keep the --scope CLI flag and non-TTY auto-detection unchanged so scripted/local installs (--scope local) still work; rationale: interactive users effectively always want user scope (~/.claude); the prompt was noise; deep removal of the local-scope branch from uninstall.ts/paths.ts and the --scope flag itself is explicitly deferred to a later pass","quality_ok":true,"mayBeStale":true,"staleReason":"code-ref-missing:uninstall.ts/paths.ts","anchor_id":"ADR-010","decisions_status":"Accepted","raw_body":"\n## ADR-010: Interactive devflow init always installs on user scope — interactive scope prompt removed, --scope flag retained\n\n- **Date**: 2026-06-01\n- **Status**: Accepted\n- **Context**: devflow init interactively prompted user vs local/project install scope, adding unwanted friction since user scope is the intended default for interactive installs\n- **Decision**: remove the interactive Installation scope prompt and hardcode interactive scope to user, while keeping the --scope CLI flag and non-TTY auto-detection unchanged so scripted and local installs (--scope local) continue to work\n- **Consequences**: interactive users effectively always want user scope (~/.claude) so the prompt was noise\n- **Source**: self-learning:obs_scopeu1\n","date":"2026-06-01"} -{"id":"obs_plug2st","type":"decision","pattern":"Interactive plugin selection split into two sequential multiselects (workflow plugins, then language/ecosystem plugins) with combined non-empty validation; custom grid rendering rejected","confidence":0.95,"observations":1,"first_seen":"2026-06-01T12:14:34Z","last_seen":"2026-06-01T12:14:34Z","status":"created","evidence":["The single plugin multiselect mixes workflow/command plugins (plan, implement, code-review) with language/ecosystem plugins (typescript, react, go). Splitting this into two sequential steps is clearer","Both steps are optional individually, but the combined selection must be non-empty; if empty, the user is re-prompted (bounded) rather than silently installing nothing. This permits a language-only interactive install","Excluded: Custom 2-column grid multiselect rendering (rejected — @clack multiselect is single-column and shows the description only for the focused row; a grid would require a custom prompt component, not worth it)"],"details":"context: the single plugin multiselect conflated workflow/command plugins with language/ecosystem plugins, making selection unclear; decision: present interactive plugin selection as two sequential @clack multiselects — Step 1 workflow plugins, Step 2 language plugins — partitioned by a pure partitionSelectablePlugins helper; each step is individually optional but the combined selection must be non-empty (bounded re-prompt, max 3 attempts, then graceful cancel), permitting a language-only install; the --plugin non-interactive path stays a single combined parse; rationale: clearer mental model; a custom 2-column grid was rejected because @clack multiselect is single-column and shows the focused-row description only — a grid would need a custom prompt component, not worth the cost","quality_ok":true,"anchor_id":"ADR-011","decisions_status":"Accepted","raw_body":"\n## ADR-011: Interactive plugin selection split into two sequential multiselects (workflow then language plugins); custom grid rejected\n\n- **Date**: 2026-06-01\n- **Status**: Accepted\n- **Context**: the single interactive plugin multiselect conflated workflow/command plugins (plan, implement, code-review) with language/ecosystem plugins (typescript, react, go), making selection unclear\n- **Decision**: present interactive plugin selection as two sequential @clack multiselects — Step 1 workflow plugins, Step 2 language plugins — partitioned by a pure partitionSelectablePlugins helper\n- **Consequences**: clearer mental model and discoverability\n- **Source**: self-learning:obs_plug2st\n","date":"2026-06-01"} -{"id":"obs_devd01x","type":"decision","pattern":".devflow/ knowledge artifacts (decisions.md, pitfalls.md, feature KNOWLEDGE.md, design/review docs) must be committed to git as shared project-level data","confidence":0.95,"observations":1,"first_seen":"2026-06-02T09:52:17Z","last_seen":"2026-06-02T09:52:17Z","status":"created","evidence":["Yes, you can remove them just with rm, without any flags or modifiers. I think that should work for you and the dev flow artifacts. Everything that is in the dev flow folder should be committed and pushed to our branch. Those are important files; its basically our knowledge base. It should be a shared thing.","Knowledge base committed and pushed (4e0dc91) — decisions.md, pitfalls.md, feature KNOWLEDGE.md, and the design/review docs. .gitignore correctly kept all transient state (memory/, sidecar/, logs, locks, .last-review-head) out — verified zero transient files were staged."],"details":"context: after PR #233 fixes were complete, the user clarified that .devflow/ knowledge artifacts are project-level shared data that should be committed and pushed with the branch; decision: .devflow/decisions/decisions.md, .devflow/decisions/pitfalls.md, .devflow/features/*/KNOWLEDGE.md, .devflow/docs/ design/review artifacts are committed to git and shared across all collaborators — they are the project knowledge base; transient per-developer state (memory/, sidecar/, .pending-turns.jsonl, logs, locks, .last-review-head, .last-analysis-head) remains gitignored; rationale: decisions, pitfalls, and feature knowledge bases accumulate institutional knowledge about the project — committing them makes this knowledge available to all contributors and persists across developer machine changes; the .devflow/.gitignore template already handles the correct exclusions","quality_ok":true,"mayBeStale":true,"staleReason":"code-ref-missing:/KNOWLEDGE.md","anchor_id":"ADR-012","decisions_status":"Accepted","raw_body":"\n## ADR-012: .devflow/ knowledge artifacts must be committed to git as shared project-level data\n\n- **Date**: 2026-06-02\n- **Status**: Accepted\n- **Context**: after PR #233 fixes were complete, the user clarified that .devflow/ knowledge artifacts are project-level shared data that should be committed and pushed with the branch\n- **Decision**: .devflow/decisions/decisions.md, .devflow/decisions/pitfalls.md, .devflow/features/*/KNOWLEDGE.md, .devflow/docs/ design/review artifacts are committed to git and shared across all collaborators — they are the project knowledge base\n- **Consequences**: decisions, pitfalls, and feature knowledge bases accumulate institutional knowledge about the project — committing them makes this knowledge available to all contributors and persists across developer machine changes\n- **Source**: self-learning:obs_devd01x\n","date":"2026-06-02"} -{"id":"obs_preamble1","type":"decision","pattern":"Preamble hook ambient mode redesigned: first-word keyword dispatch replaces three-marker structured-plan detection","confidence":0.95,"observations":2,"first_seen":"2026-06-02T14:57:13Z","last_seen":"2026-06-02T14:57:55Z","status":"created","evidence":["I think I would like to also do it in case the first word in a prompt is explore; then we should replace it with the /devflow:explore command. If the first word is research, we should replace it with the flow research command, and if the word is debug, we should replace it with the /devflow:debug command. Only the first word","I want to do is change this mechanism and extend it a bit — if a prompt starts with the word implement, uppercase, lowercase, capital case, we would just replace that first word with our /devflow:implement command","Prior design: preamble hook detected three markers (## Goal, ## Steps, ## Files) and injected an additionalContext directive — new design: detect first word only (implement/explore/research/debug, case-insensitive) and replace it with the matching devflow skill invocation","if a prompt starts with the word implement, uppercase, lowercase, capital case, we would just replace that first word with our /devflow:implement command","also do it in case the first word in a prompt is explore; then we should replace it with the /devflow:explore command"],"details":"context: preamble UserPromptSubmit hook previously detected structured implementation plans (## Goal + ## Steps + ## Files markers) and injected a directive; decision: replace this mechanism with first-word keyword dispatch — if the first word of a prompt is implement/explore/research/debug (any case), replace only that word with the corresponding devflow skill invocation; rationale: simpler UX, broader coverage (four commands), eliminates structured plan authoring step","quality_ok":true,"anchor_id":"ADR-013","decisions_status":"Accepted","raw_body":"\n## ADR-013: Preamble hook ambient mode redesigned: first-word keyword dispatch replaces three-marker structured-plan detection\n\n- **Date**: 2026-06-02\n- **Status**: Accepted\n- **Context**: preamble UserPromptSubmit hook previously detected structured implementation plans (## Goal + ## Steps + ## Files markers) and injected a directive\n- **Decision**: add first-word keyword dispatch as the primary detection path — if the first word of a prompt is implement/explore/research/debug/plan (any case) followed by at least one additional word and the prompt does not end in ?, inject a directive to invoke the matching devflow: skill via the Skill tool; the three-marker structured-plan detection (## Goal + ## Steps + ## Files) is retained as a coexisting elif fallback path that fires only when the keyword path does not match\n- **Consequences**: simpler UX (users type natural commands like implement fix the login bug instead of constructing a structured plan), broader coverage (five keywords instead of one structured-plan path), and the two detection paths coexist — keyword dispatch takes precedence, structured-plan detection remains available as a fallback\n- **Source**: self-learning:obs_preamble1\n","date":"2026-06-02"} -{"id":"obs_preamble2","type":"decision","pattern":"Preamble hook test plan must cover four independent suites: functionality truth table, JSON API contract, security fuzz for prompt injection, and performance bounded by methodology","confidence":0.95,"observations":2,"first_seen":"2026-06-02T14:57:29Z","last_seen":"2026-06-02T14:58:01Z","status":"created","evidence":["Suite 1 — Functionality: a full prompt→expected truth table covering F1–F11","Suite 2 — API contract: exact JSON-schema assertions (only hookSpecificOutput, correct hookEventName, additionalContext is a non-empty string, no stray keys), byte-zero empty output on no-match, exit-0 across all paths","Suite 3 — Security/fuzz: hostile tails (backticks, $(), ${IFS}, quotes, 200 KB body) asserting the output equals the fixed template — proving no user text reaches the directive","Suite 4 — Performance: verified by methodology — length-independence (1 KB vs 200 KB, assert bounded delta/ratio, never absolute ms) plus a static no-subprocess check","Suite 1 — Functionality: a full prompt→expected truth table","Suite 3 — Security/fuzz: hostile tails asserting output equals fixed template","Suite 4 — Performance: verified by methodology, length-independence"],"details":"context: preamble hook test plan design after ambient mode keyword-dispatch redesign; decision: test with four independent suites covering correctness, API stability, security injection prevention, and performance predictability; rationale: four suites map to four risk dimensions of the hook","quality_ok":true,"anchor_id":"ADR-014","decisions_status":"Accepted","raw_body":"\n## ADR-014: Preamble hook test plan must cover four independent suites: functionality truth table, JSON API contract, security fuzz for prompt injection, and performance bounded by methodology\n\n- **Date**: 2026-06-02\n- **Status**: Accepted\n- **Context**: preamble hook test plan design after ambient mode keyword-dispatch redesign\n- **Decision**: test the preamble hook with four independent suites — (1) functionality truth table (prompt→expected output for all keyword variants, case permutations, non-matching inputs, boundary cases), (2) API contract (JSON schema assertions on hookSpecificOutput and hookEventName keys, zero-byte output on no-match, exit-0 on all paths, file-I/O snapshot, bash-4-construct guard), (3) security/fuzz (hostile prompt tails including backticks, command substitution, IFS injection, 200KB payload — assert output equals fixed template proving no user text leaks into the directive), (4) performance (length-independence methodology: compare 1KB vs 200KB payload, assert bounded delta/ratio — no absolute ms assertions, plus static no-subprocess check)\n- **Consequences**: the four suites map directly to the four risk dimensions of the hook — correctness, API stability, security injection, and performance predictability\n- **Source**: self-learning:obs_preamble2\n","date":"2026-06-02"} -{"id":"obs_learnrm1","type":"decision","pattern":"Remove the learning pipeline (auto-generated workflow skills) — keep memory, decisions, knowledge, curation; auto-generating skills did not prove its value","confidence":0.95,"observations":1,"first_seen":"2026-06-06T19:36:04Z","last_seen":"2026-06-06T19:36:04Z","status":"created","evidence":["I think I want to scratch the learning category, so only keeping memory, decisions, knowledge, and curation. Automatically generating workflow skills doesnt prove itself, to be honest","The decision (captured in working memory and the sidecar-learning-removal-plan note) was conditional: post-PR #231 keep decisions/knowledge, remove learning after a 1-2 week memory eval — prove memory first","Phase A: Deleted eval-learning/eval-reinforce, devflow learn CLI, HUD learningCounts; DreamConfig is now {memory,decisions,knowledge} (coerces away legacy learning); two idempotent migrations — purge-learning-pipeline-v1 (per-project) + purge-learning-global-v1"],"details":"context: the Dream subsystem ran a learning task that auto-generated self-learning workflow command/skill artifacts (.claude/commands/self-learning, generated SKILL.md); a prior conditional decision had deferred its removal pending a memory-pipeline proof window; decision: remove the learning pipeline entirely — keep only memory, decisions, knowledge, and curation as Dream task types; rationale: auto-generating workflow skills never demonstrated value in practice; removing it simplifies DreamConfig to {memory,decisions,knowledge}, eliminates eval-learning/eval-reinforce hooks, the devflow learn CLI, and HUD learning counts, and is delivered with idempotent per-project + global purge migrations","quality_ok":true,"mayBeStale":true,"staleReason":"code-ref-missing:SKILL.md","anchor_id":"ADR-015","decisions_status":"Accepted","raw_body":"\n## ADR-015: Remove the learning pipeline (auto-generated workflow skills) — keep memory, decisions, knowledge, curation; auto-generating skills did not prove its value\n\n- **Date**: 2026-06-06\n- **Status**: Accepted\n- **Context**: the Dream subsystem ran a learning task that auto-generated self-learning workflow command/skill artifacts (.claude/commands/self-learning, generated SKILL.md)\n- **Decision**: remove the learning pipeline entirely — keep only memory, decisions, knowledge, and curation as Dream task types\n- **Consequences**: auto-generating workflow skills never demonstrated value in practice\n- **Source**: self-learning:obs_learnrm1\n","date":"2026-06-06"} -{"id":"obs_dreamsplit1","type":"decision","pattern":"Split Dream into one agent + per-task skills loaded on demand, spawned per-model (haiku memory, sonnet knowledge, opus decisions+curation) — to stop context accumulation degrading later tasks and to match each task to its right model","confidence":0.95,"observations":1,"first_seen":"2026-06-06T19:36:20Z","last_seen":"2026-06-06T19:36:20Z","status":"created","evidence":["if per-model is the goal, you cant pair memory with knowledge — memory wants Haiku, knowledge wants real code comprehension (Sonnet). Grouping forces one model across both and throws away the exact lever youre optimizing for","my main reason was actually contextized relation and model arguments. I just know from experience that works better","The tasks are already decoupled through files, not shared agent context ... So splitting loses zero cross-task synergy. The single agent isnt sharing useful context across tasks; its just sharing a context window, which only hurts the later tasks","I want to go with option B ... one Dream agent + four devflow:dream-* skills loaded on demand ... maybe we should promote the decisions category to Opus","Architecture flipped to one Dream agent + four devflow:dream-* skills loaded on demand. No per-category agent files"],"details":"context: the single Dream agent ran all task procedures in one context window; the user wanted per-category isolation for model-fit and to avoid context accumulation degrading later tasks; an MDS-compiler modularization and a 5-way agent split were both considered; decision: keep ONE Dream agent that dynamically loads a per-task skill (devflow:dream-memory|decisions|knowledge|curation), and assign models per task at spawn time via session-start-context — haiku for memory, sonnet for knowledge, opus for the combined decisions+curation spawn; rationale: tasks already communicate only through marker files and JSONL (zero cross-task context synergy to lose), so a shared context window only hurts later tasks; per-model spawning matches each task to the right capability (memory is cheap/haiku, knowledge needs code comprehension/sonnet, decisions needs strong judgment/opus); MDS and per-category agent files were rejected because a single agent removes the DRY need and avoids churn right after the rename","quality_ok":true,"anchor_id":"ADR-016","decisions_status":"Accepted","raw_body":"\n## ADR-016: Split Dream into one agent + per-task skills loaded on demand, spawned per-model (haiku memory, sonnet knowledge, opus decisions+curation) — to stop context accumulation degrading later tasks and to match each task to its right model\n\n- **Date**: 2026-06-06\n- **Status**: Accepted\n- **Context**: the single Dream agent ran all task procedures in one context window\n- **Decision**: keep ONE Dream agent that dynamically loads a per-task skill (devflow:dream-memory|decisions|knowledge|curation), and assign models per task at spawn time via session-start-context — haiku for memory, sonnet for knowledge, opus for the combined decisions+curation spawn\n- **Consequences**: tasks already communicate only through marker files and JSONL (zero cross-task context synergy to lose), so a shared context window only hurts later tasks\n- **Source**: self-learning:obs_dreamsplit1\n- **Amendment (2026-06-07, PR #239)**: Memory is no longer a Dream task. Working-memory refresh moved to the detached `background-memory-update` worker (a Stop-hook-spawned `claude -p haiku` process), and the `devflow:dream-memory` skill was removed. Active Dream tasks are now decisions (opus), knowledge (sonnet), and curation (opus). The \"haiku for memory\" model assignment and the `dream-memory` skill reference above are **superseded** to this extent; the agent-plus-per-task-skill split and the sonnet/opus assignments remain in force.\n","amendments":[{"date":"2026-06-07, PR #239","note":"Memory is no longer a Dream task. Working-memory refresh moved to the detached `background-memory-update` worker (a Stop-hook-spawned `claude -p haiku` process), and the `devflow:dream-memory` skill was removed. Active Dream tasks are now decisions (opus), knowledge (sonnet), and curation (opus). The \"haiku for memory\" model assignment and the `dream-memory` skill reference above are **superseded** to this extent; the agent-plus-per-task-skill split and the sonnet/opus assignments remain in force."}],"date":"2026-06-06"} -{"id":"obs_dreamlock1","type":"decision","pattern":"Keep the decisions lock through the Dream restructure but harden it from give-up-fast to bounded retry+backoff — the lock guards cross-session writes, which no agent restructuring eliminates","confidence":0.95,"observations":1,"first_seen":"2026-06-06T19:36:33Z","last_seen":"2026-06-06T19:36:33Z","status":"created","evidence":["Lock kept + hardened rather than removed ... the lock guards cross-session writes to decisions.md, which no agent restructuring eliminates. The real fix is the give-up-fast wait → bounded retry+backoff","decisions+curation never concurrent: when both pending (~weekly), one Opus spawn runs them sequentially","Writes already serialize through locks (.decisions.lock, .reinforce.lock). Concurrent category agents are already safe — no new race surface"],"details":"context: during the Dream restructure it was tempting to remove the .decisions.lock since the agent design changed; decision: keep the cross-session lock and harden its acquisition from give-up-fast to a bounded retry+backoff (explicit attempt cap with exponential backoff, leave .processing for retry on exhaustion); also run decisions+curation as one sequential Opus spawn so they are never concurrent; rationale: the lock protects against concurrent writes to decisions.md/pitfalls.md from different sessions — a hazard that exists regardless of how the agent is structured; the actual reliability bug was the give-up-fast wait dropping writes silently, not the lock itself; bounded retry+backoff preserves the write under contention without unbounded blocking","quality_ok":true,"mayBeStale":true,"staleReason":"code-ref-missing:decisions.md/pitfalls.md","anchor_id":"ADR-017","decisions_status":"Accepted","raw_body":"\n## ADR-017: Keep the decisions lock through the Dream restructure but harden it from give-up-fast to bounded retry+backoff — the lock guards cross-session writes, which no agent restructuring eliminates\n\n- **Date**: 2026-06-06\n- **Status**: Accepted\n- **Context**: during the Dream restructure it was tempting to remove the .decisions.lock since the agent design changed\n- **Decision**: keep the cross-session lock and harden its acquisition from give-up-fast to a bounded retry+backoff (explicit attempt cap with exponential backoff, leave .processing for retry on exhaustion)\n- **Consequences**: the lock protects against concurrent writes to decisions.md/pitfalls.md from different sessions — a hazard that exists regardless of how the agent is structured\n- **Source**: self-learning:obs_dreamlock1\n","date":"2026-06-06"} -{"id":"obs_preamble3","type":"decision","pattern":"Drop the preamble ambient hook trailing-? guard so command-style keyword prompts ending in a question mark still dispatch","confidence":0.95,"observations":1,"first_seen":"2026-06-08T20:12:14Z","last_seen":"2026-06-08T20:12:14Z","status":"created","evidence":["Lets drop god be [Guard B] entirely. Lets see what that does. If it works, we will keep it","Guard B in preamble:69 suppressed the directive whenever the prompt ended in a ? (optionally followed by whitespace); the keyword detection itself worked at any length","Dropped Guard B from scripts/hooks/preamble (the ! [[ $PROMPT =~ [?][[:space:]]*$ ]] clause) and synced it; a prompt like Explore Could it be? now fires devflow:explore"],"details":"context: the preamble ambient hook had a Guard B clause (! [[ $PROMPT =~ [?][[:space:]]*$ ]]) that suppressed keyword dispatch whenever the prompt ended in a question mark, so command-style prompts like Explore X. Could it be? silently failed to trigger the matching devflow skill; the user initially attributed failures to prompt length but the real cause was the trailing-? guard; decision: drop Guard B entirely from scripts/hooks/preamble so first-word keyword prompts dispatch regardless of a trailing question mark; rationale: users phrase command-style prompts as questions (Could it be?, what do you think?); the guard was originally intended to avoid hijacking genuine questions but in practice it broke far more legitimate keyword dispatches than it protected — length was only correlated because longer prompts tend to end in a question","quality_ok":true,"anchor_id":"ADR-018","decisions_status":"Accepted","raw_body":"\n## ADR-018: Drop the preamble ambient hook trailing-? guard so command-style keyword prompts ending in a question mark still dispatch\n\n- **Date**: 2026-06-08\n- **Status**: Accepted\n- **Context**: the preamble ambient hook had a Guard B clause (! [[ $PROMPT =~ [?][[:space:]]*$ ]]) that suppressed first-word keyword dispatch whenever a prompt ended in a question mark, so command-style prompts phrased as questions (Explore X. Could it be?) silently failed to trigger the matching devflow skill\n- **Decision**: drop Guard B entirely from scripts/hooks/preamble so first-word keyword prompts dispatch regardless of a trailing question mark\n- **Consequences**: users routinely phrase command-style prompts as questions\n- **Source**: self-learning:obs_preamble3\n","date":"2026-06-08"} -{"id":"obs_wdyvxg","type":"pitfall","pattern":"Migration skip-list prevents directory cleanup — skipped legacy files block rmdir of old directories","evidence":["Step 7 only removes old directories if they're empty. Since the migration intentionally skips legacy files (.knowledge-usage.json, .working-memory-last-trigger, .gitignore-configured, knowledge/ subdir), those stay behind, which means the old directories are never empty and never get deleted","migration should leave a clean house, unless there's a risk. The migration should leave a clean house, and we should clean up after us"],"details":"area: migrations.ts consolidate-to-devflow-dir; issue: migration skip-list leaves legacy files in place preventing rmdir — dirs remain non-empty and old directories are never cleaned up; impact: legacy .memory/ .features/ .docs/ directories persist alongside new .devflow/ structure across all user projects until manually removed; resolution: extend migration to explicitly delete known legacy files (MEMORY_LEGACY_SKIP_FILES) before attempting rmdir, so old directories are removed completely","count":1,"confidence":0.95,"quality_ok":true,"status":"created","created":"2026-05-19T14:23:29.773Z","last_seen":"2026-05-19T14:23:29.773Z","artifact_path":"/Users/dean/Sandbox/devflow/.devflow/decisions/pitfalls.md#PF-002","mayBeStale":true,"staleReason":"code-ref-missing:migrations.ts","anchor_id":"PF-002","decisions_status":"Active","raw_body":"\n## PF-002: Migration skip-list prevents directory cleanup — skipped legacy files block rmdir of old directories\n\n- **Area**: `migrations.ts` — `consolidate-to-devflow-dir` Step 7\n- **Issue**: Migration skip-list leaves legacy files in place (`.knowledge-usage.json`, `.working-memory-last-trigger`, `.gitignore-configured`, `knowledge/` subdir) preventing rmdir — old directories remain non-empty and are never cleaned up automatically\n- **Impact**: Legacy `.memory/`, `.features/`, `.docs/` directories persist alongside new `.devflow/` structure across all user projects until manually removed. Caused 15+ projects to require manual cleanup sweeps.\n- **Resolution**: Extend migration to explicitly delete all known legacy files before attempting rmdir, so old directories are emptied and removed completely. Skip-lists should be for files to migrate (move), not files to preserve indefinitely.\n- **Status**: Active\n- **Source**: self-learning:obs_wdyvxg\n"} -{"id":"obs_qmt7kz","type":"pitfall","pattern":"Migration idempotency means buggy-run projects are never re-swept — manual cross-project cleanup required when fixing migration bugs after first run","evidence":["I didn't run the migration again after we added the fix to the migrations. Assuming because the migration has already run once for me, it wouldn't run again. Am I right? I think that's fine. That's fine. We can do things manually.","Cleaned 15 projects (18 total minus 3 skipped)","All three projects are clean. Here's what was done: Skim: Moved ADR-001 from legacy .memory/knowledge/decisions.md → .devflow/decisions/decisions.md; Merged legacy PF-001 + PF-002 into .devflow/decisions/pitfalls.md"],"details":"area: migrations.ts idempotency, consolidate-to-devflow-dir, cross-project sweeps; issue: migration idempotency (tracked in ~/.devflow/migrations.json) correctly prevents re-running migrations, but this means projects that ran a buggy version of a migration are never automatically re-swept when the bug is fixed; impact: 15+ projects required a manual cleanup sweep after consolidate-to-devflow-dir migration bug was fixed — legacy .memory/ directories and data (including decisions/pitfalls in .memory/knowledge/decisions.md) had to be manually merged into .devflow/decisions/ for each project; resolution: when fixing a migration bug post-release, either bump migration version to force a re-sweep (e.g., consolidate-to-devflow-dir-v2) or document and execute a manual sweep script; include legacy decisions/pitfalls merge step in the sweep","count":1,"confidence":0.95,"quality_ok":true,"status":"created","created":"2026-05-19T00:00:00.000Z","last_seen":"2026-05-19T00:00:00.000Z","artifact_path":"/Users/dean/Sandbox/devflow/.devflow/decisions/pitfalls.md#PF-004","mayBeStale":true,"staleReason":"code-ref-missing:migrations.ts","anchor_id":"PF-004","decisions_status":"Active","raw_body":"\n## PF-004: Migration idempotency means buggy-run projects are never re-swept — manual cross-project cleanup required when fixing migration bugs after first run\n\n- **Area**: `migrations.ts` idempotency, `consolidate-to-devflow-dir`, cross-project sweeps\n- **Issue**: Migration idempotency (tracked in `~/.devflow/migrations.json`) correctly prevents re-running migrations, but projects that ran a buggy migration version are never automatically re-swept when the bug is fixed. Legacy data including decisions/pitfalls stored in `.memory/knowledge/decisions.md` must be manually merged into `.devflow/decisions/`.\n- **Impact**: 15+ projects required a manual cleanup sweep after the `consolidate-to-devflow-dir` migration bug was fixed — legacy `.memory/` directories persisted and legacy ADR/PF content had to be manually merged into the new structure per-project.\n- **Resolution**: When fixing a migration bug post-release, either bump the migration version to force a re-sweep (e.g., `consolidate-to-devflow-dir-v2`) or document and execute a manual sweep script. Include a legacy decisions/pitfalls merge step in the sweep runbook.\n- **Status**: Active\n- **Source**: self-learning:obs_qmt7kz\n"} -{"id":"obs_k7mx2p","type":"pitfall","pattern":"Claude Code hook API changed silently — Stop hook field renamed response_text → last_assistant_message and stop_reason removed — causing systemic working memory failure across all projects","evidence":["The Stop hook receives JSON that does NOT contain stop_reason or response_text fields. The hook checks if [ \"$STOP_REASON\" != \"end_turn\" ] — since the field is absent, STOP_REASON is empty, which != \"end_turn\", causing a silent exit","What broke: Claude Code changed the Stop hook input format sometime in mid-May 2026: response_text was renamed to last_assistant_message; stop_reason field was removed entirely; New fields added: session_id, transcript_path, effort, hook_event_name, stop_hook_active","Impact: Working memory frozen across ALL projects (devflow stuck at session 195, autobeat at session 288, skim had no working memory at all). Pending turn queues accumulated 1,640 user-only entries that were never processed"],"details":"area: sidecar-capture Stop hook, Claude Code hook API compatibility; issue: Claude Code renamed response_text → last_assistant_message and removed stop_reason from Stop hook JSON input in mid-May 2026; sidecar-capture silently exited on every turn because (a) stop_reason was absent causing the != end_turn guard to always exit, and (b) response_text was absent causing assistant turn capture to write empty strings; impact: systemic — working memory frozen across all 3+ projects for weeks, 1,640 queued turns were user-only with zero assistant turns captured, background memory agent never dispatched; resolution: changed sidecar-capture to read last_assistant_message instead of response_text, removed dead stop_reason guard; lesson: hook input schemas must be verified against current Claude Code docs after any Claude Code version update, and hook field reads must be validated at startup so failures surface immediately rather than silently","count":1,"confidence":0.95,"quality_ok":true,"status":"created","created":"2026-05-27T00:00:00.000Z","last_seen":"2026-05-27T00:00:00.000Z","artifact_path":"/Users/dean/Sandbox/devflow/.devflow/decisions/pitfalls.md#PF-006","anchor_id":"PF-006","decisions_status":"Active","raw_body":"\n## PF-006: Claude Code hook API changed silently — Stop hook field rename broke working memory across all projects\n\n- **Area**: `sidecar-capture` Stop hook, Claude Code hook API compatibility, `scripts/hooks/sidecar-capture`\n- **Issue**: Claude Code renamed `response_text` → `last_assistant_message` and removed `stop_reason` from Stop hook JSON input (mid-May 2026). `sidecar-capture` silently exited on every turn because (a) the absent `stop_reason` caused the `!= end_turn` guard to always exit, and (b) absent `response_text` meant assistant turns were captured as empty strings. No errors were emitted.\n- **Impact**: Systemic — working memory frozen across all 3+ projects for weeks. Pending queues accumulated 1,640 user-only entries with zero assistant turns captured. Background memory agent never dispatched. Projects stuck at sessions from 6+ weeks earlier.\n- **Resolution**: Changed `sidecar-capture` to read `last_assistant_message` instead of `response_text`; removed the dead `stop_reason` guard. After any Claude Code version update, verify hook input schemas against current docs. Add startup validation of required hook fields so failures surface immediately rather than silently.\n- **Status**: Active\n- **Source**: self-learning:obs_k7mx2p\n"} -{"id":"obs_n4rs8t","type":"pitfall","pattern":"Editing globally installed hook scripts directly instead of editing source + rebuild + reinstall — changes are lost on next reinstall and creates divergence between source and installed copies","evidence":["I see you edited scripts directly in our installation, not here in the project. That makes no sense","Again, you edited the global files. Why are you doing this? Edit the project files, rebuild, and then I don't know, reinstall, re-init from source","Please edit the project files, rebuild, and then reinstall"],"details":"area: scripts/hooks/, ~/.devflow/scripts/hooks/ installed copies, devflow development workflow; issue: when debugging hook failures, the assistant repeatedly edited the globally installed hook files (~/.devflow/scripts/hooks/) instead of the source files (scripts/hooks/) — this creates divergence between source and installed copies, and changes are silently overwritten on the next devflow init; impact: debug changes looked like they worked but were not committed to source; required additional rebuild+reinstall cycle after user caught the error; resolution: always edit source files (scripts/hooks/), run npm run build, then run devflow init to reinstall — never directly edit installed copies at ~/.devflow/scripts/ or ~/.claude/","count":1,"confidence":0.95,"quality_ok":true,"status":"created","created":"2026-05-27T00:00:00.000Z","last_seen":"2026-05-27T00:00:00.000Z","artifact_path":"/Users/dean/Sandbox/devflow/.devflow/decisions/pitfalls.md#PF-007","anchor_id":"PF-007","decisions_status":"Active","raw_body":"\n## PF-007: Editing globally installed hook scripts directly instead of source + rebuild + reinstall\n\n- **Area**: `scripts/hooks/` (source), `~/.devflow/scripts/hooks/` (installed), devflow development workflow\n- **Issue**: When debugging hook failures, the assistant repeatedly edited globally installed hook files (`~/.devflow/scripts/hooks/`) instead of source files (`scripts/hooks/`). Changes to installed copies are silently overwritten on the next `devflow init`, and they are never committed to the repository.\n- **Impact**: Debug changes appeared to work but were not committed to source. Required an additional rebuild+reinstall cycle after the user caught the error. Creates divergence between what is installed and what is in source control.\n- **Resolution**: Always edit source files (`scripts/hooks/`), run `npm run build`, then run `devflow init` to reinstall. Never directly edit installed copies at `~/.devflow/scripts/` or `~/.claude/`. The same rule applies to any other installed artifact (commands, agents, skills).\n- **Status**: Active\n- **Source**: self-learning:obs_n4rs8t\n"} -{"id":"obs_m5v2xt","type":"pitfall","pattern":"Using additionalContext for critical maintenance directives — models deprioritize soft context when competing with an active user task, causing markers to silently accumulate","confidence":0.9,"observations":2,"first_seen":"2026-06-01T10:33:39Z","last_seen":"2026-06-01T10:34:23Z","status":"created","evidence":["The model ignoring the SIDECAR directive. The directive arrives as a system-reminder tag alongside the user message. The model is supposed to load devflow:sidecar skill, rename markers to .processing, and spawn background agents. But in practice, the model almost never does this because it prioritizes the user actual question","Proof — markers are piling up across all your projects: alefy decisions/knowledge (x3)/learning, autobeat decisions (x3)","What hooks CANNOT do: Force the model to call a specific tool, Force the model to load a specific skill, Guarantee the"],"details":"area: sidecar consumption architecture, Claude Code hook additionalContext; issue: injecting critical background directives via additionalContext (system-reminder) relies on the model to act on them when a user question is also present — in practice the model almost always prioritizes answering the user, leaving maintenance markers unprocessed; impact: markers accumulated for weeks across all projects (alefy, autobeat, devflow) with no errors surfaced — purely silent backlog growth; resolution: anchor critical directives to hook events where no user task competes (SessionStart is the correct hook); reserve additionalContext for informational context that benefits the session but is not required for correctness; never rely on additionalContext for time-sensitive or required maintenance actions","quality_ok":true,"anchor_id":"PF-008","decisions_status":"Active","raw_body":"\n## PF-008: Using additionalContext for critical maintenance directives — models deprioritize soft context when competing with an active user task, causing markers to silently accumulate\n\n- **Area**: sidecar consumption architecture, Claude Code hook additionalContext\n- **Issue**: injecting critical background directives via additionalContext (system-reminder) relies on the model to act on them when a user question is also present — in practice the model almost always prioritizes answering the user, leaving maintenance markers unprocessed\n- **Impact**: markers accumulated for weeks across all projects (alefy, autobeat, devflow) with no errors surfaced — purely silent backlog growth\n- **Resolution**: anchor critical directives to hook events where no user task competes (SessionStart is the correct hook)\n- **Status**: Active\n- **Source**: self-learning:obs_m5v2xt\n"} -{"id":"obs_renamemiss1","type":"pitfall","pattern":"A subsystem rename leaves stale references and dead paths in untracked-by-grep places — reference docs, the runtime .gitignore template, and knowledge-base referencedFiles — that a code-only rename pass misses","confidence":0.9,"observations":1,"first_seen":"2026-06-06T19:36:48Z","last_seen":"2026-06-06T19:36:48Z","status":"created","evidence":["docs/working-memory.md was never synced — describes the architecture with the old name throughout (SIDECAR MAINTENANCE, sidecar/ dir, sidecar processor) despite a commit claiming docs were synced","stale .devflow/.gitignore still had the pre-rename rule sidecar/ — never regenerated after the rename; the stale rule would have failed to ignore dream transient state the moment one was created","tracked file .create-result.json + the hooks knowledge base pointed at deleted sidecar-* paths — those paths drive the knowledge-base staleness check, which was tracking dead files","Knowledge companion-file called sidecar in command docs — inconsistent with the source-side sidecar→result rename"],"details":"area: large subsystem/path renames (sidecar→Dream), reference docs, runtime templates, feature knowledge bases; issue: a rename that focuses on source code and primary docs reliably leaves stragglers in lower-visibility surfaces — narrative reference docs (docs/working-memory.md, file-organization.md), the .devflow/.gitignore template, and knowledge-base index referencedFiles / .create-result.json — which a single grep-and-fix pass under-counts; impact: user-facing docs describe a name that no longer exists, the gitignore silently stops ignoring transient state under the new name, and the KB staleness check tracks deleted files; some misses survived a commit that explicitly claimed completeness; resolution: after any rename, sweep ALL surfaces — case-insensitive grep across tracked files for both the old name and any concept it renamed (e.g. processor), plus the runtime .gitignore template, every reference doc, and every feature KB referencedFiles list; verify, do not trust a sync commit message","quality_ok":true,"mayBeStale":true,"staleReason":"code-ref-missing:file-organization.md","anchor_id":"PF-009","decisions_status":"Active","raw_body":"\n## PF-009: A subsystem rename leaves stale references and dead paths in untracked-by-grep places — reference docs, the runtime .gitignore template, and knowledge-base referencedFiles — that a code-only rename pass misses\n\n- **Area**: large subsystem/path renames (sidecar->Dream), reference docs, runtime templates, feature knowledge bases\n- **Issue**: a rename that focuses on source code and primary docs reliably leaves stragglers in lower-visibility surfaces — narrative reference docs (docs/working-memory.md, file-organization.md), the .devflow/.gitignore template, and knowledge-base index referencedFiles / .create-result.json — which a single grep-and-fix pass under-counts\n- **Impact**: user-facing docs describe a name that no longer exists, the gitignore silently stops ignoring transient state under the new name, and the KB staleness check tracks deleted files\n- **Resolution**: after any rename, sweep ALL surfaces — case-insensitive grep across tracked files for both the old name and any concept it renamed (e.g. processor), plus the runtime .gitignore template, every reference doc, and every feature KB referencedFiles list\n- **Status**: Active\n- **Source**: self-learning:obs_renamemiss1\n"} -{"id":"obs_leghook1","type":"pitfall","pattern":"An init-time legacy-cleanup list (LEGACY_HOOK_FILES) contained a still-active hook file, so devflow init deleted the very worker it had just installed","confidence":0.9,"observations":1,"first_seen":"2026-06-07T11:51:32Z","last_seen":"2026-06-07T11:51:32Z","status":"created","evidence":["The blocker — background-memory-update removed from LEGACY_HOOK_FILES so devflow init no longer deletes the worker it just installs (8c157db), guarded by an install-survival test","background-memory-update is a Stop-hook worker that init installs; its presence in the legacy-removal list meant every init wiped it immediately after copying it in"],"details":"area: devflow init install/cleanup, LEGACY_HOOK_FILES removal list, background-memory-update worker; issue: the legacy-hook removal list (LEGACY_HOOK_FILES, also removeMemoryHooks) carried the name of a hook file that is part of the CURRENT install set (background-memory-update) — so devflow init deleted the worker immediately after installing it, leaving memory refresh permanently broken with no error; impact: a ship-blocking self-deleting install — the feature appeared installed but the worker file was gone after every init; resolution: removed background-memory-update from LEGACY_HOOK_FILES and added an install-survival test that asserts the worker exists on disk after init; lesson: any legacy-cleanup/removal list must be cross-checked against the current install manifest — a name appearing in both means init destroys its own output","quality_ok":true,"anchor_id":"PF-010","decisions_status":"Active","raw_body":"\n## PF-010: An init-time legacy-cleanup list (LEGACY_HOOK_FILES) contained a still-active hook file, so devflow init deleted the worker it had just installed\n\n- **Area**: devflow init install/cleanup, LEGACY_HOOK_FILES removal list, background-memory-update worker\n- **Issue**: the legacy-hook removal list (LEGACY_HOOK_FILES, also removeMemoryHooks) carried the name of a hook file that is part of the CURRENT install set (background-memory-update) — so devflow init deleted the worker immediately after installing it, leaving memory refresh permanently broken with no error\n- **Impact**: a ship-blocking self-deleting install — the feature appeared installed but the worker file was gone after every init\n- **Resolution**: removed background-memory-update from LEGACY_HOOK_FILES and added an install-survival test that asserts the worker exists on disk after init\n- **Status**: Active\n- **Source**: self-learning:obs_leghook1\n"} -{"id":"obs_wdogkill1","type":"pitfall","pattern":"A watchdog that escalates to a process-group SIGKILL kills its own process group (self-kill) unless the supervised worker is isolated into a separate group with set -m","confidence":0.9,"observations":1,"first_seen":"2026-06-07T11:51:43Z","last_seen":"2026-06-07T11:51:43Z","status":"created","evidence":["Watchdog hardened to SIGKILL escalation — and I caught a self-kill regression the first watchdog fix introduced (process-group kill hit the workers own group); fixed via set -m isolation","now proven by a behavioral test that confirms the worker survives the kill and exits cleanly"],"details":"area: background-memory-update watchdog, shell process-group signaling (kill -- -PGID), set -m job control; issue: the first watchdog hardening escalated a timeout to a process-group kill (kill negative-PGID) but the watchdog and the worker shared a process group, so the group-kill also terminated the watchdog/parent itself — a self-kill regression; impact: the timeout-escalation path could kill the wrong processes including its own supervisor, defeating the watchdog and risking the parent shell; resolution: enable job control with set -m so the worker is launched in its OWN process group, making the group-targeted SIGKILL hit only the worker subtree; add a behavioral test asserting the worker survives the kill and exits cleanly; lesson: before sending a signal to a negative PID (process group), confirm the sender is NOT a member of that group — isolate the supervised child into its own group first","quality_ok":true,"anchor_id":"PF-011","decisions_status":"Active","raw_body":"\n## PF-011: A watchdog that escalates to a process-group SIGKILL kills its own process group (self-kill) unless the supervised worker is isolated into a separate group with set -m\n\n- **Area**: background-memory-update watchdog, shell process-group signaling (kill -- -PGID), set -m job control\n- **Issue**: the first watchdog hardening escalated a timeout to a process-group kill (kill negative-PGID) but the watchdog and the worker shared a process group, so the group-kill also terminated the watchdog/parent itself — a self-kill regression\n- **Impact**: the timeout-escalation path could kill the wrong processes including its own supervisor, defeating the watchdog and risking the parent shell\n- **Resolution**: enable job control with set -m so the worker is launched in its OWN process group, making the group-targeted SIGKILL hit only the worker subtree\n- **Status**: Active\n- **Source**: self-learning:obs_wdogkill1\n"} -{"id":"obs_preambleq1","type":"pitfall","pattern":"A trailing-? guard in the preamble ambient hook silently suppressed keyword dispatch for any prompt ending in a question mark — and the failure was misdiagnosed as a length problem","confidence":0.9,"observations":1,"first_seen":"2026-06-08T20:12:29Z","last_seen":"2026-06-08T20:12:29Z","status":"created","evidence":["Your instinct that longer prompts fail is correlated with the truth but isnt the cause. The actual culprit is Guard B in scripts/hooks/preamble:69","A 437-character prompt fires fine; a 28-character one ending in ? does not","| implement a dark mode toggle | TRIGGER | | implement a dark mode toggle? | no-fire |","maybe there was some question mark in between and I missed it — both length and mid-? hypotheses were wrong; only a trailing ? at the very end triggered the guard"],"details":"area: scripts/hooks/preamble ambient first-word keyword dispatch; issue: Guard B (! [[ $PROMPT =~ [?][[:space:]]*$ ]]) suppressed the ambient directive whenever a prompt ended in a question mark, so command-style keyword prompts phrased as questions silently never dispatched the matching devflow skill — with no error surfaced; impact: keyword dispatch appeared flaky and was misattributed to prompt length (a red herring — length only correlated because longer prompts tend to end in a question) and to a stray mid-prompt ? (also wrong — a mid-prompt ? already fired under the old code; only an end-of-prompt ? hit the guard); resolution: drop Guard B; proof harness — run the actual prompt through run-hook preamble and build a truth table varying only the trailing ? to isolate the real cause rather than guessing from symptom correlation; lesson: when a hook fails intermittently, reproduce through the real hook path and isolate one variable at a time instead of trusting a plausible-sounding correlation (length)","quality_ok":true,"anchor_id":"PF-012","decisions_status":"Active","raw_body":"\n## PF-012: A trailing-? guard in the preamble ambient hook silently suppressed keyword dispatch for any prompt ending in a question mark — and the failure was misdiagnosed as a length problem\n\n- **Area**: scripts/hooks/preamble ambient first-word keyword dispatch\n- **Issue**: Guard B (! [[ $PROMPT =~ [?][[:space:]]*$ ]]) suppressed the ambient directive whenever a prompt ended in a question mark, so command-style keyword prompts phrased as questions silently never dispatched the matching devflow skill, with no error surfaced\n- **Impact**: keyword dispatch appeared flaky and was misattributed to prompt length (a red herring — length only correlated because longer prompts tend to end in a question) and to a stray mid-prompt ? (also wrong — a mid-prompt ? already fired under the old code\n- **Resolution**: drop Guard B, and diagnose hook flakiness by reproducing through the real run-hook preamble path with a truth table that varies only the trailing ? to isolate the actual cause\n- **Status**: Active\n- **Source**: self-learning:obs_preambleq1\n"} -{"id":"obs_u8elbu","type":"decision","pattern":"Migrations must leave a clean house — delete all legacy artifacts, not just move new-path files","evidence":["I think that the migration should leave a clean house, unless there's a risk. The migration should leave a clean house, and we should clean up after us. Let's do that, please","Straightforward plan — extend Step 7 to delete the known legacy files before attempting rmdir, fix the one stale comment, and add test coverage","Cleaned 15 projects (18 total minus 3 skipped)"],"details":"context: consolidate-to-devflow-dir migration moved files to .devflow/ but left legacy directories behind because skip-list files were never deleted; decision: migrations must explicitly delete all legacy files (including those in skip-lists) and clean up old empty directories — the goal is a fully clean state, not just successful file movement; rationale: leaving legacy directories alongside new ones creates confusion, risks stale writes from non-reinstalled hooks, and requires manual cleanup across all user projects","count":1,"confidence":0.95,"quality_ok":true,"status":"created","created":"2026-05-19T14:23:29.773Z","last_seen":"2026-05-19T14:23:29.773Z","artifact_path":"/Users/dean/Sandbox/devflow/.devflow/decisions/decisions.md#ADR-002","anchor_id":"ADR-002","decisions_status":"Retired"} -{"id":"obs_6rp5ri","type":"pitfall","pattern":"Post-migration hook writes land at old path when hooks are not rebuilt and reinstalled after a path refactor","evidence":["Why did the refresh write to the old path? Because the hooks installed in your system at that point still used getFeaturesDir() → .features/. The new code that uses .devflow/features/ is on this branch — it wasn't installed globally until you rebuilt and re-inited today","the .features/ copy says updated: 2026-05-19 (today's refresh) — a knowledge refresh hook fires (session-end or background) — it regenerates KNOWLEDGE.md at the old .features/ path","Same story for index.js"],"details":"area: knowledge refresh hooks, sidecar-evaluate, path refactors generally; issue: after a migration moves data to a new path, background hooks (session-end, sidecar) still point to the old path if not yet rebuilt+reinstalled — they silently regenerate files at the legacy location; impact: data divergence between old and new paths; knowledge refreshes updating stale .features/ copy while .devflow/features/ has an older version; impact is silent (no errors, just wrong destination); resolution: any hook path refactor requires explicit rebuild (npm run build) and reinstall (devflow init) on every affected machine before hooks will write to the correct new location; document this dependency in migration notes","count":1,"confidence":0.95,"quality_ok":true,"status":"created","created":"2026-05-19T14:23:29.773Z","last_seen":"2026-05-19T14:23:29.773Z","artifact_path":"/Users/dean/Sandbox/devflow/.devflow/decisions/pitfalls.md#PF-003","mayBeStale":true,"staleReason":"code-ref-missing:KNOWLEDGE.md","anchor_id":"PF-003","decisions_status":"Retired"} -{"id":"obs_3vt99r","type":"pitfall","pattern":"Assuming a workflow capability does not exist without checking existing agents — the Evaluator already implements intent-vs-implementation comparison","evidence":["are you sure devflow doesn't already do this? isn't it exactly what the evaluator is doing?","You're right to push back — the Evaluator is doing intent-vs-implementation comparison. Let me be precise about what it already does vs what's actually new.","No production tool compares plan/spec intent against implementation. (Confirmed across all 3 research tracks.) — this claim was made before checking devflow's own Evaluator agent"],"details":"area: bug-analysis workflow design, research phase; issue: research concluded no tool performs plan-intent vs implementation comparison, then proceeded to design this as a new capability — without checking whether devflow's own Evaluator agent already does this; impact: wasted design effort and potential duplication; the Evaluator already receives ORIGINAL_REQUEST, EXECUTION_PLAN, FILES_CHANGED, ACCEPTANCE_CRITERIA and performs goal-backward verification; resolution: before designing any new capability that sounds like it overlaps with existing agents (Evaluator, Scrutinizer, Reviewer), explicitly check the existing agent roster and their input contracts first","count":1,"confidence":0.9,"quality_ok":true,"status":"created","created":"2026-05-23T21:17:01.106Z","last_seen":"2026-05-23T21:17:01.106Z","artifact_path":"/Users/dean/Sandbox/devflow/.devflow/decisions/pitfalls.md#PF-005","anchor_id":"PF-005","decisions_status":"Retired"} -{"id":"obs_pfyb8b","type":"decision","pattern":"Dynamic workflow plugin ships as pure-instruction command recipes — markdown that teaches the model to author and run a dynamic workflow at runtime, with ZERO authored orchestration code (no parser, scheduler, topo-sort, or formula), now or ever","details":"context: the devflow-dynamic plugin (tickets->plan->build delivery pipeline) needed a build/runtime architecture; an L0 ticket-DAG parser (Kahn topological sort run via Bash and passed to the workflow as args) had been drafted into the design doc as the one programmatic dependency; decision: ship the dynamic commands as pure-instruction command recipes — markdown that instructs the main model how to author and run a Claude Code dynamic workflow at runtime — carrying ZERO deterministic code that devflow authors (no parser, scheduler, topo-sort, FP-ratio/cycle formulas); every judgment (which tickets are independent, wave ordering, parallel vs serial, review-cycle counts) is LLM reasoning at runtime, done by agents that read the GitHub issues and their Depends-on relationships with gh; the recipes are thin orchestrators over devflows ALREADY-installed agents (agentType resolves the real agent identity/skills/per-agent model tier, confirmed by spike F5), so no agent prompts are inlined; rationale: a pure-instruction recipe survives the moving dynamic-workflow API, adapts to arbitrary input, and distributes through the command channel devflow already ships, while any authored parser/formula becomes a brittle deterministic dependency the user categorically rejected (not now, not ever); this extends the LLM-vs-plumbing principle from artifact CONTENT to workflow ORCHESTRATION","anchor_id":"ADR-019","decisions_status":"Accepted","date":"2026-06-12"} -{"id":"obs_10svdf","type":"decision","pattern":"In the dynamic-build pipeline, every Coder code mutation runs a post-code quality pipeline in fixed order Validate->Simplify->Scrutinize, the Evaluator runs ONLY when there is an implementation plan (not after fixes), and the Resolver is split — a Coder writes fixes while adversarial verification strips false positives before any fix is attempted","details":"context: defining the agent topology for the /devflow:dynamic-build recipe (a fusion of /implement + /code-review + /resolve); decision: (1) the Simplifier->Scrutinizer->Evaluator order is a load-bearing, non-negotiable invariant; (2) every Coder mutation (initial implement, resolve-fix, alignment-fix, qa-fix) runs a post-code pipeline of Validate->Simplify->Scrutinize, but the Evaluator runs ONLY when there is an implementation plan to verify against — it is skipped after plain fixes; the Tester is part of this gate; (3) the Resolver is split into two halves — its validate-the-issue-is-real half becomes an adversarial verification pass that strips false positives BEFORE any fix, and its write-the-fix half is handled by a Coder (a Coder loads far more relevant context than a Resolver); rationale: the Evaluators job is to confirm the PLAN was implemented properly, so it is meaningless without a plan and wasteful on every fix; a Coder produces better fixes than a Resolver because of the context it loads; gating every fix behind adversarial false-positive verification prevents wasting Coder effort on non-real findings; preserving the Simplify/Scrutinize order on every code mutation keeps the same quality dynamic the static /implement pipeline enforces","anchor_id":"ADR-020","decisions_status":"Accepted","date":"2026-06-12"} -{"id":"obs_cutline1","type":"pitfall","pattern":"Shell cut is line-oriented — using cut to extract a delimited field from a multi-line value silently corrupts the extraction, dropping all lines after the first","details":"area: scripts/hooks/dream-capture, shell string parsing with cut; issue: cut -d -f operates per-line — when the delimiter appears only on the first line of a multi-line string (e.g., SOH joining CWD and a multi-line assistant message), cut extracts the field correctly from line 1 but emits every subsequent line verbatim, corrupting the extracted value; in dream-capture this meant CWD became a multi-line string containing the assistant message body, which failed the directory check silently; impact: systemic — working memory broken machine-wide for ~8 days across all projects; assistant turns silently dropped on every multi-line response; only single-line responses slipped through, making the failure appear intermittent; resolution: replace cut with bash parameter expansion (${PAYLOAD%%$SOH*}) which operates on the whole string regardless of newlines; lesson: never use cut to split fields that may contain newlines — cut is a line-oriented tool and will silently produce wrong results on multi-line values","anchor_id":"PF-013","decisions_status":"Active"} diff --git a/.devflow/decisions/decisions.md b/.devflow/decisions/decisions.md deleted file mode 100644 index 0aa53bcc..00000000 --- a/.devflow/decisions/decisions.md +++ /dev/null @@ -1,176 +0,0 @@ - -# Architectural Decisions - -Append-only. Status changes allowed; deletions prohibited. - -## ADR-001: No migration code for devflow refactors — clean break philosophy - -- **Date**: 2026-05-06 -- **Status**: Accepted -- **Context**: Phase 2 rename refactor (kb→knowledge) was implemented with a full backward-compat layer including a shim re-export, .alias('kb'), deprecated --kb/--no-kb flags, manifest fallback, and migration scripts -- **Decision**: remove all compat code except one-time cleanup items (legacy hook file removal, manifest self-heal write-back) -- **Consequences**: 'Don't want to start accumulating backward compatible code. And we don't really have that many users of devflow yet' — avoid polluting codebase with compat cruft when user base is small -- **Source**: self-learning:obs_c9d3m1 - -## ADR-003: .devflow/.gitignore template must exclude transient per-developer artifacts - -- **Date**: 2026-05-19 -- **Status**: Accepted -- **Context**: After migrating Alefy project to `.devflow/` layout, `learning/debug/` and `WORKING-MEMORY.md` appeared as untracked files in git status, causing confusion and risk of accidental commits of ephemeral session data -- **Decision**: The `.devflow/.gitignore` template (applied at `devflow init` time) must explicitly exclude all transient per-developer artifacts (`learning/debug/`, runtime logs, in-progress state files) while still tracking project-level artifacts (`features/` knowledge bases, `decisions/`, sidecar markers). -- **Consequences**: Clean git status after init across all projects. No accidental commits of session-transient data. Template is the single place to maintain this exclusion list. -- **Source**: self-learning:obs_okp1fh - -## ADR-004: /bug-analysis must be a completely separate workflow from the Evaluator - -- **Date**: 2026-05-23 -- **Status**: Accepted -- **Context**: The Evaluator agent already performs intent-vs-implementation comparison inside the implement pipeline (receives ORIGINAL_REQUEST, EXECUTION_PLAN, FILES_CHANGED, ACCEPTANCE_CRITERIA and performs goal-backward verification). When designing /bug-analysis, the question arose whether to integrate with the Evaluator or build a separate workflow. -- **Decision**: Create `/bug-analysis` as a completely independent post-pipeline workflow rather than extending the Evaluator. -- **Consequences**: Three non-substitutable properties make them distinct — (1) Timing: Evaluator sees pre-review code, bug-analysis sees the final post-resolve code; (2) Persistence: Evaluator findings are ephemeral (not written to disk), bug-analysis writes reports to `.devflow/docs/bug-analysis/`; (3) Circularity: Evaluator shares session/model with the Coder, while bug-analysis is fully independent. Separation avoids conflating mid-pipeline quality checks with final-state bug detection. -- **Source**: self-learning:obs_686xoq - -## ADR-005: Bug analysis scope includes business logic bugs via upstream plan/PRD intent - -- **Date**: 2026-05-23 -- **Status**: Accepted -- **Context**: Initial research scoped out business logic bugs as undetectable by static analysis tools. Devflow's post-pipeline position (after plan→implement→review→resolve) means bug-analysis agents can access plan documents and PRD intent — information no general-purpose static tool has. -- **Decision**: Bug analysis must include business logic bugs, functional usability bugs, and integration bugs by providing LLM agents with upstream plan/PRD context alongside the code, enabling plan-intent vs implementation comparison. -- **Consequences**: Devflow gains a unique capability not present in any surveyed tool — LLM agents compare "the plan says X should happen when Y" against "the code does Z when Y". This is only possible because bug-analysis runs post-pipeline with access to the full artifact chain. Bug categories: security, functional, integration, usability, and business logic. -- **Source**: self-learning:obs_3pp5sq - -## ADR-006: Bug analysis uses hybrid static analysis + LLM semantic reasoning architecture - -- **Date**: 2026-05-23 -- **Status**: Accepted -- **Context**: Three independent research streams (codebase, external, academic) converged on the same architecture. Semgrep (~10s, single-file pattern matching) and CodeQL (minutes, cross-file data flow) each cover different threat classes. LLM semantic reasoning alone without static candidates has high false-negative rates. -- **Decision**: Use a hybrid approach: Semgrep and CodeQL run in parallel as static candidate generators producing structured alerts, which are then fed to LLM semantic reasoning agents that filter false positives and reason about business logic, feasibility, and intent. -- **Consequences**: Static tools provide speed and breadth of coverage while LLM agents handle semantic reasoning neither tool can do alone. Multi-agent consensus prevents model-specific error amplification. Production deployment (Tencent LLM4PFA) reports 94-98% false positive reduction using this pattern. -- **Source**: self-learning:obs_dwm8fa - -## ADR-007: Hook debug tracing must be a single global toggle (devflow debug) covering all hooks - -- **Date**: 2026-05-27 -- **Status**: Accepted -- **Context**: Adding debug tracing to `sidecar-capture` raised the question of whether the toggle should be per-feature (e.g., `devflow memory --debug`) or a single global flag. The system has 7 hooks across 4 feature areas (memory, learning, decisions, knowledge). -- **Decision**: Implement a single global `DEVFLOW_HOOK_DEBUG=1` env var toggle exposed as `devflow debug --enable/--disable/--status`, covering ALL hooks via a shared `scripts/hooks/debug-trace` helper script. Stored in `~/.claude/settings.json` env block so it survives reinstalls. -- **Consequences**: When debugging any hook issue, all hooks emit traces simultaneously — enabling cross-hook interaction visibility. Per-feature toggles would require enabling multiple flags and could miss interactions between hooks (e.g., sidecar-capture writing a queue entry that sidecar-dispatch reads). The shared helper means debug tracing is consistent across all hooks and can be updated in one place. -- **Source**: self-learning:obs_h9bw3c - -## ADR-008: LLM-vs-plumbing principle: artifact content must be LLM-authored — deterministic scripts must not write memory, observations, ADR/PF bodies, or knowledge bases - -- **Date**: 2026-06-01 -- **Status**: Accepted -- **Context**: investigation found dead deterministic promotion code (process-observations, calculateConfidence, tryImmediatePromotion) that was writing artifact files via threshold calculations — contradicting the system design -- **Decision**: all artifact content (working memory, learning observations, ADR/PF bodies, knowledge bases) MUST be authored by an LLM agent -- **Consequences**: artifact quality requires semantic intelligence that deterministic thresholds cannot provide -- **Source**: self-learning:obs_7xk9qm - -## ADR-009: Sidecar processor must be spawned at SessionStart — not via additionalContext injection — because soft context directives are unreliably acted upon when a user task is present - -- **Date**: 2026-06-01 -- **Status**: Accepted -- **Context**: original sidecar design injected SIDECAR directives via additionalContext (UserPromptSubmit hook) and relied on the model to spawn a background processor -- **Decision**: move processor spawning entirely to SessionStart (session-start-context hook) — a clean hook event where no competing user task is present -- **Consequences**: SessionStart fires before any user turn is visible to the model — there is no competing user request, so the spawn directive receives full attention -- **Source**: self-learning:obs_p3r8wn - -## ADR-010: Interactive devflow init always installs on user scope — interactive scope prompt removed, --scope flag retained - -- **Date**: 2026-06-01 -- **Status**: Accepted -- **Context**: devflow init interactively prompted user vs local/project install scope, adding unwanted friction since user scope is the intended default for interactive installs -- **Decision**: remove the interactive Installation scope prompt and hardcode interactive scope to user, while keeping the --scope CLI flag and non-TTY auto-detection unchanged so scripted and local installs (--scope local) continue to work -- **Consequences**: interactive users effectively always want user scope (~/.claude) so the prompt was noise -- **Source**: self-learning:obs_scopeu1 - -## ADR-011: Interactive plugin selection split into two sequential multiselects (workflow then language plugins); custom grid rejected - -- **Date**: 2026-06-01 -- **Status**: Accepted -- **Context**: the single interactive plugin multiselect conflated workflow/command plugins (plan, implement, code-review) with language/ecosystem plugins (typescript, react, go), making selection unclear -- **Decision**: present interactive plugin selection as two sequential @clack multiselects — Step 1 workflow plugins, Step 2 language plugins — partitioned by a pure partitionSelectablePlugins helper -- **Consequences**: clearer mental model and discoverability -- **Source**: self-learning:obs_plug2st - -## ADR-012: .devflow/ knowledge artifacts must be committed to git as shared project-level data - -- **Date**: 2026-06-02 -- **Status**: Accepted -- **Context**: after PR #233 fixes were complete, the user clarified that .devflow/ knowledge artifacts are project-level shared data that should be committed and pushed with the branch -- **Decision**: .devflow/decisions/decisions.md, .devflow/decisions/pitfalls.md, .devflow/features/*/KNOWLEDGE.md, .devflow/docs/ design/review artifacts are committed to git and shared across all collaborators — they are the project knowledge base -- **Consequences**: decisions, pitfalls, and feature knowledge bases accumulate institutional knowledge about the project — committing them makes this knowledge available to all contributors and persists across developer machine changes -- **Source**: self-learning:obs_devd01x - -## ADR-013: Preamble hook ambient mode redesigned: first-word keyword dispatch replaces three-marker structured-plan detection - -- **Date**: 2026-06-02 -- **Status**: Accepted -- **Context**: preamble UserPromptSubmit hook previously detected structured implementation plans (## Goal + ## Steps + ## Files markers) and injected a directive -- **Decision**: add first-word keyword dispatch as the primary detection path — if the first word of a prompt is implement/explore/research/debug/plan (any case) followed by at least one additional word and the prompt does not end in ?, inject a directive to invoke the matching devflow: skill via the Skill tool; the three-marker structured-plan detection (## Goal + ## Steps + ## Files) is retained as a coexisting elif fallback path that fires only when the keyword path does not match -- **Consequences**: simpler UX (users type natural commands like implement fix the login bug instead of constructing a structured plan), broader coverage (five keywords instead of one structured-plan path), and the two detection paths coexist — keyword dispatch takes precedence, structured-plan detection remains available as a fallback -- **Source**: self-learning:obs_preamble1 - -## ADR-014: Preamble hook test plan must cover four independent suites: functionality truth table, JSON API contract, security fuzz for prompt injection, and performance bounded by methodology - -- **Date**: 2026-06-02 -- **Status**: Accepted -- **Context**: preamble hook test plan design after ambient mode keyword-dispatch redesign -- **Decision**: test the preamble hook with four independent suites — (1) functionality truth table (prompt→expected output for all keyword variants, case permutations, non-matching inputs, boundary cases), (2) API contract (JSON schema assertions on hookSpecificOutput and hookEventName keys, zero-byte output on no-match, exit-0 on all paths, file-I/O snapshot, bash-4-construct guard), (3) security/fuzz (hostile prompt tails including backticks, command substitution, IFS injection, 200KB payload — assert output equals fixed template proving no user text leaks into the directive), (4) performance (length-independence methodology: compare 1KB vs 200KB payload, assert bounded delta/ratio — no absolute ms assertions, plus static no-subprocess check) -- **Consequences**: the four suites map directly to the four risk dimensions of the hook — correctness, API stability, security injection, and performance predictability -- **Source**: self-learning:obs_preamble2 - -## ADR-015: Remove the learning pipeline (auto-generated workflow skills) — keep memory, decisions, knowledge, curation; auto-generating skills did not prove its value - -- **Date**: 2026-06-06 -- **Status**: Accepted -- **Context**: the Dream subsystem ran a learning task that auto-generated self-learning workflow command/skill artifacts (.claude/commands/self-learning, generated SKILL.md) -- **Decision**: remove the learning pipeline entirely — keep only memory, decisions, knowledge, and curation as Dream task types -- **Consequences**: auto-generating workflow skills never demonstrated value in practice -- **Source**: self-learning:obs_learnrm1 - -## ADR-016: Split Dream into one agent + per-task skills loaded on demand, spawned per-model (haiku memory, sonnet knowledge, opus decisions+curation) — to stop context accumulation degrading later tasks and to match each task to its right model - -- **Date**: 2026-06-06 -- **Status**: Accepted -- **Context**: the single Dream agent ran all task procedures in one context window -- **Decision**: keep ONE Dream agent that dynamically loads a per-task skill (devflow:dream-memory|decisions|knowledge|curation), and assign models per task at spawn time via session-start-context — haiku for memory, sonnet for knowledge, opus for the combined decisions+curation spawn -- **Consequences**: tasks already communicate only through marker files and JSONL (zero cross-task context synergy to lose), so a shared context window only hurts later tasks -- **Source**: self-learning:obs_dreamsplit1 -- **Amendment (2026-06-07, PR #239)**: Memory is no longer a Dream task. Working-memory refresh moved to the detached `background-memory-update` worker (a Stop-hook-spawned `claude -p haiku` process), and the `devflow:dream-memory` skill was removed. Active Dream tasks are now decisions (opus), knowledge (sonnet), and curation (opus). The "haiku for memory" model assignment and the `dream-memory` skill reference above are **superseded** to this extent; the agent-plus-per-task-skill split and the sonnet/opus assignments remain in force. - -## ADR-017: Keep the decisions lock through the Dream restructure but harden it from give-up-fast to bounded retry+backoff — the lock guards cross-session writes, which no agent restructuring eliminates - -- **Date**: 2026-06-06 -- **Status**: Accepted -- **Context**: during the Dream restructure it was tempting to remove the .decisions.lock since the agent design changed -- **Decision**: keep the cross-session lock and harden its acquisition from give-up-fast to a bounded retry+backoff (explicit attempt cap with exponential backoff, leave .processing for retry on exhaustion) -- **Consequences**: the lock protects against concurrent writes to decisions.md/pitfalls.md from different sessions — a hazard that exists regardless of how the agent is structured -- **Source**: self-learning:obs_dreamlock1 - -## ADR-018: Drop the preamble ambient hook trailing-? guard so command-style keyword prompts ending in a question mark still dispatch - -- **Date**: 2026-06-08 -- **Status**: Accepted -- **Context**: the preamble ambient hook had a Guard B clause (! [[ $PROMPT =~ [?][[:space:]]*$ ]]) that suppressed first-word keyword dispatch whenever a prompt ended in a question mark, so command-style prompts phrased as questions (Explore X. Could it be?) silently failed to trigger the matching devflow skill -- **Decision**: drop Guard B entirely from scripts/hooks/preamble so first-word keyword prompts dispatch regardless of a trailing question mark -- **Consequences**: users routinely phrase command-style prompts as questions -- **Source**: self-learning:obs_preamble3 - -## ADR-019: Dynamic workflow plugin ships as pure-instruction command recipes — markdown that teaches the model to author and run a dynamic workflow at runtime, with ZERO authored orchestration code (no parser, scheduler, topo-sort, or formula), now or ever - -- **Date**: 2026-06-12 -- **Status**: Accepted -- **Context**: the devflow-dynamic plugin (tickets->plan->build delivery pipeline) needed a build/runtime architecture -- **Decision**: ship the dynamic commands as pure-instruction command recipes — markdown that instructs the main model how to author and run a Claude Code dynamic workflow at runtime — carrying ZERO deterministic code that devflow authors (no parser, scheduler, topo-sort, FP-ratio/cycle formulas) -- **Consequences**: a pure-instruction recipe survives the moving dynamic-workflow API, adapts to arbitrary input, and distributes through the command channel devflow already ships, while any authored parser/formula becomes a brittle deterministic dependency the user categorically rejected (not now, not ever) -- **Source**: self-learning:obs_pfyb8b - -## ADR-020: In the dynamic-build pipeline, every Coder code mutation runs a post-code quality pipeline in fixed order Validate->Simplify->Scrutinize, the Evaluator runs ONLY when there is an implementation plan (not after fixes), and the Resolver is split — a Coder writes fixes while adversarial verification strips false positives before any fix is attempted - -- **Date**: 2026-06-12 -- **Status**: Accepted -- **Context**: defining the agent topology for the /devflow:dynamic-build recipe (a fusion of /implement + /code-review + /resolve) -- **Decision**: (1) the Simplifier->Scrutinizer->Evaluator order is a load-bearing, non-negotiable invariant -- **Consequences**: the Evaluators job is to confirm the PLAN was implemented properly, so it is meaningless without a plan and wasteful on every fix -- **Source**: self-learning:obs_10svdf diff --git a/.devflow/decisions/pitfalls.md b/.devflow/decisions/pitfalls.md deleted file mode 100644 index 10bf697e..00000000 --- a/.devflow/decisions/pitfalls.md +++ /dev/null @@ -1,94 +0,0 @@ - -# Known Pitfalls - -Area-specific gotchas, fragile areas, and past bugs. - -## PF-002: Migration skip-list prevents directory cleanup — skipped legacy files block rmdir of old directories - -- **Area**: `migrations.ts` — `consolidate-to-devflow-dir` Step 7 -- **Issue**: Migration skip-list leaves legacy files in place (`.knowledge-usage.json`, `.working-memory-last-trigger`, `.gitignore-configured`, `knowledge/` subdir) preventing rmdir — old directories remain non-empty and are never cleaned up automatically -- **Impact**: Legacy `.memory/`, `.features/`, `.docs/` directories persist alongside new `.devflow/` structure across all user projects until manually removed. Caused 15+ projects to require manual cleanup sweeps. -- **Resolution**: Extend migration to explicitly delete all known legacy files before attempting rmdir, so old directories are emptied and removed completely. Skip-lists should be for files to migrate (move), not files to preserve indefinitely. -- **Status**: Active -- **Source**: self-learning:obs_wdyvxg - -## PF-004: Migration idempotency means buggy-run projects are never re-swept — manual cross-project cleanup required when fixing migration bugs after first run - -- **Area**: `migrations.ts` idempotency, `consolidate-to-devflow-dir`, cross-project sweeps -- **Issue**: Migration idempotency (tracked in `~/.devflow/migrations.json`) correctly prevents re-running migrations, but projects that ran a buggy migration version are never automatically re-swept when the bug is fixed. Legacy data including decisions/pitfalls stored in `.memory/knowledge/decisions.md` must be manually merged into `.devflow/decisions/`. -- **Impact**: 15+ projects required a manual cleanup sweep after the `consolidate-to-devflow-dir` migration bug was fixed — legacy `.memory/` directories persisted and legacy ADR/PF content had to be manually merged into the new structure per-project. -- **Resolution**: When fixing a migration bug post-release, either bump the migration version to force a re-sweep (e.g., `consolidate-to-devflow-dir-v2`) or document and execute a manual sweep script. Include a legacy decisions/pitfalls merge step in the sweep runbook. -- **Status**: Active -- **Source**: self-learning:obs_qmt7kz - -## PF-006: Claude Code hook API changed silently — Stop hook field rename broke working memory across all projects - -- **Area**: `sidecar-capture` Stop hook, Claude Code hook API compatibility, `scripts/hooks/sidecar-capture` -- **Issue**: Claude Code renamed `response_text` → `last_assistant_message` and removed `stop_reason` from Stop hook JSON input (mid-May 2026). `sidecar-capture` silently exited on every turn because (a) the absent `stop_reason` caused the `!= end_turn` guard to always exit, and (b) absent `response_text` meant assistant turns were captured as empty strings. No errors were emitted. -- **Impact**: Systemic — working memory frozen across all 3+ projects for weeks. Pending queues accumulated 1,640 user-only entries with zero assistant turns captured. Background memory agent never dispatched. Projects stuck at sessions from 6+ weeks earlier. -- **Resolution**: Changed `sidecar-capture` to read `last_assistant_message` instead of `response_text`; removed the dead `stop_reason` guard. After any Claude Code version update, verify hook input schemas against current docs. Add startup validation of required hook fields so failures surface immediately rather than silently. -- **Status**: Active -- **Source**: self-learning:obs_k7mx2p - -## PF-007: Editing globally installed hook scripts directly instead of source + rebuild + reinstall - -- **Area**: `scripts/hooks/` (source), `~/.devflow/scripts/hooks/` (installed), devflow development workflow -- **Issue**: When debugging hook failures, the assistant repeatedly edited globally installed hook files (`~/.devflow/scripts/hooks/`) instead of source files (`scripts/hooks/`). Changes to installed copies are silently overwritten on the next `devflow init`, and they are never committed to the repository. -- **Impact**: Debug changes appeared to work but were not committed to source. Required an additional rebuild+reinstall cycle after the user caught the error. Creates divergence between what is installed and what is in source control. -- **Resolution**: Always edit source files (`scripts/hooks/`), run `npm run build`, then run `devflow init` to reinstall. Never directly edit installed copies at `~/.devflow/scripts/` or `~/.claude/`. The same rule applies to any other installed artifact (commands, agents, skills). -- **Status**: Active -- **Source**: self-learning:obs_n4rs8t - -## PF-008: Using additionalContext for critical maintenance directives — models deprioritize soft context when competing with an active user task, causing markers to silently accumulate - -- **Area**: sidecar consumption architecture, Claude Code hook additionalContext -- **Issue**: injecting critical background directives via additionalContext (system-reminder) relies on the model to act on them when a user question is also present — in practice the model almost always prioritizes answering the user, leaving maintenance markers unprocessed -- **Impact**: markers accumulated for weeks across all projects (alefy, autobeat, devflow) with no errors surfaced — purely silent backlog growth -- **Resolution**: anchor critical directives to hook events where no user task competes (SessionStart is the correct hook) -- **Status**: Active -- **Source**: self-learning:obs_m5v2xt - -## PF-009: A subsystem rename leaves stale references and dead paths in untracked-by-grep places — reference docs, the runtime .gitignore template, and knowledge-base referencedFiles — that a code-only rename pass misses - -- **Area**: large subsystem/path renames (sidecar->Dream), reference docs, runtime templates, feature knowledge bases -- **Issue**: a rename that focuses on source code and primary docs reliably leaves stragglers in lower-visibility surfaces — narrative reference docs (docs/working-memory.md, file-organization.md), the .devflow/.gitignore template, and knowledge-base index referencedFiles / .create-result.json — which a single grep-and-fix pass under-counts -- **Impact**: user-facing docs describe a name that no longer exists, the gitignore silently stops ignoring transient state under the new name, and the KB staleness check tracks deleted files -- **Resolution**: after any rename, sweep ALL surfaces — case-insensitive grep across tracked files for both the old name and any concept it renamed (e.g. processor), plus the runtime .gitignore template, every reference doc, and every feature KB referencedFiles list -- **Status**: Active -- **Source**: self-learning:obs_renamemiss1 - -## PF-010: An init-time legacy-cleanup list (LEGACY_HOOK_FILES) contained a still-active hook file, so devflow init deleted the worker it had just installed - -- **Area**: devflow init install/cleanup, LEGACY_HOOK_FILES removal list, background-memory-update worker -- **Issue**: the legacy-hook removal list (LEGACY_HOOK_FILES, also removeMemoryHooks) carried the name of a hook file that is part of the CURRENT install set (background-memory-update) — so devflow init deleted the worker immediately after installing it, leaving memory refresh permanently broken with no error -- **Impact**: a ship-blocking self-deleting install — the feature appeared installed but the worker file was gone after every init -- **Resolution**: removed background-memory-update from LEGACY_HOOK_FILES and added an install-survival test that asserts the worker exists on disk after init -- **Status**: Active -- **Source**: self-learning:obs_leghook1 - -## PF-011: A watchdog that escalates to a process-group SIGKILL kills its own process group (self-kill) unless the supervised worker is isolated into a separate group with set -m - -- **Area**: background-memory-update watchdog, shell process-group signaling (kill -- -PGID), set -m job control -- **Issue**: the first watchdog hardening escalated a timeout to a process-group kill (kill negative-PGID) but the watchdog and the worker shared a process group, so the group-kill also terminated the watchdog/parent itself — a self-kill regression -- **Impact**: the timeout-escalation path could kill the wrong processes including its own supervisor, defeating the watchdog and risking the parent shell -- **Resolution**: enable job control with set -m so the worker is launched in its OWN process group, making the group-targeted SIGKILL hit only the worker subtree -- **Status**: Active -- **Source**: self-learning:obs_wdogkill1 - -## PF-012: A trailing-? guard in the preamble ambient hook silently suppressed keyword dispatch for any prompt ending in a question mark — and the failure was misdiagnosed as a length problem - -- **Area**: scripts/hooks/preamble ambient first-word keyword dispatch -- **Issue**: Guard B (! [[ $PROMPT =~ [?][[:space:]]*$ ]]) suppressed the ambient directive whenever a prompt ended in a question mark, so command-style keyword prompts phrased as questions silently never dispatched the matching devflow skill, with no error surfaced -- **Impact**: keyword dispatch appeared flaky and was misattributed to prompt length (a red herring — length only correlated because longer prompts tend to end in a question) and to a stray mid-prompt ? (also wrong — a mid-prompt ? already fired under the old code -- **Resolution**: drop Guard B, and diagnose hook flakiness by reproducing through the real run-hook preamble path with a truth table that varies only the trailing ? to isolate the actual cause -- **Status**: Active -- **Source**: self-learning:obs_preambleq1 - -## PF-013: Shell cut is line-oriented — using cut to extract a delimited field from a multi-line value silently corrupts the extraction, dropping all lines after the first - -- **Area**: scripts/hooks/dream-capture, shell string parsing with cut -- **Issue**: cut -d -f operates per-line — when the delimiter appears only on the first line of a multi-line string (e.g., SOH joining CWD and a multi-line assistant message), cut extracts the field correctly from line 1 but emits every subsequent line verbatim, corrupting the extracted value -- **Impact**: systemic — working memory broken machine-wide for ~8 days across all projects -- **Resolution**: replace cut with bash parameter expansion (${PAYLOAD%%$SOH*}) which operates on the whole string regardless of newlines -- **Status**: Active -- **Source**: self-learning:obs_cutline1 diff --git a/.devflow/features/.gitignore b/.devflow/features/.gitignore deleted file mode 100644 index fb08deec..00000000 --- a/.devflow/features/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.knowledge.lock/ -.knowledge-last-refresh -.gitignore-configured diff --git a/.devflow/features/cli-rules/KNOWLEDGE.md b/.devflow/features/cli-rules/KNOWLEDGE.md deleted file mode 100644 index 423052c0..00000000 --- a/.devflow/features/cli-rules/KNOWLEDGE.md +++ /dev/null @@ -1,402 +0,0 @@ ---- -feature: cli-rules -name: Rules System CLI -description: "Use when adding new rules, modifying the rules install flow, implementing rule shadowing, or wiring rules into init/uninstall. Keywords: rules, shared/rules, rulesMap, buildRulesMap, isValidRuleName, LEGACY_RULE_NAMES, rulesEnabled, devflow rules, ~/.claude/rules/devflow, installRuleFile, removeLegacyCommandsRule, ambient.ts, partitionSelectablePlugins, WORKFLOW_ORDER, combineSelection, shouldRetry, autoCommit, DreamConfig, decisions-ledger-unify-v1, sync-devflow-gitignore-v3, devflow-dynamic, build-recipes, shared/recipes." -category: architecture -directories: [src/cli/commands/, src/cli/utils/, shared/rules/, scripts/] -referencedFiles: - - src/cli/commands/rules.ts - - src/cli/commands/init.ts - - src/cli/commands/uninstall.ts - - src/cli/commands/ambient.ts - - src/cli/plugins.ts - - src/cli/utils/installer.ts - - src/cli/utils/manifest.ts - - src/cli/utils/flags.ts - - src/cli/utils/teammate-mode-cleanup.ts - - src/cli/utils/dream-config.ts - - src/cli/utils/migrations.ts - - scripts/build-plugins.ts - - scripts/build-recipes.ts - - shared/rules/security.md - - shared/rules/engineering.md - - shared/rules/quality.md - - shared/rules/reliability.md -created: 2026-05-10 -updated: 2026-06-16 ---- - -# Rules System CLI - -## Overview - -Rules are ultra-condensed, always-on engineering principle files (~10 lines each) installed as flat `.md` files to `~/.claude/rules/devflow/`. Claude Code loads them automatically on every prompt, filling the guidance gap for quick edits that don't trigger a full skill pipeline. The system mirrors the skill build pipeline exactly: rules live in `shared/rules/`, are declared in `plugin.json` manifests and `DEVFLOW_PLUGINS`, distributed to plugins at build time, and installed (or shadowed) at runtime. - -Unlike skills, which install universally from all plugins, rules are **plugin-scoped**: only rules belonging to the currently installed plugins are installed. This keeps core rules (security, engineering, quality, reliability) always present and optional-plugin rules (typescript, react, accessibility, ui-design, go, java, python, rust) only present when the user has that plugin installed. There are currently 12 rules total: 4 core + 8 language/ecosystem. - -## System Context - -Rules flow through four distinct stages that parallel the skill pipeline: - -1. **Authoring** — flat `.md` files with YAML frontmatter in `shared/rules/` -2. **Build-time distribution** — `scripts/build-plugins.ts` copies each rule from `shared/rules/{name}.md` into `plugins/{plugin}/rules/{name}.md` based on the plugin's `plugin.json` `rules` array -3. **Install-time placement** — `installRuleFile` in `src/cli/utils/installer.ts` copies rules from the built plugin directory to `~/.claude/rules/devflow/{name}.md`, respecting shadow overrides -4. **Runtime activation** — Claude Code reads rules from `~/.claude/rules/devflow/` on every prompt automatically (no hooks required, unlike skills) - -## Component Architecture - -### Rule Anatomy - -Rules use a two-part structure, but the `paths` frontmatter differs by rule type: - -**Core rules** (security, engineering, quality, reliability) — apply to every file Claude touches: -```markdown ---- -paths: [] ---- -# Engineering Principles - -**Never throw in business logic.** - -- Bullet enforcement principles (5-7 lines) -``` - -**Language/ecosystem rules** (typescript, react, go, etc.) — activate only when editing files matching their pattern: -```markdown ---- -paths: ["**/*.ts", "**/*.tsx"] ---- -# TypeScript - -**Type safety is non-negotiable — `unknown` over `any`, always.** - -- Bullet enforcement principles (4-5 lines) -``` - -This two-tier design is what makes language rules low-cost: a Go rule never loads during TypeScript edits. Rules must be ultra-concise — ~10-15 lines total. Longer explanations belong in a skill, not a rule. - -### Plugin Declaration - -Rules are added to `PluginDefinition` in `src/cli/plugins.ts` via the required `rules` field (`string[]`). Core rules belong on `devflow-core-skills`; language-specific rules belong on their respective optional plugin. All 8 optional language/ecosystem plugins carry rules — typescript, react, accessibility, ui-design, go, java, python, rust. Non-language optional plugins (devflow-audit-claude, **devflow-dynamic**) and all non-language workflow plugins have `rules: []`. Only `devflow-core-skills` and the 8 language/UI plugins carry rules through the plugin system: - -```typescript -// In DEVFLOW_PLUGINS: -{ - name: 'devflow-core-skills', - rules: ['security', 'engineering', 'quality', 'reliability'], // always installed -}, -{ - name: 'devflow-typescript', - optional: true, - rules: ['typescript'], // only installed when plugin is selected -}, -// devflow-react, devflow-accessibility, devflow-ui-design, -// devflow-go, devflow-java, devflow-python, devflow-rust -// all follow the same pattern — one rule per plugin, same name as plugin suffix -``` - -Plugins that have no rules must still include `rules: []` — the field is required on `PluginDefinition` (not optional). `devflow-ambient` has `rules: []` — its legacy `commands` rule was removed; any stale `~/.claude/rules/devflow/commands.md` file is purged automatically on every `devflow ambient --enable/--disable` or `devflow init`. - -Four helper functions in `plugins.ts` serve distinct scopes: -- `getAllRuleNames()` — unique names across ALL plugins, sorted (used by `devflow rules --list`) -- `buildRulesMap(plugins)` — name → ownerPlugin map for a GIVEN plugin subset; throws on invalid names (used during install and by `devflow rules --enable` and `--status`/`--list`) -- `isValidRuleName(name)` — validates rule names match `/^[a-z0-9-]+$/`; called by `buildRulesMap` at map-build time as a path-traversal defense -- `LEGACY_RULE_NAMES` — currently empty; add entries here when renaming or removing a rule - -The `devflow-core-skills` plugin's `skills` array in `plugins.ts` registers the three active per-task Dream skills (`dream-decisions`, `dream-knowledge`, `dream-curation`). `dream-memory` was removed from the active skills list in PR #239 — memory is now handled entirely by the `background-memory-update` detached worker, not a Dream subagent. Both `dream-memory` (bare) and `devflow:dream-memory` (namespaced) are in `LEGACY_SKILLS_V2X` so older installs that had them are swept during `devflow init`. The learning pipeline skills (`eval-learning`, `eval-reinforce`, and the `devflow learn` CLI) were removed in PR #238. - -**PR #241 (decisions ledger + deterministic render)**: Added `decisions-ledger-unify-v1` (per-project migration) and `sync-devflow-gitignore-v3` (per-project) migrations in `src/cli/utils/migrations.ts`. The `DreamConfig` interface in `src/cli/utils/dream-config.ts` now has a fourth field: `autoCommit: boolean` (default `true`). When `autoCommit` is true (the default), the Dream agent's `dream-commit` helper automatically creates `chore(dream):` commits after each Dream maintenance write. `devflow decisions --status` now outputs an `Auto-commit: ON/OFF` line. `coerceConfig` in `dream-config.ts` silently drops the legacy `learning` key AND reads `autoCommit` — the interface is `{memory, decisions, knowledge, autoCommit}`. Do NOT reference `features.teams` (removed PR #240) or `features.learn` (removed PR #238) as fields of `DreamConfig`. - -**Agent Teams removal (PR #240)**: The bespoke Agent Teams machinery was removed. The eight `*-teams.md` command variants, the `agent-teams` skill, all `teamsEnabled`/`applyTeamsConfig`/`stripTeamsConfig` touch-points, and the `--teams`/`--no-teams` init flags were deleted. Agent Teams is re-exposed as a single optional flag `agent-teams` in `FLAG_REGISTRY` (defaultEnabled: false), toggled via `devflow flags --enable agent-teams`. Key cleanup mechanics: -- `devflow:agent-teams` (namespaced) is in `LEGACY_SKILLS_V2X` for install cleanup -- `init.ts` sweeps orphaned `*-teams.md` files from the commands directory unconditionally on every install type (full install clears the dir; partial install runs the explicit blanket sweep) -- Two migrations clean stale `teammateMode: "auto"` written by prior Devflow installs: `purge-devflow-teammate-mode-global-v1` (global, `~/.claude/settings.json`) and `purge-devflow-teammate-mode-v1` (per-project, `/.claude/settings.json`) -- `stripDevflowTeammateModeFromJson` in `src/cli/utils/teammate-mode-cleanup.ts` is the pure function that handles the cleanup; called by both migrations and by `uninstall.ts` - -**Dual-role bare entries in `LEGACY_SKILLS_V2X`**: the bare entries `dream-decisions`, `dream-knowledge`, and `dream-curation` are still-active skills installed at `devflow:`. These bare entries in `LEGACY_SKILLS_V2X` exist only to clean up pre-namespace V2.x install directories (e.g., `~/.claude/skills/dream-decisions`). On current installs, the `fs.rm` targeting the bare path is a harmless no-op because active skills always install under the `devflow:` prefix. A block comment in `plugins.ts` documents this explicitly — do NOT remove these entries or V2.x upgraders will be left with stale bare-name dirs. - -### Manifest: ManifestData and syncManifestFeature - -`ManifestData.features` now includes a `security` field (PR #244): - -```typescript -features: { - ambient: boolean; - memory: boolean; - hud: boolean; - knowledge: boolean; - decisions: boolean; - rules: boolean; - flags: string[]; - viewMode?: ViewMode; - security?: 'none' | 'user' | 'managed'; // Added PR #244 -} -``` - -The `security` field is optional (absent in pre-Phase-F manifests — treated as `undefined`/unknown by `resolveSecurityTriState`). `readManifest` validates it against the `SECURITY_MODES` union and defaults to `undefined` if invalid or absent. - -**`syncManifestFeature`** is the canonical pattern for toggle commands updating the manifest. It reads the existing manifest, updates one feature key, refreshes `updatedAt`, and writes atomically. The pattern: -```typescript -await syncManifestFeature(devflowDir, 'security', targetMode); -``` -Used by: `ambient --enable/--disable`, `decisions --enable/--disable`, `hud --enable/--disable`, `memory --enable/--disable`, `security --enable/--disable`. Never creates a manifest if one doesn't exist — it is a no-op when `readManifest` returns null. - -### Build Pipeline - -`scripts/build-plugins.ts` extends the skill/agent build to handle rules. The key difference from skills: rules are **flat files** (not directories), so no recursive copy is needed. The build script reads `plugin.json`'s `rules` array, clears and recreates the plugin's `rules/` directory, then copies each `shared/rules/{name}.md` into `plugins/{plugin}/rules/{name}.md`. The build fails with exit 1 if a declared rule is missing from `shared/rules/`. - -**Recipe compilation** (`build:recipes`): `scripts/build-recipes.ts` compiles `.mds` recipe files from `shared/recipes/` into Markdown command files in `plugins/devflow-dynamic/commands/`. Partials (files whose basename starts with `_`) are skipped. The build hard-fails on any compile error, ensuring a broken or stale command never ships. Recipe commands cannot be installed from `shared/recipes/` directly — always run `npm run build:recipes` (or `npm run build`) before testing the dynamic plugin commands. The full build order is: `build:cli && build:plugins && build:recipes && build:hud`. - -### Install Flow - -Rule installation is handled by `installRuleFile`, an exported function in `src/cli/utils/installer.ts`. It is called from both `installViaFileCopy` (during init) and the `devflow rules --enable` command. Shadow resolution is centralized here: - -```typescript -// installRuleFile: shadow-respecting copy for a single rule. -// Called via Promise.all in both init and devflow rules --enable. -export async function installRuleFile( - ruleName: string, - ownerPlugin: string, - pluginsDir: string, - devflowDir: string, - rulesTarget: string, -): Promise { - const shadowFile = path.join(devflowDir, 'rules', `${ruleName}.md`); - const targetFile = path.join(rulesTarget, `${ruleName}.md`); - - try { - await fs.access(shadowFile); - await fs.copyFile(shadowFile, targetFile); // shadow wins - return; - } catch { /* no shadow — fall through to plugin source */ } - - const ruleSource = path.join(pluginsDir, ownerPlugin, 'rules', `${ruleName}.md`); - try { - await fs.access(ruleSource); - await fs.copyFile(ruleSource, targetFile); - } catch { /* source missing — skip silently */ } -} -``` - -Key install properties: -- Target: `~/.claude/rules/devflow/{name}.md` (flat, no subdirectory nesting) -- Shadow: `~/.devflow/rules/{name}.md` overrides the Devflow source — same pattern as skills but for a flat file -- Disabled: if `rulesEnabled` is false, no rules directory is created; post-install step in init removes it if it already exists - -### Manifest Tracking - -`ManifestData.features.rules: boolean` tracks whether rules are enabled. The manifest reader in `src/cli/utils/manifest.ts` self-heals — when reading a manifest that lacks the `rules` field, it defaults to `true` (rules-on is the safe default for upgrades from pre-rules installs). The `features.learn` field was removed from `ManifestData` in PR #238 (learning pipeline removal); `DreamConfig` (in `src/cli/utils/dream-config.ts`) now tracks only `{memory, decisions, knowledge, autoCommit}`. The `--learn`/`--no-learn` CLI option and `learnEnabled` variable in `init.ts` no longer exist. The `features.teams`/`teamsEnabled` manifest field was removed in PR #240 (agent-teams removal) — `manifest.features` no longer tracks agent-teams state at all; it is handled entirely by `features.flags: string[]`. - -### `devflow rules` Command - -The `rules` command in `src/cli/commands/rules.ts` has four subcommands: - -| Subcommand | Behavior | -|---|---| -| `--enable` | Wipes `~/.claude/rules/devflow/` first (stale cleanup), reads manifest plugins, copies rules from built plugin dirs (respecting shadows), updates `manifest.features.rules = true` | -| `--disable` | Removes `~/.claude/rules/devflow/` entirely, updates `manifest.features.rules = false` | -| `--status` | Lists installed rules with owner plugin (shortened) and `[shadowed]` tag | -| `--list` | Lists ALL available rules from all plugins with install indicator (✓/✗) | - -Two private helpers are top-level named functions in `rules.ts` (not inline): -- `isShadowed(devflowDir, ruleName)` — `fs.access` on `~/.devflow/rules/{name}.md`; returns `Promise` -- `formatRuleRow(name, devflowDir, ownerMap, suffix)` — builds a colorized display row; both `--status` and `--list` build their own `buildRulesMap(DEVFLOW_PLUGINS)` call locally and pass it in — there is no module-level constant. - -## Init Flow Integration - -**Scope**: The interactive scope prompt was removed in feat(init). User scope is now the default for all TTY interactive runs. Only `--scope` flag or non-TTY path can set `local` scope. Non-TTY detects and logs "Non-interactive mode detected, using scope: user". - -**Two-step plugin selection**: `devflow init` (TTY, no `--plugin`) now presents two sequential `p.multiselect` prompts instead of one: -- **Step 1 — Workflow plugins**: All command-bearing plugins (excluding `devflow-core-skills`, `devflow-ambient`, `devflow-audit-claude`). Pre-selected: non-optional workflow plugins. **Includes `devflow-dynamic`** (optional, command-bearing). -- **Step 2 — Language plugins**: All command-less selectable plugins (language/ecosystem). Nothing pre-selected. - -The split is computed by `partitionSelectablePlugins(DEVFLOW_PLUGINS)` in `plugins.ts`, which returns `{ workflow, language }` buckets. This is a pure function — no I/O, no mutation of the input array, deterministic, no side effects. - -**`devflow-dynamic` in workflow bucket**: `partitionSelectablePlugins` places `devflow-dynamic` in the **workflow** bucket because `commands.length > 0`. It carries `optional: true` so it is not pre-selected at init. The test in `tests/plugins.test.ts` allowlists `devflow-dynamic` in the `allowedOptional` set alongside `devflow-audit-claude` and the 8 language plugins. - -**Bounded retry loop**: A `while (attempts < MAX_ATTEMPTS)` loop (MAX_ATTEMPTS = 3) guards both steps: -```typescript -const { plugins: combined, accepted } = combineSelection(workflowSelected, languageSelected); -if (accepted) { selectedPlugins = combined; break; } -if (!shouldRetry(attempts, MAX_ATTEMPTS, accepted)) { - p.cancel('Installation cancelled — no plugins selected.'); - process.exit(0); -} -p.log.warn('Select at least one plugin.'); -``` - -Two exported pure functions power the loop: -- `combineSelection(workflowSelected, languageSelected)` → `{ plugins: string[], accepted: boolean }` — merges the two arrays; `accepted` is true iff the union is non-empty. -- `shouldRetry(attempt, maxAttempts, accepted)` → `boolean` — returns true iff the selection was empty AND the attempt ceiling has not been reached; returns false when accepted or exhausted (caller exits on false + !accepted). - -**WORKFLOW_ORDER is now exported from `plugins.ts`**: -```typescript -export const WORKFLOW_ORDER: string[] = [ - '/research', '/explore', '/plan', '/implement', - '/code-review', '/resolve', '/self-review', '/bug-analysis', - '/debug', '/release', '/audit-claude', - '/dynamic-tickets', '/dynamic-plan', '/dynamic-build', '/dynamic-wave', '/dynamic-profile', -]; -``` -`init.ts` imports it from `plugins.ts` rather than keeping a local duplicate. A regression guard test in `tests/plugins.test.ts` verifies every entry has a real backing command in `DEVFLOW_PLUGINS` (bidirectional: WORKFLOW_ORDER ⊆ commands AND commands ⊆ WORKFLOW_ORDER for the non-excluded set). The 5 dynamic commands were added to WORKFLOW_ORDER in the same commit that landed the dynamic plugin — the regression guard catches future omissions. - -**Security mode in init (PR #244)**: `initCommand` now has a `--security ` flag (`user`/`managed`/`none`). The init flow detects the current deny list state (`detectDenyState`), reads the manifest mode, and resolves the final action via `resolveSecurityAction()` (a pure resolver). A dedicated security step after the managed-install step handles the actual deny list write to `~/.claude/settings.json`. The `securityMode` is written to `manifest.features.security` so subsequent `devflow list` and `devflow security --status` can surface it. The security step always uses atomic writes (`writeFileAtomicExclusive`) — both the strip and merge paths, not just the merge path. - -**Atomic writes for toggle commands (PR #244)**: `ambient --enable`, `memory --enable/--disable`, `hud --enable/--disable`, `uninstall --security` all now use `writeFileAtomicExclusive` for settings.json writes instead of plain `fs.writeFile`. This ensures Claude Code never reads a partially-written settings.json. - -**`--no-memory` queue drain (PR #244)**: when `--no-memory` is specified (or memory is disabled via Recommended/Advanced prompts), `init.ts` now drains orphaned queue files (`getPendingTurnsPath` + `getPendingTurnsProcessingPath`) via `Promise.all([fs.unlink, fs.unlink])`, mirroring the `memory.ts --disable` drain. ENOENT is treated as a no-op; other errors propagate. - -**Excluded from plugin selection buckets**: `devflow-core-skills` (always installed), `devflow-ambient` (always installed), `devflow-audit-claude` (installable via `--plugin` only). - -**Rules in init**: `rulesEnabled` defaults to `true`. In Recommended mode, applied silently (no prompt). In Advanced mode, an explicit `p.note()` explains the per-language token model, followed by `p.confirm()`. CLI flag `--rules`/`--no-rules` overrides in both modes. `buildRulesMap(pluginsToInstall)` is called with the user's selected plugins — rules from non-selected optional plugins are excluded. Rules directory is NOT wiped on init (only on `devflow rules --enable`); stale rules are cleaned up via `LEGACY_RULE_NAMES` loop. - -## Component Interactions - -**init → rules**: `rulesEnabled` flows through to `buildRulesMap(pluginsToInstall)` → `installViaFileCopy`. When disabled, post-install removes `~/.claude/rules/devflow/` entirely. - -**uninstall → rules**: Full uninstall (`removeAllDevFlow`) includes `~/.claude/rules/devflow/` in its target list. Selective plugin uninstall (`computeAssetsToRemove`) computes which rules to remove using the same "retained by remaining plugins" logic as skills. `uninstall.ts` also calls `stripDevflowTeammateModeFromJson` to clean `teammateMode: "auto"` written by prior Devflow installs. The uninstall security step now also uses `writeFileAtomicExclusive` (PR #244). - -**list → rules**: `devflow list` shows `rules` in the Features line when `manifest.features.rules` is true. `devflow list` also surfaces `security` and `safe-delete` as tri-state values via `formatFeatures(manifest.features, { security, safeDelete })`. - -**build → install**: Rules are not installed from `shared/rules/` directly at runtime — the installer reads from `plugins/{plugin}/rules/`, which is the build output. Always run `npm run build` after modifying `shared/rules/` before testing install. Similarly, dynamic recipe commands are not installable from `shared/recipes/` — always run `npm run build:recipes` (or `npm run build`) first; the installer reads from `plugins/devflow-dynamic/commands/`. - -**plugins.ts → init.ts**: `partitionSelectablePlugins`, `WORKFLOW_ORDER`, `combineSelection`, `shouldRetry` are all exported from their respective modules and imported by `init.ts`. `combineSelection` and `shouldRetry` are in `init.ts` (not `plugins.ts`). - -## New Commands (PR #244) - -### `devflow security` - -New standalone command in `src/cli/commands/security.ts` for lifecycle management of the security deny list. Subcommands: -- `--status` (default): reports installed locations and entry counts; flags dual-install anomaly; shows manifest mode if available -- `--enable [--managed|--user]`: add-to-target-first, verify, then strip-other (self-healing mode switch); updates manifest to `'user'` or `'managed'`; uses atomic writes -- `--disable`: strips from user settings (must be parseable JSON, hard fails otherwise) and removes managed settings unconditionally; updates manifest to `'none'`; uses atomic writes - -Pure helpers: -- `loadTemplateDenyEntries(rootDir)` — reads from `src/templates/managed-settings.json` bundled with the package -- `countDenyEntries(settingsJson: string)` — count of `permissions.deny` entries; returns 0 on parse error - -Key invariants: `--enable` always writes to only ONE location then strips the other (prevents dual-location installs). `--disable --user` fails hard when settings.json is unparseable (avoids corruption). - -### `devflow safe-delete` - -New command in `src/cli/commands/safe-delete.ts` for shell-profile safe-delete function management. Subcommands: -- `--status` (default): reports tri-state (`installed`/`outdated`/`absent`/`unknown`) + profile path -- `--enable`: idempotent install/upgrade of versioned shell block to profile; checks trash CLI availability first (platform-specific) -- `--disable`: removes block from profile; no-op if not installed - -**`getSafeDeleteStatus()`** is an exported async function that determines install status and profile path: -```typescript -export type SafeDeleteStatus = 'installed' | 'outdated' | 'absent' | 'unknown'; -export async function getSafeDeleteStatus(): Promise<{ status: SafeDeleteStatus; profilePath: string | null }> -``` -Used directly by `devflow list` (no subprocess) for the safe-delete tri-state. - -### `devflow list` — tri-state security + safe-delete - -`list.ts` now exposes `security` and `safe-delete` as tri-state values after regular boolean features: - -```typescript -export type TriState = 'on' | 'off' | 'unknown'; - -export function resolveSecurityTriState( - security: ManifestData['features']['security'], -): TriState // 'user'|'managed' → 'on', 'none' → 'off', absent → 'unknown' - -export function formatFeatures( - features: ManifestData['features'], - extra?: { security?: TriState; safeDelete?: TriState }, -): string -``` - -Order is: `rules → security → safe-delete` (last two are tri-state, appended after standard boolean features). When `extra.security === 'unknown'`, `formatFeatures` emits `security: unknown`; when `'off'`, emits `security: off`; when `'on'`, emits `security`. Same tri-label logic for safe-delete. - -Security fallback probe: if manifest field is absent (`resolveSecurityTriState` returns `'unknown'`), `list.ts` probes `getManagedSettingsPath()` via `fs.access` (wrapped in `try/catch` to survive unsupported platforms); if the path exists, security is considered `'on'`. - -## Constraints - -- Rules have no namespace prefix (unlike skills which install as `devflow:{name}/`). The directory `~/.claude/rules/devflow/` itself provides the namespace. -- Rules are plugin-scoped by design — no `buildFullRulesMap()` equivalent exists. -- `LEGACY_RULE_NAMES` in `plugins.ts` is currently empty. Add entries here when renaming or removing a rule. -- The `paths` frontmatter key must always be present. Core rules use `paths: []` (global); language rules use a glob array (file-type-scoped). Omitting the key may break rule loading. -- `buildRulesMap` throws if any rule name fails `isValidRuleName` — misconfigured `plugin.json` entries are caught at map-build time, not at path-construction time. -- `partitionSelectablePlugins` uses the presence of `commands.length > 0` as the sole criterion for the workflow bucket — command-less selectable plugins always land in the language bucket. `devflow-dynamic` has 5 commands and lands in the workflow bucket. - -## Anti-Patterns - -- **Adding a language rule to `devflow-core-skills`**: Core rules install for every user. Language-specific rules belong in their optional plugin. -- **Using `paths: []` on a language-specific rule**: Language rules must scope to their file types. Using `paths: []` makes them load on every prompt, eliminating per-language token savings. -- **Using a file-type path on a core rule**: Core rules (security, engineering, quality, reliability) must use `paths: []` — they apply cross-language. -- **Installing rules from `shared/rules/` directly at runtime**: The installer reads from `plugins/{plugin}/rules/` (build output). Skipping `npm run build` silently installs the old version. -- **Installing dynamic recipe commands from `shared/recipes/`**: Recipe `.mds` files are source-only; compiled `.md` command files live in `plugins/devflow-dynamic/commands/`. Always build before testing. -- **Unbounded plugin selection loop**: The bounded `while (attempts < MAX_ATTEMPTS)` + `shouldRetry` guard is the pattern — never replace with `while (true)`. -- **Long rule files**: Rules should be ~10-15 lines. If a rule grows beyond ~20 lines, extract the detail into a skill's `references/` directory. -- **Omitting `rules: []` on a plugin**: The `rules` field is required on `PluginDefinition`. Omitting it causes TypeScript errors at build time. -- **Adding a bespoke feature toggle to `manifest.features`**: Agent Teams was removed partly because it was a bespoke `teamsEnabled` field on the manifest. New optional toggleable Claude Code behaviours belong in `FLAG_REGISTRY` with `defaultEnabled: false`, not as custom manifest fields. The `security` field is a justified exception because it stores the selected mode (user/managed/none), not just on/off. -- **Inline read→mutate→write manifest patterns in toggle commands**: All toggle commands must use `syncManifestFeature` instead of the inline pattern. This consolidation (PR #244) eliminates a class of atomic-write omissions. -- **Using plain `fs.writeFile` for settings.json**: Always use `writeFileAtomicExclusive` (temp+rename). Claude Code reads settings.json every turn — a partial write during a crash causes Claude Code to boot with a corrupt settings. - -## Gotchas - -- **Rules ARE wiped on full install but not on partial**: `installViaFileCopy` wipes `~/.claude/rules/devflow/` at the start of a full install. On a partial install (`devflow init --plugin=typescript`), the rules directory is NOT wiped. Use `devflow rules --enable` to get a clean reinstall — it always wipes first. -- **`devflow rules --enable` resolves plugin dirs from dist/`**: Computes the plugins directory relative to the compiled CLI file. Must build before running. -- **Shadow files are flat, not directories**: Skills shadow at `~/.devflow/skills/{name}/` (a directory). Rules shadow at `~/.devflow/rules/{name}.md` (a flat file). -- **Manifest defaults `rules: true` on read**: Old manifests without the `rules` field are read as `rules: true`. Upgrading users get rules enabled automatically. -- **`buildRulesMap` throws on invalid names**: Uppercase letters, dots, or slashes in a `plugin.json` rules entry cause an immediate throw — intentional early-catch. -- **`commands.md` has been removed**: The ambient-managed commands rule no longer exists. Any stale `~/.claude/rules/devflow/commands.md` from prior installs is purged automatically by `removeLegacyCommandsRule()` which runs unconditionally in both `addAmbientHook` and `removeAmbientHook`. `devflow rules --enable/--disable` never touched it and still does not. -- **Scope prompt removed**: Interactive TTY runs no longer ask for scope — user scope is the automatic default. The `--scope` flag still works (for `local` installs or scripted `user` overrides), and non-TTY still logs and defaults to `user`. -- **Two-step selection requires `partitionSelectablePlugins` for bucket assignment**: Do NOT sort or filter `DEVFLOW_PLUGINS` manually in init code. Always delegate to `partitionSelectablePlugins`. The workflow-bucket predicate is `commands.length > 0` — `devflow-dynamic` is in the workflow bucket. The language-bucket is every command-less selectable plugin. -- **`WORKFLOW_ORDER` regression guard is bidirectional**: `tests/plugins.test.ts` verifies WORKFLOW_ORDER entries correspond to real commands AND that commands not in the excluded set are covered. Adding a new workflow command requires updating WORKFLOW_ORDER or the test will fail. The 5 dynamic commands are already registered. -- **Rules have no runtime sentinel**: Unlike knowledge (`.devflow/features/.disabled`), decisions, and memory, rules have no `.disabled` file. Disabling rules is destructive: `devflow rules --disable` removes the directory entirely. There is no temporary suppression path. -- **`background-memory-update` is NOT in `LEGACY_HOOK_FILES`**: The worker is an active installed script — it must NOT be listed in the `LEGACY_HOOK_FILES` cleanup array in `init.ts`. It was accidentally listed there (fixed in `8c157db`), which caused `installViaFileCopy` to install it and the cleanup loop to immediately delete it, making memory refresh dead-on-arrival for installed users. If a future hook rename is needed, use `LEGACY_HOOK_FILES` only for truly retired scripts, never for scripts that are still installed and active. -- **Core vs language rules have different token behavior**: Core rules load on every prompt. Language rules only activate when Claude is working with a matching file type. -- **manifest.ts contains a `kb → knowledge` migration self-heal**: `readManifest` detects `features.kb` and migrates it to `features.knowledge` in-place. This is the only backward-compat code in `manifest.ts`; do not add more. For rules, `LEGACY_RULE_NAMES` is the correct pattern when renaming rule files. -- **`features.learn` no longer exists in ManifestData**: The learning pipeline was removed in PR #238. `manifest.features.learn`, `--learn`/`--no-learn` init flags, and `learnEnabled` in `init.ts` are all gone. Two migrations (`purge-learning-pipeline-v1` per-project, `purge-learning-global-v1` global) in `src/cli/utils/migrations.ts` sweep legacy learning artifacts on `devflow init`. `eval-learning` and `eval-reinforce` hook scripts are removed and in the legacy hook cleanup list. -- **`features.teams` no longer exists in ManifestData**: The agent-teams bespoke manifest field was removed in PR #240. Agent Teams is now a standard flag entry in `FLAG_REGISTRY` (`id: 'agent-teams'`, `defaultEnabled: false`). The env var `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` is applied/stripped by the normal `applyFlags`/`stripFlags` machinery. Migration `purge-devflow-teammate-mode-global-v1` (global) and `purge-devflow-teammate-mode-v1` (per-project) clean up stale `teammateMode: "auto"` written by prior installs. -- **`manifest.features.security` is absent in pre-Phase-F manifests**: `readManifest` returns `undefined` for missing or invalid values. `resolveSecurityTriState(undefined)` returns `'unknown'`. All consumers of `security` must handle `undefined`. -- **`getSafeDeleteStatus()` never throws**: Wrapped in try/catch. Returns `{ status: 'unknown', profilePath: null }` on any error. Safe to call from `devflow list` without additional error handling. -- **`getWorkingMemoryDisabledSentinel` removed from `project-paths.cjs`**: The function was deleted (PR #244 cleanup). Any reference to `getWorkingMemoryDisabledSentinel` in hooks or plumbing is stale. -- **Security `--disable` hard-fails on unparseable JSON**: Unlike `--enable`, which can create a new settings.json, `--disable` must parse the existing settings.json to safely strip entries. If the file is present but unparseable, the command exits with an error — avoids corrupting the file by writing a stripped version that drops all settings. -- **`HUD --disable` self-heals lingering statusLine**: `hud --disable` always attempts to read and strip the statusLine from settings.json, regardless of `hud.json` config state. This self-heals drift where hud.json says disabled but a Devflow statusLine still lingers from a partial prior state. -- **`DreamConfig` tracks 4 features**: `{memory, decisions, knowledge, autoCommit}` — the `learning` key was removed in PR #238; `autoCommit` was added in PR #241 (default `true`; governs whether Dream tasks auto-commit maintenance writes). Do not add `learning` back or reference `features.learn`. -- **`devflow-dynamic` plugin is optional**: It installs only when the user selects it at `devflow init` (Step 1 — Workflow plugins) or passes `--plugin=dynamic`. It is not pre-selected. Its 5 recipe commands (`/dynamic-tickets`, `/dynamic-plan`, `/dynamic-build`, `/dynamic-wave`, `/dynamic-profile`) are compiled from `shared/recipes/*.mds` — not hand-authored `.md` files. - -## Key Files - -- `shared/rules/` — source of truth for all rule content; flat `.md` files (12 total) -- `shared/recipes/` — source of truth for dynamic plugin recipe commands; `.mds` files compiled at build time; partials prefixed with `_` are not compiled to commands -- `scripts/build-recipes.ts` — compiles `shared/recipes/*.mds` → `plugins/devflow-dynamic/commands/*.md`; hard-fails on any compile error; skips partials -- `src/cli/plugins.ts` — `DEVFLOW_PLUGINS` `rules` field, `buildRulesMap()`, `getAllRuleNames()`, `isValidRuleName()`, `LEGACY_RULE_NAMES`, `WORKFLOW_ORDER` (now includes 5 dynamic commands), `partitionSelectablePlugins()`; active Dream skills: `dream-decisions`, `dream-knowledge`, `dream-curation` (NOT `dream-memory`); `LEGACY_SKILLS_V2X` includes `dream-memory`, `devflow:dream-memory`, and `devflow:agent-teams` for cleanup; `devflow-dynamic` is optional, command-bearing, workflow bucket -- `src/cli/commands/init.ts` — `rulesEnabled` flag; two-step plugin selection with `partitionSelectablePlugins`; `combineSelection`, `shouldRetry` pure helpers (exported for tests); `WORKFLOW_ORDER` import; Recommended-mode silent apply vs Advanced-mode note+confirm; `buildRulesMap(pluginsToInstall)`; `LEGACY_RULE_NAMES` stale-file cleanup loop; blanket `*-teams.md` command sweep; `--security ` flag + dedicated security step after managed install; `--no-memory` pending queue drain -- `src/cli/commands/rules.ts` — `devflow rules` command (enable/disable/status/list) -- `src/cli/commands/ambient.ts` — purges legacy `commands.md` via `COMMANDS_RULE_PATH` / `removeLegacyCommandsRule()`; `ambient --enable` now uses `writeFileAtomicExclusive` + `syncManifestFeature` -- `src/cli/commands/security.ts` — NEW: `devflow security` command (status/enable/disable); `loadTemplateDenyEntries`, `countDenyEntries` pure helpers; atomic writes throughout -- `src/cli/commands/safe-delete.ts` — NEW: `devflow safe-delete` command (status/enable/disable); `getSafeDeleteStatus()` exported for `devflow list` -- `src/cli/commands/list.ts` — `TriState`, `resolveSecurityTriState()`, `formatFeatures(features, extra?)` with tri-state support for security + safe-delete; security fallback probe via `getManagedSettingsPath` -- `src/cli/commands/hud.ts` — `hud --disable` self-heals lingering statusLine unconditionally; uses `writeFileAtomicExclusive` + `syncManifestFeature` -- `src/cli/commands/memory.ts` — `memory --enable/--disable` use `writeFileAtomicExclusive` + `syncManifestFeature`; `memory --disable` drains pending queue files -- `src/cli/commands/decisions.ts` — `decisions --enable/--disable` use `syncManifestFeature` -- `src/cli/utils/installer.ts` — `installRuleFile` (exported); `installViaFileCopy` rules section -- `src/cli/commands/uninstall.ts` — `computeAssetsToRemove` includes rules; `removeAllDevFlow` removes rules dir; `removeSelectedPlugins` removes per-rule files; calls `stripDevflowTeammateModeFromJson` to clean `teammateMode`; unified security strip uses `writeFileAtomicExclusive` -- `src/cli/utils/manifest.ts` — `ManifestData.features.rules` with `true` self-heal default; `ManifestData.features.security?: 'none'|'user'|'managed'`; `syncManifestFeature` helper for atomic single-field updates; `features.learn` removed in PR #238; no `features.teams` (removed PR #240) -- `src/cli/utils/flags.ts` — `FLAG_REGISTRY` with 18 entries including `agent-teams` (defaultEnabled: false); `applyFlags`/`stripFlags`/`getDefaultFlags`; `applyViewMode`/`stripViewMode` -- `src/cli/utils/teammate-mode-cleanup.ts` — `stripDevflowTeammateModeFromJson` (pure, tolerant) and `stripDevflowTeammateMode` (file I/O wrapper); used by both migrations and uninstall -- `src/cli/utils/migrations.ts` — PR #238: `purge-learning-pipeline-v1` + `purge-learning-global-v1` + `sync-devflow-gitignore-v2`; PR #239: `purge-stale-memory-markers-v1`; PR #240: `purge-devflow-teammate-mode-global-v1` + `purge-devflow-teammate-mode-v1`; PR #241: `decisions-ledger-unify-v1` + `sync-devflow-gitignore-v3` -- `scripts/build-plugins.ts` — build-time distribution from `shared/rules/` → `plugins/*/rules/` -- `tests/plugins.test.ts` — `partitionSelectablePlugins` (8 cases) + `WORKFLOW_ORDER` regression guard (4 cases, bidirectional) + `LEGACY_SKILL_NAMES consistency` guard; `allowedOptional` set includes `devflow-dynamic` -- `tests/init.test.ts` — `combineSelection` and `shouldRetry` unit tests -- `tests/list-logic.test.ts` — `resolveSecurityTriState` suite (on/off/unknown cases); `formatFeatures` with extras (8 cases covering all tri-states); order-asserting test for rules→security→safe-delete ordering -- `tests/safe-delete-command.test.ts` — `getSafeDeleteStatus` and safe-delete command tests -- `tests/manifest.test.ts` — `readManifest` security field round-trip and self-heal tests - -## Related - -- ADR-002: Migrations leave a clean house — learning-pipeline purge migrations and agent-teams cleanup migrations follow this pattern -- ADR-012: `.devflow` knowledge committed to git — governs feature knowledge storage; rules themselves install outside the repo to `~/.claude/rules/devflow/` -- Skills system (parallel architecture): `src/cli/utils/installer.ts` `installViaFileCopy` skills section is the model rules followed -- Feature flags: `src/cli/utils/flags.ts` — another toggleable feature using the same manifest.features pattern; now the home for `agent-teams` after bespoke pipeline removal -- PR #238 (learning removal): removed `dream-memory` SKILL.md file, removed `features.learn`, removed `--learn`/`--no-learn`, added `purge-learning-pipeline-v1` + `sync-devflow-gitignore-v2` migrations; note — `dream-memory` was removed from the `devflow-core-skills` skills ARRAY in PR #239 (not #238) -- PR #239 (eager memory refresh): removed `dream-memory` from `devflow-core-skills` skills array, added `background-memory-update` worker, added `purge-stale-memory-markers-v1` migration; `background-memory-update` is NOT in `LEGACY_HOOK_FILES` (fixed in `8c157db`) -- PR #240 (agent-teams removal): removed bespoke Agent Teams machinery; re-exposed as `agent-teams` flag in `FLAG_REGISTRY`; added `purge-devflow-teammate-mode-global-v1` + `purge-devflow-teammate-mode-v1` migrations; blanket `*-teams.md` sweep in `init.ts`; `devflow:agent-teams` skill in `LEGACY_SKILLS_V2X` -- PR #241 (decisions ledger + deterministic render): added `decisions-ledger.jsonl` as committed render source of truth; new `assign-anchor`/`retire-anchor`/`rotate-observations` ops in `json-helper.cjs`; `dream-commit` shell helper for attributable maintenance commits; `autoCommit: boolean` added to `DreamConfig`; `devflow decisions --status` now surfaces auto-commit state; `decisions-ledger-unify-v1` + `sync-devflow-gitignore-v3` per-project migrations added -- PR #242 (devflow-dynamic plugin): added `devflow-dynamic` optional plugin with 5 recipe commands compiled from `shared/recipes/*.mds` via `scripts/build-recipes.ts`; `WORKFLOW_ORDER` extended with 5 dynamic commands; `devflow-dynamic` added to `allowedOptional` in `tests/plugins.test.ts`; `COMMAND_REFS` in `tests/skill-references.test.ts` updated with dynamic command names -- PR #244 (close feature-toggle gaps): added `devflow security` + `devflow safe-delete` commands; `manifest.features.security`; `syncManifestFeature` helper; `TriState` + `resolveSecurityTriState` in `list.ts`; `formatFeatures` extended with extra params; `hud --disable` self-healing; `ambient`/`memory`/`hud`/`decisions` toggle commands converted to `writeFileAtomicExclusive` + `syncManifestFeature`; `--no-memory` init queue drain; `--security` init flag + dedicated security step; `getWorkingMemoryDisabledSentinel` removed from `project-paths.cjs` diff --git a/.devflow/features/decisions/KNOWLEDGE.md b/.devflow/features/decisions/KNOWLEDGE.md deleted file mode 100644 index 34a1d44d..00000000 --- a/.devflow/features/decisions/KNOWLEDGE.md +++ /dev/null @@ -1,240 +0,0 @@ ---- -feature: decisions -name: Decisions & Pitfalls Ledger -description: "Use when working on the decisions/pitfalls pipeline, adding ops to json-helper.cjs, modifying render output, writing migrations, or modifying Dream SKILL behavior for decisions/curation. Keywords: decisions, pitfalls, ADR, ledger, assign-anchor, retire-anchor, render, dream-decisions, dream-curation, observations, decisions-log, decisions-ledger." -category: architecture -directories: [scripts/hooks, scripts/hooks/lib, src/cli/utils] -referencedFiles: - - scripts/hooks/lib/decisions-format.cjs - - scripts/hooks/lib/render-decisions.cjs - - scripts/hooks/lib/decisions-index.cjs - - scripts/hooks/lib/project-paths.cjs - - scripts/hooks/lib/mkdir-lock.cjs - - scripts/hooks/json-helper.cjs - - scripts/hooks/dream-commit - - src/cli/utils/decisions-ledger-migration.ts - - src/cli/utils/observations.ts - - shared/skills/dream-decisions/SKILL.md - - shared/skills/dream-curation/SKILL.md -created: 2026-06-10 -updated: 2026-06-16 ---- - -# Decisions & Pitfalls Ledger - -## Overview - -The decisions & pitfalls ledger is a three-tier storage system where `decisions-ledger.jsonl` is the single source of truth for rendering, `decisions-log.jsonl` holds raw observation lifecycle state, and `decisions.md`/`pitfalls.md` are deterministic, active-only rendered views. All write operations flow through `json-helper.cjs` operations (`assign-anchor`, `retire-anchor`, `rotate-observations`) which own numbering, status transitions, and render triggering. The renderer (`render-decisions.cjs`) and format helpers (`decisions-format.cjs`) are kept deliberately separate: format can never drift between the add-path and the render-path because they share the exact same format functions. - -The ledger lives in `.devflow/decisions/` alongside the raw log and archive. Only three files are committed to git: `decisions-ledger.jsonl`, `decisions.md`, and `pitfalls.md`. Everything else (log, archive, config, locks, usage state) is gitignored. - -## System Context - -**Purpose**: Capture architectural decisions (ADRs) and non-obvious failure modes (PFs) from development sessions. Surfaces them as `DECISIONS_CONTEXT` to all workflow commands so agents avoid re-discovering known patterns. - -**Role in the larger system**: The Dream pipeline drives writes. `eval-decisions` (SessionEnd hook) emits a marker; the Dream agent at SessionStart claims it, calls `merge-observation` and `assign-anchor` via `json-helper.cjs`, then runs `dream-commit` to commit the ledger + rendered `.md`. Orchestrators (`/plan`, `/code-review`, `/resolve`) load a compact index via `decisions-index.cjs` and pass it as `DECISIONS_CONTEXT`; consumer agents use `devflow:apply-decisions` to read full bodies on demand. - -**External dependencies**: `mkdir`-based locking (POSIX atomic), `git` for dream-commit safety rails, Node.js for all CJS helpers. - -## Component Architecture - -### Three-File Storage Split - -| File | Committed | Purpose | -|---|---|---| -| `decisions-ledger.jsonl` | YES | Anchored rows only — the render source of truth | -| `decisions-log.jsonl` | NO | Raw observation lifecycle (observing → created) | -| `decisions-log.archive.jsonl` | NO | Rotated-out stale `observing` rows (>30 days old) | -| `decisions.md` | YES | Deterministic active-only render of ADR rows | -| `pitfalls.md` | YES | Deterministic active-only render of PF rows | - -`decisions-ledger.jsonl` stores only rows with `anchor_id` set. `decisions-log.jsonl` stores all lifecycle rows; those promoted to anchored status are marked `status: 'created'` in the log but the canonical source is the ledger. The archive collects stale unanchored `observing` rows older than 30 days via `rotate-observations`. - -### LearningObservation Schema (key fields) - -The `LearningObservation` interface (canonical in `src/cli/utils/observations.ts`) extends the base observation fields with ledger-specific optional fields: - -- `anchor_id` — stable ADR-NNN/PF-NNN ID, set once by `assign-anchor`, never recomputed -- `date` — YYYY-MM-DD, **decisions only** (pitfalls have no date — byte-compat contract) -- `decisions_status` — `'Accepted' | 'Active' | 'Deprecated' | 'Superseded' | 'Retired'`; distinct from `status` (observation lifecycle); omitted = active -- `amendments` — ordered array of `{date, note}` for ADR amendment history -- `raw_body` — verbatim `.md` body for migrated entries; when present the renderer emits it verbatim instead of re-formatting from `details` - -### Format Authority: decisions-format.cjs - -`decisions-format.cjs` is the **single source of truth** for all byte-compat output strings. It is imported by both `json-helper.cjs` (add-path via `assign-anchor`) and `render-decisions.cjs` (render-path), ensuring the format can never drift between them. - -Key exported functions: -- `formatDecisionBody(row)` — formats from `details` string when `raw_body` absent; parses `context:`, `decision:`, `rationale:` segments -- `formatPitfallBody(row)` — formats from `details`; parses `area:`, `issue:`, `impact:`, `resolution:` segments -- `buildTldrLine(kind, rows)` — TL;DR comment line 1: ``; empty corpus = `Key: -->` (no trailing space) -- `initDecisionsContent(kind)` — initial file header with zero-corpus TL;DR - -**Byte-compat asymmetry** (critical): decisions have `- **Date**: YYYY-MM-DD\n`; pitfalls have `- **Area**: ...\n` and NO Date line. This asymmetry is intentional and must be preserved — `assign-anchor` sets `date` only on decisions, not pitfalls. - -### Renderer: render-decisions.cjs - -Pure, idempotent, clock-free render function. Takes all ledger rows (unfiltered) and produces complete file content. - -Filtering rules: -- `row.type` must match kind (`decision` → `decisions.md`, `pitfall` → `pitfalls.md`) -- `row.anchor_id` must be set (unanchored `observing` rows excluded) -- `decisions_status` not in `{Deprecated, Superseded, Retired}` (active only) - -Per-row content: if `raw_body` present → emit verbatim (migrated entries); otherwise → call `formatDecisionBody`/`formatPitfallBody` from `decisions-format.cjs`. - -Standalone CLI: `render-decisions.cjs render ` (takes lock before writing) and `render-decisions.cjs --check ` (diff without writing, exits 1 on drift). - -Lock-free helper `renderAndWriteAll(worktreePath, rows)` is called by callers that already hold `.decisions.lock` (`assign-anchor`, `retire-anchor`, and the migration). This prevents double-lock deadlock. - -## Component Interactions - -### json-helper.cjs Operations - -Three ledger-mutating operations (all run from `process.cwd()` as project root): - -**`assign-anchor `** — The primary add-path: -1. Acquires `.decisions.lock` (30s timeout, 60s stale-break) -2. Reads ledger to compute `max+1` over ALL anchored rows (including Retired) — ADR and PF sequences are independent -3. Zero-pads to 3 digits: ADR-001, ADR-002, ..., ADR-999 -4. Reads the log row by `obs_id`; exits 1 if absent -5. Builds anchored ledger row (copies log row + adds `anchor_id`, `decisions_status`, and `date` for decisions) -6. Atomically appends row to ledger (temp+rename) -7. Marks log row `status: 'created'` -8. Registers entry in `.decisions-usage.json` (initial `cites: 0`) -9. Calls `renderAndWriteAll` (lock-free — already holds lock) -10. Releases lock; prints anchor ID to stdout - -**`retire-anchor `** — Status flip: -- `status` must be `Deprecated | Superseded | Retired` -- Acquires `.decisions.lock`, flips `decisions_status` on the ledger row, re-renders both `.md` -- Idempotent (same status twice is safe) -- The entry vanishes from rendered `.md` but survives in the ledger — numbers are never reused - -**`rotate-observations [] []`** — Stale log cleanup: -- Runs under `.observations.lock` (NOT `.decisions.lock`) -- Moves `status === 'observing'` rows with no `anchor_id` older than 30 days to archive -- Never touches anchored or `created`/`ready` rows - -**`merge-observation `** — Observation upsert: -- ID-keyed: reinforces existing obs (increments count, merges evidence) or inserts new -- Caller-locked: the Dream agent acquires `.observations.lock` externally before calling this -- Passthrough for ledger fields: `anchor_id`, `date`, `decisions_status`, `amendments`, `raw_body` - -**`count-active `** — Reads ledger; returns count of active anchored rows. Unlike `assign-anchor`, `retire-anchor`, and `rotate-observations` (which derive project root from `process.cwd()`), `count-active` requires the worktree path as `args[0]` and the type as `args[1]` — always call as `node "$HOME/.devflow/scripts/hooks/json-helper.cjs" count-active "$(pwd)" "decision"` or `"$(pwd)" "pitfall"`. The `devflow:dream-curation` SKILL.md example shows `count-active "decision"` (single arg), which would resolve `"decision"` as a filesystem path — use `"$(pwd)"` as the first arg in practice. - -### Locking Discipline (ADR-017) - -Two independent lock domains: - -| Lock | Path | Held by | -|---|---|---| -| `.decisions.lock` | `.devflow/decisions/.decisions.lock` | `assign-anchor`, `retire-anchor`, `render` CLI | -| `.observations.lock` | `.devflow/dream/.observations.lock` | `rotate-observations`, Dream agent (wraps `merge-observation`) | - -**Critical rule**: never hold both locks simultaneously. If both are needed, take `.decisions.lock` as outer. In practice: `assign-anchor` never needs `.observations.lock`; `rotate-observations` never needs `.decisions.lock`. The Dream agent holds `.observations.lock` around `merge-observation` calls, then releases it before calling `assign-anchor` (which self-locks `.decisions.lock`). - -Lock implementation: POSIX `mkdir` atomic — `mkdir-lock.cjs` exports `acquireMkdirLock(lockDir, timeoutMs=30000, staleMs=60000)` and `releaseLock(lockDir)`. Stale lock break at 60 seconds. - -### decisions-index.cjs - -Parses `decisions.md` and `pitfalls.md` to produce a compact index string for `DECISIONS_CONTEXT`. Reads the already-rendered `.md` files (which contain only active entries) — no in-memory filtering needed. Output format: ID, title truncated to 60 chars, `[status]` tag, plus area suffix for pitfalls. Used by orchestrators via `node scripts/hooks/lib/decisions-index.cjs index `. - -## Integration Patterns - -### Dream Pipeline Integration - -The Dream SKILL for decisions (`shared/skills/dream-decisions/SKILL.md`) defines the add-path procedure: -1. Read `decisions-log.jsonl` for dedup context -2. Apply LLM judgment with the **abstain-by-default creation bar** (most sessions produce nothing) -3. **ADR-XOR-PF**: one incident → exactly one of ADR or PF, never both -4. **Dedup first**: if a matching obs exists in the log (any status including Retired), reinforce via `merge-observation` reusing its `obs_` id -5. Acquire `.observations.lock` externally, call `merge-observation`, release -6. If promoting: call `assign-anchor` (self-locks `.decisions.lock`) -7. After lock released: call `dream-commit decisions "add " ` - -The Dream SKILL for curation (`shared/skills/dream-curation/SKILL.md`) defines periodic housekeeping: -- Runs `rotate-observations` first (under `.observations.lock`) -- LLM selects up to 5 entries to retire per curation run (7-day protection window) -- Calls `retire-anchor` once per entry (each self-locks `.decisions.lock` — do NOT hold lock across multiple calls, that would deadlock) -- Calls `dream-commit curation "" ` after all retirements - -**Curation depends on decisions** (PR #244): `eval-curation` is now gated by `DECISIONS_ENABLED` — it returns immediately (using `return 0`, not `exit`) without touching `.curation-last` when decisions is disabled. `dream-collect-tasks` Pass 1 also sweeps curation markers when decisions is disabled. This prevents: (a) stray curation markers triggering an opus spawn when decisions is off, and (b) disabling decisions burning the 7-day curation suppression window on re-enable. - -### dream-commit - -Shell helper that stages only the allowed paths and commits with structured trailers: -``` -chore(dream): - -Dream-Task: -Dream-Session: -Co-Authored-By: Devflow Dream -``` - -Staged paths depend on task: decisions/curation tasks stage `decisions-ledger.jsonl`, `decisions.md`, `pitfalls.md`; knowledge task additionally stages `features/index.json` and all `KNOWLEDGE.md` files. - -Safety rails: skips if `autoCommit: false` in dream config (default ON), mid-rebase, mid-merge, mid-cherry-pick, or detached HEAD. Best-effort: git commit failure exits 0 (never blocks session). - -The `autoCommit` gate is read from `.devflow/dream/config.json` — the `DreamConfig` interface (`src/cli/utils/dream-config.ts`) now has four fields: `{memory, decisions, knowledge, autoCommit}` (default all `true`). Toggling auto-commit per-project requires editing `config.json` directly or implementing a CLI toggle; `devflow decisions --status` reports the current `autoCommit` value. `dream-commit` reads `autoCommit` via jq (preferred) or `node json-helper.cjs get-field-file` as fallback — both accept the file path directly (no shell interpolation of file content). - -## Constraints - -**Render invariant**: `decisions.md` and `pitfalls.md` are always the output of `renderAndWriteAll`. Any manual edit will be silently overwritten on the next `assign-anchor` or `retire-anchor` call. - -**Number reservation**: anchor IDs once assigned (including Retired) are never reused. `nextAnchorFromLedger` scans ALL anchored rows including retired ones for the current max. - -**Gitignore policy** (in `.devflow/.gitignore`): ignore-by-default with explicit re-includes. Only `decisions-ledger.jsonl`, `decisions.md`, `pitfalls.md`, `features/index.json`, and `features/*/KNOWLEDGE.md` are committed. - -## Anti-Patterns - -**Hand-editing decisions.md or pitfalls.md**: Both files are generated. Any edit will be silently overwritten on the next `assign-anchor` or `retire-anchor` call. Use `retire-anchor` to remove entries; entries can be re-activated by editing `decisions-ledger.jsonl` directly and re-rendering. - -**Calling decisions-append**: This operation was removed. All numbering is owned exclusively by `assign-anchor`. - -**Holding .decisions.lock across multiple retire-anchor calls**: Each `retire-anchor` invocation self-acquires `.decisions.lock`. Attempting to hold the lock externally across multiple calls deadlocks. - -**Calling rotate-observations under .decisions.lock**: Violates ADR-017. `rotate-observations` uses `.observations.lock`. Never hold both locks simultaneously. - -**Adding new format strings outside decisions-format.cjs**: Any format addition outside this module creates a drift risk between the add-path and render-path outputs. - -**Using ~/ paths for the renderer in migration code**: The migration resolves `render-decisions.cjs` from the bundled package (`dist/utils/` → `../../scripts/hooks/lib/`) not from `~/.devflow/scripts/`. The installed copy may not exist at migration time (PF-007). - -## Gotchas - -**decisions_status vs status**: Two distinct fields. `status` = observation lifecycle (`observing | ready | created | deprecated`). `decisions_status` = rendered entry status (`Accepted | Active | Deprecated | Superseded | Retired`). Confusing them leads to entries incorrectly excluded from (or included in) the rendered output. - -**Date field asymmetry**: `assign-anchor` sets `date` only for `type === 'decision'`. Pitfall rows must not have a `date` field — `formatPitfallBody` does not emit one, so a pitfall row with `date` set would silently be ignored by the renderer. - -**TL;DR empty corpus**: `buildTldrLine` with no rows produces `Key: -->` (single space, no trailing content before `-->`). Any other spacing breaks the byte-compat contract that `initDecisionsContent` establishes. - -**Idempotency path in migration**: `migrateDecisionsLedger` re-renders even when `newRowsAdded === 0` (if the existing ledger is non-empty). This heals crashes that happened between ledger write and `renderAndWriteAll`. A completely empty ledger with no new rows returns early without acquiring the lock. - -**Lock timeout vs stale break**: `acquireMkdirLock` defaults to 30s timeout and 60s stale break. A lock older than 60 seconds is forcibly broken. This means a process holding the lock for longer than 60 seconds risks having it stolen. - -**dream-commit stages only allowed paths**: `git add` is called explicitly on individual files — never `git add -A`. Changing a file outside the allowed paths requires adding it to the staging list in `dream-commit`. - -**re-activating a retired entry**: `retire-anchor` only accepts retiring statuses. To re-activate, directly edit the `decisions_status` field in `decisions-ledger.jsonl` to `Accepted` or `Active`, then run `render-decisions.cjs render `. - -## Key Files - -- `scripts/hooks/lib/decisions-format.cjs` — byte-compat format authority; single source of truth for all output strings -- `scripts/hooks/lib/render-decisions.cjs` — pure renderer; `renderDecisionsFile()` + `renderAndWriteAll()` + CLI; exports `parseLedger()` -- `scripts/hooks/json-helper.cjs` — all ledger-mutating ops: `assign-anchor`, `retire-anchor`, `rotate-observations`, `merge-observation`, `count-active` -- `scripts/hooks/lib/decisions-index.cjs` — compact index builder for `DECISIONS_CONTEXT`; CLI: `node decisions-index.cjs index ` -- `scripts/hooks/lib/project-paths.cjs` — path registry for all `.devflow/decisions/` file paths; CJS counterpart to `src/cli/utils/project-paths.ts` -- `scripts/hooks/lib/mkdir-lock.cjs` — POSIX mkdir-based lock helper -- `scripts/hooks/dream-commit` — attributable git commit helper for Dream maintenance tasks -- `src/cli/utils/decisions-ledger-migration.ts` — `decisions-ledger-unify-v1` migration; preserve-verbatim backfill from existing `.md` + log -- `src/cli/utils/observations.ts` — canonical `LearningObservation` interface and type guard -- `shared/skills/dream-decisions/SKILL.md` — Dream agent procedure for detection/promotion (abstain-by-default, ADR-XOR-PF, dedup) -- `shared/skills/dream-curation/SKILL.md` — Dream agent procedure for housekeeping (retire-by-status iron law, rotation wiring) - -## Related - -- ADR-008 — LLM-vs-plumbing: all ops in `json-helper.cjs` and `render-decisions.cjs` are deterministic plumbing; LLM judgment lives exclusively in the Dream SKILLs -- ADR-017 — Locking discipline: `.decisions.lock` and `.observations.lock` are independent domains; never hold both simultaneously -- ADR-001 — Clean-break philosophy: `decisions-ledger-unify-v1` is the explicitly approved data-preserving exception -- PF-007 — Edit `scripts/hooks/` + `shared/` source, not installed `~/.devflow`; `npm run build` + `devflow init` to deploy; migration resolves renderer from bundled package path -- PF-002/PF-004 — Migration skip-list + idempotency patterns -- PF-010 — Installer file-list drift diff --git a/.devflow/features/hooks/KNOWLEDGE.md b/.devflow/features/hooks/KNOWLEDGE.md deleted file mode 100644 index 90195a67..00000000 --- a/.devflow/features/hooks/KNOWLEDGE.md +++ /dev/null @@ -1,341 +0,0 @@ ---- -feature: hooks -name: Dream & Hooks System -description: "Use when modifying dream hooks, background maintenance, marker lifecycle, memory/decisions/knowledge/curation processing, or per-task dream skills. Keywords: dream, hooks, background processor, merge-observation, assign-anchor, retire-anchor, rotate-observations, render-decisions, decisions-ledger, marker, .processing, SessionStart, dream-capture, background-memory-update, dream-evaluate, dream-decisions, dream-knowledge, dream-curation, dream-commit, autoCommit." -category: architecture -directories: ["scripts/hooks/", "shared/agents/", "shared/skills/"] -referencedFiles: - - scripts/hooks/lib/feature-knowledge.cjs - - scripts/hooks/lib/decisions-index.cjs - - scripts/hooks/lib/transcript-filter.cjs - - scripts/hooks/lib/staleness.cjs - - scripts/hooks/json-helper.cjs - - scripts/hooks/dream-capture - - scripts/hooks/background-memory-update - - scripts/hooks/dream-collect-tasks - - scripts/hooks/dream-dispatch - - scripts/hooks/dream-evaluate - - scripts/hooks/dream-recover - - scripts/hooks/eval-curation - - scripts/hooks/session-start-memory - - scripts/hooks/session-start-context - - scripts/hooks/dream-commit - - shared/agents/dream.md - - shared/skills/dream-decisions/SKILL.md - - shared/skills/dream-knowledge/SKILL.md - - shared/skills/dream-curation/SKILL.md - - src/cli/commands/decisions.ts - - src/cli/utils/dream-config.ts -created: 2026-06-01 -updated: 2026-06-16 ---- - -# Dream & Hooks System - -## Overview - -The Dream system is a three-hook pipeline that captures session context, evaluates what background work is needed, and coordinates per-task background LLM agents to do that work. The hooks are installed from `scripts/hooks/` (source of truth) to `~/.devflow/scripts/hooks/` at `devflow init` time. The background agent spec lives at `shared/agents/dream.md`; per-task procedures live in three skills (`dream-decisions`, `dream-knowledge`, `dream-curation`). - -**Memory is NOT a Dream task.** Working-memory refresh happens eagerly via a detached `claude -p` worker (`background-memory-update`) spawned by `dream-capture` after the 120s throttle. This gives memory that is fresh during the session and ready before the next boot, instead of waiting until SessionStart. - -The system is explicitly split into two layers: **plumbing** (hooks, locks, marker files, atomic writes, JSONL logs) and **LLM** (all detection, semantic matching, content authoring, curation judgment). The Dream agent body holds shared plumbing (claim + heartbeat + dispatch); all task intelligence lives in per-task skill files loaded dynamically at runtime. - -## System Context - -Three active task types can be pending at any session start: `decisions`, `knowledge`, `curation`. Each gets its own marker file in `.devflow/dream/`. The hooks are: - -| Hook / Worker | Trigger | Role | -|---------------|---------|------| -| `dream-capture` | Stop (per turn) | Append assistant turn to queue; spawn `background-memory-update` worker after 120s throttle; run decisions usage scanner | -| `background-memory-update` | Spawned by dream-capture | Drain queue → `claude -p haiku` → rewrite WORKING-MEMORY.md with git stamp | -| `dream-evaluate` | SessionEnd | Source eval-* modules; write per-session markers for decisions/knowledge/curation | -| `session-start-context` | SessionStart | Recover stale `.processing`, collect pending markers, emit per-task DREAM MAINTENANCE directives | - -The learning task type has been removed. `dream-collect-tasks` now unconditionally deletes any orphaned `learning.*` AND `memory.*` marker files on sight. - -## Spawn Model: Per-Task Background Agents - -`session-start-context` spawns **one background Dream agent per pending task type**, using a hardcoded task→model map built by `dream_build_spawn_directive` (in `dream-collect-tasks`): - -| Task | Model | Rationale | -|------|-------|-----------| -| `knowledge` | sonnet | KB refresh needs code reading; no cross-cutting analysis | -| `decisions` | opus | Semantic matching + quality ADR/PF authoring | -| `curation` | opus | Nuanced deprecation judgment against live docs | - -**Exception — decisions + curation co-pending**: exactly ONE opus spawn is emitted, running decisions fully then curation fully (sequential). This prevents concurrent `.decisions.lock` contention between two opus agents. - -Unknown task types are silently skipped — `dream-collect-tasks` should never emit them, but the emitter has belt-and-suspenders defense. - -`dream_build_spawn_directive TASKS` sets the `_DREAM_DIRECTIVE` global (not stdout — preserves exact whitespace). The directive is a `--- DREAM MAINTENANCE ---` block with one `Agent()` call per task. - -## Agent Architecture: Plumbing + Dynamic Skill - -`shared/agents/dream.md` now holds only shared plumbing — Steps 0–1 (discover task, claim/heartbeat/merge) and error discipline — plus a dispatch table in Step 2: - -> For each task type you claimed markers for, load the matching skill via the Skill tool and follow its procedure exactly. - -The three per-task procedures live in separate skill files: - -- `devflow:dream-decisions` — dialog-pair analysis + ADR/PF creation via `assign-anchor` (renders `decisions.md`/`pitfalls.md` from the ledger) -- `devflow:dream-knowledge` — stale KB refresh + index update -- `devflow:dream-curation` — ADR/PF housekeeping (deprecate, merge, TL;DR rewrite) - -The `model: sonnet` in the Dream agent frontmatter is a default only; `session-start-context` overrides it per-spawn with the correct model for each task. - -## Marker Lifecycle - -``` -dream-evaluate writes: - .devflow/dream/{type}.{session_id}.json ← pending marker - -session-start-context reads: - - dream_recover_stale() ← .processing → .json if stale - - dream_collect_tasks() ← collects .json marker types; sweeps memory.* + learning.* unconditionally - - emits per-task DREAM MAINTENANCE directives ← one Agent() call per task (or combined for dec+cur) - -Dream agent (background) claims: - mv {type}.{session}.json → {type}.{session}.processing ← atomic claim - touch {type}.{session}.processing ← heartbeat at phase start - ... loads per-task skill, follows procedure ... - rm {type}.{session}.processing ← success: delete - (on error: leave .processing in place for retry) -``` - -The session suffix on marker filenames (`{type}.{session_id}.json`) is a concurrency fix (D57a): two simultaneous SessionStart events each process their own markers without racing on the same file. - -**Multi-marker merge**: When multiple `{type}.{session}.processing` files exist for one type, the Dream agent reads them all and unions their payloads before processing: decisions concatenate `dialogPairs` strings and union `existingObservationIds`; knowledge unions `staleSlugs`; curation uses single marker only. Input cap: last 30 dialog-pairs (bounds token cost per run). - -## Stale Recovery (dream-recover) - -`dream_recover_stale()` in `scripts/hooks/dream-recover` runs at the start of every SessionStart Section 2. It examines `.processing` files and applies per-type stale thresholds: - -| Marker type | Stale threshold | Rationale | -|-------------|----------------|-----------| -| All (`decisions`, `knowledge`, `curation`) | 1800s (30 min) | LLM runs can be slow; avoid yanking active processor | - -Note: `memory` markers no longer exist — they are swept unconditionally by `dream_collect_tasks` and purged by migration `purge-stale-memory-markers-v1`. The `background-memory-update` worker uses its own 300s-stale worker lock at `.devflow/memory/.working-memory.lock/`. - -When a `.processing` file exceeds its threshold, it is renamed back to `.json` for retry. Retry count is stored in a sibling `.retries` file; at 3 retries the marker is renamed to `.failed` instead. `.failed` files are cleaned up after 24 hours. The `JUST_RECOVERED` variable (exported by `dream_recover_stale`) tells `dream_collect_tasks` to preserve existing `.retries` counters rather than resetting them (D56b). - -The heartbeat rule pairs with these thresholds: the Dream agent `touch`es each `.processing` file at the start of every phase to refresh its mtime, preventing the recovery mechanism from yanking an actively running processor. - -## Spawn Throttle - -`session-start-context` enforces a 120s spawn throttle via `.devflow/dream/.processor-spawned-at` (D57b). SessionStart fires on `/clear`, compact, and every new window. Without this throttle, three rapid `/clear` commands would spawn three sets of background agents racing on the same markers. The throttle is written atomically (temp+mv). - -## Queue: Memory Channel - -`dream-capture` (Stop hook) appends assistant turns to `.devflow/memory/.pending-turns.jsonl` (JSONL, each line `{role, content, ts}`). After the 120s throttle (keyed by `.working-memory-last-trigger` mtime), it spawns `background-memory-update` as a detached worker (`nohup ... & disown`). The worker claims the queue atomically by renaming `.pending-turns.jsonl` → `.pending-turns.processing`. If the worker crashes mid-processing, recovery has two lock-separated paths: the worker is the **primary** recovery owner — on its next spawn (under `.working-memory.lock/`) it merges any leftover `.processing` back into the queue; `dream_recover_stale` (SessionStart) is the **cold-path fallback** for when the worker never re-spawns, renaming `.processing` → `.jsonl` only after a 300s age gate and only when no fresh `.jsonl` already exists (non-clobber guard, D56c). This is safe-by-construction: the 300s gate strictly exceeds the worker's ~125s watchdog total (WATCHDOG_SECS 120 + grace + margin), so the fallback never races a live worker. The worker's lock is a dedicated 300s-stale mkdir lock (not `dream_lock_acquire`, whose 30s stale-break is too short for a ≤120s `claude -p` call). - -`session-start-memory` injects WORKING-MEMORY.md with a git-reconciled header (3-state rendering): -- **State A (in-sync)**: stamp SHA matches HEAD → `--- WORKING MEMORY (synced @ on , m ago) ---` -- **State B (drifted)**: stamp SHA is an ancestor of HEAD but N commits behind → header notes drift + `git log --oneline STAMP..HEAD` (max 10) -- **State C (refresh failing)**: queue non-empty AND `.last-refresh-ok` missing or >600s old → unmissable top banner -- **Branch mismatch**: stamp branch ≠ current branch → prepend warning (shown for any state) - -No raw UNPROCESSED TURNS dump in session-start-memory (removed). The background worker handles synthesis. - -Queue overflow: capped at 200 lines, truncated to 100 under a mkdir-based lock to prevent multi-session truncation races. - -Decisions usage scanner (`decisions-usage-scan.cjs`) also runs in `dream-capture` — it increments cite counts in `.decisions-usage.json` when the assistant response contains `ADR-NNN` or `PF-NNN` references. **Dual-signal gate (PR #244)**: the scanner is now skipped when decisions is disabled via EITHER dream config (`decisions: false`) OR the `.disabled` sentinel — mirrors the same dual-signal check used in `dream-evaluate`. A supplementary guard (D29) skips the Node subprocess entirely when no `ADR-[0-9]+|PF-[0-9]+` pattern is found in the response (~50-100ms savings). - -## Curation Gate: Decisions-Dependency (PR #244) - -Curation depends on the decisions pipeline — it curates ADR/PF entries that decisions writes. Two new gates enforce this dependency: - -**`eval-curation` gate**: before writing the `.curation-last` throttle file, `eval-curation` checks `DECISIONS_ENABLED`. If decisions is disabled, it returns immediately (using `return 0`, never `exit` — it is sourced under `set -e` inside the orchestrator). This prevents disabling decisions from accidentally burning the 7-day curation suppression window. - -**`dream-collect-tasks` curation sweep**: Pass 1 now includes a `curation)` branch — when decisions is disabled (`dec_enabled != "true"`), curation markers are deleted unconditionally, same as `learning.*` and `memory.*`. This prevents stale curation markers from triggering an opus spawn when decisions is disabled. - -**`dream-evaluate` dual-signal gate**: after reading `DECISIONS_ENABLED` from dream config, a `[ -f "$DECISIONS_DIR_DATA/.disabled" ]` check can override it to `"false"`. This ensures both config-based disabling and sentinel-based disabling suppress marker writes — the same gate is mirrored in `dream-capture` for the usage scanner. - -## dream-capture: SOH-Delimited Field Parsing - -`dream-capture` parses `cwd` and `last_assistant_message` from the Stop hook JSON input in a single subprocess, using ASCII SOH (U+0001, `$'\001'`) as the binary delimiter between fields: - -```bash -_FIELDS=$(printf '%s' "$INPUT" | jq -r '(.cwd // "") + "" + (.last_assistant_message // "")') -CWD="${_FIELDS%%$'\001'*}" -ASSISTANT_MSG="${_FIELDS#*$'\001'}" -``` - -**Why SOH and not `cut`**: `cut` is line-oriented — a multi-line `last_assistant_message` would split across newlines, causing the second and subsequent lines to be misinterpreted as `CWD`. This caused a silent drop of every multi-line assistant turn (the `[ ! -d "$CWD" ]` guard would fail on the garbled value). The fix (commit 495b2d0) replaced `cut` with parameter expansion, which is newline-safe and bash 3.2 compatible. - -`dream-dispatch` (UserPromptSubmit) uses the same pattern via `json_extract_cwd_prompt()` in `json-parse`, which outputs `cwd + SOH + prompt`. Both hooks now use the identical split idiom: -```bash -CWD="${FIELDS%%$'\001'*}" -PROMPT_OR_MSG="${FIELDS#*$'\001'}" -``` - -**Diagnostic signal**: if `dream-capture` silently drops all multi-line assistant turns (memory queue stays empty despite active sessions), suspect a regression to line-oriented parsing. Look for `cut -d$'\001'` or `@tsv` patterns near the field-split. - -## Transcript Channels (transcript-filter.cjs) - -`dream-evaluate` uses `scripts/hooks/lib/transcript-filter.cjs` to extract channels from the session transcript before writing markers: - -- **DIALOG_PAIRS** — adjacent `(assistant, user)` pairs; fed to `decisions` markers (decision/pitfall detection) - -The `user-signals` extraction operation still exists in `transcript-filter.cjs` but is now orphaned-but-harmless: the learning pipeline has been removed and no remaining module consumes USER_SIGNALS. Only `decisions` uses `dialog-pairs`. - -The filter rejects: `isMeta:true` entries, tool scaffolding, framework-injected XML wrappers, `tool_result` content items, and turns under 5 characters. Cap: last 80 turns, 1200 chars per turn. - -## Lock Hardening in Per-Task Skills - -The old `dream-evaluate` / Dream agent used a give-up-fast `mkdir || { sleep; exit }` pattern. The per-task skills now use **bounded retry+backoff** for both lock types: - -```bash -# Pattern used in dream-decisions (.observations.lock) and dream-curation (.decisions.lock) -LOCK="" -_ACQUIRED=false -_BACKOFF=1 -for _ATTEMPT in 1 2 3 4 5 6 7 8 9; do - if mkdir "$LOCK" 2>/dev/null; then - _ACQUIRED=true; break - fi - sleep "$_BACKOFF" - _BACKOFF=$(( _BACKOFF < 8 ? _BACKOFF * 2 : 8 )) -done -if [ "$_ACQUIRED" != "true" ]; then - echo "...: lock exhausted — leaving .processing for retry" >&2; exit 1 -fi -``` - -9 attempts, ~47s total backoff cap (sequence: 1+2+4+8+8+8+8+8+8 seconds of sleep between attempts). On exhaustion, exits with code 1 leaving `.processing` in place — dream-recover will retry rather than silently dropping the work. - -## Plumbing Operations (json-helper.cjs) - -Two key plumbing operations in `json-helper.cjs` handle all observation writes: - -**`merge-observation `** — id-keyed reinforcement: -- Finds existing entry by `id` field in the JSONL log, merges evidence (FIFO cap-10, D12) -- If `id` type collides with an existing entry, appends `_b` suffix (D11) -- Self-creates parent directory and empty log on first write -- Caller holds `.devflow/dream/.observations.lock` (mkdir-based) EXTERNALLY — lock acquired by the per-task skill around the Bash call, then released. Never held across tool calls. -- Writes atomically via temp+mv with O_EXCL flag - -**`assign-anchor `** — anchor an observation into the committed ledger (replaces the removed `decisions-append`): -- Assigns the next `ADR-NNN` or `PF-NNN` anchor (max+1 over all anchored rows incl. Retired; ADR/PF numbered independently; 3-digit pad). Retired numbers are reserved, never resurrected. -- Appends a projected row to `decisions-ledger.jsonl`, then deterministically renders `decisions.md`/`pitfalls.md` from the ledger via `render-decisions.cjs` (active entries only — Deprecated/Superseded/Retired are dropped from the `.md`). -- Acquires `.devflow/decisions/.decisions.lock` INTERNALLY — self-locking; asserts the anchor isn't already present and the obs isn't already anchored. -- Never call from a context that already holds `.decisions.lock` (deadlock). - -**`retire-anchor `** — recoverable removal: -- Flips the row's `decisions_status` (e.g. → `Retired`/`Deprecated`/`Superseded`) and re-renders. The ledger row is never deleted, so the entry is recoverable; the rendered `.md` simply drops it. Errors if the anchor_id isn't found. Self-locks `.decisions.lock`. - -**`rotate-observations`** — log bound: -- Archives observing rows older than 30d from `decisions-log.jsonl` to `decisions-log.archive.jsonl` (dedup-by-id append). Keeps the active log small. - -**`read-dream `** — reads a field from a dream JSON marker file; returns `[]` on any error. - -## LLM-vs-Plumbing Principle - -The boundary is strict: - -| Plumbing (deterministic code) | LLM (background Dream agent + skills) | -|-------------------------------|-------------------------------| -| Hook triggers, throttles, locks | Detection of patterns from dialog pairs | -| Atomic file writes, marker management | Semantic matching for obs_id reuse | -| JSONL log structure, id-keyed records | Content authoring (artifacts, ADR/PF bodies) | -| `assign-anchor`/`retire-anchor` numbering + `render-decisions` | Curation judgment (what to retire, what to merge) | -| `staleness.cjs` annotation | Interpretation of staleness signal | -| `decisions-index.cjs` filtering | Promotion decisions (status, confidence) | - -**Exception for working memory**: WORKING-MEMORY.md content is authored by the LLM, but the spawn mechanism is the detached `background-memory-update` worker (a `claude -p haiku` subprocess started by `dream-capture`), not the SessionStart Dream agent. For all three Dream tasks (decisions, knowledge, curation), no `claude -p` subprocess is spawned directly — those run inside the Dream agent process spawned by `session-start-context`. - -## Staleness Signal (staleness.cjs) - -`scripts/hooks/lib/staleness.cjs` annotates decisions log entries with `mayBeStale: true` + `staleReason` when file paths extracted from `details`/`evidence` fields no longer exist on disk. The Dream curation skill runs this before selecting deprecation candidates. Staleness is a signal to the LLM, not an automatic action — a heavily-cited stale entry should survive over an uncited stale one. - -## Curation (eval-curation + dream-curation skill) - -`eval-curation` (sourced by `dream-evaluate`) writes a `curation.{session}.json` marker, throttled to once every 7 days via `.devflow/dream/.curation-last` epoch file. Curation is now **gated by `DECISIONS_ENABLED`** (PR #244): if decisions is disabled, `eval-curation` returns immediately (using `return 0` not `exit`) without touching `.curation-last`, so the suppression window is not burned. The `devflow:dream-curation` skill (loaded by the Dream agent) then: - -- Reads `.decisions-usage.json` directly for cite counts (never calls `decisions-usage-scan.cjs`) -- Retires/deprecates/supersedes by calling `retire-anchor ` — flips `decisions_status` on the ledger row and re-renders both `.md` files atomically. The `.md` files are rendered views of the ledger and are NEVER hand-edited; retired entries vanish from the `.md` but survive (recoverable) in the committed ledger. -- `retire-anchor` self-locks `.decisions.lock` internally per call; call it once per entry. Do NOT hold `.decisions.lock` across multiple calls (deadlocks). Re-activating a retired entry is done by editing its ledger row directly, then re-rendering. -- Runs under `.observations.lock`; if both locks are needed, `.decisions.lock` is the outer (ADR-017) -- 5 changes per run maximum; 7-day protection window per entry; auto-commits via `dream-commit` after all `retire-anchor` calls complete - -Note: `.curation-last` lives in `.devflow/dream/` (not `.devflow/decisions/`), co-located with other Dream state. - -## Feature Knowledge Staleness (feature-knowledge.cjs) - -`scripts/hooks/lib/feature-knowledge.cjs` is the runtime module for knowledge base operations: -- `checkStaleness(worktreePath, slug)` — runs `git log --after={lastUpdated}` against `referencedFiles` to detect whether a KB is stale -- `checkAllStaleness(worktreePath)` — single git log call for all entries (batched for efficiency) -- `updateIndex(worktreePath, entry)` — acquires `.devflow/features/.knowledge.lock` (mkdir-based) before writing `index.json` -- `findOverlapping(worktreePath, changedFiles)` — finds KBs whose referencedFiles overlap changed files (used by SessionEnd to decide which KBs need refresh) -- Slug validation: kebab-case only, no `..` or `/` (D52 defense-in-depth) - -## Decisions Index (decisions-index.cjs) - -`scripts/hooks/lib/decisions-index.cjs` provides a compact index for orchestration surfaces. It applies a belt-and-suspenders status filter (`INACTIVE_STATUSES`): strips sections with `- **Status**: Deprecated`, `Superseded`, or `Retired` before building the index (defense-in-depth — the renderer already excludes inactive entries from the `.md`). The compact format is what orchestrators inject as `DECISIONS_CONTEXT`. Never loads the full decisions.md/pitfalls.md body into context — consumers call Read on demand. - -## Dream Config - -The sole source of truth for feature enabled-state is `.devflow/dream/config.json` (ADR-001 clean break — there is no runtime fallback). `DreamConfig` is `{memory, decisions, knowledge, autoCommit}` (`src/cli/utils/dream-config.ts`); the legacy `learning` field has been removed, and `coerceConfig` silently drops it when reading old configs. The `autoCommit` field (added PR #241, default `true`) controls whether Dream tasks auto-commit maintenance writes; `dream-commit` reads it at runtime via jq or `json-helper.cjs get-field-file`. Legacy `.devflow/sidecar/config.json` files are migrated to `dream/config.json` once at `devflow init` time by the `rename-sidecar-to-dream-v1` migration — the hooks do **not** read the sidecar path at runtime. - -## Anti-Patterns - -- **Editing installed copies** — always edit `scripts/hooks/`, then `npm run build` + `devflow init`. Changes to `~/.devflow/scripts/hooks/` are silently overwritten on reinstall (PF-007). -- **Hand-editing the rendered `.md` to deprecate** — `decisions.md`/`pitfalls.md` are rendered views of the ledger; hand-edits are overwritten on the next render. Retire via `retire-anchor ` (flips ledger status + re-renders), as documented in the `dream-curation` skill. Each call self-locks `.decisions.lock`, so never hold that lock across multiple `retire-anchor` calls (deadlocks). -- **Holding a lock across tool calls** — the Dream agent's lock lifecycle must be: acquire Bash → Edit tool(s) → release Bash. Never span multiple unrelated tool calls under one lock. -- **Spawning one agent to handle all tasks** — the new model is N per-task background agents (one per task type), not one agent doing all tasks sequentially. The only exception is decisions+curation co-pending, which shares one opus spawn to avoid lock contention. -- **Assuming `dream-dispatch` injects the DREAM directive** — `dream-dispatch` is capture-only (UserPromptSubmit); the DREAM MAINTENANCE directives are emitted by `session-start-context` (SessionStart) (ADR-009). -- **Using `additionalContext` for critical directives** — models deprioritize `additionalContext` when a user question is present; critical maintenance directives must be anchored to SessionStart (PF-008). -- **Using `decisions-usage-scan.cjs` to read cite counts** — it is a write-path tool that increments counts from session text. Read `.decisions-usage.json` directly for reporting or curation decisions. -- **Writing artifact content in deterministic scripts** — observations, ADR/PF bodies, and knowledge bases must be authored by the LLM Dream agent via per-task skills; WORKING-MEMORY.md is authored by the LLM inside the `background-memory-update` worker; plumbing scripts handle only structural writes (ADR-008). -- **Expecting USER_SIGNALS to feed any active pipeline** — the learning pipeline is gone. `transcript-filter.cjs` still emits a `user-signals` op but nothing consumes it. Only `dialog-pairs` (decisions) is active. -- **Using line-oriented `cut` to split multi-field jq output** — `cut` splits on newlines, so a multi-line field value (e.g. `last_assistant_message`) will bleed body lines into the preceding field. Use SOH delimiter with bash parameter expansion instead (fixed in 495b2d0). -- **Calling `eval-curation` return 0 as exit** — `eval-curation` is sourced under `set -e` inside `dream-evaluate`. Using `exit` would kill the whole orchestrator process. Always use `return 0` when short-circuiting from a sourced file. -- **Expecting curation to run when decisions is disabled** — curation is not an independent feature. Both `eval-curation` (write-time gate) and `dream-collect-tasks` (collect-time sweep) enforce this dependency. Disabling decisions suppresses curation too. - -## Gotchas - -- **PF-006**: Claude Code renamed `response_text` → `last_assistant_message` in the Stop hook JSON silently (mid-May 2026). All 3+ projects had frozen memory for weeks. After any Claude Code version update, verify hook input field names against current docs. `dream-capture` now reads `last_assistant_message`; if Claude Code changes the API again, this will break silently — always test hook field presence after upgrades. -- **PF-007**: Source is `scripts/hooks/`; installed is `~/.devflow/scripts/hooks/`. Editing installed copies creates repo divergence and the changes are overwritten on next `devflow init`. Always source-first. -- **Multi-line assistant message parsing (fixed 495b2d0)**: `dream-capture` previously used `cut -d$'\001'` to split `cwd + SOH + last_assistant_message`. `cut` is line-oriented: multi-line messages produced multiple lines of output, making the second line of `last_assistant_message` the apparent CWD. The `-d "$CWD"` guard would fail silently, dropping every multi-line turn from the memory queue. Fixed by switching to `${_FIELDS%%$'\001'*}` / `${_FIELDS#*$'\001'}` parameter expansion (bash 3.2 compatible, newline-safe). -- **set -e / no-abort discipline** — `dream-capture` and `dream-evaluate` use `set -e`. However, `session-start-context` intentionally omits `set -e` because its two sections (1.5 decisions TL;DR and 2 dream pending-work) are independent — a failure in one must not prevent the other from running. Never add `set -e` to `session-start-context`. -- **Background session guards** — all three hooks check `DEVFLOW_BG_UPDATER`, `DEVFLOW_BG_LEARNER`, `DEVFLOW_BG_KNOWLEDGE_REFRESH` env vars at the top and exit immediately to prevent feedback loops when the hook fires inside a background agent's subprocess. -- **Atomic writes everywhere** — all marker and state file writes use `tmp.$$` (PID-unique) + atomic `mv`. The `json-helper.cjs` uses `writeExclusive` (O_EXCL flag) for temp files to prevent TOCTOU symlink attacks. -- **`feature-knowledge.cjs` uses `execFileSync` with array args** — never string-interpolate `lastUpdated` or worktree paths into shell strings. The module explicitly avoids shell injection via array arguments. -- **`dream-collect-tasks` 3-arg signature** — the function signature is now `dream_collect_tasks DREAM_DIR DEC_EN KNOW_EN`. The `MEM_EN` argument was removed when memory left the Dream pipeline (memory is refreshed by the `background-memory-update` worker); there is no `MEM_EN` or `LEARN_EN` argument. Any call site passing 4+ arguments is stale. -- **Per-task skill model override** — `shared/agents/dream.md` has `model: sonnet` in its frontmatter, but `session-start-context` overrides this per spawn via `dream_build_spawn_directive`. The agent frontmatter model is never actually used in production; the spawn-time model is authoritative. -- **`dream_build_spawn_directive` communicates via global** — uses `_DREAM_DIRECTIVE` global variable (not stdout) so exact directive bytes including trailing newlines survive intact; command substitution would strip them. -- **eval-decisions daily cap blocks late-session writes** — at 3 runs/day (default), decisions markers stop being written mid-session. No marker = no Dream agent spawn for decisions that session. Configurable via `.devflow/decisions/decisions.json` → `max_daily_runs`. -- **Decisions usage scanner dual-signal (PR #244)** — the scanner in `dream-capture` checks both config (`decisions: false`) AND the `.disabled` sentinel. Previously it only checked the sentinel. Any code reading the usage scanner gate must check both signals. -- **Curation gate is `return 0`, not `exit 0`** — `eval-curation` is sourced (not executed) under `set -e` inside `dream-evaluate`. Using `exit` would kill the entire `dream-evaluate` orchestrator, not just the curation eval. All short-circuits in sourced eval modules must use `return`. -- **`dream-collect-tasks` curation pass-through gone** — curation no longer blindly passes through when decisions is disabled. The prior comment "curation and unknown types: pass through unchanged" is outdated. A curation marker found when `dec_enabled != "true"` is now deleted in Pass 1. - -## Key Files - -- `scripts/hooks/dream-capture` — Stop hook; queue append + spawns `background-memory-update` worker (120s throttle); decisions usage scanner with dual-signal gate (config OR sentinel); SOH-delimited `cwd`+`last_assistant_message` parsing via parameter expansion -- `scripts/hooks/background-memory-update` — detached `claude -p haiku` worker: claims `.pending-turns.jsonl` → `.pending-turns.processing` atomically, calls `claude -p` with Write permission, verifies stamp on line 1, touches `.last-refresh-ok` on success; uses 300s-stale mkdir lock at `.working-memory.lock/` -- `scripts/hooks/dream-dispatch` — UserPromptSubmit hook; capture-only (user turn append to pending-turns queue); no directive emission; uses `json_extract_cwd_prompt()` from `json-parse` for SOH-delimited field split -- `scripts/hooks/dream-evaluate` — SessionEnd hook; orchestrator sourcing eval-helpers + eval-decisions + eval-knowledge + eval-curation; dual-signal gate for `DECISIONS_ENABLED` (config + sentinel override) -- `scripts/hooks/eval-decisions` — sourced by dream-evaluate; daily-cap check (default 3/day via `.decisions-runs-today`); extracts dialog pairs via transcript-filter.cjs; writes `decisions.{session}.json` marker -- `scripts/hooks/eval-knowledge` — sourced by dream-evaluate; 2-hour throttle (`.knowledge-last-refresh`); queries `feature-knowledge.cjs stale-slugs`; writes `knowledge.{session}.json` marker; optimistically updates throttle -- `scripts/hooks/eval-curation` — sourced by dream-evaluate; gated by `DECISIONS_ENABLED` (returns immediately without burning `.curation-last` when decisions disabled); writes `curation.{session}.json` marker on 7-day throttle -- `scripts/hooks/dream-recover` — sourced helper; stale `.processing` recovery per-type thresholds; JUST_RECOVERED guard; orphaned pending-turns recovery -- `scripts/hooks/dream-collect-tasks` — 3-arg sourced helper; two-pass design: Pass 1 unconditional sweep (deletes `learning.*` + `memory.*` + disabled-feature markers + curation markers when decisions disabled), Pass 2 type accumulation; `dream_build_spawn_directive` function; COLLECT_LIMIT=50 FIFO -- `scripts/hooks/session-start-context` — SessionStart hook (no set -e); two independent sections: 1.5 decisions TL;DR, 2 per-task dream spawn directives (calls `dream_build_spawn_directive`) -- `scripts/hooks/json-helper.cjs` — plumbing ops: `merge-observation`, `assign-anchor`, `retire-anchor`, `rotate-observations`, `read-dream`, atomic writes; does NOT contain judgment logic -- `scripts/hooks/json-parse` — sourced by all hooks; provides `json_field`, `json_field_file`, `json_extract_cwd_prompt` and friends; `json_extract_cwd_prompt` uses SOH delimiter + node/jq fallback for safe multi-field extraction -- `scripts/hooks/lib/transcript-filter.cjs` — two-channel filter: USER_SIGNALS (orphaned, unused) + DIALOG_PAIRS (active, decisions only) -- `scripts/hooks/lib/staleness.cjs` — annotates log entries with `mayBeStale` based on file existence; signal-only (no CLI display surface) -- `scripts/hooks/lib/feature-knowledge.cjs` — KB index, staleness checks (`checkAllStaleness` batches all KBs in one git log call), `updateIndex`, `stale-slugs` CLI op, slug validation -- `scripts/hooks/lib/decisions-index.cjs` — compact decisions index with D-A filter for orchestrators -- `shared/agents/dream.md` — Dream agent plumbing spec: Step 0 task discovery, Step 1 claim/heartbeat/multi-marker-merge, Step 2 per-task skill dispatch, error discipline -- `scripts/hooks/dream-commit` — deterministic plumbing helper that stages ONLY allowed `.devflow` paths and commits `chore(dream): ` with `Dream-Task:` / `Dream-Session:` / `Co-Authored-By:` trailers; reads `autoCommit` from dream config via jq or `get-field-file`; self-exits cleanly mid-rebase/merge/cherry-pick/detached-HEAD/nothing-staged; must be called AFTER any lock is released -- `shared/skills/dream-decisions/SKILL.md` — decisions task procedure: dialog-pair analysis, bounded retry+backoff on `.observations.lock`, `assign-anchor` promotion (opus) -- `shared/skills/dream-knowledge/SKILL.md` — knowledge task procedure: stale KB refresh + index update (sonnet) -- `shared/skills/dream-curation/SKILL.md` — curation task procedure: deprecate/merge ADR/PF, bounded retry+backoff on `.decisions.lock`, Edit-tool deprecation (opus) - -## Related - -- **PF-006** (`.devflow/decisions/pitfalls.md`) — Claude Code Stop hook API changed silently; `response_text` → `last_assistant_message`. Defense: verify hook field names after every Claude Code update. -- **PF-007** (`.devflow/decisions/pitfalls.md`) — Edit source hooks (`scripts/hooks/`), never installed copies (`~/.devflow/scripts/hooks/`). -- **ADR-008** (`.devflow/decisions/decisions.md`) — LLM-vs-plumbing principle: artifact content must be LLM-authored; deterministic scripts handle only structural writes. -- **ADR-009** (`.devflow/decisions/decisions.md`) — Dream processor must be spawned at SessionStart, not via UserPromptSubmit (additionalContext deprioritized). -- **KB: cli-rules** (`.devflow/features/cli-rules/KNOWLEDGE.md`) — covers `src/cli/commands/` and `src/cli/utils/`, including the `devflow decisions` CLI which manages the decisions feature toggle and config surfaced here. diff --git a/.devflow/features/index.json b/.devflow/features/index.json deleted file mode 100644 index 16093727..00000000 --- a/.devflow/features/index.json +++ /dev/null @@ -1,95 +0,0 @@ -{ - "version": 1, - "features": { - "cli-rules": { - "name": "Rules System CLI", - "description": "Use when adding new rules, modifying the rules install flow, implementing rule shadowing, or wiring rules into init/uninstall. Keywords: rules, shared/rules, rulesMap, buildRulesMap, isValidRuleName, LEGACY_RULE_NAMES, rulesEnabled, devflow rules, ~/.claude/rules/devflow, installRuleFile, removeLegacyCommandsRule, ambient.ts, partitionSelectablePlugins, WORKFLOW_ORDER, combineSelection, shouldRetry, autoCommit, DreamConfig, decisions-ledger-unify-v1, sync-devflow-gitignore-v3, devflow-dynamic, build-recipes, shared/recipes.", - "directories": [ - "src/cli/commands/", - "src/cli/utils/", - "shared/rules/", - "scripts/" - ], - "referencedFiles": [ - "src/cli/commands/rules.ts", - "src/cli/commands/init.ts", - "src/cli/commands/uninstall.ts", - "src/cli/commands/ambient.ts", - "src/cli/plugins.ts", - "src/cli/utils/installer.ts", - "src/cli/utils/manifest.ts", - "src/cli/utils/flags.ts", - "src/cli/utils/teammate-mode-cleanup.ts", - "src/cli/utils/dream-config.ts", - "src/cli/utils/migrations.ts", - "scripts/build-plugins.ts", - "scripts/build-recipes.ts", - "shared/rules/security.md", - "shared/rules/engineering.md", - "shared/rules/quality.md", - "shared/rules/reliability.md" - ], - "lastUpdated": "2026-06-16T12:28:06.273Z", - "createdBy": "devflow-knowledge" - }, - "hooks": { - "name": "Dream & Hooks System", - "description": "Use when modifying dream hooks, background maintenance, marker lifecycle, memory/decisions/knowledge/curation processing, or per-task dream skills. Keywords: dream, hooks, background processor, merge-observation, assign-anchor, retire-anchor, rotate-observations, render-decisions, decisions-ledger, marker, .processing, SessionStart, dream-capture, background-memory-update, dream-evaluate, dream-decisions, dream-knowledge, dream-curation, dream-commit, autoCommit.", - "directories": [ - "scripts/hooks/", - "shared/agents/", - "shared/skills/" - ], - "referencedFiles": [ - "scripts/hooks/lib/feature-knowledge.cjs", - "scripts/hooks/lib/decisions-index.cjs", - "scripts/hooks/lib/transcript-filter.cjs", - "scripts/hooks/lib/staleness.cjs", - "scripts/hooks/json-helper.cjs", - "scripts/hooks/dream-capture", - "scripts/hooks/background-memory-update", - "scripts/hooks/dream-collect-tasks", - "scripts/hooks/dream-dispatch", - "scripts/hooks/dream-evaluate", - "scripts/hooks/dream-recover", - "scripts/hooks/eval-curation", - "scripts/hooks/session-start-memory", - "scripts/hooks/session-start-context", - "scripts/hooks/dream-commit", - "shared/agents/dream.md", - "shared/skills/dream-decisions/SKILL.md", - "shared/skills/dream-knowledge/SKILL.md", - "shared/skills/dream-curation/SKILL.md", - "src/cli/commands/decisions.ts", - "src/cli/utils/dream-config.ts" - ], - "lastUpdated": "2026-06-16T12:27:57.174Z", - "createdBy": "implement" - }, - "decisions": { - "name": "Decisions & Pitfalls Ledger", - "description": "Use when working on the decisions/pitfalls pipeline, adding ops to json-helper.cjs, modifying render output, writing migrations, or modifying Dream SKILL behavior for decisions/curation. Keywords: decisions, pitfalls, ADR, ledger, assign-anchor, retire-anchor, render, dream-decisions, dream-curation, observations, decisions-log, decisions-ledger, autoCommit, dream-commit, count-active.", - "directories": [ - "scripts/hooks", - "scripts/hooks/lib", - "src/cli/utils" - ], - "referencedFiles": [ - "scripts/hooks/lib/decisions-format.cjs", - "scripts/hooks/lib/render-decisions.cjs", - "scripts/hooks/lib/decisions-index.cjs", - "scripts/hooks/lib/project-paths.cjs", - "scripts/hooks/lib/mkdir-lock.cjs", - "scripts/hooks/json-helper.cjs", - "scripts/hooks/dream-commit", - "src/cli/utils/decisions-ledger-migration.ts", - "src/cli/utils/observations.ts", - "src/cli/utils/dream-config.ts", - "shared/skills/dream-decisions/SKILL.md", - "shared/skills/dream-curation/SKILL.md" - ], - "lastUpdated": "2026-06-16T12:28:13.817Z", - "createdBy": "implement" - } - } -} diff --git a/.gitignore b/.gitignore index 74e83e09..1fa77596 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,11 @@ install.log # Devflow local scope installation (use --scope local) .claude/ +# Devflow runtime data — per-developer state (memory, dream, docs, decisions, +# feature knowledge, locks). Ignored wholesale by default; sharing is opt-in +# (remove this entry, or re-include specific files in your own .gitignore). +.devflow/ + src/claude/CLAUDE.legacy.md # Launch marketing materials diff --git a/CLAUDE.md b/CLAUDE.md index a626b325..6bdab933 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ Plugin marketplace with 22 plugins (12 core + 9 optional language/ecosystem + 1 **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`. -**Decisions pipeline** (`eval-decisions` SessionEnd module → `decisions.{session_id}.json` marker → Dream agent): The `eval-decisions` module runs every session, extracts DIALOG_PAIRS from the transcript, and writes a decisions marker. At SessionStart the Dream agent claims the marker, detects **decision** and **pitfall** observation types via LLM analysis of the dialog pairs, and materializes entries via `assign-anchor` (internally self-locks `.decisions.lock`; assigns the next ADR-NNN/PF-NNN anchor number into the committed `decisions-ledger.jsonl`, then deterministically renders `decisions.md`/`pitfalls.md` from the ledger — active entries only). Removal is recoverable via `retire-anchor` (flips `decisions_status`, never deletes). Raw observations accumulate in the gitignored `.devflow/decisions/decisions-log.jsonl` (rotated to `decisions-log.archive.jsonl` by `rotate-observations`). No deterministic thresholds or confidence formulas — the LLM determines whether an observation warrants a new entry or should be merged with an existing one. Global config: `~/.devflow/decisions.json`. Project config: `.devflow/decisions/decisions.json`. Runtime sentinel: `.devflow/decisions/.disabled` — the decisions sections in `session-start-context` skip if present; `devflow decisions --enable` removes it, `devflow decisions --disable` creates it. Toggleable via `devflow decisions --enable/--disable/--status` or `devflow init --decisions/--no-decisions`. Management subcommands: `devflow decisions list`, `devflow decisions --configure`, `devflow decisions --clear/--reset`. +**Decisions pipeline** (`eval-decisions` SessionEnd module → `decisions.{session_id}.json` marker → Dream agent): The `eval-decisions` module runs every session, extracts DIALOG_PAIRS from the transcript, and writes a decisions marker. At SessionStart the Dream agent claims the marker, detects **decision** and **pitfall** observation types via LLM analysis of the dialog pairs, and materializes entries via `assign-anchor` (internally self-locks `.decisions.lock`; assigns the next ADR-NNN/PF-NNN anchor number into `decisions-ledger.jsonl`, then deterministically renders `decisions.md`/`pitfalls.md` from the ledger — active entries only). Removal is recoverable via `retire-anchor` (flips `decisions_status`, never deletes). Raw observations accumulate in the gitignored `.devflow/decisions/decisions-log.jsonl` (rotated to `decisions-log.archive.jsonl` by `rotate-observations`). No deterministic thresholds or confidence formulas — the LLM determines whether an observation warrants a new entry or should be merged with an existing one. Global config: `~/.devflow/decisions.json`. Project config: `.devflow/decisions/decisions.json`. Runtime sentinel: `.devflow/decisions/.disabled` — the decisions sections in `session-start-context` skip if present; `devflow decisions --enable` removes it, `devflow decisions --disable` creates it. Toggleable via `devflow decisions --enable/--disable/--status` or `devflow init --decisions/--no-decisions`. Management subcommands: `devflow decisions list`, `devflow decisions --configure`, `devflow decisions --clear/--reset`. Debug logs stored at `~/.devflow/logs/{project-slug}/`. @@ -81,12 +81,12 @@ devflow/ │ └── 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) ├── src/cli/ # TypeScript CLI (init, list, uninstall, ambient, decisions, flags, knowledge, rules, debug) ├── .claude-plugin/ # Marketplace registry -├── .devflow/ # All per-project runtime data (docs, memory, decisions, features) +├── .devflow/ # All per-project runtime data — gitignored wholesale by default (ensure-devflow-init adds .devflow/ to the root .gitignore; sharing is opt-in) │ ├── docs/ # Project docs (reviews, design) │ ├── memory/ # Working memory files │ ├── dream/ # Dream marker files │ ├── decisions/ # Decisions agent observations and ADR/PF files -│ └── features/ # Per-feature knowledge bases (committed to git) +│ └── features/ # Per-feature knowledge bases (gitignored by default; opt-in to share) ├── .release/ # Release configuration (lazy-init) │ ├── RELEASE-FLOW.md # Learned release process config │ ├── .gitignore # Excludes .progress.json, .lock/ @@ -162,7 +162,7 @@ Per-project runtime files live under `.devflow/`: │ └── .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) ├── decisions/ -│ ├── decisions-ledger.jsonl # Anchored ledger (committed) — render source of truth; one row per ADR/PF incl. retired +│ ├── 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) │ ├── decisions-log.archive.jsonl # Archived observing rows >30d, moved by rotate-observations (gitignored) │ ├── decisions.json # Project-level decisions agent config (max runs, throttle, model, debug) @@ -172,7 +172,7 @@ Per-project runtime files live under `.devflow/`: │ ├── decisions.md # Architectural decisions (ADR-NNN) — rendered from decisions-ledger.jsonl (active only) by Dream agent via assign-anchor + render-decisions │ ├── pitfalls.md # Known pitfalls (PF-NNN, area-specific gotchas) — rendered from decisions-ledger.jsonl (active only) by Dream agent via assign-anchor + render-decisions │ └── .disabled # Runtime sentinel — decisions sections in session-start-context skip if present -└── features/ # Per-feature knowledge bases (committed to git) +└── 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 diff --git a/scripts/hooks/ensure-devflow-init b/scripts/hooks/ensure-devflow-init index c27ae5de..3b8b45ae 100755 --- a/scripts/hooks/ensure-devflow-init +++ b/scripts/hooks/ensure-devflow-init @@ -1,6 +1,6 @@ #!/bin/bash -# Ensures .devflow/ and all subdirectories exist, .devflow/.gitignore is configured, -# and .devflow/features/index.json is bootstrapped. +# 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. # Called from dream-capture, dream-dispatch, and pre-compact-memory. Idempotent. # Usage: source ensure-devflow-init "$CWD" @@ -13,7 +13,7 @@ _DEVFLOW_DIR="$1/.devflow" if [ -d "$_DEVFLOW_DIR/memory" ] && [ -d "$_DEVFLOW_DIR/docs" ] && \ [ -d "$_DEVFLOW_DIR/dream" ] && \ [ -d "$_DEVFLOW_DIR/decisions" ] && [ -d "$_DEVFLOW_DIR/features" ] && \ - [ -f "$_DEVFLOW_DIR/.gitignore-configured" ]; then + [ -f "$_DEVFLOW_DIR/.root-gitignore-configured" ]; then return 0 fi @@ -32,41 +32,21 @@ if [ ! -f "$_DEVFLOW_DIR/features/index.json" ]; then mv "$_DEVFLOW_DIR/features/index.json.tmp" "$_DEVFLOW_DIR/features/index.json" fi -# One-time .devflow/.gitignore setup (marker prevents repeated checks) -# CANONICAL SOURCE: scripts/hooks/lib/project-paths.cjs — getDevflowGitignoreContent() -# Keep this heredoc in sync with that function when adding or removing entries. -if [ ! -f "$_DEVFLOW_DIR/.gitignore-configured" ]; then - cat > "$_DEVFLOW_DIR/.gitignore.tmp" << 'EOF' -# .devflow/ git-tracking policy -# --------------------------------------------------------------------------- -# Only curated, shared team knowledge is committed to git: -# - decisions/decisions-ledger.jsonl (anchored render source) -# - decisions/decisions.md, decisions/pitfalls.md (ADR / pitfall records) -# - features/index.json, features//KNOWLEDGE.md (feature knowledge bases) -# -# Everything else under .devflow/ is per-developer or transient (memory, dream, -# docs, locks, runtime state, manifest, scratch results) and is -# intentionally ignored. Model: ignore-by-default, then re-include the curated -# files. Any NEW file under .devflow/ is ignored unless explicitly listed below. - -# 1. Ignore everything under .devflow/ by default -* - -# 2. Keep this policy file -!.gitignore - -# 3. Track the decisions knowledge (not its log / config / locks / usage state) -!decisions/ -!decisions/decisions.md -!decisions/pitfalls.md -!decisions/decisions-ledger.jsonl - -# 4. Track the feature knowledge bases (not locks / sentinels / scratch results) -!features/ -!features/index.json -!features/*/ -!features/*/KNOWLEDGE.md -EOF - mv "$_DEVFLOW_DIR/.gitignore.tmp" "$_DEVFLOW_DIR/.gitignore" && \ - touch "$_DEVFLOW_DIR/.gitignore-configured" +# One-time root .gitignore setup (marker prevents repeated checks). +# .devflow/ holds per-developer runtime state (memory, dream, docs, decisions, +# feature knowledge, locks) — ignore it wholesale by default. Sharing is opt-in: +# a user removes the entry, or re-includes specific files in their own .gitignore. +if [ ! -f "$_DEVFLOW_DIR/.root-gitignore-configured" ]; then + _ROOT_GITIGNORE="$1/.gitignore" + _GITIGNORE_OK=0 + if [ ! -f "$_ROOT_GITIGNORE" ]; then + printf '# Devflow runtime data (local by default; remove to share via git)\n.devflow/\n' \ + > "$_ROOT_GITIGNORE" && _GITIGNORE_OK=1 + elif ! grep -qE '^/?\.devflow/?[[:space:]]*$' "$_ROOT_GITIGNORE"; then + printf '\n# Devflow runtime data (local by default; remove to share via git)\n.devflow/\n' \ + >> "$_ROOT_GITIGNORE" && _GITIGNORE_OK=1 + else + _GITIGNORE_OK=1 + fi + [ "$_GITIGNORE_OK" = 1 ] && touch "$_DEVFLOW_DIR/.root-gitignore-configured" fi diff --git a/scripts/hooks/lib/project-paths.cjs b/scripts/hooks/lib/project-paths.cjs index d83febd9..cc230dae 100644 --- a/scripts/hooks/lib/project-paths.cjs +++ b/scripts/hooks/lib/project-paths.cjs @@ -221,55 +221,17 @@ function getHandoffPath(projectRoot, branchSlug) { // --------------------------------------------------------------------------- /** - * The canonical list of gitignore entries for a Devflow local-scope install. - * After PR 5b, .devflow/ is NOT gitignored — it holds committed content. - * Internal .devflow/ transients are handled by .devflow/.gitignore. - */ -function getGitignoreEntries() { - return ['.claude/']; -} - -/** - * The canonical content of .devflow/.gitignore — lists all transient per-developer - * files that must NOT be committed. - * - * Single source of truth: migrations.ts imports this function instead of - * maintaining an inline copy. The shell hook (ensure-devflow-init) keeps its - * heredoc in sync manually — a comment in that file points here as canonical. + * The canonical list of gitignore entries Devflow adds to a project's root + * .gitignore. `.devflow/` holds per-developer runtime state (memory, dream, + * docs, decisions, feature knowledge, locks) and is ignored wholesale by + * default — sharing is opt-in. `.claude/` covers local-scope installs. * - * CANONICAL SOURCE — TS counterpart at src/cli/utils/project-paths.ts must mirror this exactly. + * The ensure-devflow-init hook is the live, every-project mechanism that + * appends `.devflow/` to the root .gitignore lazily; this list mirrors that + * intent for the TS install path. */ -function getDevflowGitignoreContent() { - return `# .devflow/ git-tracking policy -# --------------------------------------------------------------------------- -# Only curated, shared team knowledge is committed to git: -# - decisions/decisions-ledger.jsonl (anchored render source) -# - decisions/decisions.md, decisions/pitfalls.md (ADR / pitfall records) -# - features/index.json, features//KNOWLEDGE.md (feature knowledge bases) -# -# Everything else under .devflow/ is per-developer or transient (memory, dream, -# docs, locks, runtime state, manifest, scratch results) and is -# intentionally ignored. Model: ignore-by-default, then re-include the curated -# files. Any NEW file under .devflow/ is ignored unless explicitly listed below. - -# 1. Ignore everything under .devflow/ by default -* - -# 2. Keep this policy file -!.gitignore - -# 3. Track the decisions knowledge (not its log / config / locks / usage state) -!decisions/ -!decisions/decisions.md -!decisions/pitfalls.md -!decisions/decisions-ledger.jsonl - -# 4. Track the feature knowledge bases (not locks / sentinels / scratch results) -!features/ -!features/index.json -!features/*/ -!features/*/KNOWLEDGE.md -`; +function getGitignoreEntries() { + return ['.claude/', '.devflow/']; } module.exports = { @@ -316,5 +278,4 @@ module.exports = { getHandoffPath, // Gitignore entries getGitignoreEntries, - getDevflowGitignoreContent, }; diff --git a/src/cli/utils/migrations.ts b/src/cli/utils/migrations.ts index 37d0e3ec..0563eb04 100644 --- a/src/cli/utils/migrations.ts +++ b/src/cli/utils/migrations.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'fs'; import * as path from 'path'; import * as os from 'os'; import { writeFileAtomicExclusive } from './fs-atomic.js'; -import { getMemoryDir, getFeaturesDir, getDevflowGitignoreContent } from './project-paths.js'; +import { getMemoryDir, getFeaturesDir } from './project-paths.js'; // --------------------------------------------------------------------------- // consolidate-to-devflow-dir helpers @@ -313,28 +313,10 @@ const CONSOLIDATE_STALE_GITIGNORE_ENTRIES = [ '.features/.disabled', '.features/.knowledge-last-refresh', '.features/.knowledge-refresh.lock', - '.devflow/', ] as const; /** - * Step 5 helper: create .devflow/.gitignore only when absent. - * - * Uses O_EXCL (flag: 'wx') so the kernel rejects the open atomically if the - * file already exists — no TOCTOU window between the existence check and the - * write. EEXIST is silently ignored (idempotent). - */ -async function createDevflowGitignoreIfAbsent(devflowDir: string): Promise { - const gitignore = path.join(devflowDir, '.gitignore'); - try { - await fs.writeFile(gitignore, getDevflowGitignoreContent(), { encoding: 'utf-8', flag: 'wx' }); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; - // File already present — idempotent skip - } -} - -/** - * Step 6 helper: remove stale old-layout entries from the project .gitignore. + * Step 5 helper: remove stale old-layout entries from the project .gitignore. * * Uses writeFileAtomicExclusive (temp+rename) so a crash between read and * write never leaves the .gitignore truncated. @@ -465,15 +447,12 @@ const MIGRATION_CONSOLIDATE_TO_DEVFLOW: Migration<'per-project'> = { // 4. Move .docs/ contents → .devflow/docs/ (skip WORKING-MEMORY.md — belongs in memory/) const docsWarnings = await moveDirContents(docsSrc, path.join(devflowDir, 'docs'), new Set(['WORKING-MEMORY.md'])); - // 5. Create .devflow/.gitignore if not present (atomic exclusive create — no TOCTOU) - await createDevflowGitignoreIfAbsent(devflowDir); - - // 6. Clean up project .gitignore — remove stale entries (atomic temp+rename write) + // 5. Clean up project .gitignore — remove stale entries (atomic temp+rename write) const gitignoreInfos = await cleanStaleGitignoreEntries(projectRoot); - // 7. Clean up legacy/leftover files and remove old directories (best-effort) + // 6. Clean up legacy/leftover files and remove old directories (best-effort) - // 7a. Delete legacy skip files from old .memory/ — these were intentionally + // 6a. Delete legacy skip files from old .memory/ — these were intentionally // not migrated (obsolete V1 artifacts) await Promise.all( MEMORY_LEGACY_SKIP_FILES.map(name => @@ -481,7 +460,7 @@ const MIGRATION_CONSOLIDATE_TO_DEVFLOW: Migration<'per-project'> = { ), ); - // 7b. Delete leftover entries in .features/ and .docs/ — after moveDirContents, + // 6b. Delete leftover entries in .features/ and .docs/ — after moveDirContents, // any remaining entries are duplicates whose dest already existed for (const oldDir of [featSrc, docsSrc]) { try { @@ -494,7 +473,7 @@ const MIGRATION_CONSOLIDATE_TO_DEVFLOW: Migration<'per-project'> = { } catch { /* dir may not exist */ } } - // 7c. Attempt rmdir on all three old directories — if .memory/ has user files + // 6c. Attempt rmdir on all three old directories — if .memory/ has user files // not in the legacy skip list, the dir survives for (const oldDir of [memSrc, featSrc, docsSrc]) { try { await fs.rmdir(oldDir); } catch { /* non-empty or already removed */ } @@ -524,59 +503,6 @@ const MIGRATION_CLEANUP_STALE_WORKING_MEMORY: Migration<'per-project'> = { }, }; -const MIGRATION_SYNC_DEVFLOW_GITIGNORE: Migration<'per-project'> = { - id: 'sync-devflow-gitignore-v1', - description: 'Sync .devflow/.gitignore to latest canonical template', - scope: 'per-project', - async run(ctx) { - const devflowDir = path.join(ctx.projectRoot, '.devflow'); - try { await fs.access(devflowDir); } catch { return { infos: [], warnings: [] }; } - - const canonical = getDevflowGitignoreContent(); - const gitignorePath = path.join(devflowDir, '.gitignore'); - - try { - const existing = await fs.readFile(gitignorePath, 'utf-8'); - if (existing === canonical) return { infos: [], warnings: [] }; - } catch { /* file missing — will be created below */ } - - await writeFileAtomicExclusive(gitignorePath, canonical); - return { infos: ['Synced .devflow/.gitignore to latest template'], warnings: [] }; - }, -}; - -/** - * Re-sync .devflow/.gitignore to the new ignore-by-default allowlist policy. - * - * v1 already executed on existing machines (writing the old per-entry blocklist). - * v2 is required to overwrite those with the new template — a new ID forces - * machines that already ran v1 to re-fire. - * - * Applies PF-004 / PF-001: idempotent — no-op if content already matches - * (covers this very repo which was manually updated to the new policy). Also - * no-ops cleanly when .devflow/ does not exist. - */ -const MIGRATION_SYNC_DEVFLOW_GITIGNORE_V2: Migration<'per-project'> = { - id: 'sync-devflow-gitignore-v2', - description: 'Re-sync .devflow/.gitignore to new ignore-by-default allowlist policy', - scope: 'per-project', - async run(ctx: PerProjectMigrationContext): Promise { - const devflowDir = path.join(ctx.projectRoot, '.devflow'); - try { await fs.access(devflowDir); } catch { return { infos: [], warnings: [] }; } - - const canonical = getDevflowGitignoreContent(); - const gitignorePath = path.join(devflowDir, '.gitignore'); - - try { - const existing = await fs.readFile(gitignorePath, 'utf-8'); - if (existing === canonical) return { infos: [], warnings: [] }; - } catch { /* file missing — will be created below */ } - - await writeFileAtomicExclusive(gitignorePath, canonical); - return { infos: ['Synced .devflow/.gitignore to ignore-by-default allowlist policy'], warnings: [] }; - }, -}; - /** * Phase 3 (reliable LLM sidecar consumption): remove orphaned state files * left by the removed deterministic capacity/manifest/reconcile features. @@ -793,7 +719,7 @@ async function _dropLearningKeyFromConfig(configPath: string): Promise { delete config['learning']; // D34: use writeFileAtomicExclusive (O_EXCL temp+rename) — TOCTOU-safe. - // The sibling sync-devflow-gitignore migration uses the same helper. + // Other per-project migrations use the same helper for crash-safe writes. await writeFileAtomicExclusive(configPath, JSON.stringify(config, null, 2) + '\n'); } @@ -933,57 +859,6 @@ const MIGRATION_PURGE_TEAMMATE_MODE_PER_PROJECT: Migration<'per-project'> = { }, }; -/** - * Per-project: add `!decisions/decisions-ledger.jsonl` re-include to the - * .devflow/.gitignore of any project that already ran `sync-devflow-gitignore-v2` - * and therefore has an older template that lacks the new ledger re-include. - * - * Idempotent: only adds the line if absent. Preserves existing content. - * Applies ADR-012 (decisions artifacts committed to git) and PF-004 (idempotent). - */ -const MIGRATION_SYNC_DEVFLOW_GITIGNORE_V3: Migration<'per-project'> = { - id: 'sync-devflow-gitignore-v3', - description: 'Add !decisions/decisions-ledger.jsonl re-include to .devflow/.gitignore', - scope: 'per-project', - async run(ctx: PerProjectMigrationContext): Promise { - const devflowDir = path.join(ctx.projectRoot, '.devflow'); - try { await fs.access(devflowDir); } catch { return { infos: [], warnings: [] }; } - - const gitignorePath = path.join(devflowDir, '.gitignore'); - const ledgerLine = '!decisions/decisions-ledger.jsonl'; - - let existing: string; - try { - existing = await fs.readFile(gitignorePath, 'utf-8'); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; - // .gitignore absent — no-op (full template sync is v1/v2's job) - return { infos: [], warnings: [] }; - } - - // Idempotent: only add if absent - if (existing.includes(ledgerLine)) { - return { infos: [], warnings: [] }; - } - - // Insert the new line immediately after `!decisions/pitfalls.md` - const insertAfter = '!decisions/pitfalls.md'; - let updated: string; - if (existing.includes(insertAfter)) { - updated = existing.replace(insertAfter, `${insertAfter}\n${ledgerLine}`); - } else { - // Fallback: append before the features section or at end - updated = existing.trimEnd() + '\n' + ledgerLine + '\n'; - } - - await writeFileAtomicExclusive(gitignorePath, updated); - return { - infos: ['Added !decisions/decisions-ledger.jsonl to .devflow/.gitignore'], - warnings: [], - }; - }, -}; - /** * Per-project: migrate existing decisions.md + pitfalls.md + decisions-log.jsonl * to the two-file split layout (committed anchored ledger + gitignored raw log). @@ -1058,8 +933,6 @@ export const MIGRATIONS: readonly Migration[] = [ MIGRATION_RENAME_KB_TO_KNOWLEDGE, MIGRATION_CONSOLIDATE_TO_DEVFLOW, MIGRATION_CLEANUP_STALE_WORKING_MEMORY, - MIGRATION_SYNC_DEVFLOW_GITIGNORE, - MIGRATION_SYNC_DEVFLOW_GITIGNORE_V2, MIGRATION_PURGE_ORPHANED_SIDECAR_JUDGMENT_STATE, MIGRATION_RENAME_SIDECAR_TO_DREAM, MIGRATION_PURGE_LEARNING_PIPELINE, @@ -1067,7 +940,6 @@ export const MIGRATIONS: readonly Migration[] = [ MIGRATION_PURGE_STALE_MEMORY_MARKERS, MIGRATION_PURGE_TEAMMATE_MODE_GLOBAL, MIGRATION_PURGE_TEAMMATE_MODE_PER_PROJECT, - MIGRATION_SYNC_DEVFLOW_GITIGNORE_V3, MIGRATION_DECISIONS_LEDGER_UNIFY, MIGRATION_PURGE_DEAD_WORKING_MEMORY_SENTINEL, ]; diff --git a/src/cli/utils/project-paths.ts b/src/cli/utils/project-paths.ts index 0452ef0b..2b489a26 100644 --- a/src/cli/utils/project-paths.ts +++ b/src/cli/utils/project-paths.ts @@ -222,55 +222,17 @@ export function getHandoffPath(projectRoot: string, branchSlug: string): string // --------------------------------------------------------------------------- /** - * The canonical list of gitignore entries for a Devflow local-scope install. - * After PR 5b, .devflow/ is NOT gitignored because it holds committed content - * (features/, decisions/decisions.md, decisions/pitfalls.md). Only .claude/ - * remains as an install-scope gitignore entry. Internal .devflow/ transients - * are handled by .devflow/.gitignore. - */ -export function getGitignoreEntries(): string[] { - return ['.claude/']; -} - -/** - * The canonical content of .devflow/.gitignore — lists all transient per-developer - * files that must NOT be committed. + * The canonical list of gitignore entries Devflow adds to a project's root + * .gitignore. `.devflow/` holds per-developer runtime state (memory, dream, + * docs, decisions, feature knowledge, locks) and is ignored wholesale by + * default — sharing is opt-in. `.claude/` covers local-scope installs. * - * Single source of truth: migrations.ts imports this function instead of - * maintaining an inline copy. The shell hook (ensure-devflow-init) keeps its - * heredoc in sync manually — a comment in that file points here as canonical. + * The ensure-devflow-init hook is the live, every-project mechanism that + * appends `.devflow/` to the root .gitignore lazily; this list mirrors that + * intent for the TS install path. * - * CJS is canonical source: scripts/hooks/lib/project-paths.cjs — this must mirror it exactly. + * CJS mirror: scripts/hooks/lib/project-paths.cjs getGitignoreEntries(). */ -export function getDevflowGitignoreContent(): string { - return `# .devflow/ git-tracking policy -# --------------------------------------------------------------------------- -# Only curated, shared team knowledge is committed to git: -# - decisions/decisions-ledger.jsonl (anchored render source) -# - decisions/decisions.md, decisions/pitfalls.md (ADR / pitfall records) -# - features/index.json, features//KNOWLEDGE.md (feature knowledge bases) -# -# Everything else under .devflow/ is per-developer or transient (memory, dream, -# docs, locks, runtime state, manifest, scratch results) and is -# intentionally ignored. Model: ignore-by-default, then re-include the curated -# files. Any NEW file under .devflow/ is ignored unless explicitly listed below. - -# 1. Ignore everything under .devflow/ by default -* - -# 2. Keep this policy file -!.gitignore - -# 3. Track the decisions knowledge (not its log / config / locks / usage state) -!decisions/ -!decisions/decisions.md -!decisions/pitfalls.md -!decisions/decisions-ledger.jsonl - -# 4. Track the feature knowledge bases (not locks / sentinels / scratch results) -!features/ -!features/index.json -!features/*/ -!features/*/KNOWLEDGE.md -`; +export function getGitignoreEntries(): string[] { + return ['.claude/', '.devflow/']; } diff --git a/tests/decisions/decisions-ledger-migration.test.ts b/tests/decisions/decisions-ledger-migration.test.ts index c87a003b..f3b1070d 100644 --- a/tests/decisions/decisions-ledger-migration.test.ts +++ b/tests/decisions/decisions-ledger-migration.test.ts @@ -15,7 +15,6 @@ import * as path from 'path'; import * as os from 'os'; import { createRequire } from 'module'; import { migrateDecisionsLedger } from '../../src/cli/utils/decisions-ledger-migration.js'; -import { getDevflowGitignoreContent } from '../../src/cli/utils/project-paths.js'; const ROOT = path.resolve(import.meta.dirname, '../..'); const require = createRequire(import.meta.url); @@ -856,123 +855,3 @@ describe('migrateDecisionsLedger — edge cases', () => { } }); }); - -// --------------------------------------------------------------------------- -// AC-F11: gitignore template — anchored ledger tracked, log + archive gitignored -// --------------------------------------------------------------------------- - -describe('gitignore template — AC-F11', () => { - it('re-includes decisions-ledger.jsonl in the template', () => { - const content = getDevflowGitignoreContent(); - expect(content).toContain('!decisions/decisions-ledger.jsonl'); - }); - - it('does NOT re-include decisions-log.jsonl (remains gitignored)', () => { - const content = getDevflowGitignoreContent(); - expect(content).not.toContain('!decisions/decisions-log.jsonl'); - }); - - it('does NOT re-include decisions-log.archive.jsonl (remains gitignored)', () => { - const content = getDevflowGitignoreContent(); - expect(content).not.toContain('!decisions/decisions-log.archive.jsonl'); - }); -}); - -// --------------------------------------------------------------------------- -// sync-devflow-gitignore-v3 migration: adds ledger line idempotently -// --------------------------------------------------------------------------- - -describe('sync-devflow-gitignore-v3 migration', () => { - it('adds !decisions/decisions-ledger.jsonl to an existing .devflow/.gitignore', async () => { - const devflowDir = path.join(projectRoot, '.devflow'); - await fs.mkdir(devflowDir, { recursive: true }); - const gitignorePath = path.join(devflowDir, '.gitignore'); - - // Simulate an older template without the ledger re-include - const olderContent = `* -!.gitignore -!decisions/ -!decisions/decisions.md -!decisions/pitfalls.md -!features/ -!features/index.json -!features/*/ -!features/*/KNOWLEDGE.md -`; - await fs.writeFile(gitignorePath, olderContent, 'utf-8'); - - // Run the migration manually using the registry shape - const { MIGRATIONS } = await import('../../src/cli/utils/migrations.js'); - const migration = MIGRATIONS.find(m => m.id === 'sync-devflow-gitignore-v3'); - expect(migration).toBeTruthy(); - const result = await (migration as { run: (ctx: { scope: 'per-project'; devflowDir: string; memoryDir: string; projectRoot: string }) => Promise<{ infos: string[]; warnings: string[] }> }).run({ - scope: 'per-project', - devflowDir: path.join(path.dirname(devflowDir), '.devflow'), - memoryDir: path.join(path.dirname(devflowDir), '.devflow', 'memory'), - projectRoot, - }); - - expect(result.infos).toHaveLength(1); - expect(result.infos[0]).toContain('decisions-ledger.jsonl'); - - const updated = await fs.readFile(gitignorePath, 'utf-8'); - expect(updated).toContain('!decisions/decisions-ledger.jsonl'); - }); - - it('is idempotent when ledger line already present', async () => { - const devflowDir = path.join(projectRoot, '.devflow'); - await fs.mkdir(devflowDir, { recursive: true }); - const gitignorePath = path.join(devflowDir, '.gitignore'); - - // Write the full current template (already has the ledger line) - const { getDevflowGitignoreContent: getContent } = await import('../../src/cli/utils/project-paths.js'); - await fs.writeFile(gitignorePath, getContent(), 'utf-8'); - - const { MIGRATIONS } = await import('../../src/cli/utils/migrations.js'); - const migration = MIGRATIONS.find(m => m.id === 'sync-devflow-gitignore-v3'); - const result = await (migration as { run: (ctx: { scope: 'per-project'; devflowDir: string; memoryDir: string; projectRoot: string }) => Promise<{ infos: string[]; warnings: string[] }> }).run({ - scope: 'per-project', - devflowDir: path.join(path.dirname(devflowDir), '.devflow'), - memoryDir: path.join(path.dirname(devflowDir), '.devflow', 'memory'), - projectRoot, - }); - - // No-op: nothing changed - expect(result.infos).toHaveLength(0); - expect(result.warnings).toHaveLength(0); - }); - - it('is a no-op when .devflow/ directory does not exist', async () => { - const emptyRoot = path.join(tmpDir, 'empty-project'); - await fs.mkdir(emptyRoot, { recursive: true }); - - const { MIGRATIONS } = await import('../../src/cli/utils/migrations.js'); - const migration = MIGRATIONS.find(m => m.id === 'sync-devflow-gitignore-v3'); - const result = await (migration as { run: (ctx: { scope: 'per-project'; devflowDir: string; memoryDir: string; projectRoot: string }) => Promise<{ infos: string[]; warnings: string[] }> }).run({ - scope: 'per-project', - devflowDir: path.join(emptyRoot, '.devflow'), - memoryDir: path.join(emptyRoot, '.devflow', 'memory'), - projectRoot: emptyRoot, - }); - - expect(result.infos).toHaveLength(0); - expect(result.warnings).toHaveLength(0); - }); -}); - -// --------------------------------------------------------------------------- -// CJS parity: getDevflowGitignoreContent TS == CJS -// Already tested in project-paths.test.ts, but verify the new ledger line -// --------------------------------------------------------------------------- - -describe('gitignore CJS parity for decisions-ledger.jsonl', () => { - it('CJS getDevflowGitignoreContent includes !decisions/decisions-ledger.jsonl', () => { - const cjsPaths = require(path.join(ROOT, 'scripts/hooks/lib/project-paths.cjs')) as { - getDevflowGitignoreContent: () => string; - }; - const cjsContent = cjsPaths.getDevflowGitignoreContent(); - expect(cjsContent).toContain('!decisions/decisions-ledger.jsonl'); - // CJS and TS must be identical - expect(cjsContent).toBe(getDevflowGitignoreContent()); - }); -}); diff --git a/tests/eager-memory-refresh.test.ts b/tests/eager-memory-refresh.test.ts index 3f2cd3a2..48cdbac4 100644 --- a/tests/eager-memory-refresh.test.ts +++ b/tests/eager-memory-refresh.test.ts @@ -812,30 +812,24 @@ describe('S8: AC-C2/C4 — security constraints', () => { }); // ============================================================================= -// S9 — AC-C4: Transient files covered by .devflow/.gitignore wildcard +// S9 — AC-C4: .devflow/ is git-ignored wholesale at the project root // ============================================================================= -describe('S9: AC-C4 — transient files git-ignored by .devflow/.gitignore', () => { - it('actual .devflow/.gitignore uses * (ignore all) + re-includes curated files', () => { +describe('S9: AC-C4 — .devflow/ ignored wholesale by the root .gitignore', () => { + it('this repo root .gitignore ignores .devflow/', () => { const gitignoreContent = fs.readFileSync( - path.join(__dirname, '..', '.devflow', '.gitignore'), + path.join(__dirname, '..', '.gitignore'), 'utf-8' ); - expect(gitignoreContent).toContain('\n*\n'); // wildcard on its own line - expect(gitignoreContent).toContain('!decisions/decisions.md'); - expect(gitignoreContent).toContain('!decisions/pitfalls.md'); + expect(gitignoreContent.split('\n').map(l => l.trim())).toContain('.devflow/'); }); - it('transient files are untracked (git-ignored) in scratch repo with .gitignore', () => { + it('transient files are untracked (git-ignored) in a scratch repo with root .gitignore', () => { const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emr-s9-')); try { initGitRepo(projectDir); fs.mkdirSync(path.join(projectDir, '.devflow', 'memory'), { recursive: true }); - fs.writeFileSync( - path.join(projectDir, '.devflow', '.gitignore'), - ['*', '!.gitignore', '!decisions/', '!decisions/decisions.md', '!decisions/pitfalls.md', - '!features/', '!features/index.json', '!features/*/', '!features/*/KNOWLEDGE.md', ''].join('\n') - ); + fs.writeFileSync(path.join(projectDir, '.gitignore'), '.devflow/\n'); for (const f of ['.working-memory-last-trigger', '.last-refresh-ok', '.pending-turns.processing']) { fs.writeFileSync(path.join(projectDir, '.devflow', 'memory', f), 'test'); @@ -846,6 +840,8 @@ describe('S9: AC-C4 — transient files git-ignored by .devflow/.gitignore', () for (const f of ['.working-memory-last-trigger', '.last-refresh-ok', '.pending-turns.processing', '.working-memory.lock']) { expect(statusOut).not.toContain(f); } + // The entire .devflow/ tree is excluded from git status under wholesale ignore + expect(statusOut).not.toContain('.devflow/'); } finally { fs.rmSync(projectDir, { recursive: true, force: true }); } diff --git a/tests/migrations.test.ts b/tests/migrations.test.ts index 5763752e..eeff6d78 100644 --- a/tests/migrations.test.ts +++ b/tests/migrations.test.ts @@ -12,7 +12,6 @@ import { type MigrationLogger, type RunMigrationsResult, } from '../src/cli/utils/migrations.js'; -import { getDevflowGitignoreContent } from '../src/cli/utils/project-paths.js'; describe('readAppliedMigrations', () => { let tmpDir: string; @@ -164,32 +163,6 @@ describe('MIGRATIONS', () => { expect(renameIdx).toBeGreaterThanOrEqual(0); expect(consolidateIdx).toBeGreaterThan(renameIdx); }); - - it('contains sync-devflow-gitignore-v1 with per-project scope', () => { - const m = MIGRATIONS.find(m => m.id === 'sync-devflow-gitignore-v1'); - expect(m).toBeDefined(); - expect(m?.scope).toBe('per-project'); - }); - - it('sync-devflow-gitignore-v1 follows cleanup-stale-working-memory in array', () => { - const cleanupIdx = MIGRATIONS.findIndex(m => m.id === 'cleanup-stale-working-memory'); - const syncIdx = MIGRATIONS.findIndex(m => m.id === 'sync-devflow-gitignore-v1'); - expect(cleanupIdx).toBeGreaterThanOrEqual(0); - expect(syncIdx).toBeGreaterThan(cleanupIdx); - }); - - it('contains sync-devflow-gitignore-v2 with per-project scope', () => { - const m = MIGRATIONS.find(m => m.id === 'sync-devflow-gitignore-v2'); - expect(m).toBeDefined(); - expect(m?.scope).toBe('per-project'); - }); - - it('sync-devflow-gitignore-v2 follows sync-devflow-gitignore-v1 in array', () => { - const v1Idx = MIGRATIONS.findIndex(m => m.id === 'sync-devflow-gitignore-v1'); - const v2Idx = MIGRATIONS.findIndex(m => m.id === 'sync-devflow-gitignore-v2'); - expect(v1Idx).toBeGreaterThanOrEqual(0); - expect(v2Idx).toBeGreaterThan(v1Idx); - }); }); describe('runMigrations', () => { @@ -728,31 +701,6 @@ describe('consolidate-to-devflow-dir migration', () => { expect(review).toBe('# Review\n'); }); - it('creates .devflow/.gitignore with correct content when not present', async () => { - await getMigration().run(makeCtx()); - - const gitignorePath = path.join(devflowDir, '.gitignore'); - const content = await fs.readFile(gitignorePath, 'utf-8'); - // Spot-check key entries from the ignore-by-default allowlist policy - expect(content).toContain('\n*\n'); - expect(content).toContain('!.gitignore'); - expect(content).toContain('!decisions/decisions.md'); - expect(content).toContain('!decisions/pitfalls.md'); - expect(content).toContain('!features/index.json'); - expect(content).toContain('!features/*/KNOWLEDGE.md'); - }); - - it('does not overwrite an existing .devflow/.gitignore', async () => { - await fs.mkdir(devflowDir, { recursive: true }); - const existing = '# custom\nfeatures/\n'; - await fs.writeFile(path.join(devflowDir, '.gitignore'), existing, 'utf-8'); - - await getMigration().run(makeCtx()); - - const content = await fs.readFile(path.join(devflowDir, '.gitignore'), 'utf-8'); - expect(content).toBe(existing); - }); - it('removes stale .gitignore entries from the root .gitignore', async () => { const gitignorePath = path.join(projectRoot, '.gitignore'); await fs.writeFile(gitignorePath, [ @@ -776,8 +724,9 @@ describe('consolidate-to-devflow-dir migration', () => { expect(updated).not.toContain('.features/.disabled'); expect(updated).not.toContain('.features/.knowledge-last-refresh'); expect(updated).not.toContain('.features/.knowledge-refresh.lock'); - expect(updated).not.toContain('.devflow/'); - // Non-stale entries are preserved + // Non-stale entries are preserved — including .devflow/, which is now the + // canonical wholesale-ignore entry (no longer stripped by consolidation). + expect(updated).toContain('.devflow/'); expect(updated).toContain('node_modules/'); expect(updated).toContain('dist/'); }); @@ -1028,212 +977,6 @@ describe('reportMigrationResult', () => { }); }); -describe('sync-devflow-gitignore-v1 migration', () => { - let tmpDir: string; - let projectRoot: string; - let devflowDir: string; - let fakeHome: string; - let originalHome: string | undefined; - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devflow-sync-gitignore-test-')); - projectRoot = path.join(tmpDir, 'project'); - devflowDir = path.join(projectRoot, '.devflow'); - 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 === 'sync-devflow-gitignore-v1'); - if (!m) throw new Error('sync-devflow-gitignore-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('overwrites stale .devflow/.gitignore with canonical content', async () => { - await fs.mkdir(devflowDir, { recursive: true }); - await fs.writeFile(path.join(devflowDir, '.gitignore'), '# old stale content\nmemory/\n', 'utf-8'); - - const result = await getMigration().run(makeCtx()); - - const content = await fs.readFile(path.join(devflowDir, '.gitignore'), 'utf-8'); - expect(content).toBe(getDevflowGitignoreContent()); - expect(result?.infos).toContain('Synced .devflow/.gitignore to latest template'); - }); - - it('is a no-op when content already matches', async () => { - await fs.mkdir(devflowDir, { recursive: true }); - await fs.writeFile(path.join(devflowDir, '.gitignore'), getDevflowGitignoreContent(), 'utf-8'); - - const result = await getMigration().run(makeCtx()); - - const content = await fs.readFile(path.join(devflowDir, '.gitignore'), 'utf-8'); - expect(content).toBe(getDevflowGitignoreContent()); - expect(result?.infos ?? []).toHaveLength(0); - }); - - it('skips when .devflow/ directory does not exist', async () => { - // projectRoot exists but .devflow/ does not - await fs.mkdir(projectRoot, { recursive: true }); - - const result = await getMigration().run(makeCtx()); - - await expect(fs.access(devflowDir)).rejects.toThrow(); - expect(result?.infos ?? []).toHaveLength(0); - }); - - it('creates .devflow/.gitignore when file is missing but directory exists', async () => { - await fs.mkdir(devflowDir, { recursive: true }); - - await getMigration().run(makeCtx()); - - const content = await fs.readFile(path.join(devflowDir, '.gitignore'), 'utf-8'); - expect(content).toBe(getDevflowGitignoreContent()); - }); - - it('is idempotent — running twice produces same result', async () => { - await fs.mkdir(devflowDir, { recursive: true }); - await fs.writeFile(path.join(devflowDir, '.gitignore'), '# stale\n', 'utf-8'); - - await getMigration().run(makeCtx()); - await getMigration().run(makeCtx()); - - const content = await fs.readFile(path.join(devflowDir, '.gitignore'), 'utf-8'); - expect(content).toBe(getDevflowGitignoreContent()); - }); - - it('returns structured MigrationRunResult', async () => { - await fs.mkdir(devflowDir, { recursive: true }); - await fs.writeFile(path.join(devflowDir, '.gitignore'), '# stale\n', 'utf-8'); - - const result = await getMigration().run(makeCtx()); - - expect(result).toBeDefined(); - expect(Array.isArray(result?.infos)).toBe(true); - expect(Array.isArray(result?.warnings)).toBe(true); - expect(result?.warnings).toHaveLength(0); - }); -}); - -describe('sync-devflow-gitignore-v2 migration', () => { - let tmpDir: string; - let projectRoot: string; - let devflowDir: string; - let fakeHome: string; - let originalHome: string | undefined; - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devflow-sync-gitignore-v2-test-')); - projectRoot = path.join(tmpDir, 'project'); - devflowDir = path.join(projectRoot, '.devflow'); - 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 === 'sync-devflow-gitignore-v2'); - if (!m) throw new Error('sync-devflow-gitignore-v2 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 with per-project scope', () => { - const m = MIGRATIONS.find(m => m.id === 'sync-devflow-gitignore-v2'); - expect(m).toBeDefined(); - expect(m?.scope).toBe('per-project'); - }); - - it('overwrites a stale old-format .devflow/.gitignore with the new canonical content', async () => { - await fs.mkdir(devflowDir, { recursive: true }); - // Old-format content (blocklist style from v1) - await fs.writeFile(path.join(devflowDir, '.gitignore'), '# old\nmemory/\ndream/\nlearning/learning-log.jsonl\nfeatures/.knowledge.lock/\nmanifest.json\n', 'utf-8'); - - const result = await getMigration().run(makeCtx()); - - const content = await fs.readFile(path.join(devflowDir, '.gitignore'), 'utf-8'); - expect(content).toBe(getDevflowGitignoreContent()); - expect(result?.infos).toContain('Synced .devflow/.gitignore to ignore-by-default allowlist policy'); - }); - - it('is a no-op when content already matches the new canonical template', async () => { - await fs.mkdir(devflowDir, { recursive: true }); - await fs.writeFile(path.join(devflowDir, '.gitignore'), getDevflowGitignoreContent(), 'utf-8'); - - const result = await getMigration().run(makeCtx()); - - const content = await fs.readFile(path.join(devflowDir, '.gitignore'), 'utf-8'); - expect(content).toBe(getDevflowGitignoreContent()); - expect(result?.infos ?? []).toHaveLength(0); - }); - - it('creates .devflow/.gitignore when file is missing but directory exists', async () => { - await fs.mkdir(devflowDir, { recursive: true }); - - await getMigration().run(makeCtx()); - - const content = await fs.readFile(path.join(devflowDir, '.gitignore'), 'utf-8'); - expect(content).toBe(getDevflowGitignoreContent()); - }); - - it('skips when .devflow/ directory does not exist', async () => { - await fs.mkdir(projectRoot, { recursive: true }); - - const result = await getMigration().run(makeCtx()); - - await expect(fs.access(devflowDir)).rejects.toThrow(); - expect(result?.infos ?? []).toHaveLength(0); - }); - - it('is idempotent — running twice produces the same result', async () => { - await fs.mkdir(devflowDir, { recursive: true }); - await fs.writeFile(path.join(devflowDir, '.gitignore'), '# stale blocklist\nmemory/\n', 'utf-8'); - - await getMigration().run(makeCtx()); - await getMigration().run(makeCtx()); - - const content = await fs.readFile(path.join(devflowDir, '.gitignore'), 'utf-8'); - expect(content).toBe(getDevflowGitignoreContent()); - }); -}); - describe('rename-sidecar-to-dream-v1 migration', () => { let tmpDir: string; let projectRoot: string; diff --git a/tests/project-paths.test.ts b/tests/project-paths.test.ts index 7a4cc672..5dcb2451 100644 --- a/tests/project-paths.test.ts +++ b/tests/project-paths.test.ts @@ -51,7 +51,6 @@ import { getResearchDir, getHandoffPath, getGitignoreEntries, - getDevflowGitignoreContent, } from '../src/cli/utils/project-paths.js'; import * as tsPathsNs from '../src/cli/utils/project-paths.js'; @@ -218,31 +217,12 @@ describe('project-paths TypeScript module', () => { expect(getGitignoreEntries()).toContain('.claude/'); }); - it('does not include old .memory/ entry', () => { - expect(getGitignoreEntries()).not.toContain('.memory/'); + it('includes .devflow/ (ignored wholesale by default)', () => { + expect(getGitignoreEntries()).toContain('.devflow/'); }); - }); - describe('getDevflowGitignoreContent', () => { - it('uses ignore-by-default allowlist policy', () => { - const content = getDevflowGitignoreContent(); - // Allowlist header (ignore everything, re-include curated files) - expect(content).toContain('\n*\n'); - expect(content).toContain('!.gitignore'); - // Decisions knowledge tracked - expect(content).toContain('!decisions/'); - expect(content).toContain('!decisions/decisions.md'); - expect(content).toContain('!decisions/pitfalls.md'); - // Feature knowledge tracked - expect(content).toContain('!features/'); - expect(content).toContain('!features/index.json'); - expect(content).toContain('!features/*/'); - expect(content).toContain('!features/*/KNOWLEDGE.md'); - // Transient/per-developer paths are NOT enumerated (no longer needed) - expect(content).not.toContain('dream/'); - expect(content).not.toContain('sidecar/'); - expect(content).not.toContain('memory/'); - expect(content).not.toContain('manifest.json'); + it('does not include old .memory/ entry', () => { + expect(getGitignoreEntries()).not.toContain('.memory/'); }); }); @@ -323,10 +303,6 @@ describe('CJS project-paths parity', () => { expect(cjsPaths.getGitignoreEntries()).toEqual(getGitignoreEntries()); }); - it('getDevflowGitignoreContent — TypeScript and CJS agree', () => { - expect(cjsPaths.getDevflowGitignoreContent()).toBe(getDevflowGitignoreContent()); - }); - // TS/CJS parity: getDreamDir and getDreamConfigPath return .devflow/dream/ it('getDreamDir returns .devflow/dream/ in both TS and CJS', () => { expect(getDreamDir(ROOT)).toBe('/some/project/.devflow/dream'); diff --git a/tests/shell-hooks.test.ts b/tests/shell-hooks.test.ts index 16fd8cf0..64967a7d 100644 --- a/tests/shell-hooks.test.ts +++ b/tests/shell-hooks.test.ts @@ -1302,41 +1302,43 @@ describe('ensure-devflow-init behavioral', () => { expect(content).toBe('{"existing":"data"}'); }); - it('creates .devflow/.gitignore with ignore-by-default allowlist content', () => { + it('adds .devflow/ to the project root .gitignore (creates it when absent)', () => { execSync(`bash -c 'source "${ENSURE_DEVFLOW}" "${tmpDir}"'`, { stdio: 'pipe' }); - const gitignore = fs.readFileSync(path.join(tmpDir, '.devflow', '.gitignore'), 'utf-8'); - expect(gitignore).toContain('\n*\n'); - expect(gitignore).toContain('!.gitignore'); - expect(gitignore).toContain('!decisions/decisions.md'); - expect(gitignore).toContain('!features/*/KNOWLEDGE.md'); - expect(fs.existsSync(path.join(tmpDir, '.devflow', '.gitignore-configured'))).toBe(true); + const gitignore = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8'); + expect(gitignore.split('\n').map(l => l.trim())).toContain('.devflow/'); + expect(fs.existsSync(path.join(tmpDir, '.devflow', '.root-gitignore-configured'))).toBe(true); + // Under the wholesale-ignore model no nested .devflow/.gitignore is written + expect(fs.existsSync(path.join(tmpDir, '.devflow', '.gitignore'))).toBe(false); }); - it('is idempotent — marker prevents repeated gitignore writes', () => { - // Run twice - execSync(`bash -c 'source "${ENSURE_DEVFLOW}" "${tmpDir}"'`, { stdio: 'pipe' }); + it('appends .devflow/ to an existing root .gitignore without clobbering it', () => { + fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\ndist/\n'); execSync(`bash -c 'source "${ENSURE_DEVFLOW}" "${tmpDir}"'`, { stdio: 'pipe' }); - const gitignore = fs.readFileSync(path.join(tmpDir, '.devflow', '.gitignore'), 'utf-8'); - // The allowlist negation for decisions.md should appear exactly once - const decisionsEntries = gitignore.split('\n').filter(l => l === '!decisions/decisions.md'); - expect(decisionsEntries).toHaveLength(1); + const gitignore = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8'); + expect(gitignore).toContain('node_modules/'); + expect(gitignore).toContain('dist/'); + expect(gitignore.split('\n').map(l => l.trim())).toContain('.devflow/'); }); - it('heredoc matches getDevflowGitignoreContent() from project-paths.cjs', () => { - // Source ensure-devflow-init in a fresh temp dir, then compare the resulting - // .devflow/.gitignore to the canonical CJS function output. - const PROJECT_PATHS_CJS = path.join(HOOKS_DIR, 'lib', 'project-paths.cjs'); + it('is idempotent — .devflow/ appears exactly once after repeated runs', () => { + execSync(`bash -c 'source "${ENSURE_DEVFLOW}" "${tmpDir}"'`, { stdio: 'pipe' }); execSync(`bash -c 'source "${ENSURE_DEVFLOW}" "${tmpDir}"'`, { stdio: 'pipe' }); - const hookContent = fs.readFileSync(path.join(tmpDir, '.devflow', '.gitignore'), 'utf-8'); - const canonical = execSync( - `node -e "process.stdout.write(require('${PROJECT_PATHS_CJS}').getDevflowGitignoreContent())"`, - { stdio: 'pipe' }, - ).toString(); + const gitignore = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8'); + const entries = gitignore.split('\n').map(l => l.trim()).filter(l => l === '.devflow/'); + expect(entries).toHaveLength(1); + }); + + it('does not append .devflow/ when the root .gitignore already ignores it', () => { + fs.writeFileSync(path.join(tmpDir, '.gitignore'), '/.devflow/\n'); + execSync(`bash -c 'source "${ENSURE_DEVFLOW}" "${tmpDir}"'`, { stdio: 'pipe' }); - expect(hookContent).toBe(canonical); + const gitignore = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8'); + // Pre-existing /.devflow/ already matches the guard — no duplicate added + expect(gitignore.split('\n').map(l => l.trim()).filter(l => l === '.devflow/')).toHaveLength(0); + expect(gitignore).toContain('/.devflow/'); }); it('returns non-zero and creates no .devflow/ when called with empty argument (SEC-3 guard)', () => { From c8d41e50113547b116f51625a7b0b9a1db5e580f Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 21 Jun 2026 11:59:59 +0300 Subject: [PATCH 2/3] fix: close .devflow/ wholesale-ignore gaps (memory coupling, dead dream-commit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the .devflow/ wholesale-ignore change (PR #245), closing gaps surfaced in self-review. Decouple the root .gitignore write from the memory toggle (PF-014). Extract a single-source ensure-root-gitignore helper; ensure-devflow-init sources it, and the always-on session-start-context sources it too — so memory-off projects (decisions/knowledge only) still get .devflow/ ignored. Add deterministic init-time ensureDevflowGitignore (appends only .devflow/, never .claude/), called unconditionally when a git root exists. Remove dead dream-commit machinery: the helper auto-committed .devflow/ artifacts via 'git add' (no -f), a permanent no-op under wholesale ignore (ADR-021). Delete the script and its three skill Auto-commit sections, and drop the now-orphaned autoCommit dream-config field plus its misleading 'decisions --status' line. Add purge-orphaned-dream-commit-hook-v1 global migration (the installer copies scripts/ additively, so the orphan would otherwise linger on existing machines). Fix stale 'committed to git' comments (migrations.ts ADR-012, init.ts features/) now that .devflow/ is gitignored by default. Document Privacy & Sharing (opt-in) in README. Tests: ensure-root-gitignore, memory-independent session-start-context, ensureDevflowGitignore, and the new migration. Full suite green (64 files / 1870). --- README.md | 21 + scripts/hooks/dream-commit | 222 -------- scripts/hooks/ensure-devflow-init | 25 +- scripts/hooks/ensure-root-gitignore | 45 ++ scripts/hooks/session-start-context | 7 + shared/skills/dream-curation/SKILL.md | 11 +- shared/skills/dream-decisions/SKILL.md | 10 +- shared/skills/dream-knowledge/SKILL.md | 11 +- src/cli/commands/decisions.ts | 4 +- src/cli/commands/init.ts | 19 +- src/cli/utils/dream-config.ts | 12 +- src/cli/utils/migrations.ts | 34 +- src/cli/utils/post-install.ts | 48 ++ tests/decisions/dream-commit.test.ts | 685 ------------------------- tests/init-logic.test.ts | 53 ++ tests/migrations.test.ts | 54 ++ tests/shell-hooks.test.ts | 126 +++++ 17 files changed, 411 insertions(+), 976 deletions(-) delete mode 100755 scripts/hooks/dream-commit create mode 100644 scripts/hooks/ensure-root-gitignore delete mode 100644 tests/decisions/dream-commit.test.ts diff --git a/README.md b/README.md index e0894fb6..9c4aad4b 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,27 @@ npx devflow-kit init That's it. The interactive wizard handles plugin selection, feature configuration, and security settings. Ambient mode, working memory, and decisions tracking are on by default. +## Privacy & Sharing + +Everything Devflow generates lives under `.devflow/` — working memory, decisions and pitfalls, feature knowledge bases, dream state, and transient locks. That directory is **gitignored wholesale by default**, so this per-developer runtime state stays on your machine and never lands in a commit. Devflow adds the `.devflow/` line to your project's root `.gitignore` automatically on first use. + +Sharing is opt-in. To share **everything** with your team, remove the `.devflow/` line from `.gitignore`. To share only curated knowledge (and keep memory, queues, and locks local), replace the `.devflow/` line with a pattern that ignores everything except the files you want tracked: + +```gitignore +# Ignore all Devflow runtime data… +.devflow/** +# …except the team knowledge you want to share +!.devflow/decisions/ +!.devflow/decisions/decisions.md +!.devflow/decisions/pitfalls.md +!.devflow/features/ +!.devflow/features/index.json +!.devflow/features/**/ +!.devflow/features/**/KNOWLEDGE.md +``` + +(The directory re-includes — `!.devflow/decisions/` — are required: git won't descend into an excluded directory to reach a re-included file.) + ## Commands | Command | What it does | diff --git a/scripts/hooks/dream-commit b/scripts/hooks/dream-commit deleted file mode 100755 index 4cc07659..00000000 --- a/scripts/hooks/dream-commit +++ /dev/null @@ -1,222 +0,0 @@ -#!/bin/bash -# dream-commit — Deterministic plumbing helper for attributable Dream maintenance commits. -# -# Applies ADR-008 (LLM-vs-plumbing: this is DETERMINISTIC PLUMBING — no judgment here). -# Applies ADR-012 (.devflow knowledge artifacts are committed to git as shared team knowledge). -# -# Usage: -# dream-commit [session_id] -# -# One of: decisions | curation | knowledge -# Short action suffix for the commit subject -# e.g. "add ADR-019", "retire 2 stale entries", "refresh cli-rules knowledge" -# [session_id] Optional session identifier; defaults to "unknown" -# -# Commit format: -# Subject: chore(dream): -# Trailers: Dream-Task: -# Dream-Session: -# Co-Authored-By: Devflow Dream -# -# Safe skip conditions (exit 0, no commit): -# - autoCommit disabled in .devflow/dream/config.json -# - mid-rebase, mid-merge, mid-cherry-pick, or detached HEAD -# - nothing staged after explicit staging of allowed paths -# - git commit fails for any reason (best-effort maintenance, never blocks session) -# -# Staged paths (ONLY these, never git add -A): -# .devflow/decisions/decisions-ledger.jsonl -# .devflow/decisions/decisions.md -# .devflow/decisions/pitfalls.md -# .devflow/features/**/KNOWLEDGE.md (knowledge task only) -# .devflow/features/index.json (knowledge task only) - -# Safe no-op fallback — must exist before hook-bootstrap is sourced. -# NOTE: no set -e here; dream-commit is agent-invoked best-effort plumbing, -# not a registered Claude Code hook — failure must never block the session. -dbg() { :; } - -# Resolve script directory (handles symlinks) -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -# Source shared debug-trace bootstrap (preferred over inline sourcing; avoids -# duplicating the debug-trace/devflow_debug_init pattern from hook-bootstrap). -source "$SCRIPT_DIR/hook-bootstrap" "dream-commit" -dbg "=== dream-commit START ===" - -# --------------------------------------------------------------------------- -# Argument validation -# --------------------------------------------------------------------------- - -TASK="${1:-}" -ACTION="${2:-}" -SESSION_ID="${3:-unknown}" - -if [ -z "$TASK" ] || [ -z "$ACTION" ]; then - echo "dream-commit: usage: dream-commit [session_id]" >&2 - echo " task: decisions | curation | knowledge" >&2 - echo " action: short subject suffix (e.g. 'add ADR-019')" >&2 - exit 1 -fi - -case "$TASK" in - decisions|curation|knowledge) ;; - *) - echo "dream-commit: unknown task '$TASK' — must be decisions, curation, or knowledge" >&2 - exit 1 - ;; -esac - -dbg "TASK=$TASK ACTION=$ACTION SESSION_ID=$SESSION_ID" - -# --------------------------------------------------------------------------- -# Locate project root (git rev-parse, handle worktrees) -# --------------------------------------------------------------------------- - -PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || { - dbg "EXIT: git rev-parse --show-toplevel failed — not in a git repo" - exit 0 -} - -dbg "PROJECT_ROOT=$PROJECT_ROOT" - -devflow_debug_set_cwd "$PROJECT_ROOT" - -# --------------------------------------------------------------------------- -# Config gate: read autoCommit from .devflow/dream/config.json (default ON) -# --------------------------------------------------------------------------- - -DREAM_CONFIG="$PROJECT_ROOT/.devflow/dream/config.json" -# Default ON — canonical source: src/cli/utils/dream-config.ts DEFAULT_CONFIG.autoCommit -AUTO_COMMIT="true" - -if [ -f "$DREAM_CONFIG" ]; then - # Prefer jq; fall back to json-helper.cjs get-field-file (path via argv, no interpolation) - if command -v jq >/dev/null 2>&1; then - _RAW=$(jq -r 'if (.autoCommit | type) == "null" then "true" else (.autoCommit | tostring) end' "$DREAM_CONFIG" 2>/dev/null) && AUTO_COMMIT="$_RAW" - elif command -v node >/dev/null 2>&1; then - _RAW=$(node "$SCRIPT_DIR/json-helper.cjs" get-field-file "$DREAM_CONFIG" autoCommit true 2>/dev/null) && AUTO_COMMIT="${_RAW:-true}" - fi -fi - -dbg "AUTO_COMMIT=$AUTO_COMMIT" - -if [ "$AUTO_COMMIT" = "false" ]; then - dbg "EXIT: autoCommit disabled in dream config" - exit 0 -fi - -# --------------------------------------------------------------------------- -# Safety rails: detect rebase / merge / cherry-pick / detached HEAD -# --------------------------------------------------------------------------- - -# Use git rev-parse --git-dir to locate .git (handles worktrees where .git is a file) -GIT_DIR=$(git rev-parse --git-dir 2>/dev/null) || { - dbg "EXIT: git rev-parse --git-dir failed" - exit 0 -} - -# Resolve absolute path for GIT_DIR state file checks -if [[ "$GIT_DIR" != /* ]]; then - GIT_DIR="$(pwd)/$GIT_DIR" -fi - -dbg "GIT_DIR=$GIT_DIR" - -# Mid-rebase -if [ -d "$GIT_DIR/rebase-merge" ] || [ -d "$GIT_DIR/rebase-apply" ]; then - dbg "EXIT: mid-rebase detected" - exit 0 -fi - -# Mid-merge -if [ -f "$GIT_DIR/MERGE_HEAD" ]; then - dbg "EXIT: mid-merge detected" - exit 0 -fi - -# Mid-cherry-pick -if [ -f "$GIT_DIR/CHERRY_PICK_HEAD" ]; then - dbg "EXIT: mid-cherry-pick detected" - exit 0 -fi - -# Detached HEAD — git symbolic-ref -q HEAD exits non-zero when detached -if ! git symbolic-ref -q HEAD >/dev/null 2>&1; then - dbg "EXIT: detached HEAD" - exit 0 -fi - -# --------------------------------------------------------------------------- -# Stage ONLY the allowed paths (never git add -A) -# --------------------------------------------------------------------------- - -DECISIONS_DIR="$PROJECT_ROOT/.devflow/decisions" -FEATURES_DIR="$PROJECT_ROOT/.devflow/features" - -# Stage decisions files (for all tasks that touch decisions) -for _F in \ - "$DECISIONS_DIR/decisions-ledger.jsonl" \ - "$DECISIONS_DIR/decisions.md" \ - "$DECISIONS_DIR/pitfalls.md" -do - if [ -f "$_F" ]; then - git -C "$PROJECT_ROOT" add -- "$_F" 2>/dev/null || true - dbg "Staged: $_F" - fi -done - -# Stage knowledge files (only for knowledge task) -if [ "$TASK" = "knowledge" ]; then - # Stage index.json - if [ -f "$FEATURES_DIR/index.json" ]; then - git -C "$PROJECT_ROOT" add -- "$FEATURES_DIR/index.json" 2>/dev/null || true - dbg "Staged: $FEATURES_DIR/index.json" - fi - # Stage all KNOWLEDGE.md files under features/ - if [ -d "$FEATURES_DIR" ]; then - find "$FEATURES_DIR" -name "KNOWLEDGE.md" | while IFS= read -r _KF; do - git -C "$PROJECT_ROOT" add -- "$_KF" 2>/dev/null || true - dbg "Staged: $_KF" - done - fi -fi - -# --------------------------------------------------------------------------- -# No-op check: exit cleanly if nothing staged -# --------------------------------------------------------------------------- - -if git -C "$PROJECT_ROOT" diff --cached --quiet 2>/dev/null; then - dbg "EXIT: nothing staged — no-op" - exit 0 -fi - -dbg "Staged diff is non-empty — proceeding to commit" - -# --------------------------------------------------------------------------- -# Commit with documented format -# --------------------------------------------------------------------------- - -SUBJECT="chore(dream): $ACTION" - -# Build the commit message with a blank separator line between subject and trailers, -# so git parses the trailers correctly (git trailer convention: blank line before trailers). -COMMIT_MSG="$SUBJECT - -Dream-Task: $TASK -Dream-Session: $SESSION_ID -Co-Authored-By: Devflow Dream " - -dbg "COMMIT_MSG subject: $SUBJECT" - -# Best-effort commit — if it fails (e.g. pre-commit hook rejects, nothing to do), -# exit 0 without throwing. Maintenance commits must never block the session. -if git -C "$PROJECT_ROOT" commit -m "$COMMIT_MSG" >/dev/null 2>&1; then - dbg "Commit succeeded: $SUBJECT" -else - _EXIT=$? - dbg "Commit failed (exit $_EXIT): $SUBJECT — treating as no-op (maintenance is best-effort)" -fi - -dbg "=== dream-commit COMPLETE ===" -exit 0 diff --git a/scripts/hooks/ensure-devflow-init b/scripts/hooks/ensure-devflow-init index 3b8b45ae..4dd2335a 100755 --- a/scripts/hooks/ensure-devflow-init +++ b/scripts/hooks/ensure-devflow-init @@ -32,21 +32,10 @@ if [ ! -f "$_DEVFLOW_DIR/features/index.json" ]; then mv "$_DEVFLOW_DIR/features/index.json.tmp" "$_DEVFLOW_DIR/features/index.json" fi -# One-time root .gitignore setup (marker prevents repeated checks). -# .devflow/ holds per-developer runtime state (memory, dream, docs, decisions, -# feature knowledge, locks) — ignore it wholesale by default. Sharing is opt-in: -# a user removes the entry, or re-includes specific files in their own .gitignore. -if [ ! -f "$_DEVFLOW_DIR/.root-gitignore-configured" ]; then - _ROOT_GITIGNORE="$1/.gitignore" - _GITIGNORE_OK=0 - if [ ! -f "$_ROOT_GITIGNORE" ]; then - printf '# Devflow runtime data (local by default; remove to share via git)\n.devflow/\n' \ - > "$_ROOT_GITIGNORE" && _GITIGNORE_OK=1 - elif ! grep -qE '^/?\.devflow/?[[:space:]]*$' "$_ROOT_GITIGNORE"; then - printf '\n# Devflow runtime data (local by default; remove to share via git)\n.devflow/\n' \ - >> "$_ROOT_GITIGNORE" && _GITIGNORE_OK=1 - else - _GITIGNORE_OK=1 - fi - [ "$_GITIGNORE_OK" = 1 ] && touch "$_DEVFLOW_DIR/.root-gitignore-configured" -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. Resolve our own directory +# via BASH_SOURCE so this works whether sourced by a hook (which sets SCRIPT_DIR) or +# sourced directly (tests source this file with no SCRIPT_DIR in scope). +_EDI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$_EDI_DIR/ensure-root-gitignore" "$1" diff --git a/scripts/hooks/ensure-root-gitignore b/scripts/hooks/ensure-root-gitignore new file mode 100644 index 00000000..e8f5ca92 --- /dev/null +++ b/scripts/hooks/ensure-root-gitignore @@ -0,0 +1,45 @@ +#!/bin/bash +# ensure-root-gitignore — single source of truth for the project root .gitignore +# entry that ignores .devflow/ wholesale. +# +# .devflow/ holds per-developer runtime state (memory, dream, docs, decisions, +# feature knowledge, locks) — ignored by default. Sharing is opt-in: a user removes +# the entry, or re-includes specific files in their own .gitignore. +# +# Sourced by: +# - ensure-devflow-init (reached via the memory/dream hooks) +# - session-start-context (always-on, memory-independent — covers memory-off +# projects that still use decisions/knowledge) +# Both reach this one writer so the rule is applied identically everywhere; this +# decouples git-tracking of .devflow/ from any single feature toggle (avoids PF-014). +# +# Idempotent and O(1) after the first run via the .root-gitignore-configured marker. +# +# Usage: source ensure-root-gitignore "$PROJECT_ROOT" +# Sourced helper: uses `return` (never exit), _ERG_-prefixed locals (never clobbers +# caller vars). + +[ -z "$1" ] && return 1 + +_ERG_DEVFLOW_DIR="$1/.devflow" + +# Fast-path: the marker means the root .gitignore was already configured for this project. +[ -f "$_ERG_DEVFLOW_DIR/.root-gitignore-configured" ] && return 0 + +# The marker lives under .devflow/, so the directory must exist before we touch it. +# (ensure-devflow-init creates it earlier; session-start-context may reach here first.) +mkdir -p "$_ERG_DEVFLOW_DIR" 2>/dev/null || return 1 + +_ERG_ROOT_GITIGNORE="$1/.gitignore" +_ERG_OK=0 +if [ ! -f "$_ERG_ROOT_GITIGNORE" ]; then + printf '# Devflow runtime data (local by default; remove to share via git)\n.devflow/\n' \ + > "$_ERG_ROOT_GITIGNORE" && _ERG_OK=1 +elif ! grep -qE '^/?\.devflow/?[[:space:]]*$' "$_ERG_ROOT_GITIGNORE"; then + printf '\n# Devflow runtime data (local by default; remove to share via git)\n.devflow/\n' \ + >> "$_ERG_ROOT_GITIGNORE" && _ERG_OK=1 +else + _ERG_OK=1 +fi +[ "$_ERG_OK" = 1 ] && touch "$_ERG_DEVFLOW_DIR/.root-gitignore-configured" +return 0 diff --git a/scripts/hooks/session-start-context b/scripts/hooks/session-start-context index 93e2d90a..0e6eea25 100755 --- a/scripts/hooks/session-start-context +++ b/scripts/hooks/session-start-context @@ -32,6 +32,13 @@ fi devflow_debug_set_cwd "$CWD" dbg "CWD=$CWD" +# Ensure the project root .gitignore ignores .devflow/ wholesale. This runs on every +# session regardless of feature toggles, so memory-off projects (decisions/knowledge +# only) still get .devflow/ ignored — this is the memory-independent path that fixes +# the gitignore/memory coupling (PF-014). Single source of truth: ensure-root-gitignore. +# Soft-fail: a gitignore write must never block context injection. Marker keeps it O(1). +[ -d "$CWD" ] && [ -f "$SCRIPT_DIR/ensure-root-gitignore" ] && source "$SCRIPT_DIR/ensure-root-gitignore" "$CWD" || true + CONTEXT="" DEVFLOW_DIR="$CWD/.devflow" diff --git a/shared/skills/dream-curation/SKILL.md b/shared/skills/dream-curation/SKILL.md index 8ecffbf0..15d5125e 100644 --- a/shared/skills/dream-curation/SKILL.md +++ b/shared/skills/dream-curation/SKILL.md @@ -129,16 +129,7 @@ entry instead (edit those ledger rows directly, then re-render). **Cap enforcement**: stop after 5 changes regardless of remaining candidates. -**Auto-commit** (after all retire-anchor calls complete, all locks released): - -Run the installed commit helper — summarise what changed as the action: -```bash -"$HOME/.devflow/scripts/hooks/dream-commit" curation "" "" -``` -Where `` describes what happened, e.g. `"retire 2 stale entries"` or -`"retire ADR-007 (superseded)"`. Pass the session id from the marker you claimed. -This is best-effort: the helper exits 0 silently on no-op or if auto-commit is disabled. -Run it AFTER all `retire-anchor` calls complete (each self-releases `.decisions.lock`). +`.devflow/` is gitignored by default (ADR-021) — ledger and rendered `.md` files stay local. **Transparency**: after curation, emit a brief note in the agent output listing what was retired/merged. If nothing was changed, stay silent. diff --git a/shared/skills/dream-decisions/SKILL.md b/shared/skills/dream-decisions/SKILL.md index a8626e17..2cca4dce 100644 --- a/shared/skills/dream-decisions/SKILL.md +++ b/shared/skills/dream-decisions/SKILL.md @@ -110,15 +110,7 @@ both `decisions.md` and `pitfalls.md` — all atomically under `.decisions.lock` NEVER call `decisions-append`. NEVER hand-edit `decisions.md` or `pitfalls.md`. -**Auto-commit** (after assign-anchor succeeds, lock released): - -Run the installed commit helper — pass the session id from the marker you claimed: -```bash -"$HOME/.devflow/scripts/hooks/dream-commit" decisions "add " "" -``` -This is best-effort: the helper exits 0 silently on no-op or if auto-commit is disabled. -Run it AFTER the lock is released (assign-anchor releases `.decisions.lock` before returning). - +`.devflow/` is gitignored by default (ADR-021) — materialized entries 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/dream-knowledge/SKILL.md b/shared/skills/dream-knowledge/SKILL.md index 2335a448..2b731708 100644 --- a/shared/skills/dream-knowledge/SKILL.md +++ b/shared/skills/dream-knowledge/SKILL.md @@ -47,16 +47,7 @@ After all slugs, write the refresh timestamp: date +%s > .devflow/features/.knowledge-last-refresh ``` -**Auto-commit** (after all slugs refreshed and refresh timestamp written): - -Run the installed commit helper — summarise the refreshed slugs as the action: -```bash -"$HOME/.devflow/scripts/hooks/dream-commit" knowledge "refresh knowledge" "" -``` -Use `"refresh , knowledge"` when multiple slugs were refreshed. -Pass the session id from the marker you claimed. This is best-effort: the helper exits 0 -silently on no-op or if auto-commit is disabled. - +`.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/src/cli/commands/decisions.ts b/src/cli/commands/decisions.ts index 16e8d8e0..ec167f96 100644 --- a/src/cli/commands/decisions.ts +++ b/src/cli/commands/decisions.ts @@ -16,7 +16,7 @@ import { getDecisionsBatchIdsPath, getDecisionsDisabledSentinel, } from '../utils/project-paths.js'; -import { updateFeature, isFeatureEnabled, readConfig } from '../utils/dream-config.js'; +import { updateFeature, isFeatureEnabled } from '../utils/dream-config.js'; import { syncManifestFeature } from '../utils/manifest.js'; import { getDevFlowDirectory } from '../utils/paths.js'; import { getGitRoot } from '../utils/git.js'; @@ -92,7 +92,6 @@ export const decisionsCommand = new Command('decisions') return; } const enabled = await isFeatureEnabled(gitRoot, 'decisions'); - const dreamConfig = await readConfig(gitRoot); const { observations, invalidCount } = await readObservations(logPath); const decisionObs = observations.filter(o => o.type === 'decision' || o.type === 'pitfall'); @@ -104,7 +103,6 @@ export const decisionsCommand = new Command('decisions') const deprecated = decisionObs.filter(o => o.status === 'deprecated'); const lines: string[] = [`Decisions learning: ${enabled ? 'enabled' : 'disabled'}`]; - lines.push(`Auto-commit: ${dreamConfig.autoCommit ? 'ON' : 'OFF'} (chore(dream): commits after each Dream write)`); if (decisionObs.length === 0) { lines.push('Observations: none'); } else { diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 229a5080..b7cda164 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -15,6 +15,7 @@ import { installClaudeignore, discoverProjectGitRoots, updateGitignore, + ensureDevflowGitignore, createDocsStructure, applyUserSecurityDenyList, stripUserDenyList, @@ -1183,7 +1184,7 @@ export const initCommand = new Command('init') // Create .devflow/features/ directory with empty index (feature knowledge bases) - // .devflow/features/ is committed to the project repo (not scope-dependent) + // .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 }); @@ -1205,20 +1206,15 @@ export const initCommand = new Command('init') } // Write dream config.json to manage per-feature enable/disable at runtime. - // Uses writeConfig (full atomic write) rather than four updateFeature calls because - // init always sets all four features at once and is never concurrent with toggle + // Uses writeConfig (full atomic write) rather than three updateFeature calls because + // init always sets all three features at once and is never concurrent with toggle // commands — it is a one-time setup action. See D1 in dream-config.ts for the // concurrency assumption shared by both write strategies. if (gitRoot) { - // autoCommit: preserve existing value (if set by user), default ON for new installs. - // We read the current config to avoid clobbering a user-set autoCommit=false. - const { readConfig: readDreamConfig } = await import('../utils/dream-config.js'); - const existingDreamConfig = await readDreamConfig(gitRoot); await writeDreamConfig(gitRoot, { memory: memoryEnabled, decisions: decisionsEnabled, knowledge: knowledgeEnabled, - autoCommit: existingDreamConfig.autoCommit, }); // Drain orphaned queue files when memory is disabled so stale turns @@ -1251,6 +1247,13 @@ export const initCommand = new Command('init') await installClaudeignore(gitRoot, rootDir, verbose); } } + // Deterministically ensure .devflow/ is gitignored at the repo root — independent + // of install scope and every feature toggle. The always-on ensure-root-gitignore + // hook covers projects that never re-run init; this covers the init-time path so a + // fresh install never tracks .devflow/. Decoupled from memory (avoids PF-014). + if (gitRoot) { + await ensureDevflowGitignore(gitRoot, verbose); + } if (scope === 'local' && gitRoot) { await updateGitignore(gitRoot, verbose); } diff --git a/src/cli/utils/dream-config.ts b/src/cli/utils/dream-config.ts index b411cd40..216b624c 100644 --- a/src/cli/utils/dream-config.ts +++ b/src/cli/utils/dream-config.ts @@ -5,20 +5,12 @@ export interface DreamConfig { memory: boolean; decisions: boolean; knowledge: boolean; - /** - * When true (default), Dream tasks auto-commit maintenance writes to .devflow/ using - * the `dream-commit` helper. Greppable via `git log --grep 'chore(dream)'`. - * Set to false to disable Dream auto-commits project-wide. - * Single source of truth: .devflow/dream/config.json (key: autoCommit, default: true). - */ - autoCommit: boolean; } const DEFAULT_CONFIG: DreamConfig = { memory: true, decisions: true, knowledge: true, - autoCommit: true, }; export function getConfigPath(projectRoot: string): string { @@ -35,12 +27,12 @@ export function getConfigPath(projectRoot: string): string { function coerceConfig(parsed: unknown): DreamConfig | null { if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null; const p = parsed as Record; - // Silently ignore legacy `learning` key — old configs may still contain it + // Silently ignore legacy `learning` and `autoCommit` keys — old configs may still + // contain them (autoCommit was dropped when the dream-commit helper was removed). return { memory: typeof p.memory === 'boolean' ? p.memory : DEFAULT_CONFIG.memory, decisions: typeof p.decisions === 'boolean' ? p.decisions : DEFAULT_CONFIG.decisions, knowledge: typeof p.knowledge === 'boolean' ? p.knowledge : DEFAULT_CONFIG.knowledge, - autoCommit: typeof p.autoCommit === 'boolean' ? p.autoCommit : DEFAULT_CONFIG.autoCommit, }; } diff --git a/src/cli/utils/migrations.ts b/src/cli/utils/migrations.ts index 0563eb04..379f6f62 100644 --- a/src/cli/utils/migrations.ts +++ b/src/cli/utils/migrations.ts @@ -745,6 +745,36 @@ const MIGRATION_PURGE_LEARNING_GLOBAL: Migration<'global'> = { }, }; +/** + * Global: remove the orphaned dream-commit hook left in prior installs. + * + * dream-commit auto-committed curated .devflow/ artifacts via `git add` (no -f). + * It became a permanent no-op once .devflow/ was gitignored wholesale (ADR-021) and + * was deleted from the repo along with its three skill call-sites. The installer + * copies scripts/ additively (copyDirectory never deletes target files absent from + * source), so an unreferenced ~/.devflow/scripts/hooks/dream-commit would otherwise + * linger on every existing machine. This sweeps it once per machine. Order-independent: + * the source no longer contains dream-commit, so the script copy can never re-create it. + * + * ENOENT-idempotent (fresh installs never had it); rethrows other errors. + */ +const MIGRATION_PURGE_ORPHANED_DREAM_COMMIT_HOOK: Migration<'global'> = { + id: 'purge-orphaned-dream-commit-hook-v1', + description: 'Remove orphaned ~/.devflow/scripts/hooks/dream-commit (dream-commit helper removed)', + scope: 'global', + async run(ctx: GlobalMigrationContext): Promise { + const hookPath = path.join(ctx.devflowDir, 'scripts', 'hooks', 'dream-commit'); + try { + await fs.unlink(hookPath); + return { infos: ['Removed orphaned ~/.devflow/scripts/hooks/dream-commit'], warnings: [] }; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return { infos: [], warnings: [] }; // already absent / fresh install + throw err; + } + }, +}; + /** * Per-project: remove stale memory dream markers left by the old Dream-subagent * memory pipeline. Memory refresh is now handled by background-memory-update @@ -871,7 +901,8 @@ const MIGRATION_PURGE_TEAMMATE_MODE_PER_PROJECT: Migration<'per-project'> = { * * Applies ADR-001 EXCEPTION (data-preserving migration explicitly approved). * Applies ADR-008 (renderer is deterministic plumbing; content was LLM-authored). - * Applies ADR-012 (decisions-ledger.jsonl committed to git). + * Applies ADR-021 (.devflow/ is gitignored by default — the ledger stays local and + * sharing is opt-in; this supersedes ADR-012's commit-the-ledger-by-default premise). * Applies ADR-017 (.decisions.lock held for the full operation). * Avoids PF-007 (renderer resolved from bundled package, not installed ~/.devflow). */ @@ -937,6 +968,7 @@ export const MIGRATIONS: readonly Migration[] = [ MIGRATION_RENAME_SIDECAR_TO_DREAM, MIGRATION_PURGE_LEARNING_PIPELINE, MIGRATION_PURGE_LEARNING_GLOBAL, + MIGRATION_PURGE_ORPHANED_DREAM_COMMIT_HOOK, MIGRATION_PURGE_STALE_MEMORY_MARKERS, MIGRATION_PURGE_TEAMMATE_MODE_GLOBAL, MIGRATION_PURGE_TEAMMATE_MODE_PER_PROJECT, diff --git a/src/cli/utils/post-install.ts b/src/cli/utils/post-install.ts index 37cc7a2f..65c5dabe 100644 --- a/src/cli/utils/post-install.ts +++ b/src/cli/utils/post-install.ts @@ -891,6 +891,54 @@ export async function updateGitignore( } } +/** + * Deterministically ensure the project root .gitignore ignores `.devflow/`. + * + * Appends ONLY `.devflow/` — never `.claude/` — because user-scope installs must + * not gitignore `.claude/`. This is the init-time counterpart to the always-on + * scripts/hooks/ensure-root-gitignore shell helper; both write the identical + * comment block and entry, so the two paths are byte-compatible and mutually + * idempotent. Called unconditionally (independent of install scope and every + * feature toggle) whenever a git root is known, so a fresh install never tracks + * `.devflow/` even before any hook fires. + * + * Idempotent: reuses computeGitignoreAppend, so a second run is a no-op once + * `.devflow/` is present. Errors are swallowed (verbose-logged) — a gitignore + * write must never abort init. + */ +export async function ensureDevflowGitignore( + gitRoot: string, + verbose: boolean, +): Promise { + try { + const gitignorePath = path.join(gitRoot, '.gitignore'); + + let gitignoreContent = ''; + try { + gitignoreContent = await fs.readFile(gitignorePath, 'utf-8'); + } catch { /* doesn't exist yet */ } + + const linesToAdd = computeGitignoreAppend(gitignoreContent, ['.devflow/']); + if (linesToAdd.length === 0) { + return; + } + + const comment = '# Devflow runtime data (local by default; remove to share via git)'; + const newContent = gitignoreContent + ? `${gitignoreContent.trimEnd()}\n\n${comment}\n.devflow/\n` + : `${comment}\n.devflow/\n`; + + await fs.writeFile(gitignorePath, newContent, 'utf-8'); + if (verbose) { + p.log.success('.gitignore configured (.devflow/ ignored)'); + } + } catch (error) { + if (verbose) { + p.log.warn(`Could not update .gitignore: ${error instanceof Error ? error.message : error}`); + } + } +} + /** * Create .devflow/docs/ directory structure for Devflow artifacts. */ diff --git a/tests/decisions/dream-commit.test.ts b/tests/decisions/dream-commit.test.ts deleted file mode 100644 index f51731e4..00000000 --- a/tests/decisions/dream-commit.test.ts +++ /dev/null @@ -1,685 +0,0 @@ -// tests/decisions/dream-commit.test.ts -// -// Tests for the scripts/hooks/dream-commit shell plumbing helper. -// -// AC-A9: dream-commit produces commits matching the documented format/trailer -// touching only allowed paths. -// AC-F10: Dream maintenance is auto-committed in the documented format, scoped -// to .devflow maintenance paths, never bundling user code; skipped safely -// during rebase/merge/detached-HEAD and when clean. -// -// Also covers SKILL wiring assertions: dream-decisions, dream-curation, -// dream-knowledge all reference dream-commit with the correct invocation pattern. - -import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest'; -import { execSync } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; - -const ROOT = path.resolve(import.meta.dirname, '../..'); -const DREAM_COMMIT_BIN = path.join(ROOT, 'scripts/hooks/dream-commit'); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function initGitRepo(dir: string, withInitialCommit = true): void { - // Init with deterministic author to avoid system config dependency - execSync('git init', { cwd: dir }); - execSync('git config user.email "test@devflow.test"', { cwd: dir }); - execSync('git config user.name "Test User"', { cwd: dir }); - if (withInitialCommit) { - // Need at least one commit so HEAD is valid and staging works - fs.writeFileSync(path.join(dir, 'README.md'), 'test\n', 'utf8'); - execSync('git add README.md', { cwd: dir }); - execSync('git commit -m "initial commit"', { cwd: dir }); - } -} - -function writeDevflowFiles(dir: string): void { - const decisionsDir = path.join(dir, '.devflow', 'decisions'); - fs.mkdirSync(decisionsDir, { recursive: true }); - fs.writeFileSync(path.join(decisionsDir, 'decisions-ledger.jsonl'), '{"anchor_id":"ADR-001"}\n', 'utf8'); - fs.writeFileSync(path.join(decisionsDir, 'decisions.md'), '# Decisions\n', 'utf8'); - fs.writeFileSync(path.join(decisionsDir, 'pitfalls.md'), '# Pitfalls\n', 'utf8'); -} - -function writeKnowledgeFiles(dir: string, slug = 'test-feature'): void { - const featuresDir = path.join(dir, '.devflow', 'features'); - fs.mkdirSync(path.join(featuresDir, slug), { recursive: true }); - fs.writeFileSync(path.join(featuresDir, 'index.json'), '{"test-feature":{}}\n', 'utf8'); - fs.writeFileSync(path.join(featuresDir, slug, 'KNOWLEDGE.md'), '# Knowledge\n', 'utf8'); -} - -function writeDreamConfig(dir: string, config: Record): void { - const dreamDir = path.join(dir, '.devflow', 'dream'); - fs.mkdirSync(dreamDir, { recursive: true }); - fs.writeFileSync(path.join(dreamDir, 'config.json'), JSON.stringify(config, null, 2) + '\n', 'utf8'); -} - -function runCommit( - args: string, - cwd: string, - env?: Record, -): { stdout: string; stderr: string; code: number } { - try { - const stdout = execSync(`bash "${DREAM_COMMIT_BIN}" ${args}`, { - cwd, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, ...(env ?? {}) }, - }); - return { stdout, stderr: '', code: 0 }; - } catch (e: unknown) { - const err = e as { stdout?: string; status?: number; stderr?: string }; - return { - stdout: err.stdout ?? '', - stderr: err.stderr ?? '', - code: err.status ?? 1, - }; - } -} - -/** Get git log from a repo */ -function getGitLog(dir: string, format: string = '%s'): string { - try { - return execSync(`git log --format="${format}"`, { - cwd: dir, - encoding: 'utf8', - }).trim(); - } catch { - return ''; - } -} - -/** Get staged files (relative paths) */ -function getStagedFiles(dir: string): string[] { - try { - const out = execSync('git diff --cached --name-only', { - cwd: dir, - encoding: 'utf8', - }).trim(); - return out ? out.split('\n') : []; - } catch { - return []; - } -} - -/** Count total commits */ -function countCommits(dir: string): number { - try { - return parseInt(execSync('git rev-list --count HEAD', { - cwd: dir, - encoding: 'utf8', - }).trim(), 10); - } catch { - return 0; - } -} - -// --------------------------------------------------------------------------- -// Test setup / teardown -// --------------------------------------------------------------------------- - -let tmpDir: string; - -beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dream-commit-test-')); -}); - -afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); -}); - -// --------------------------------------------------------------------------- -// Format: subject + trailers -// --------------------------------------------------------------------------- - -describe('commit format', () => { - it('creates a commit with chore(dream): subject prefix', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - execSync('git add .devflow/', { cwd: tmpDir }); - - const result = runCommit('decisions "add ADR-001" session123', tmpDir); - expect(result.code).toBe(0); - - const subject = getGitLog(tmpDir, '%s'); - expect(subject.split('\n')[0]).toBe('chore(dream): add ADR-001'); - }); - - it('body contains Dream-Task: decisions trailer', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - execSync('git add .devflow/', { cwd: tmpDir }); - - runCommit('decisions "add ADR-001" session123', tmpDir); - - const body = getGitLog(tmpDir, '%b'); - expect(body).toContain('Dream-Task: decisions'); - }); - - it('body contains Dream-Session: trailer with provided session id', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - execSync('git add .devflow/', { cwd: tmpDir }); - - runCommit('decisions "add ADR-001" session123', tmpDir); - - const body = getGitLog(tmpDir, '%b'); - expect(body).toContain('Dream-Session: session123'); - }); - - it('body contains Co-Authored-By trailer', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - execSync('git add .devflow/', { cwd: tmpDir }); - - runCommit('decisions "add ADR-001" session123', tmpDir); - - const body = getGitLog(tmpDir, '%b'); - expect(body).toContain('Co-Authored-By: Devflow Dream '); - }); - - it('session_id defaults to unknown when omitted', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - execSync('git add .devflow/', { cwd: tmpDir }); - - runCommit('decisions "add ADR-001"', tmpDir); - - const body = getGitLog(tmpDir, '%b'); - expect(body).toContain('Dream-Session: unknown'); - }); - - it('curation task produces Dream-Task: curation', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - execSync('git add .devflow/', { cwd: tmpDir }); - - runCommit('curation "retire 2 stale entries" sess456', tmpDir); - - const subject = getGitLog(tmpDir, '%s'); - const body = getGitLog(tmpDir, '%b'); - expect(subject.split('\n')[0]).toBe('chore(dream): retire 2 stale entries'); - expect(body).toContain('Dream-Task: curation'); - }); - - it('knowledge task produces Dream-Task: knowledge', () => { - initGitRepo(tmpDir); - writeKnowledgeFiles(tmpDir); - execSync('git add .devflow/', { cwd: tmpDir }); - - runCommit('knowledge "refresh cli-rules knowledge" sess789', tmpDir); - - const subject = getGitLog(tmpDir, '%s'); - const body = getGitLog(tmpDir, '%b'); - expect(subject.split('\n')[0]).toBe('chore(dream): refresh cli-rules knowledge'); - expect(body).toContain('Dream-Task: knowledge'); - }); - - it('commit is greppable via git log --grep chore(dream)', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - execSync('git add .devflow/', { cwd: tmpDir }); - - runCommit('decisions "add ADR-019" sessXYZ', tmpDir); - - const grepResult = execSync("git log --grep='chore(dream)' --oneline", { - cwd: tmpDir, - encoding: 'utf8', - }).trim(); - expect(grepResult).toContain('chore(dream): add ADR-019'); - }); - - it('commit is greppable via git log --grep Dream-Task:', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - execSync('git add .devflow/', { cwd: tmpDir }); - - runCommit('decisions "add ADR-019" sessXYZ', tmpDir); - - const grepResult = execSync("git log --grep='Dream-Task:' --oneline", { - cwd: tmpDir, - encoding: 'utf8', - }).trim(); - expect(grepResult).not.toBe(''); - }); -}); - -// --------------------------------------------------------------------------- -// Path scope: only allowed .devflow paths staged; user files never staged -// --------------------------------------------------------------------------- - -describe('path scope', () => { - it('stages decisions-ledger.jsonl for decisions task', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - - runCommit('decisions "add ADR-001" s1', tmpDir); - - const committedFiles = execSync('git show --name-only --format="" HEAD', { - cwd: tmpDir, - encoding: 'utf8', - }).trim().split('\n').filter(Boolean); - - const committedRelative = committedFiles.map(f => f.replace(/\\/g, '/')); - const expectedPaths = [ - '.devflow/decisions/decisions-ledger.jsonl', - '.devflow/decisions/decisions.md', - '.devflow/decisions/pitfalls.md', - ]; - for (const p of expectedPaths) { - expect(committedRelative.some(f => f.endsWith(p.split('/').slice(-1)[0]) || f.includes(p))).toBe(true); - } - }); - - it('does NOT stage a dirty user file in the same repo', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - // Write a dirty user file (tracked but modified) - fs.writeFileSync(path.join(tmpDir, 'README.md'), 'user dirty content\n', 'utf8'); - - runCommit('decisions "add ADR-001" s1', tmpDir); - - // README.md should NOT be in the commit - const committedFiles = execSync('git show --name-only --format="" HEAD', { - cwd: tmpDir, - encoding: 'utf8', - }).trim().split('\n').filter(Boolean); - - expect(committedFiles.some(f => f.includes('README.md'))).toBe(false); - // And it should still be dirty (unstaged) - const status = execSync('git status --porcelain', { cwd: tmpDir, encoding: 'utf8' }).trim(); - expect(status).toContain('README.md'); - }); - - it('does NOT stage decisions-log.jsonl (gitignored observation lifecycle log)', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - // Write decisions-log.jsonl (should NOT be committed) - const decisionsDir = path.join(tmpDir, '.devflow', 'decisions'); - fs.writeFileSync(path.join(decisionsDir, 'decisions-log.jsonl'), '{"status":"observing"}\n', 'utf8'); - - runCommit('decisions "add ADR-001" s1', tmpDir); - - const committedFiles = execSync('git show --name-only --format="" HEAD', { - cwd: tmpDir, - encoding: 'utf8', - }).trim().split('\n').filter(Boolean); - - expect(committedFiles.some(f => f.includes('decisions-log.jsonl'))).toBe(false); - }); - - it('stages KNOWLEDGE.md files for knowledge task', () => { - initGitRepo(tmpDir); - writeKnowledgeFiles(tmpDir); - - runCommit('knowledge "refresh test-feature knowledge" s1', tmpDir); - - const committedFiles = execSync('git show --name-only --format="" HEAD', { - cwd: tmpDir, - encoding: 'utf8', - }).trim().split('\n').filter(Boolean); - - expect(committedFiles.some(f => f.includes('KNOWLEDGE.md'))).toBe(true); - expect(committedFiles.some(f => f.includes('index.json'))).toBe(true); - }); - - it('does NOT stage KNOWLEDGE.md for decisions task', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - writeKnowledgeFiles(tmpDir); - - // Only run decisions task — KNOWLEDGE.md should not be staged - runCommit('decisions "add ADR-001" s1', tmpDir); - - const committedFiles = execSync('git show --name-only --format="" HEAD', { - cwd: tmpDir, - encoding: 'utf8', - }).trim().split('\n').filter(Boolean); - - expect(committedFiles.some(f => f.includes('KNOWLEDGE.md'))).toBe(false); - }); -}); - -// --------------------------------------------------------------------------- -// No-op when clean -// --------------------------------------------------------------------------- - -describe('no-op when clean', () => { - it('exits 0 without creating a commit when nothing staged', () => { - initGitRepo(tmpDir); - // No .devflow files — nothing to stage - - const before = countCommits(tmpDir); - const result = runCommit('decisions "add ADR-001" s1', tmpDir); - - expect(result.code).toBe(0); - expect(countCommits(tmpDir)).toBe(before); - }); - - it('exits 0 without creating a commit when .devflow files already committed (clean)', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - execSync('git add .devflow/', { cwd: tmpDir }); - execSync('git commit -m "add devflow files"', { cwd: tmpDir }); - - // Run commit again — no new changes, nothing to stage - const before = countCommits(tmpDir); - const result = runCommit('decisions "add ADR-001" s1', tmpDir); - - expect(result.code).toBe(0); - expect(countCommits(tmpDir)).toBe(before); - }); -}); - -// --------------------------------------------------------------------------- -// Skip during rebase / merge / detached HEAD -// --------------------------------------------------------------------------- - -describe('safety rails', () => { - it('skips cleanly when MERGE_HEAD exists', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - - // Simulate mid-merge by creating MERGE_HEAD state file - const gitDir = execSync('git rev-parse --git-dir', { cwd: tmpDir, encoding: 'utf8' }).trim(); - const absGitDir = path.isAbsolute(gitDir) ? gitDir : path.join(tmpDir, gitDir); - fs.writeFileSync(path.join(absGitDir, 'MERGE_HEAD'), 'fakehash\n', 'utf8'); - - const before = countCommits(tmpDir); - const result = runCommit('decisions "add ADR-001" s1', tmpDir); - - expect(result.code).toBe(0); - expect(countCommits(tmpDir)).toBe(before); - }); - - it('skips cleanly when CHERRY_PICK_HEAD exists', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - - const gitDir = execSync('git rev-parse --git-dir', { cwd: tmpDir, encoding: 'utf8' }).trim(); - const absGitDir = path.isAbsolute(gitDir) ? gitDir : path.join(tmpDir, gitDir); - fs.writeFileSync(path.join(absGitDir, 'CHERRY_PICK_HEAD'), 'fakehash\n', 'utf8'); - - const before = countCommits(tmpDir); - const result = runCommit('decisions "add ADR-001" s1', tmpDir); - - expect(result.code).toBe(0); - expect(countCommits(tmpDir)).toBe(before); - }); - - it('skips cleanly when rebase-merge directory exists', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - - const gitDir = execSync('git rev-parse --git-dir', { cwd: tmpDir, encoding: 'utf8' }).trim(); - const absGitDir = path.isAbsolute(gitDir) ? gitDir : path.join(tmpDir, gitDir); - fs.mkdirSync(path.join(absGitDir, 'rebase-merge'), { recursive: true }); - - const before = countCommits(tmpDir); - const result = runCommit('decisions "add ADR-001" s1', tmpDir); - - expect(result.code).toBe(0); - expect(countCommits(tmpDir)).toBe(before); - }); - - it('skips cleanly when rebase-apply directory exists', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - - const gitDir = execSync('git rev-parse --git-dir', { cwd: tmpDir, encoding: 'utf8' }).trim(); - const absGitDir = path.isAbsolute(gitDir) ? gitDir : path.join(tmpDir, gitDir); - fs.mkdirSync(path.join(absGitDir, 'rebase-apply'), { recursive: true }); - - const before = countCommits(tmpDir); - const result = runCommit('decisions "add ADR-001" s1', tmpDir); - - expect(result.code).toBe(0); - expect(countCommits(tmpDir)).toBe(before); - }); - - it('skips cleanly when HEAD is detached', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - // Add a second commit so we can detach - fs.writeFileSync(path.join(tmpDir, 'file2.txt'), 'v2\n', 'utf8'); - execSync('git add file2.txt', { cwd: tmpDir }); - execSync('git commit -m "second commit"', { cwd: tmpDir }); - // Detach HEAD by checking out a specific commit - const sha = execSync('git rev-parse HEAD~1', { cwd: tmpDir, encoding: 'utf8' }).trim(); - execSync(`git checkout --detach ${sha}`, { cwd: tmpDir }); - - const before = countCommits(tmpDir); - const result = runCommit('decisions "add ADR-001" s1', tmpDir); - - expect(result.code).toBe(0); - expect(countCommits(tmpDir)).toBe(before); - }); -}); - -// --------------------------------------------------------------------------- -// Config gate: autoCommit OFF disables commits -// --------------------------------------------------------------------------- - -describe('config gate', () => { - it('skips commit when autoCommit is false in dream config', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - writeDreamConfig(tmpDir, { memory: true, decisions: true, knowledge: true, autoCommit: false }); - - const before = countCommits(tmpDir); - const result = runCommit('decisions "add ADR-001" s1', tmpDir); - - expect(result.code).toBe(0); - expect(countCommits(tmpDir)).toBe(before); - }); - - it('commits when autoCommit is true in dream config', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - writeDreamConfig(tmpDir, { memory: true, decisions: true, knowledge: true, autoCommit: true }); - - const before = countCommits(tmpDir); - runCommit('decisions "add ADR-001" s1', tmpDir); - - expect(countCommits(tmpDir)).toBeGreaterThan(before); - }); - - it('commits when autoCommit key is absent in dream config (defaults ON)', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - // Config without autoCommit key - writeDreamConfig(tmpDir, { memory: true, decisions: true, knowledge: true }); - - const before = countCommits(tmpDir); - runCommit('decisions "add ADR-001" s1', tmpDir); - - expect(countCommits(tmpDir)).toBeGreaterThan(before); - }); - - it('commits when no dream config file exists (defaults ON)', () => { - initGitRepo(tmpDir); - writeDevflowFiles(tmpDir); - // No dream config file at all - - const before = countCommits(tmpDir); - runCommit('decisions "add ADR-001" s1', tmpDir); - - expect(countCommits(tmpDir)).toBeGreaterThan(before); - }); -}); - -// --------------------------------------------------------------------------- -// Argument validation -// --------------------------------------------------------------------------- - -describe('argument validation', () => { - it('exits 1 when task argument is missing', () => { - initGitRepo(tmpDir); - const result = runCommit('', tmpDir); - expect(result.code).toBe(1); - }); - - it('exits 1 when action argument is missing', () => { - initGitRepo(tmpDir); - const result = runCommit('decisions', tmpDir); - expect(result.code).toBe(1); - }); - - it('exits 1 for unknown task', () => { - initGitRepo(tmpDir); - const result = runCommit('unknown "some action"', tmpDir); - expect(result.code).toBe(1); - }); -}); - -// --------------------------------------------------------------------------- -// SKILL wiring assertions — dream-decisions, dream-curation, dream-knowledge -// must all reference dream-commit with the correct invocation pattern. -// --------------------------------------------------------------------------- - -describe('SKILL wiring: dream-decisions calls dream-commit after assign-anchor', () => { - const SKILL_PATH = path.join(ROOT, 'shared/skills/dream-decisions/SKILL.md'); - let skillContent: string; - - beforeAll(() => { - skillContent = fs.readFileSync(SKILL_PATH, 'utf8'); - }); - - it('references dream-commit helper', () => { - expect(skillContent).toContain('dream-commit'); - }); - - it('uses decisions task in the invocation', () => { - expect(skillContent).toContain('dream-commit" decisions'); - }); - - it('includes "add " pattern in the invocation', () => { - expect(skillContent).toContain('add '); - }); - - it('instructs to run AFTER the lock is released', () => { - expect(skillContent).toMatch(/after.*lock.*released|lock is released/i); - }); - - it('documents that it is best-effort (exits 0 silently)', () => { - expect(skillContent).toMatch(/best.effort|exits 0/i); - }); - - it('invokes the INSTALLED helper at $HOME/.devflow/scripts/hooks/dream-commit', () => { - expect(skillContent).toContain('$HOME/.devflow/scripts/hooks/dream-commit'); - }); -}); - -describe('SKILL wiring: dream-curation calls dream-commit after retire-anchor', () => { - const SKILL_PATH = path.join(ROOT, 'shared/skills/dream-curation/SKILL.md'); - let skillContent: string; - - beforeAll(() => { - skillContent = fs.readFileSync(SKILL_PATH, 'utf8'); - }); - - it('references dream-commit helper', () => { - expect(skillContent).toContain('dream-commit'); - }); - - it('uses curation task in the invocation', () => { - expect(skillContent).toContain('dream-commit" curation'); - }); - - it('instructs to run AFTER all retire-anchor calls complete', () => { - expect(skillContent).toMatch(/after all.*retire-anchor|retire-anchor.*calls complete/i); - }); - - it('documents that it is best-effort', () => { - expect(skillContent).toMatch(/best.effort|exits 0/i); - }); - - it('invokes the INSTALLED helper at $HOME/.devflow/scripts/hooks/dream-commit', () => { - expect(skillContent).toContain('$HOME/.devflow/scripts/hooks/dream-commit'); - }); -}); - -describe('SKILL wiring: dream-knowledge calls dream-commit after slug refresh', () => { - const SKILL_PATH = path.join(ROOT, 'shared/skills/dream-knowledge/SKILL.md'); - let skillContent: string; - - beforeAll(() => { - skillContent = fs.readFileSync(SKILL_PATH, 'utf8'); - }); - - it('references dream-commit helper', () => { - expect(skillContent).toContain('dream-commit'); - }); - - it('uses knowledge task in the invocation', () => { - expect(skillContent).toContain('dream-commit" knowledge'); - }); - - it('includes "refresh knowledge" pattern', () => { - expect(skillContent).toContain('refresh knowledge'); - }); - - it('documents that it is best-effort', () => { - expect(skillContent).toMatch(/best.effort|exits 0/i); - }); - - it('invokes the INSTALLED helper at $HOME/.devflow/scripts/hooks/dream-commit', () => { - expect(skillContent).toContain('$HOME/.devflow/scripts/hooks/dream-commit'); - }); -}); - -// --------------------------------------------------------------------------- -// DreamConfig interface: autoCommit key present with default ON -// --------------------------------------------------------------------------- - -describe('DreamConfig autoCommit key', () => { - it('dream-config.ts DreamConfig interface includes autoCommit boolean', () => { - const dreamConfigPath = path.join(ROOT, 'src/cli/utils/dream-config.ts'); - const content = fs.readFileSync(dreamConfigPath, 'utf8'); - expect(content).toContain('autoCommit: boolean'); - }); - - it('DEFAULT_CONFIG has autoCommit: true', () => { - const dreamConfigPath = path.join(ROOT, 'src/cli/utils/dream-config.ts'); - const content = fs.readFileSync(dreamConfigPath, 'utf8'); - // Find the DEFAULT_CONFIG block and verify autoCommit is true - expect(content).toMatch(/autoCommit:\s*true/); - }); - - it('coerceConfig reads autoCommit with boolean typeof guard', () => { - const dreamConfigPath = path.join(ROOT, 'src/cli/utils/dream-config.ts'); - const content = fs.readFileSync(dreamConfigPath, 'utf8'); - expect(content).toContain("typeof p.autoCommit === 'boolean'"); - }); -}); - -// --------------------------------------------------------------------------- -// decisions --status reports auto-commit state -// --------------------------------------------------------------------------- - -describe('decisions --status auto-commit reporting', () => { - it('decisions.ts --status imports readConfig from dream-config', () => { - const decisionsPath = path.join(ROOT, 'src/cli/commands/decisions.ts'); - const content = fs.readFileSync(decisionsPath, 'utf8'); - expect(content).toContain('readConfig'); - expect(content).toContain('dream-config'); - }); - - it('decisions.ts --status includes Auto-commit line in status output', () => { - const decisionsPath = path.join(ROOT, 'src/cli/commands/decisions.ts'); - const content = fs.readFileSync(decisionsPath, 'utf8'); - expect(content).toContain('Auto-commit:'); - // Verify it uses the dreamConfig value - expect(content).toContain('dreamConfig.autoCommit'); - }); - - it('decisions.ts --status shows ON/OFF for auto-commit', () => { - const decisionsPath = path.join(ROOT, 'src/cli/commands/decisions.ts'); - const content = fs.readFileSync(decisionsPath, 'utf8'); - expect(content).toMatch(/autoCommit.*'ON'.*'OFF'|'ON'.*'OFF'.*autoCommit/); - }); -}); diff --git a/tests/init-logic.test.ts b/tests/init-logic.test.ts index f4e4c57a..ddd81b0e 100644 --- a/tests/init-logic.test.ts +++ b/tests/init-logic.test.ts @@ -24,6 +24,7 @@ import { DEVFLOW_HISTORICAL_DENY, applyUserSecurityDenyList, loadTemplateDenyEntries, + ensureDevflowGitignore, } from '../src/cli/utils/post-install.js'; import { installViaFileCopy, type Spinner } from '../src/cli/utils/installer.js'; import { DEVFLOW_PLUGINS, buildAssetMaps, buildRulesMap, prefixSkillName } from '../src/cli/plugins.js'; @@ -192,6 +193,58 @@ describe('computeGitignoreAppend', () => { }); }); +describe('ensureDevflowGitignore', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devflow-ensure-ignore-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + const read = (): Promise => fs.readFile(path.join(tmpDir, '.gitignore'), 'utf-8'); + const lines = (content: string): string[] => content.split('\n').map(l => l.trim()); + + it('creates .gitignore with only .devflow/ when absent (never .claude/)', async () => { + await ensureDevflowGitignore(tmpDir, false); + + const content = await read(); + expect(lines(content)).toContain('.devflow/'); + // User-scope installs must NOT gitignore .claude/ — this is the key difference + // from updateGitignore (which also adds .claude/). + expect(content).not.toContain('.claude/'); + expect(content).toContain('# Devflow runtime data'); + }); + + it('appends .devflow/ to existing content without clobbering it', async () => { + await fs.writeFile(path.join(tmpDir, '.gitignore'), 'node_modules/\ndist/\n'); + await ensureDevflowGitignore(tmpDir, false); + + const content = await read(); + expect(content).toContain('node_modules/'); + expect(content).toContain('dist/'); + expect(lines(content)).toContain('.devflow/'); + }); + + it('is idempotent — .devflow/ appears exactly once after repeated runs', async () => { + await ensureDevflowGitignore(tmpDir, false); + await ensureDevflowGitignore(tmpDir, false); + + const content = await read(); + expect(lines(content).filter(l => l === '.devflow/')).toHaveLength(1); + }); + + it('is a no-op when .devflow/ is already present (content unchanged)', async () => { + await fs.writeFile(path.join(tmpDir, '.gitignore'), 'node_modules/\n.devflow/\n'); + await ensureDevflowGitignore(tmpDir, false); + + const content = await read(); + expect(content).toBe('node_modules/\n.devflow/\n'); + }); +}); + describe('getManagedSettingsPath', () => { it('returns macOS path on darwin', () => { diff --git a/tests/migrations.test.ts b/tests/migrations.test.ts index eeff6d78..60985dfe 100644 --- a/tests/migrations.test.ts +++ b/tests/migrations.test.ts @@ -1443,6 +1443,60 @@ describe('purge-learning-global-v1 migration', () => { }); }); +// purge-orphaned-dream-commit-hook-v1 (global) +// --------------------------------------------------------------------------- + +describe('purge-orphaned-dream-commit-hook-v1 migration', () => { + let tmpDir: string; + let fakeHome: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devflow-purge-dream-commit-test-')); + fakeHome = path.join(tmpDir, 'home', '.devflow'); + await fs.mkdir(path.join(fakeHome, 'scripts', 'hooks'), { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + const hookPath = (): string => path.join(fakeHome, 'scripts', 'hooks', 'dream-commit'); + + function getMigration(): Migration<'global'> { + const m = MIGRATIONS.find(m => m.id === 'purge-orphaned-dream-commit-hook-v1'); + if (!m) throw new Error('purge-orphaned-dream-commit-hook-v1 migration not found'); + return m as Migration<'global'>; + } + + function makeCtx(): import('../src/cli/utils/migrations.js').GlobalMigrationContext { + return { scope: 'global', devflowDir: fakeHome }; + } + + it('is registered in MIGRATIONS with global scope', () => { + const m = MIGRATIONS.find(m => m.id === 'purge-orphaned-dream-commit-hook-v1'); + expect(m).toBeDefined(); + expect(m?.scope).toBe('global'); + }); + + it('removes the orphaned dream-commit hook when present (additive copy would leave it)', async () => { + await fs.writeFile(hookPath(), '#!/bin/bash\n', 'utf-8'); + + await getMigration().run(makeCtx()); + + await expect(fs.access(hookPath())).rejects.toThrow(); + }); + + it('is a no-op when the hook does not exist (fresh install)', async () => { + await expect(getMigration().run(makeCtx())).resolves.not.toThrow(); + }); + + it('is idempotent — running twice produces no errors', async () => { + await fs.writeFile(hookPath(), '#!/bin/bash\n', 'utf-8'); + await getMigration().run(makeCtx()); + await expect(getMigration().run(makeCtx())).resolves.not.toThrow(); + }); +}); + // purge-stale-memory-markers-v1 (per-project) // --------------------------------------------------------------------------- diff --git a/tests/shell-hooks.test.ts b/tests/shell-hooks.test.ts index 64967a7d..4660d169 100644 --- a/tests/shell-hooks.test.ts +++ b/tests/shell-hooks.test.ts @@ -24,6 +24,7 @@ const HOOK_SCRIPTS = [ 'json-parse', 'get-mtime', 'ensure-devflow-init', + 'ensure-root-gitignore', 'dream-capture', 'dream-evaluate', 'dream-dispatch', @@ -1356,6 +1357,85 @@ describe('ensure-devflow-init behavioral', () => { }); }); +describe('ensure-root-gitignore behavioral', () => { + const ENSURE_ROOT = path.join(HOOKS_DIR, 'ensure-root-gitignore'); + + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-rootignore-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + const ignoreLines = (file: string): string[] => + fs.readFileSync(file, 'utf-8').split('\n').map(l => l.trim()); + + it('creates the root .gitignore (and .devflow/) when absent, writes the marker', () => { + // Standalone case (as session-start-context calls it): no .devflow/ exists yet. + execSync(`bash -c 'source "${ENSURE_ROOT}" "${tmpDir}"'`, { stdio: 'pipe' }); + + const gitignore = path.join(tmpDir, '.gitignore'); + expect(fs.existsSync(gitignore)).toBe(true); + expect(ignoreLines(gitignore)).toContain('.devflow/'); + // The helper must create .devflow/ to host the marker even when called standalone + expect(fs.existsSync(path.join(tmpDir, '.devflow', '.root-gitignore-configured'))).toBe(true); + // Wholesale-ignore model — no nested .devflow/.gitignore written + expect(fs.existsSync(path.join(tmpDir, '.devflow', '.gitignore'))).toBe(false); + }); + + it('appends .devflow/ to an existing root .gitignore without clobbering it', () => { + fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\ndist/\n'); + execSync(`bash -c 'source "${ENSURE_ROOT}" "${tmpDir}"'`, { stdio: 'pipe' }); + + const gitignore = path.join(tmpDir, '.gitignore'); + const content = fs.readFileSync(gitignore, 'utf-8'); + expect(content).toContain('node_modules/'); + expect(content).toContain('dist/'); + expect(ignoreLines(gitignore)).toContain('.devflow/'); + }); + + it('is idempotent — .devflow/ appears exactly once after repeated runs', () => { + execSync(`bash -c 'source "${ENSURE_ROOT}" "${tmpDir}"'`, { stdio: 'pipe' }); + execSync(`bash -c 'source "${ENSURE_ROOT}" "${tmpDir}"'`, { stdio: 'pipe' }); + + const entries = ignoreLines(path.join(tmpDir, '.gitignore')).filter(l => l === '.devflow/'); + expect(entries).toHaveLength(1); + }); + + it('does not append .devflow/ when the root .gitignore already ignores it (/.devflow/ guard)', () => { + fs.writeFileSync(path.join(tmpDir, '.gitignore'), '/.devflow/\n'); + execSync(`bash -c 'source "${ENSURE_ROOT}" "${tmpDir}"'`, { stdio: 'pipe' }); + + const gitignore = path.join(tmpDir, '.gitignore'); + // Pre-existing /.devflow/ already matches the guard — no bare .devflow/ duplicate added + expect(ignoreLines(gitignore).filter(l => l === '.devflow/')).toHaveLength(0); + expect(fs.readFileSync(gitignore, 'utf-8')).toContain('/.devflow/'); + }); + + it('marker short-circuits subsequent runs (O(1) fast-path) — does not touch .gitignore', () => { + // Pre-seed the marker; the helper must return early and leave .gitignore untouched. + fs.mkdirSync(path.join(tmpDir, '.devflow'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.devflow', '.root-gitignore-configured'), ''); + execSync(`bash -c 'source "${ENSURE_ROOT}" "${tmpDir}"'`, { stdio: 'pipe' }); + + // No .gitignore should have been created because the marker fast-path fired first + expect(fs.existsSync(path.join(tmpDir, '.gitignore'))).toBe(false); + }); + + it('returns non-zero and creates nothing when called with an empty argument', () => { + const result = execSync( + `bash -c 'source "${ENSURE_ROOT}" ""; echo $?'`, + { stdio: 'pipe' }, + ).toString().trim(); + + expect(result).toBe('1'); + expect(fs.existsSync(path.join(tmpDir, '.devflow'))).toBe(false); + }); +}); + describe('get-mtime behavioral', () => { it('returns a valid positive epoch timestamp for a real file', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); @@ -2078,6 +2158,52 @@ describe('session-start-context pending-work directive', () => { }); }); +// ============================================================================= +// session-start-context: memory-independent root .gitignore write (PF-014 fix) +// ============================================================================= +// +// The root .gitignore write must NOT depend on the memory feature toggle. Before +// this fix the only writer (ensure-devflow-init) was reached only behind the memory +// gate, so a project with memory OFF but decisions/knowledge ON never got .devflow/ +// ignored. session-start-context (always-on) now sources ensure-root-gitignore early, +// covering exactly that case. + +describe('session-start-context root .gitignore (memory-independent)', () => { + const CONTEXT_HOOK = path.join(HOOKS_DIR, 'session-start-context'); + + let tmpDir: string; + let homeDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-ctx-ignore-')); + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-ctx-ignore-home-')); + 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('adds .devflow/ to the root .gitignore even when memory is disabled', () => { + // Memory OFF, decisions implicitly ON — the exact gap PF-014 describes. + fs.mkdirSync(path.join(tmpDir, '.devflow', 'dream'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, '.devflow', 'dream', 'config.json'), + JSON.stringify({ memory: false }), + ); + // No root .gitignore present to begin with. + expect(fs.existsSync(path.join(tmpDir, '.gitignore'))).toBe(false); + + runHook(CONTEXT_HOOK, { cwd: tmpDir }, homeDir); + + const gitignore = path.join(tmpDir, '.gitignore'); + expect(fs.existsSync(gitignore)).toBe(true); + expect(fs.readFileSync(gitignore, 'utf-8').split('\n').map(l => l.trim())).toContain('.devflow/'); + expect(fs.existsSync(path.join(tmpDir, '.devflow', '.root-gitignore-configured'))).toBe(true); + }); +}); + // ============================================================================= // dream-capture memory worker spawn (eager refresh — replaces old marker approach) // ============================================================================= From ad325b0ba342b2e253b8d81c6cb37ee0bef936c4 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 21 Jun 2026 12:37:01 +0300 Subject: [PATCH 3/3] docs: document purge-orphaned-dream-commit-hook-v1; drop stale 'committed' ledger wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md: document the new global migration alongside purge-learning-global-v1. - migrations.ts: decisions-ledger-unify-v1 no longer calls the anchored ledger 'committed' — both the ledger and the raw observation log are gitignored under .devflow/ by default (ADR-021). --- CLAUDE.md | 2 +- src/cli/utils/migrations.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6bdab933..8888647e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ Debug logs stored at `~/.devflow/logs/{project-slug}/`. **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 migration `purge-learning-global-v1` removes `~/.devflow/learning.json`. **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). **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 diff --git a/src/cli/utils/migrations.ts b/src/cli/utils/migrations.ts index 379f6f62..ebb6dfe5 100644 --- a/src/cli/utils/migrations.ts +++ b/src/cli/utils/migrations.ts @@ -891,7 +891,8 @@ const MIGRATION_PURGE_TEAMMATE_MODE_PER_PROJECT: Migration<'per-project'> = { /** * Per-project: migrate existing decisions.md + pitfalls.md + decisions-log.jsonl - * to the two-file split layout (committed anchored ledger + gitignored raw log). + * to the two-file split layout (anchored ledger as the render source of truth + + * raw observation log — both gitignored under .devflow/ by default per ADR-021). * * Preserve-verbatim: every existing .md entry body is captured as raw_body and * re-rendered byte-identically (except the TL;DR Key list which is repopulated). @@ -908,7 +909,7 @@ const MIGRATION_PURGE_TEAMMATE_MODE_PER_PROJECT: Migration<'per-project'> = { */ const MIGRATION_DECISIONS_LEDGER_UNIFY: Migration<'per-project'> = { id: 'decisions-ledger-unify-v1', - description: 'Migrate decisions.md + pitfalls.md to two-file split: committed anchored ledger + gitignored raw log', + description: 'Migrate decisions.md + pitfalls.md to two-file split: anchored ledger + raw observation log', scope: 'per-project', async run(ctx: PerProjectMigrationContext): Promise { const { migrateDecisionsLedger } = await import('./decisions-ledger-migration.js');