diff --git a/.devflow/features/dream-capture-system/KNOWLEDGE.md b/.devflow/features/dream-capture-system/KNOWLEDGE.md new file mode 100644 index 00000000..6981b904 --- /dev/null +++ b/.devflow/features/dream-capture-system/KNOWLEDGE.md @@ -0,0 +1,500 @@ +--- +feature: dream-capture-system +name: Dream & Capture System +description: "Use when modifying capture hooks (capture-prompt/capture-turn/capture-question), the memory or dream pending-turns queues, the background-memory-update detached worker, the Dream agent (shared/agents/dream.md), the session-start-context dream directive, or the dream/decisions config toggles. Keywords: capture-prompt, capture-turn, capture-question, queue-append, pending-turns, memory-worker, background-memory-update, Dream agent, dream directive, DREAM MAINTENANCE, DEVFLOW_BG_UPDATER, dream config, dream-lock, json_extract_cwd_field, dream-cleanup, DREAM_MODEL allowlist." +category: architecture +directories: + - scripts/hooks + - shared/agents/dream.md + - src/cli/commands/capture.ts + - src/cli/commands/dream.ts + - src/cli/commands/memory.ts + - src/cli/commands/decisions.ts + - src/cli/utils/decisions-config.ts + - src/cli/utils/dream-cleanup.ts + - src/cli/utils/project-paths.ts + - src/cli/hud/components/decisions-counts.ts +created: 2026-07-03 +updated: 2026-07-04 +--- + +# Dream & Capture System + +## Overview + +A capture-then-process model with two deliberately different processors: three always-on hooks +append conversation turns to two independently-gated JSONL queues; the **memory** queue is +drained by a detached `claude -p` worker (`background-memory-update`) on a 120s throttle, and +the **dream** (decisions) queue is drained by the **Dream agent** — a Claude Code background +subagent that `session-start-context` instructs the main model to spawn whenever the queue has +pending turns. Scripts capture and trigger; the Dream agent does all processing by reading and +editing the data files directly. There are no marker files, no per-session JSON state, no +worker locks on the dream side, and no status/stamp files: the claimed queue batch itself is +the only dream-side state, deleted as the agent's final act. + +## System Context + +**Purpose**: (1) preserve session context across restarts/`/clear`/compaction (working memory) +and (2) detect architectural decisions and pitfalls from conversation turns, rendering them +into `decisions.md`/`pitfalls.md` (decisions pipeline). + +**Role in the larger system**: two of the three per-project background systems under +`.devflow/`. The third — feature knowledge — is write-through and in-command (spawned directly +by orchestrator commands at workflow end), not part of this system; see +`.devflow/features/feature-knowledge-system/KNOWLEDGE.md`. + +**External dependencies**: the `claude` CLI on `PATH` (memory worker only — the Dream agent is +an in-session subagent, not a `claude -p` child); `jq` with a `node` fallback for all JSON +parsing (`_HAS_JQ`/`_JSON_AVAILABLE`, set once by `json-parse`); `git` (only +`session-start-memory`'s drift detection and `background-memory-update`'s stamp gathering +shell out to it). + +## Component Architecture + +**Hooks** (registered in `~/.claude/settings.json`, all always-on — no per-feature +hook-registration toggle): + +| Hook | Event | Registration position | Spawns? | +|---|---|---|---| +| `capture-prompt` | UserPromptSubmit | — | never | +| `capture-turn` | Stop | **before** `memory-worker` | never | +| `capture-question` | PostToolUse (matcher `AskUserQuestion`) | — | never | +| `memory-worker` | Stop | **after** `capture-turn` | `background-memory-update` | +| `session-start-memory` | SessionStart | before `session-start-context` | never | +| `session-start-context` | SessionStart | last | never (emits the Dream spawn **directive**) | +| `pre-compact-memory` | PreCompact | — | never | + +**Processors**: + +| Processor | Kind | Triggered by | Model | Tool surface | +|---|---|---|---|---| +| `background-memory-update` | detached `claude -p` worker (`nohup … & disown`) | `memory-worker` (120s throttle) | `haiku` | `--dangerously-skip-permissions`, `--allowedTools 'Read,Write'` | +| Dream agent (`shared/agents/dream.md`) | Claude Code background subagent | `session-start-context` Section 2 directive → main model spawns `Agent(subagent_type="Dream", run_in_background: true)` | resolved per directive (default `opus`) | frontmatter `tools`: Read, Bash, Write, Edit, Glob, Grep | + +**State** (all under `.devflow/`, none git-tracked — contrast ADR-002): + +| File | Written by | Purpose | +|---|---|---| +| `memory/.pending-turns.jsonl` | capture hooks | memory queue | +| `dream/.pending-turns.jsonl` | capture hooks | decisions queue | +| `dream/.pending-turns.processing` | Dream agent (atomic `mv` claim) | claimed batch — deleted as the agent's final act; mtime is the live/crashed discriminator (900s) | +| `dream/config.json` | `dream-config.ts` (`updateFeature`) | shared toggle: `memory`, `decisions`, `knowledge` | +| `decisions/decisions.json` / `~/.devflow/decisions.json` | `devflow decisions --configure` | Dream agent tuning: `model`, `debug` only | +| `memory/WORKING-MEMORY.md` | `background-memory-update` | rendered working memory | +| `decisions/decisions.md` / `pitfalls.md` | `render-decisions.cjs` (via `assign-anchor`/`retire-anchor`) | rendered ledger output | +| `memory/.last-refresh-ok` | memory worker, on success | memory success stamp (the dream side has no stamp — the deleted `.processing` IS success) | + +## Component Interactions + +### 1. Dual-append (capture-prompt / capture-turn / capture-question) + +All three capture hooks share one shape: resolve `PROJECT_ROOT` (via `resolve-project-root`, +falling back to `CWD`), truncate content, then call exactly two `queue-append` functions: + +- `queue_read_gates "$DREAM_DIR/config.json"` — **one config-read subprocess fork** returning + both `_QG_MEMORY` and `_QG_DECISIONS` (AC-P1). The gate is config-only (mirrors memory's + ADR-001): missing config file defaults both to `"true"`. +- `queue_append_both + ` — appends the SAME row to whichever queue(s) are enabled, independently. + +Row schema is always `{role, content, ts}`: `role` ∈ `user` (capture-prompt) | `assistant` +(capture-turn) | `qa` (capture-question, one row per answered question). Truncation: +prompts/assistant messages cap at 2000 chars; a `qa` row's question and answer are each capped +at 1000 chars **independently**, so a long question can't swallow the answer. Every queue file +is created with mode `0600` (`umask 077`) on first write. After each append, +`queue_append_row` checks line count and — only when it exceeds 200 — truncates to the newest +100 lines under `dream_lock_acquire ".lock" 2` (the *generic* `dream-lock` helper, 30s +stale-break — not the memory worker's own bespoke lock, see Gotchas). + +`capture-turn` also runs the decisions-usage scanner (`decisions-usage-scan.cjs`) directly, +gated only by a grep for `ADR-[0-9]+|PF-[0-9]+` in the assistant message (cheap pre-filter) AND +`DECISIONS_ENABLED` — independent of whether anything gets queued. The scanner itself has no +gate of its own; the caller gates. + +**Stop-array ordering contract**: `capture-turn` MUST be registered before `memory-worker` in +the Stop array. Nothing in either script enforces this — it is enforced entirely by `init.ts`'s +call order (`addCaptureHooks` before `addMemoryHooks`). `memory-worker`'s throttle/spawn +decision assumes the current turn was already appended earlier in the same Stop event +(append-before-spawn); reversing the array order would spawn the worker one turn behind. + +**Shared cwd+field extraction (`json_extract_cwd_field`)**: `capture-turn` no longer inlines its +own jq/node two-field split. The single home of the `cwd`-plus-arbitrary-field extraction is +`json_extract_cwd_field ` in `scripts/hooks/json-parse`, backed by the `extract-cwd-field` +node op in `json-helper.cjs` for the no-jq fallback. Both branches delimit the two output values +with ASCII SOH (0x01) — written only as the jq `` escape or the node `'\x01'` literal, **never +as a literal control byte in source** (an invariant enforced during review: grep for a raw SOH +byte across every hook source must come up empty). Callers split with bash-3.2-safe parameter +expansion: `CWD="${_FIELDS%%$'\001'*}"`, `VALUE="${_FIELDS#*$'\001'}"`. + +`json_extract_cwd_prompt` (extracting `cwd` + `prompt`) is now a **thin delegating wrapper** +around `json_extract_cwd_field "prompt"` — it exists only because `capture-prompt` and +`preamble` are its two call sites and a named function reads better than repeating the field +name at each call site. `capture-turn` calls `json_extract_cwd_field "last_assistant_message"` +directly since its field name differs from the wrapper's hardcoded `"prompt"`. + +### 2. memory-worker → background-memory-update + +`memory-worker` owns ONLY the throttle + spawn decision — it never touches a queue file +itself. Throttle key: `.working-memory-last-trigger` mtime, 120s window. It touches the trigger +file **before** spawning (not after) so a second concurrent Stop hook within the same window +sees a fresh trigger and exits without double-spawning. If `claude` isn't on `PATH` or the +worker binary is missing/non-executable, it logs and exits 0 — the queue is left intact for the +next Stop event to retry. Note this hook does **not** check whether the queue is actually +non-empty before spawning; it spawns unconditionally once the throttle clears, and lets the +worker itself no-op cheaply (contrast the dream directive, which gates on queue content). + +`background-memory-update`'s lifecycle, in order: +1. Re-entrancy guard (`DEVFLOW_BG_UPDATER`) first, then re-check `memory:false` at runtime + (defense-in-depth — the feature may have been disabled after `memory-worker` already + decided to spawn). +2. Acquire `.working-memory.lock` — 300s stale-break, 90s acquire timeout. 90s is deliberately + **less than** `WATCHDOG_SECS` (120): a waiter gives up before the current holder's own + watchdog would fire, so the queue is simply left for the next spawn rather than blocking. +3. **Orphan-only auto-clean**: if the queue has no `assistant`/`qa` row (user-only), truncate it + and exit without an LLM run — prevents fabrication from a queue with nothing to synthesize. +4. Claim the queue: rename `.pending-turns.jsonl` → `.pending-turns.processing`. If a leftover + `.processing` already exists (previous crash), merge new queue entries into it and cap at + 200→100 lines. +5. Build the last `MAX_TURNS=10` (20 lines) of context, read up to `65536` bytes of existing + `WORKING-MEMORY.md`, gather git state. +6. Spawn `claude -p --model haiku --dangerously-skip-permissions --allowedTools 'Read,Write'` + with the prompt on stdin (never argv — content may hold secrets), under a + `WATCHDOG_SECS=120` (default) + 5s kill-grace watchdog. +7. Verify success: `WORKING-MEMORY.md` mtime strictly newer than the pre-run baseline **AND** + its first line matches `/\1/p'`) as +Section 1 and the Dream maintenance directive as Section 2 — both gated on the same +`decisions` config field the capture hooks read. + +`session-start-memory` renders a 3-state header from the +`` stamp on line 1 of `WORKING-MEMORY.md`: **A** +in-sync (stamp SHA == HEAD), **B** drifted (stamp SHA is a provable ancestor of HEAD — shown via +one `git log` walk reused for both the commit count and the display lines), **C** +refresh-failing banner (queue depth > 0 AND `.last-refresh-ok` missing or >600s old), shown *in +addition to* A or B. Before ever passing the parsed `STAMP_SHA` to git, it is hex-validated +(7-40 lowercase-hex chars, rejecting anything with a `-`-prefix or non-hex character) — +`WORKING-MEMORY.md` is treated as untrusted input. It also runs a self-contained **D56c +cold-path recovery**: an orphaned memory `.pending-turns.processing` older than 300s is renamed +back to `.pending-turns.jsonl`, but only if the live queue file doesn't already exist +(non-clobber — a concurrent session's fresh queue is never overwritten). +`background-memory-update` is the PRIMARY recovery owner (merges leftovers on its own next +spawn); this is only the fallback for when the worker never respawns at all. The dream side +needs no equivalent cold path: the Dream agent's own stale-merge in Step 0 is the recovery. + +### 6. HUD decisions/pitfalls counts (src/cli/hud/components/decisions-counts.ts) + +`gatherDecisionsCounts` reads `decisions-ledger.jsonl` directly (the render source of truth, not +the rendered `.md` files) and counts active rows by type, mirroring `render-decisions.cjs`'s own +`isActive()` exactly: a row counts when `anchor_id` is set and `decisions_status` is either +absent or outside `INACTIVE_STATUSES` (`Deprecated`, `Superseded`, `Retired`). This mirror is no +longer an unenforced convention — `tests/hud-decisions-counts.test.ts` `require()`s +`scripts/hooks/lib/render-decisions.cjs`'s exported `isActive()` directly and asserts the TS and +CJS implementations agree across the **full** `decisions_status` matrix (every known status +value plus absent/undefined). If either side's status-set changes without the other, this test +fails — the mirror is a pinned contract, not a documentation claim. + +## Integration Patterns + +**Re-entrancy guard convention**: every hook in this system AND the memory worker check +`DEVFLOW_BG_UPDATER` first — before `hook-bootstrap` is even sourced, to minimize overhead +inside a background session. The memory worker sets `DEVFLOW_BG_UPDATER=1` on its `claude -p` +child so hooks firing from *within* that nested session bail out in one line instead of +cascading a second spawn (or handing the nested session a Dream directive). The Dream agent +needs no guard variable: it is a subagent of the main session, and subagents do not fire +SessionStart/Stop hooks of their own. + +**Two independent config layers**: `dream/config.json` (boolean feature toggles: `memory`, +`decisions`, `knowledge` — read fresh by every hook on every invocation) vs. `decisions.json` +(Dream agent tuning: `model`, `debug` — read by `session-start-context`'s model resolution and +`devflow decisions --configure`). Conflating the two is a common mistake — toggling a feature +never touches `model`/`debug`, and configuring the model never touches the boolean gates. + +**CLI enable/disable**: both `memory.ts` and `decisions.ts` write config only. Hooks are never +removed by a single-feature disable because they are shared plumbing across features. +`decisions --disable` additionally drains the dream queue + `.processing` unconditionally — a +mid-run Dream agent whose claimed batch vanishes aborts without changes, which is the desired +outcome of disabling. + +**`decisions.ts` is a thin router over named handlers**: the command's `.action` callback only +dispatches — `handleStatus`, `handleList`, `handleConfigure`, `handleReset`, `handleClear`, +`handleEnable`, `handleDisable` each own their full path resolution and I/O. The four +state-mutating handlers share a `requireGitRoot(actionSuffix)` guard, resolving the git root once +instead of each handler re-implementing the check. `--status`/`--list` now also resolve the +decisions log via `getGitRoot()` (cwd fallback only outside a git repo) — previously they +resolved from `process.cwd()` unconditionally, diverging from `--clear`/`--reset`/`--disable` +when invoked from a subdirectory. + +**`dream-cleanup.ts` centralizes the two dream-side cleanup predicates**: `sweepLegacyDreamMarkers +(dreamDir)` (fixed stamps `.decisions-runs-today`/`.curation-last`/`.processor-spawned-at` plus +per-session `decisions.*`/`curation.*` files) is shared by `devflow decisions --reset` and the +`purge-dream-marker-pipeline-v1` migration. `drainDreamQueue(gitRoot)` (ENOENT-tolerant unlink of +both queue files) is shared by `devflow decisions --clear` and `--disable` — each predicate now +has exactly one implementation, imported by both consumers, instead of hand-copied duplicates. + +**Array-order contracts are enforced entirely by `init.ts`**: `capture-turn`/`memory-worker` +ordering (append-before-spawn) exists only because `addCaptureHooks` runs before +`addMemoryHooks` in `init.ts`'s single read-modify-write pass over `settings.json`. Reordering +those calls silently breaks the contract with no runtime error. + +## Constraints + +- **No argv content, ever**: user/assistant/turn content flows only via `claude -p`'s stdin in + the memory worker, and the Dream directive's prompt is a path-only pointer — `ps(1)` can see + argv system-wide, so content there would leak. The Dream agent reads the claimed batch itself + via its Read tool. +- **Silent debug logging**: `dbg()` calls in `capture-turn`/`capture-question` explicitly never + log message content (only lengths) — "SECURITY: Never log ASSISTANT_MSG or INPUT content — + may contain secrets." +- **No daily/throttle cap on the dream side**: `DecisionsConfig` has no + `max_daily_runs`/`throttle_minutes` (a legacy config with those keys is silently ignored). + Processing is bounded by queue non-emptiness at directive time. +- **Memory lock duration is watchdog-derived, not arbitrary**: the worker's lock stale-threshold + (300s) exceeds its watchdog's total in-flight time (`WATCHDOG_SECS + WATCHDOG_KILL_GRACE_SECS` + = 125s) with margin. The dream side's equivalent constant is the shared 900s `.processing` + staleness threshold used by both the hook (suppress) and the agent (exit-vs-merge) — change it + in both places or the discriminator desyncs. +- **No literal SOH bytes in hook source**: the ASCII SOH (0x01) delimiter used by + `json_extract_cwd_field` must only appear as the jq `` escape or the node `'\x01'` literal — + never a raw control byte pasted into source. Checked by review, not an automated test. + +## Anti-Patterns + +- **Wrapping `assign-anchor`/`retire-anchor`/`rotate-observations` in your own lock + acquisition**: all three self-lock internally; an external lock around them nests against the + internal one and times out. +- **Whole-file rewrites of `decisions-log.jsonl`**: the Dream agent appends or Edit-replaces one + JSONL row at a time — a whole-file rewrite races the capture-side `decisions-usage-scan` and + any concurrent op's atomic log update. +- **Hand-editing `decisions.md`/`pitfalls.md`**: deterministically rendered from + `decisions-ledger.jsonl` by `render-decisions.cjs`; a manual edit is silently overwritten on the + next `assign-anchor`/`retire-anchor` call. +- **Adding a throttle, lock, or status file to the dream side**: the design intentionally has + none — queue emptiness gates the directive, the atomic `mv` settles races, `.processing` + mtime discriminates live from crashed, and the agent's final message is the visibility + surface. New state files here are machinery regression. +- **Passing raw queue content into the directive or worker argv**: paths only — the processor + reads content itself. +- **Re-implementing the cwd+field split inline in a new capture hook**: any new hook that needs + `cwd` plus one other top-level field should call `json_extract_cwd_field ` (or the + `json_extract_cwd_prompt` wrapper if the field is `prompt`) rather than hand-rolling a new + jq/node two-value split — that was the exact duplication this pass removed from `capture-turn`. +- **Interpolating `DREAM_MODEL` (or any config-sourced string) into a directive without an + allowlist**: `session-start-context` validates against the `opus|sonnet|haiku` `case` allowlist + before use; a new call site that skips this check reopens the injection risk it closes. + +## Gotchas + +- **Two distinct lock mechanisms, not one**: the generic `dream-lock` helper + (`dream_lock_acquire`/`dream_lock_release`, 30s stale-break) is used only by `queue-append`'s + overflow-truncation path. The memory worker defines its OWN inline + `break_stale_lock`/`acquire_lock` pair (300s stale-break) tailored to its watchdog. Don't + assume the 30s generic threshold applies to `.working-memory.lock`. The dream side has no + lock at all — the `.processing` claim file plays that role. +- **`dream/config.json` is shared, multi-feature state**: `memory`, `decisions`, and `knowledge` + all live in the same file. Any code that writes it (`updateFeature` in `dream-config.ts`) must + read-modify-write, preserving keys it doesn't own — a naive overwrite silently disables + sibling features. The read-modify-write is intentionally non-atomic (accepted: CLI toggles are + single-threaded, human-paced actions). +- **Hooks snapshot at session start**: registering a hook for the FIRST time only takes effect + for a NEW Claude Code session — the running session already loaded its hook list from + `settings.json`. Toggling an ALREADY-REGISTERED hook's feature takes effect on the very next + invocation within the same session, since every hook script re-reads `dream/config.json` fresh. +- **Directive spawn depends on model compliance**: the hook only *asks* the main model to spawn + the Dream agent. A model that skips the spawn delays processing to the next session — the + queue persists (bounded by 200→100 overflow truncation), so nothing is lost, but nothing is + processed either — an accepted trade against the deleted worker machinery. +- **`claude -p` sessions receive the directive too**: SessionStart hooks fire in non-interactive + sessions (except the memory worker's own, excluded by `DEVFLOW_BG_UPDATER`). An unrelated + `claude -p` run may receive — and may or may not act on — the directive. Accepted-class exposure. +- **The project-root prompt**: the directive's prompt carries `Project root: ` because a + session can start in a subdirectory; the agent runs commands from that root so `$(pwd)`-relative + op invocations (`render-decisions.cjs render "$(pwd)"`) resolve correctly. +- **Accepted append-vs-claim race**: `queue_append_row`'s overflow truncation is read-then-replace + (`tail -100 file > tmp && mv tmp file`), not an in-place lock-held write — a lock-free + concurrent append landing between the `tail` snapshot and the `mv` can be silently dropped. The + guarantee that actually holds is "the file is never corrupted", not "no data is ever lost." The + same acceptance covers a double stale-reclaim: two sessions starting >900s after a crash can + both re-claim the same batch; dedup rules and `assign-anchor`'s preconditions bound the damage + to redundant analysis. +- **AskUserQuestion fixtures are empirically pinned, not invented**: `capture-question`'s parser + was built against real payload samples mined from `~/.claude/projects` (see + `tests/capture-hooks.test.ts`) — no genuine cancelled/interrupted or free-text ("Other") sample + could be found despite an exhaustive search. The one real errored sample has `tool_response` as + a **plain string** (`"InputValidationError: [...]"`), not an object. Handling contract: any + non-object `tool_response`, and any cancelled/absent shape, degrades to "zero rows, exit 0" + rather than guessing at an unobserved shape. +- **Memory/dream file isolation is a hard invariant**: `cleanQueueFiles` + (`devflow memory --clear`) touches only `getPendingTurnsPath`/`getPendingTurnsProcessingPath` + under `memory/`, and skips a project entirely while `.working-memory.lock` is held. + `devflow decisions --clear`/`--reset` resolve the git root explicitly and drain only the + dream queue + decisions state (via the shared `drainDreamQueue`/`sweepLegacyDreamMarkers` + helpers in `dream-cleanup.ts`). Neither command's cleanup logic ever crosses into the sibling + feature's files. +- **The `opus` default is duplicated by design, in two languages**: `decisions-config.ts`'s + `DEFAULTS.model` and `session-start-context`'s bash `DREAM_MODEL` resolution both implement the + same project→global→`"opus"` precedence independently (the hook reads raw JSON directly rather + than shelling out to the TS loader). Changing one default without the other silently desyncs + the CLI-reported default from the directive's actual behavior — and the bash side additionally + allowlist-validates the resolved value before interpolating it, a check the TS loader has no + equivalent for. +- **`json_extract_cwd_field` is the one place both jq and node branches must stay in lockstep**: + changing the delimiter or field-defaulting behavior in one branch without the other + reintroduces the jq/node divergence this extraction was meant to eliminate. Both + `capture-turn`'s `last_assistant_message` field and `capture-prompt`/`preamble`'s `prompt` + field (via `json_extract_cwd_prompt`) go through this single function now. + +## Key Files + +- `scripts/hooks/capture-prompt`, `capture-turn`, `capture-question` — the three always-on + capture hooks; share the truncate → `queue_read_gates` → `queue_append_both` shape. + `capture-prompt`/`capture-turn` also share `json_extract_cwd_field`/`json_extract_cwd_prompt`. +- `scripts/hooks/json-parse` — sources `_HAS_JQ`/`_JSON_AVAILABLE` and every `json_*` helper, + including `json_extract_cwd_field` (single home of the SOH delimiter) and its wrapper +- `scripts/hooks/json-helper.cjs` — node fallback for every `json_*` op, including + `extract-cwd-field` backing `json_extract_cwd_field` when `jq` is unavailable +- `scripts/hooks/queue-append` — shared helper: `queue_append_row`, `queue_append_both`, + `queue_read_gates` (single-fork config read) +- `scripts/hooks/memory-worker` — Stop-hook 120s throttle + touch-before-spawn + + `background-memory-update` spawn +- `scripts/hooks/background-memory-update` — detached memory-refresh worker (haiku, + skip-permissions, 300s/90s lock, 120s watchdog) +- `scripts/hooks/session-start-context` — SessionStart injection: decisions TL;DR (Section 1) + + Dream maintenance directive with model resolution + `opus|sonnet|haiku` allowlist validation + (Section 2) +- `shared/agents/dream.md` — the Dream agent: claim protocol, detection bar, curation bounds, + consume-then-delete finishing +- `scripts/hooks/session-start-memory` — 3-state memory header + D56c cold path +- `scripts/hooks/dream-lock` — generic mkdir-based lock (30s stale-break); used only by + `queue-append`, NOT by the memory worker's own bespoke lock +- `src/cli/commands/capture.ts` — hook (de)registration for the always-on capture bundle +- `src/cli/commands/dream.ts` — `removeDreamHook`/`hasDreamHook` upgrade cleanup of the retired + spawn-dream-worker settings entry +- `src/cli/commands/memory.ts`, `decisions.ts` — CLI toggles, `cleanQueueFiles`; `decisions.ts`'s + `.action` is a thin router to `handleStatus`/`handleList`/`handleConfigure`/`handleReset`/`handleClear`/`handleEnable`/`handleDisable`, sharing the `requireGitRoot` guard +- `src/cli/utils/dream-cleanup.ts` — `sweepLegacyDreamMarkers` (shared by `decisions --reset` and + `purge-dream-marker-pipeline-v1`) and `drainDreamQueue` (shared by `--clear`/`--disable`) +- `src/cli/utils/decisions-config.ts` — TS `DecisionsConfig` loader (`model`, `debug` only) +- `src/cli/utils/project-paths.ts` — single source of truth for every path referenced above; has + a required CJS mirror at `scripts/hooks/lib/project-paths.cjs` +- `src/cli/hud/components/decisions-counts.ts` — HUD counts component; active-row semantics + contract-tested against `scripts/hooks/lib/render-decisions.cjs`'s `isActive()` +- `tests/config-disable-guards.test.ts` (renamed from `tests/sentinel.test.ts`) — guards on the + config-only disable contract across memory/decisions +- `tests/hud-decisions-counts.test.ts` — pins HUD/CJS `isActive()` agreement across the full + `decisions_status` matrix + +## Related + +- `.devflow/features/feature-knowledge-system/KNOWLEDGE.md` — sibling `.devflow/` persistence + layer; contrast its write-through/in-command model against this system's queue + background processors. +- ADR-001 — the config-only gate (no sentinel files) and `purge-dream-worker-state-v1` follow + the clean-break precedent ADR-001 established. +- ADR-002 — contrast: unlike `.devflow/features/`, none of `.devflow/memory/`, `.devflow/dream/`, + or `.devflow/decisions/` are git-tracked; every file here stays local and gitignored. +- ADR-003 — this knowledge base documents the current end state only, per ADR-003. +- `docs/working-memory.md`, `docs/reference/file-organization.md` — user-facing docs for the + same architecture; consistent source for the file-tree summary above. diff --git a/.devflow/features/index.md b/.devflow/features/index.md index 101e8cab..2e411665 100644 --- a/.devflow/features/index.md +++ b/.devflow/features/index.md @@ -1 +1,2 @@ - **feature-knowledge-system** — commands/_partials, src/cli/commands/knowledge, shared/skills/feature-knowledge, shared/skills/apply-feature-knowledge, shared/agents/knowledge.md, scripts/build-mds.ts — Use when adding a new knowledge base entry, modifying how knowledge is loaded into agents, changing the write-through save model, extending the CLI knowledge commands, or understanding the MDS knowledge module. +- **dream-capture-system** — scripts/hooks, shared/agents/dream.md, src/cli/commands/capture.ts, src/cli/commands/dream.ts, src/cli/commands/memory.ts, src/cli/commands/decisions.ts, src/cli/utils/decisions-config.ts, src/cli/utils/dream-cleanup.ts, src/cli/utils/project-paths.ts, src/cli/hud/components/decisions-counts.ts — Use when modifying capture hooks (capture-prompt/capture-turn/capture-question), the memory or dream pending-turns queues, the background-memory-update detached worker, the Dream agent (shared/agents/dream.md), the session-start-context dream directive, or the dream/decisions config toggles. Keywords: capture-prompt, capture-turn, capture-question, queue-append, pending-turns, memory-worker, background-memory-update, Dream agent, dream directive, DREAM MAINTENANCE, DEVFLOW_BG_UPDATER, dream config, dream-lock, json_extract_cwd_field, dream-cleanup, DREAM_MODEL allowlist. diff --git a/CLAUDE.md b/CLAUDE.md index afc176b9..a5e60c1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,45 +41,45 @@ Plugin marketplace with 22 plugins (12 core + 9 optional language/ecosystem + 1 **Build-time asset distribution**: Skills and agents are stored once in `shared/skills/` and `shared/agents/`, then copied to each plugin at build time based on `plugin.json` manifests. This eliminates duplication in git. -**LLM-vs-plumbing principle**: The LLM does all detection, semantic matching, materialization, and curation. Deterministic code is plumbing only: hooks, locks, throttles, file I/O, id-keyed JSONL records, `assign-anchor`/`retire-anchor` ledger numbering, `render-decisions` rendering, and `merge-observation` writes. No detection or judgment logic lives in shell or TypeScript. +**LLM-vs-plumbing principle**: The LLM does all detection, semantic matching, materialization, and curation — and reads/edits the data files directly. Deterministic code is plumbing only: hooks, locks, throttles, file I/O, `assign-anchor`/`retire-anchor` ledger numbering, `render-decisions` rendering, and `rotate-observations` archival. No detection or judgment logic lives in shell or TypeScript. -**Working Memory**: Three shell-script hooks (`scripts/hooks/`) replace the old 8-hook system with a background-maintenance (Dream) architecture. Toggleable via `devflow memory --enable/--disable/--status` or `devflow init --memory/--no-memory`. Feature state is stored in `.devflow/dream/config.json` (config-only; dream config is the sole source of truth per ADR-001). `dream-capture` (Stop hook) — captures user/assistant turns to `.devflow/memory/.pending-turns.jsonl` queue; after the 120s throttle (keyed by `.working-memory-last-trigger` mtime), spawns `background-memory-update` as a detached `nohup` worker (`claude -p --model haiku`); touches `.working-memory-last-trigger` BEFORE spawning to prevent double-spawn; uses mkdir-based locking for queue overflow truncation across concurrent sessions. `background-memory-update` (Stop-hook worker, not a hook itself) — drains `.pending-turns.jsonl`, calls `claude -p` (prompt on stdin, never argv), rewrites `WORKING-MEMORY.md` with `` on line 1, touches `.last-refresh-ok` on success; holds a 300s-stale worker lock; user-only queue truncated without LLM run. `dream-dispatch` (UserPromptSubmit hook) — **capture-only**: appends the user turn to `.pending-turns.jsonl`; it does NOT emit any directive. `dream-evaluate` (SessionEnd hook) — orchestrator that sources `eval-helpers` + 2 feature modules (`eval-decisions`, `eval-curation`) after shared setup; each module uses `${VAR:?}` fail-fast guards and `_MODULENAME_` variable prefixes for namespace isolation; evaluates whether to write decisions or curation dream markers; writes per-session marker files using atomic temp+mv; uses mkdir-based locking (`dream-lock`) to serialize operations across concurrent sessions. Always-on SessionStart hook (`session-start-context`) — recovers stale `.processing` markers (via `dream-recover` → `dream_recover_stale`), collects pending markers (via `dream-collect-tasks`), and emits a **DREAM MAINTENANCE** directive instructing the main model to spawn background `Dream` agents; directive emission is throttled to 120s. `dream-collect-tasks` unconditionally deletes orphaned `learning.*` and `memory.*` markers (both pipelines removed from Dream subagent). The Dream agent processes decisions and curation only — memory is NOT a Dream task. SessionStart hook (`session-start-memory`) → injects previous memory with git-reconciled header (3-state: A in-sync / B drifted / C refresh-failing) + optional pre-compact snapshot as `additionalContext`; stamp `` on line 1 drives drift detection; no raw-turns dump. PreCompact hook → saves git state + WORKING-MEMORY.md snapshot. Memory sections: `## Now`, `## Progress`, `## Decisions`, `## Context`, `## Session Log`. The background-memory-update worker uses rename-to-claim for queue consumption (atomically renames `.pending-turns.jsonl` → `.pending-turns.processing`). Disabling memory writes `memory: false` to dream config — hooks remain registered (shared across features). `removeMemoryHooks` (used by `devflow init --no-memory`) also removes pre-dream legacy hooks. Use `devflow memory --clear` to clean up pending queue files across projects. Zero-ceremony context preservation. +**Working Memory**: A capture/spawn split across always-on hooks in `scripts/hooks/`. Toggleable via `devflow memory --enable/--disable/--status` or `devflow init --memory/--no-memory`. Feature state is stored in `.devflow/dream/config.json` (config-only; dream config is the sole source of truth per ADR-001). `capture-prompt` (UserPromptSubmit, always-on) and `capture-turn` (Stop, always-on) — append the user/assistant turn to `.devflow/memory/.pending-turns.jsonl` via the shared `queue-append` helper (dual-write; see Decisions pipeline for the sibling dream queue), which uses mkdir-based locking for queue overflow truncation across concurrent sessions; each queue is gated independently by dream config; neither ever spawns anything. `memory-worker` (Stop, registered immediately after `capture-turn` so append-before-spawn ordering holds by array position) — after the 120s throttle (keyed by `.working-memory-last-trigger` mtime), touches the trigger then spawns `background-memory-update` as a detached `nohup` worker (`claude -p --model haiku`). `background-memory-update` (detached worker, not a hook itself) — drains `.pending-turns.jsonl`, calls `claude -p` (prompt on stdin, never argv), rewrites `WORKING-MEMORY.md` with `` on line 1, touches `.last-refresh-ok` on success; holds a 300s-stale worker lock; user-only queue truncated without LLM run. `session-start-memory` (SessionStart) → injects previous memory with git-reconciled header (3-state: A in-sync / B drifted / C refresh-failing) + optional pre-compact snapshot as `additionalContext`; stamp `` on line 1 drives drift detection; also recovers a stale orphaned `.pending-turns.processing` itself (self-contained cold path, no external helper). PreCompact hook → saves git state + WORKING-MEMORY.md snapshot. Memory sections: `## Now`, `## Progress`, `## Decisions`, `## Context`, `## Session Log`. The background-memory-update worker uses rename-to-claim for queue consumption (atomically renames `.pending-turns.jsonl` → `.pending-turns.processing`). Disabling memory writes `memory: false` to dream config — hooks remain registered (shared across features). `removeMemoryHooks` (used by `devflow init --no-memory`) also removes legacy hooks from prior architectures. Use `devflow memory --clear` to clean up pending queue files across projects. Zero-ceremony context preservation. **Ambient Mode**: Single-component system for zero-overhead session enhancement. UserPromptSubmit hook (`preamble`) uses two coexisting detection paths, both controlled by the same single toggle (`devflow ambient --enable/--disable/--status` or `devflow init`). **First-word keyword detection** — when a prompt's first word (case-insensitive) is one of `implement`, `explore`, `research`, `debug`, or `plan`, followed by at least one additional word, the hook outputs a directive instructing the model to briefly announce the workflow then invoke the matching `devflow:` skill via the Skill tool. **3-marker plan detection** — when a prompt contains `## Goal`, `## Steps`, and `## Files` markers (and the keyword path did not fire), it outputs a directive to invoke `devflow:implement`. Zero overhead for normal prompts — hook outputs nothing. Any legacy `commands.md` rule left by prior installs is auto-removed on every `devflow ambient --enable/--disable` or `devflow init`. -**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`. +**Decisions pipeline** (directive-spawned background Dream agent — scripts capture and trigger only): `capture-prompt`/`capture-turn`/`capture-question` (all always-on) append every user turn, assistant turn, and answered `AskUserQuestion` to `.devflow/dream/.pending-turns.jsonl`, gated by the `decisions` field in dream config (config-only, mirroring memory's ADR-001). `session-start-context` Section 2 (SessionStart, always-on) — when the dream queue is non-empty, or a crashed run left a `.pending-turns.processing` batch older than 900s, it resolves the model (project `decisions.json` → global `~/.devflow/decisions.json` → `opus` default) and emits a `--- DREAM MAINTENANCE ---` directive instructing the main model to spawn `Agent(subagent_type="Dream", model=, run_in_background: true)`; a fresh `.processing` suppresses the directive (a live agent owns the batch); queue emptiness is the natural gate, so there is no throttle. The **Dream agent** (`shared/agents/dream.md`, opus, self-contained) claims the queue itself (atomic `mv` → `.processing`; merges a stale leftover and re-claims it; exits silently if the claim is lost; heartbeat `touch` at the detection→curation boundary), reads `decisions-log.jsonl`/`decisions.md`/`pitfalls.md`/`.decisions-usage.json` directly, appends/edits observations in the log directly (one JSONL row at a time, never whole-file rewrites), and calls only the ledger ops via its Bash tool: **decision**/**pitfall** detection 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) and periodic curation via `retire-anchor` (flips `decisions_status`, never deletes) plus `rotate-observations`. Raw observations accumulate in the gitignored `.devflow/decisions/decisions-log.jsonl` (rotated to `decisions-log.archive.jsonl`). No deterministic thresholds or confidence formulas — the LLM determines whether an observation warrants a new entry or should be reinforced into an existing one. The agent deletes `.processing` as its final act (consume-then-delete; a crash leaves the batch for the next session's stale-merge recovery) and ends with a 1–3 line summary — native background-task visibility, no status files. Global config: `~/.devflow/decisions.json`. Project config: `.devflow/decisions/decisions.json` (`model` and `debug` only — no daily-run cap or throttle). `devflow decisions --disable` flips the config field and drains `.devflow/dream/.pending-turns.jsonl`/`.pending-turns.processing` unconditionally (a mid-run agent whose files vanish aborts without changes — the desired outcome of disabling; mirrors memory.ts's disable-drain). 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` (both resolve the git root explicitly). Debug logs stored at `~/.devflow/logs/{project-slug}/`. **Debug Tracing**: Single global toggle covering all hooks. Enabled via `devflow debug --enable/--disable/--status` CLI or by setting `DEVFLOW_HOOK_DEBUG=1` in `~/.claude/settings.json` env block (survives reinstalls). All hooks share the `scripts/hooks/debug-trace` helper script (sourced via `hook-bootstrap`) so tracing behavior is consistent and updated in one place. Two-phase logging: pre-CWD traces go to global `~/.devflow/logs/.hook-debug.log`; post-CWD traces go to per-project `~/.devflow/logs/{project-slug}/.hook-debug.log`. A 5MB size guard prevents unbounded growth. applies ADR-007 -**Claude Code Flags**: Typed registry (`src/cli/utils/flags.ts`) for managing Claude Code feature flags (env vars and top-level settings). Pure functions `applyFlags`/`stripFlags`/`getDefaultFlags` follow the `applyViewMode`/`stripViewMode` pattern. Flags (18 total): default ON — `tui`, `tool-search`, `lsp`, `prompt-caching-1h`, `show-turn-duration`, `clear-context-on-plan`; default OFF — `brief`, `thinking-summaries`, `subprocess-env-scrub`, `disable-nonessential-traffic`, `forked-subagents`, `disable-adaptive-thinking`, `always-thinking`, `disable-git-instructions`, `disable-compact`, `disable-1m-context`, `disable-autoupdater`, `agent-teams`. Manageable via `devflow flags --enable/--disable/--status/--list`. Stored in manifest `features.flags: string[]`. View mode (`default`/`verbose`/`focus`) stored in manifest `features.viewMode?: string` and applied to `settings.json` as the `viewMode` key; `applyViewMode`/`stripViewMode` utilities colocated in `flags.ts`. +**Claude Code Flags**: Typed registry (`src/cli/utils/flags.ts`) for managing Claude Code feature flags (env vars and top-level settings). Pure functions `applyFlags`/`stripFlags`/`getDefaultFlags` follow the `applyViewMode`/`stripViewMode` pattern. Flags (21 total): default ON — `tui`, `tool-search`, `lsp`, `prompt-caching-1h`, `show-turn-duration`, `clear-context-on-plan`, `disable-bundled-skills`, `pin-sonnet-4-6`, `disable-mouse-clicks`; default OFF — `brief`, `thinking-summaries`, `subprocess-env-scrub`, `disable-nonessential-traffic`, `forked-subagents`, `disable-adaptive-thinking`, `always-thinking`, `disable-git-instructions`, `disable-compact`, `disable-1m-context`, `disable-autoupdater`, `agent-teams`. Manageable via `devflow flags --enable/--disable/--status/--list`. Stored in manifest `features.flags: string[]`. View mode (`default`/`verbose`/`focus`) stored in manifest `features.viewMode?: string` and applied to `settings.json` as the `viewMode` key; `applyViewMode`/`stripViewMode` utilities colocated in `flags.ts`. **Feature Knowledge Bases**: Per-feature `.devflow/features/` directory containing KNOWLEDGE.md files that capture area-specific patterns, conventions, architecture, and gotchas. Uses a **write-through** model: load = direct file-I/O reading `.devflow/features/index.md` (regenerable cache) with frontmatter-glob fallback over `features/*/KNOWLEDGE.md` (source of truth) + verify-against-code on read; save = in-command write-through via a simplified Knowledge agent that writes `KNOWLEDGE.md` + the `index.md` line directly (no `.create-result.json`, no external scripts, no lock). **Git-tracked & shared (amends ADR-021 for `features/`)**: the root `.gitignore` carve-out (`.devflow/*` + level-by-level `!` re-includes, written byte-identically by `ensure-root-gitignore` / `ensureDevflowGitignore`) un-ignores `.devflow/features/index.md` + every `{slug}/KNOWLEDGE.md` while the rest of `.devflow/` stays local; after writing, the **Knowledge agent commits those two paths to the current worktree branch itself** by running git via its Bash tool (scoped `commit --only` pathspec, never `git add -A`, **never push, never force**, no commit script — per the LLM-vs-plumbing principle the commit is the agent's, not a deterministic helper). A user opts back out by re-adding `.devflow/features/` to their own `.gitignore`. Existing installs upgrade once via the versioned `.root-gitignore-configured-v2` marker. Freshness = write-through + verify-on-read (NO git-staleness, NO SessionEnd eval, NO Dream task). `index.md` line format: `- **{slug}** — {areas} — {Use-when description}`; frontmatter is authoritative if the line is lost. MDS module: `commands/_partials/_knowledge.mds` (defines/exports `knowledge_load` and `knowledge_writeback` partials) + 9 host `.mds` sources in `commands/` compiled to plugin commands at build time by `scripts/build-mds.ts` (`npm run build:mds`). `knowledge_load` is used up-front by: implement, plan, resolve, code-review, self-review, research, bug-analysis. `knowledge_writeback` is used at workflow end by: implement, resolve, self-review, explore, debug. explore/debug do NOT load up-front (intentional asymmetry). Config gate: single `knowledge: true|false` in dream config (default true) — gates write-back only; load is ungated. CLI: `devflow knowledge list` (read index.md / frontmatter glob), `devflow knowledge --enable/--disable/--status` (flip config). Note: `/debug` keeps FEATURE_KNOWLEDGE orchestrator-local (investigation workers examine code without pre-loaded context). Toggleable via `devflow knowledge --enable/--disable/--status` or `devflow init --knowledge/--no-knowledge`. **Rules**: Ultra-concise, always-on engineering principle files (~10-15 lines each) installed to `~/.claude/rules/devflow/` as flat `.md` files. Claude Code loads them automatically on every prompt — no hooks required — filling the guidance gap for quick edits that don't trigger a full skill pipeline. Rules flow through the same four-stage pipeline as skills: authored in `shared/rules/`, distributed to `plugins/*/rules/` at build time, installed (or shadowed) at runtime, and activated automatically. Unlike skills (which install universally from all plugins), rules are **plugin-scoped**: only rules belonging to selected plugins are installed. This keeps core rules (`security`, `engineering`, `quality`, `reliability` from `devflow-core-skills`) always present, and language/ecosystem rules (`typescript`, `react`, `go`, etc.) present only when the user has that plugin installed. Shadow overrides: `~/.devflow/rules/{name}.md` overrides the Devflow source. Toggleable via `devflow rules --enable/--disable/--status/--list` or `devflow init --rules/--no-rules`. Stored in manifest `features.rules: boolean` (self-heals to `true` on old manifests). Currently 12 rules: 4 core + 8 language/UI. `paths: []` YAML frontmatter must remain — it signals Claude Code to apply the rule globally. **One background pipeline** (toggleable): -- `devflow decisions --enable/--disable` — Decisions pipeline (decision + pitfall detection, materialized by Dream agent from DIALOG_PAIRS) +- `devflow decisions --enable/--disable` — Decisions pipeline (decision + pitfall detection, materialized by the directive-spawned Dream agent from the captured queue) Knowledge write-back is in-command (not a background pipeline): gated by `devflow knowledge --enable/--disable` (flips `knowledge` in dream config); Knowledge agent writes directly at workflow end. **Two-Mode Init**: `devflow init` offers Recommended (sensible defaults, quick setup) or Advanced (full interactive flow) after plugin selection. `--recommended` / `--advanced` CLI flags for non-interactive use. Recommended applies: ambient ON, memory ON, decisions ON, rules ON, HUD ON, default-ON flags, .claudeignore ON, auto-install safe-delete if trash CLI detected, user-mode security deny list, viewMode preserved from existing settings.json. Advanced path adds a view mode selector (default/verbose/focus) after Claude Code flags. Use `--decisions/--no-decisions` to toggle the decisions agent independently. Use `--rules/--no-rules` to toggle rules independently. -**Migrations**: Run-once migrations execute automatically on `devflow init`, tracked at `~/.devflow/migrations.json` (scope-independent; single file regardless of user-scope vs local-scope installs). Registry: append an entry to `MIGRATIONS` in `src/cli/utils/migrations.ts`. Scopes: `global` (runs once per machine, no project context) vs `per-project` (sweeps all discovered Claude-enabled projects in parallel). Failures are non-fatal — migrations retry on next init. Currently registered per-project migrations include `purge-legacy-knowledge-v2` (removes 4 hardcoded pre-v2 ADR/PF IDs and orphan `PROJECT-PATTERNS.md`), `purge-legacy-knowledge-v3` (v3: sweeps all remaining pre-v2 seeded entries using the `- **Source**: self-learning:` format discriminator — any ADR/PF section lacking this marker is removed; entries the user edited to include the marker survive), `purge-orphaned-sidecar-judgment-state` (per-project; removes orphaned `.learning-manifest.json`, `.decisions-manifest.json`, `.decisions-notifications.json` — judgment-state files written by the now-removed deterministic render/reconcile layer), `purge-learning-pipeline-v1` (per-project; removes `.devflow/learning/` directory, learning dream markers, `learning` key from dream/sidecar config, `.claude/commands/self-learning/`, and auto-generated skills), `purge-stale-memory-markers-v1` (per-project; removes stale `dream/memory.*` markers left by the old Dream-subagent memory pipeline now that `background-memory-update` handles memory refresh — ENOENT-idempotent, rethrows non-ENOENT errors), `purge-dead-working-memory-sentinel-v1` (per-project; removes the stale `.devflow/memory/.working-memory-disabled` sentinel now that the memory gate is config-only per ADR-001 — ENOENT-tolerant, rethrows non-ENOENT errors). Global migrations: `purge-learning-global-v1` removes `~/.devflow/learning.json`; `purge-orphaned-dream-commit-hook-v1` removes the orphaned `~/.devflow/scripts/hooks/dream-commit` (the `dream-commit` helper was deleted when `.devflow/` became gitignored-by-default per ADR-021, but the installer copies `scripts/` additively — `copyDirectory` never deletes — so the stale file would otherwise linger; ENOENT-idempotent). **D37 edge case**: a project cloned *after* migrations have run won't be swept (the marker is global, not per-project). Recovery: `rm ~/.devflow/migrations.json` forces a re-sweep on next `devflow init`. +**Migrations**: Run-once migrations execute automatically on `devflow init`, tracked at `~/.devflow/migrations.json` (scope-independent; single file regardless of user-scope vs local-scope installs). Registry: append an entry to `MIGRATIONS` in `src/cli/utils/migrations.ts`. Scopes: `global` (runs once per machine, no project context) vs `per-project` (sweeps all discovered Claude-enabled projects in parallel). Failures are non-fatal — migrations retry on next init. Currently registered per-project migrations include `purge-legacy-knowledge-v2` (removes 4 hardcoded pre-v2 ADR/PF IDs and orphan `PROJECT-PATTERNS.md`), `purge-legacy-knowledge-v3` (v3: sweeps all remaining pre-v2 seeded entries using the `- **Source**: self-learning:` format discriminator — any ADR/PF section lacking this marker is removed; entries the user edited to include the marker survive), `purge-orphaned-sidecar-judgment-state` (per-project; removes orphaned `.learning-manifest.json`, `.decisions-manifest.json`, `.decisions-notifications.json` — judgment-state files written by the now-removed deterministic render/reconcile layer), `purge-learning-pipeline-v1` (per-project; removes `.devflow/learning/` directory, learning dream markers, `learning` key from dream/sidecar config, `.claude/commands/self-learning/`, and auto-generated skills), `purge-stale-memory-markers-v1` (per-project; removes stale `dream/memory.*` markers left by the old Dream-subagent memory pipeline now that `background-memory-update` handles memory refresh — ENOENT-idempotent, rethrows non-ENOENT errors), `purge-dead-working-memory-sentinel-v1` (per-project; removes the stale `.devflow/memory/.working-memory-disabled` sentinel now that the memory gate is config-only per ADR-001 — ENOENT-tolerant, rethrows non-ENOENT errors), `purge-dream-worker-state-v1` (per-project; removes the `.devflow/decisions/.disabled` sentinel, `dream/.last-dream-ok`, `dream/last-run-summary`, and the `dream/.worker.lock/` directory left by the retired detached dream worker), `purge-dream-marker-pipeline-v1` (per-project; removes stale `decisions.*`/`curation.*` markers and legacy fixed-name stamps — `.decisions-runs-today`, `.curation-last`, `.processor-spawned-at` — left by the retired dream marker pipeline). 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 ``` devflow/ ├── commands/ # MDS command sources (14 hosts + 8 partials in _partials/; compiled to plugins/*/commands/ by build:mds) -├── shared/skills/ # 43 skills (single source of truth) +├── shared/skills/ # 40 skills (single source of truth) ├── shared/agents/ # 16 shared agents (single source of truth) ├── shared/rules/ # 12 rules (single source of truth; flat .md files) ├── plugins/devflow-*/ # 22 plugins (12 core + 9 optional language/ecosystem + 1 optional workflow) ├── docs/reference/ # Detailed reference documentation ├── scripts/ # Helper scripts (statusline, docs-helpers) -│ └── hooks/ # Dream + ambient + memory hooks (dream-capture, dream-dispatch [capture-only], background-memory-update [Stop-hook worker], dream-recover, dream-collect-tasks, dream-evaluate, dream-lock, session-start-memory, session-start-context, pre-compact-memory, preamble, get-mtime, hook-bootstrap, hook-log-init, eval-helpers, eval-decisions, eval-curation) +│ └── hooks/ # Capture + memory + dream + ambient hooks (capture-prompt, capture-turn, capture-question, queue-append, memory-worker, background-memory-update [Stop-hook worker], dream-lock, session-start-memory, session-start-context, pre-compact-memory, preamble, get-mtime, hook-bootstrap, hook-log-init) ├── src/cli/ # TypeScript CLI (init, list, uninstall, ambient, decisions, flags, knowledge, rules, debug) ├── .claude-plugin/ # Marketplace registry ├── .devflow/ # Per-project runtime data — local by default; EXCEPTION: features/ knowledge bases (index.md + {slug}/KNOWLEDGE.md) are tracked & shared via git (ensure-root-gitignore writes the carve-out) @@ -161,24 +161,22 @@ Per-project runtime files live under `.devflow/`: │ ├── .working-memory-last-trigger # Mtime = last worker spawn time (120s throttle key, transient) │ ├── .last-refresh-ok # Mtime = last successful WORKING-MEMORY.md write (transient) │ └── .working-memory.lock/ # Worker lock dir — 300s stale-break (transient, never tracked) -├── dream/ # Dream state: config.json (feature toggles), per-session markers (decisions.{session}.json, curation.{session}.json), .processor-spawned-at (120s spawn throttle), .curation-last (7-day curation throttle) +├── dream/ # config.json (feature toggles), .pending-turns.jsonl (decisions detection queue), .pending-turns.processing (Dream agent's atomic claim — deleted as the agent's final act; treated as crashed at 900s) ├── decisions/ │ ├── decisions-ledger.jsonl # Anchored ledger (gitignored by default) — render source of truth; one row per ADR/PF incl. retired │ ├── decisions-log.jsonl # Raw decision/pitfall observations (JSONL, gitignored) │ ├── 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) -│ ├── .decisions-runs-today # Daily run counter for decisions agent (date + count) +│ ├── decisions.json # Project-level decisions agent config (model, debug only) │ ├── .decisions.lock # Lock directory for assign-anchor/retire-anchor writers (transient) │ ├── .decisions-usage.json # Citation counts written by decisions-usage-scan.cjs Stop hook -│ ├── 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 +│ ├── decisions.md # Architectural decisions (ADR-NNN) — rendered from decisions-ledger.jsonl (active only) by the Dream agent via assign-anchor + render-decisions +│ └── pitfalls.md # Known pitfalls (PF-NNN, area-specific gotchas) — rendered from decisions-ledger.jsonl (active only) by the Dream agent via assign-anchor + render-decisions └── features/ # Per-feature knowledge bases — index.md + {slug}/KNOWLEDGE.md tracked & shared via git; rest local ├── {slug}/KNOWLEDGE.md └── index.md # Regenerable cache (line format: `- **{slug}** — {areas} — {Use-when}`); frontmatter is authoritative if absent ~/.devflow/logs/{project-slug}/ -├── .dream-capture.log # dream-capture (Stop hook) log +├── .capture-turn.log # capture-turn (Stop hook) log └── .background-memory-update.log # background-memory-update worker log ``` @@ -192,7 +190,7 @@ Per-project runtime files live under `.devflow/`: **Universal Skill Installation**: All skills from all plugins are always installed, regardless of plugin selection. Skills are tiny markdown files installed as `~/.claude/skills/devflow:{name}/` (namespaced to avoid collisions with other plugin ecosystems). Source directories in `shared/skills/` stay unprefixed — the `devflow:` prefix is applied at install-time only. Shadow overrides live at `~/.devflow/skills/{name}/` (unprefixed); when shadowed, the installer copies the user's version to the prefixed install target. Only commands and agents remain plugin-specific. -**Model Strategy**: Explicit model assignments in agent frontmatter override the user's session model. Opus for analysis agents (reviewer, scrutinizer, evaluator, designer, researcher, bug-analyzer), Sonnet for execution agents (coder, simplifier, resolver, skimmer, tester, knowledge), Haiku for I/O agents (git, synthesizer, validator). Dream uses per-task overrides via `session-start-context`: opus for decisions/curation (share one combined opus spawn). Memory is not a Dream task — `WORKING-MEMORY.md` is refreshed by the detached `background-memory-update` worker (`claude -p --model haiku`) spawned by `dream-capture`. Knowledge is not a Dream task — the Knowledge agent (sonnet) is spawned in-command by `knowledge_writeback()` at workflow end. +**Model Strategy**: Explicit model assignments in agent frontmatter override the user's session model. Opus for analysis agents (reviewer, scrutinizer, evaluator, designer, researcher, bug-analyzer, dream), Sonnet for execution agents (coder, simplifier, resolver, skimmer, tester, knowledge), Haiku for I/O agents (git, synthesizer, validator). The Dream agent's spawn directive additionally resolves a per-project model override (project `decisions.json` → global `~/.devflow/decisions.json` → `opus`). Memory is refreshed by the detached `background-memory-update` worker (`claude -p --model haiku`), spawned by the `memory-worker` Stop hook. Knowledge is not a background worker — the Knowledge agent (sonnet) is spawned in-command by `knowledge_writeback()` at workflow end. ## Agent & Command Roster diff --git a/README.md b/README.md index 6981c1c3..a01d6aac 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ you: add rate limiting to the /api/upload endpoint **18 parallel code reviewers.** Security, architecture, performance, complexity, consistency, regression, testing, and more. Each produces findings with severity, confidence scoring, and concrete fixes. Conditional reviewers activate when relevant (TypeScript for `.ts` files, database for schema changes). Every finding gets validated and resolved automatically. -**45 skills.** 41 are grounded in expert material — backed by peer-reviewed papers, canonical books, and industry standards: security (OWASP, Shostack), architecture (Parnas, Evans, Fowler), performance (Brendan Gregg), testing (Beck, Meszaros), design (Wlaschin, Hickey), 200+ sources total. The remaining 4 are procedural Dream maintenance skills. +**40 skills.** Most are grounded in expert material — backed by peer-reviewed papers, canonical books, and industry standards: security (OWASP, Shostack), architecture (Parnas, Evans, Fowler), performance (Brendan Gregg), testing (Beck, Meszaros), design (Wlaschin, Hickey), 200+ sources total. **Skill shadowing.** Override any built-in skill with your own version. Drop a file into `~/.devflow/skills/{name}/` and the installer uses yours instead of the default — same activation, your rules. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 6ecf9792..d9728fbd 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -78,11 +78,11 @@ npx devflow-kit ambient --status # Show current status ## Decisions ```bash -npx devflow-kit decisions --enable # Register the decisions hook -npx devflow-kit decisions --disable # Remove the decisions hook +npx devflow-kit decisions --enable # Enable decisions detection +npx devflow-kit decisions --disable # Disable decisions detection (drains the pending dream queue) npx devflow-kit decisions --status # Show status and entry counts npx devflow-kit decisions list # List all decisions and pitfalls -npx devflow-kit decisions --configure # Interactive config (model, throttle) +npx devflow-kit decisions --configure # Interactive config (model, debug, scope) npx devflow-kit decisions --review # Review observations or capacity npx devflow-kit decisions --purge # Remove invalid entries npx devflow-kit decisions --clear # Reset all observations diff --git a/docs/reference/file-organization.md b/docs/reference/file-organization.md index c9c63bad..c5152901 100644 --- a/docs/reference/file-organization.md +++ b/docs/reference/file-organization.md @@ -9,7 +9,7 @@ devflow/ ├── .claude-plugin/ # Marketplace registry (repo root) │ └── marketplace.json ├── shared/ -│ ├── skills/ # SINGLE SOURCE OF TRUTH (43 skills) +│ ├── skills/ # SINGLE SOURCE OF TRUTH (40 skills) │ │ ├── git/ │ │ │ ├── SKILL.md │ │ │ └── references/ @@ -47,38 +47,32 @@ devflow/ │ ├── build-hud.js # Copies dist/hud/ → scripts/hud/ │ ├── hud.sh # Thin wrapper: exec node hud/index.js │ ├── hud/ # GENERATED — compiled HUD module (gitignored) -│ └── hooks/ # Dream + ambient + memory hooks -│ ├── dream-capture # Stop hook: captures turns to queue, spawns background-memory-update worker when throttle expires -│ ├── background-memory-update # Detached claude -p haiku worker: rewrites WORKING-MEMORY.md (spawned by dream-capture) -│ ├── dream-dispatch # UserPromptSubmit hook: capture-only (appends user turn to queue) -│ ├── dream-recover # Shared helper: recovers stale .processing markers -│ ├── dream-collect-tasks # Shared helper: collects pending dream markers -│ ├── dream-evaluate # SessionEnd hook: orchestrator sourcing eval-* feature modules +│ └── hooks/ # Capture + memory + dream + ambient hooks +│ ├── capture-prompt # UserPromptSubmit hook: appends user turn to memory + dream queues (independently gated) +│ ├── capture-turn # Stop hook: appends assistant turn to memory + dream queues; never spawns +│ ├── capture-question # PostToolUse hook (matcher: AskUserQuestion): appends answered questions to both queues +│ ├── queue-append # Shared helper: queue_append_row / queue_append_both / queue_read_gates +│ ├── memory-worker # Stop hook (registered after capture-turn): 120s throttle, spawns background-memory-update +│ ├── background-memory-update # Detached claude -p haiku worker: rewrites WORKING-MEMORY.md (spawned by memory-worker) │ ├── dream-lock # Shared helper: mkdir-based locking -│ ├── eval-helpers # SessionEnd module: shared setup sourced by dream-evaluate -│ ├── eval-decisions # SessionEnd module: decisions marker (DIALOG_PAIRS) -│ ├── eval-curation # SessionEnd module: curation marker -│ ├── session-start-memory # SessionStart hook: injects memory + git state -│ ├── session-start-context # SessionStart hook: emits DREAM MAINTENANCE directive + decisions TL;DR + learned behaviors +│ ├── session-start-memory # SessionStart hook: injects memory + git state; recovers orphaned .pending-turns.processing itself +│ ├── session-start-context # SessionStart hook: injects decisions TL;DR + the Dream agent spawn directive when the queue is pending │ ├── pre-compact-memory # PreCompact hook: saves git state backup │ ├── preamble # UserPromptSubmit hook: ambient keyword + plan auto-detection (zero overhead for normal prompts) │ ├── get-mtime # Shared helper: portable mtime (BSD/GNU stat) │ ├── hook-bootstrap # Shared helper: sources debug-trace + common setup │ ├── hook-log-init # Shared helper: log initialization │ ├── debug-trace # Shared helper: debug tracing (sourced via hook-bootstrap) -│ ├── run-hook # Shared helper: hook runner with logging +│ ├── run-hook # Shared helper: hook runner with logging; exits 0 when the named script is absent │ ├── log-paths # Shared helper: per-project log path resolution │ ├── ensure-devflow-init # Shared helper: lazy .devflow/ directory creation │ ├── decisions-usage-scan.cjs # Decisions usage scanning │ ├── json-helper.cjs # Node.js jq-equivalent operations │ ├── json-parse # Shell wrapper: jq with node fallback │ └── lib/ # Node.js helper modules -│ ├── dream-ops.cjs # Dream marker + queue operations │ ├── decisions-index.cjs # Decisions index builder │ ├── project-paths.cjs # Project slug + path resolution -│ ├── safe-path.cjs # Path safety validation -│ ├── staleness.cjs # Code reference staleness checker -│ └── transcript-filter.cjs # Transcript channel extractor +│ └── safe-path.cjs # Path safety validation └── src/ └── cli/ ├── commands/ @@ -175,31 +169,32 @@ Skills and agents are **not duplicated** in git. Instead: Included settings: - `statusLine` - Configurable HUD with presets (replaces legacy statusline.sh) -- `hooks` - Dream hooks (UserPromptSubmit, Stop, SessionStart, SessionEnd, PreCompact) +- `hooks` - Capture + Dream hooks (UserPromptSubmit, PostToolUse, Stop, SessionStart, PreCompact) - `env.ENABLE_TOOL_SEARCH` - Deferred MCP tool loading (~85% token savings) - `env.ENABLE_LSP_TOOL` - Language Server Protocol support - `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` - Agent Teams (not in settings template by default; enabled on demand via the optional `agent-teams` Claude Code flag — `devflow flags --enable agent-teams`) - `extraKnownMarketplaces` - Devflow plugin marketplace (`dean0x/devflow`) - `permissions.deny` - Security deny list (140 blocked operations) + sensitive file patterns -## Dream Hooks +## Capture + Dream Hooks -Three shell-script hooks (`dream-capture`, `dream-dispatch`, `dream-evaluate`) replace the old 8-hook system with a background-maintenance (Dream) architecture. Toggleable via `devflow memory --enable/--disable/--status` or `devflow init --memory/--no-memory`. +A capture/spawn split across always-on shell-script hooks. Queue-append (`capture-prompt`/`capture-turn`/`capture-question`) is unconditional; each queue write is independently gated per-feature by dream config. Memory refresh is toggleable via `devflow memory --enable/--disable/--status` or `devflow init --memory/--no-memory`; decisions detection/curation via `devflow decisions --enable/--disable/--status` or `devflow init --decisions/--no-decisions`. | Hook / Worker | Event | Purpose | |---------------|-------|---------| -| `dream-capture` | Stop | Captures user/assistant turns to `.devflow/memory/.pending-turns.jsonl` queue; after the 120s throttle (keyed by `.working-memory-last-trigger` mtime), spawns `background-memory-update` as a detached `nohup` worker (`claude -p`). Memory refresh is handled directly by that worker; no `memory.json` Dream marker is written. | -| `background-memory-update` | Detached worker (spawned by Stop) | Drains `.pending-turns.jsonl` → calls `claude -p --model haiku` (prompt on stdin) → rewrites `WORKING-MEMORY.md` with `` on line 1. On success: removes `.processing`, touches `.last-refresh-ok`. On failure: leaves `.processing` for crash recovery at next SessionStart. | -| `dream-dispatch` | UserPromptSubmit | Capture-only: appends the user turn to `.pending-turns.jsonl` (emits no directive) | -| `dream-evaluate` | SessionEnd | Orchestrator sourcing `eval-helpers` + 2 feature modules (`eval-decisions`, `eval-curation`); writes per-session decisions/curation markers | -| `session-start-memory` | SessionStart | Reads the already-fresh `WORKING-MEMORY.md` and injects it as `additionalContext` with a git-reconciled 3-state header (A in-sync / B drifted / C refresh-failing banner). Memory refresh is NOT triggered here — `session-start-memory` only reads and injects. | -| `session-start-context` | SessionStart | Recovers stale `.processing` markers, collects pending Dream markers (decisions/curation only — memory markers are swept unconditionally), emits the DREAM MAINTENANCE directive (throttled to 120s); also injects decisions TL;DR + learned behaviors | +| `capture-prompt` | UserPromptSubmit | Appends the user turn to `.devflow/memory/.pending-turns.jsonl` and `.devflow/dream/.pending-turns.jsonl` (each gated independently); emits no directive | +| `capture-turn` | Stop | Appends the assistant turn to both queues; runs the decisions usage scanner; never spawns anything | +| `capture-question` | PostToolUse (matcher: `AskUserQuestion`) | Appends each answered question as a `{role:"qa"}` row to both queues | +| `memory-worker` | Stop (registered after `capture-turn` — append-before-spawn ordering) | After the 120s throttle (keyed by `.working-memory-last-trigger` mtime), spawns `background-memory-update` as a detached `nohup` worker (`claude -p --model haiku`) | +| `background-memory-update` | Detached worker (spawned by `memory-worker`) | Drains `.pending-turns.jsonl` → calls `claude -p --model haiku` (prompt on stdin) → rewrites `WORKING-MEMORY.md` with `` on line 1. On success: removes `.processing`, touches `.last-refresh-ok`. On failure: leaves `.processing` for crash recovery at next SessionStart. | +| `session-start-memory` | SessionStart | Reads the already-fresh `WORKING-MEMORY.md` and injects it as `additionalContext` with a git-reconciled 3-state header (A in-sync / B drifted / C refresh-failing banner); also recovers an orphaned `.pending-turns.processing` itself (self-contained cold path) | +| `session-start-context` | SessionStart | Injects the decisions TL;DR and, when the dream queue is non-empty (or a crashed run left a stale `.processing` batch), a `--- DREAM MAINTENANCE ---` directive instructing the main model to spawn the background Dream agent with the resolved model (project → global `decisions.json` → `opus` default) | | `pre-compact-memory` | PreCompact | Saves git state + WORKING-MEMORY.md snapshot | | `preamble` | UserPromptSubmit | Ambient keyword + plan auto-detection (zero overhead for normal prompts) | -**Flow**: User sends prompt → `dream-dispatch` appends the user turn to the queue → session ends → `dream-capture` appends the assistant turn to the queue; if the 120s throttle has expired, spawns `background-memory-update` detached worker that rewrites `WORKING-MEMORY.md` directly via `claude -p` → `dream-evaluate` writes decisions/knowledge/curation markers. On `/clear` or new session → `session-start-memory` injects the already-written `WORKING-MEMORY.md` as `additionalContext` (3-state git-reconciled header), and `session-start-context` emits the DREAM MAINTENANCE directive instructing the main model to spawn ONE background Dream agent (`Agent(subagent_type="Dream", run_in_background:true)`) that claims each decisions/knowledge/curation marker, performs all detection/materialization/curation, then deletes the marker. **Memory is NOT a Dream task** — WORKING-MEMORY.md is authored by the detached `background-memory-update` Stop-hook worker. +**Flow**: User sends prompt → `capture-prompt` appends the user turn to both queues → session ends → `capture-turn` appends the assistant turn to both queues, then `memory-worker` spawns `background-memory-update` (if the 120s throttle has expired) which rewrites `WORKING-MEMORY.md` directly via `claude -p`. On `/clear` or new session → `session-start-memory` injects the already-written `WORKING-MEMORY.md` as `additionalContext` (3-state git-reconciled header); `session-start-context` injects the decisions TL;DR and, when the dream queue has pending turns, the Dream maintenance directive — the main model spawns the Dream agent in the background, which claims the queue atomically, performs decision/pitfall detection and curation directly against the data files, deletes the claimed batch as its final act, and reports a 1–3 line summary. -`devflow memory --disable` disables Working Memory. Use `devflow memory --clear` to clean up pending queue files across all projects. +`devflow memory --disable` disables Working Memory (hooks stay registered; queue writes for memory are skipped). Use `devflow memory --clear` to clean up pending memory queue files across all projects, or `devflow decisions --clear`/`--reset` for the dream queue and decisions state. Hooks auto-create `.devflow/` on first run — no manual setup needed per project. @@ -209,8 +204,8 @@ Knowledge files in `.devflow/decisions/` capture decisions and pitfalls that age | File | Format | Source | Purpose | |------|--------|--------|---------| -| `decisions.md` | ADR-NNN (sequential) | Dream agent via `decisions-append` | Architectural decisions — why choices were made | -| `pitfalls.md` | PF-NNN (sequential) | Dream agent via `decisions-append` | Known gotchas, fragile areas, past bugs | +| `decisions.md` | ADR-NNN (sequential) | Dream agent via `assign-anchor` (renders via `render-decisions.cjs`) | Architectural decisions — why choices were made | +| `pitfalls.md` | PF-NNN (sequential) | Dream agent via `assign-anchor` (renders via `render-decisions.cjs`) | Known gotchas, fragile areas, past bugs | Each file has a `` comment on line 1. SessionStart injects TL;DR headers only (~30-50 tokens). Agents read full files when relevant to their work. Cap: 50 entries per file. diff --git a/docs/reference/skills-architecture.md b/docs/reference/skills-architecture.md index c14016a3..d84f7610 100644 --- a/docs/reference/skills-architecture.md +++ b/docs/reference/skills-architecture.md @@ -81,7 +81,7 @@ Language and framework patterns. Referenced by agents via frontmatter and condit Some skills exist in `shared/skills/` but are not distributed to any plugin. They serve as on-disk format specifications consumed by background processes, not by agents or commands. -- **decisions-format** — Format spec for `.devflow/decisions/decisions.md` and `pitfalls.md` (entry format, lock protocol). Consumed by the background Dream agent via `json-helper.cjs decisions-append`. Not distributed to plugins per D9. +- **decisions-format** — Format spec for `.devflow/decisions/decisions.md` and `pitfalls.md` (entry format, lock protocol). Consumed by the `assign-anchor`/`retire-anchor` render path in `json-helper.cjs`, driven by the background Dream agent. Not distributed to plugins per D9. ## How Skills Activate diff --git a/docs/working-memory.md b/docs/working-memory.md index dac9d992..11ad90e3 100644 --- a/docs/working-memory.md +++ b/docs/working-memory.md @@ -4,14 +4,15 @@ Devflow automatically preserves session context across restarts, `/clear`, and c ## How it works -Three shell hooks plus one detached worker run behind the scenes: +A capture/spawn split across always-on hooks plus one detached worker run behind the scenes: | Hook / Worker | When | What | |---------------|------|------| -| **Stop** (`dream-capture`) | After each response | Captures the user/assistant turns to `.pending-turns.jsonl`. After the 120s throttle (keyed by `.working-memory-last-trigger` mtime), touches `.working-memory-last-trigger` then spawns `background-memory-update` as a detached `nohup` worker (`claude -p --model haiku`). No `memory.json` dream marker is written — memory refresh happens directly via the detached worker, not via the Dream subagent. | -| **`background-memory-update`** (detached worker spawned by Stop) | Triggered by `dream-capture` after throttle expires | Drains `.pending-turns.jsonl` → renames to `.pending-turns.processing` (atomic claim) → calls `claude -p` (prompt on stdin) → rewrites `WORKING-MEMORY.md` with `` on line 1. On success: removes `.processing` and touches `.last-refresh-ok`. On failure: leaves `.processing` for `dream-recover` to recover at next SessionStart. User-only queues (no assistant turn) are truncated without an LLM run. | -| **SessionStart** (`session-start-memory`) | On startup, `/clear`, resume, compaction | Reads the already-fresh `WORKING-MEMORY.md` and injects it as `additionalContext` with a git-reconciled header. Uses the `` stamp on line 1 to determine state: **A** in-sync (stamp SHA = HEAD), **B** drifted (stamp SHA is an ancestor of HEAD — shows commits since last write), or **C** refresh-failing banner (queue non-empty AND `.last-refresh-ok` missing or >600s old). Memory refresh is **not** a Dream task — `session-start-memory` only reads and injects. | -| **SessionStart** (`session-start-context`) | On startup, `/clear`, resume, compaction | Emits the DREAM MAINTENANCE directive (throttled to 120s) when pending Dream markers are present (decisions/knowledge/curation). Also injects decisions TL;DR + learned behaviors. Memory is NOT a Dream task — the Dream agent handles only decisions/knowledge/curation. | +| **Stop** (`capture-turn`) | After each response | Appends the assistant turn to `.pending-turns.jsonl` (and, independently gated, to the sibling dream queue — see the Decisions pipeline in the project CLAUDE.md). Never spawns anything. | +| **Stop** (`memory-worker`, registered immediately after `capture-turn`) | After each response | After the 120s throttle (keyed by `.working-memory-last-trigger` mtime), touches `.working-memory-last-trigger` then spawns `background-memory-update` as a detached `nohup` worker (`claude -p --model haiku`). | +| **`background-memory-update`** (detached worker spawned by `memory-worker`) | Triggered by `memory-worker` after throttle expires | Drains `.pending-turns.jsonl` → renames to `.pending-turns.processing` (atomic claim) → calls `claude -p` (prompt on stdin) → rewrites `WORKING-MEMORY.md` with `` on line 1. On success: removes `.processing` and touches `.last-refresh-ok`. On failure: leaves `.processing` for `session-start-memory` to recover at next SessionStart. User-only queues (no assistant turn) are truncated without an LLM run. | +| **SessionStart** (`session-start-memory`) | On startup, `/clear`, resume, compaction | Reads the already-fresh `WORKING-MEMORY.md` and injects it as `additionalContext` with a git-reconciled header. Uses the `` stamp on line 1 to determine state: **A** in-sync (stamp SHA = HEAD), **B** drifted (stamp SHA is an ancestor of HEAD — shows commits since last write), or **C** refresh-failing banner (queue non-empty AND `.last-refresh-ok` missing or >600s old). Also recovers an orphaned `.pending-turns.processing` itself (self-contained cold path — no external helper dependency). | +| **SessionStart** (`session-start-context`) | On startup, `/clear`, resume, compaction | Injects the decisions TL;DR and, when the dream queue has pending turns, the Dream maintenance directive (spawns the background Dream agent). | | **PreCompact** | Before context compaction | Backs up git state + WORKING-MEMORY.md snapshot to `backup.json`. | Working memory is **per-project** — scoped to each repo's `.devflow/` directory. Multiple sessions across different repos don't interfere. @@ -43,13 +44,13 @@ devflow memory --status # Check current state └── pitfalls.md # Known pitfalls (PF-NNN, area-specific gotchas) ``` -Note: `dream/memory.{session}.json` markers no longer exist — memory refresh is handled by the detached Stop-hook worker, not the Dream subagent. Decisions, knowledge, and curation still use Dream markers. +Note: no marker files are involved anywhere in this flow — memory refresh is handled entirely by the queue + detached Stop-hook worker above. Decisions detection and curation follow the same pattern via a separate queue at `.devflow/dream/.pending-turns.jsonl` and a SessionStart-spawned detached worker (see the project CLAUDE.md's Decisions pipeline section). Debug logs are stored at `~/.devflow/logs/{project-slug}/`. ## Working Memory Sections -The `background-memory-update` worker (detached `claude -p` process spawned by `dream-capture`) maintains these sections in `WORKING-MEMORY.md`: +The `background-memory-update` worker (detached `claude -p` process spawned by `memory-worker`) maintains these sections in `WORKING-MEMORY.md`: | Section | Purpose | |---------|---------| diff --git a/plugins/devflow-core-skills/.claude-plugin/plugin.json b/plugins/devflow-core-skills/.claude-plugin/plugin.json index 51fda8f3..cf4d85ad 100644 --- a/plugins/devflow-core-skills/.claude-plugin/plugin.json +++ b/plugins/devflow-core-skills/.claude-plugin/plugin.json @@ -28,9 +28,7 @@ "boundary-validation", "test-driven-development", "testing", - "dependency-research", - "dream-decisions", - "dream-curation" + "dependency-research" ], "rules": [ "security", diff --git a/scripts/hooks/background-memory-update b/scripts/hooks/background-memory-update index 3f054596..50b2ecba 100755 --- a/scripts/hooks/background-memory-update +++ b/scripts/hooks/background-memory-update @@ -1,7 +1,7 @@ #!/bin/bash # background-memory-update — Eager working-memory refresh worker -# Spawned as a detached background process by dream-capture (Stop hook) after the +# Spawned as a detached background process by memory-worker (Stop hook) after the # 120s throttle expires. Drains .pending-turns.jsonl → calls `claude -p` (haiku) → # rewrites WORKING-MEMORY.md with a git-reconciliation stamp on line 1. # @@ -14,7 +14,7 @@ # Success: removes .pending-turns.processing; touches .last-refresh-ok # Failure: leaves .pending-turns.processing; this worker is the PRIMARY recovery owner — # on its next spawn it merges leftover .processing back into the queue (lines ~141-156). -# dream_recover_stale (session-start-context) is the COLD-PATH fallback for when this +# session-start-memory's own cold-path recovery is the fallback for when this # worker never re-spawns (e.g. memory disabled mid-flight, host offline); it applies a # 300s age gate before acting on .processing. No action needed here — the two paths # are correctly separated by the lock boundary. @@ -22,6 +22,16 @@ set -e +# Re-entrancy guard: a nested memory-worker claude -p session (DEVFLOW_BG_UPDATER=1 +# in its env) fires Stop hooks on its own turns. If those hooks ever chain into +# spawning this worker, bail out immediately rather than cascading a memory +# refresh out of a nested worker session (belt-and-suspenders alongside the +# equivalent guard in memory-worker, which is the intended sole spawn site). +if [ "${DEVFLOW_BG_UPDATER:-}" = "1" ]; then + echo "background-memory-update: EXIT: bg_updater" >&2 + exit 0 +fi + CWD="$1" if [ -z "$CWD" ] || [ ! -d "$CWD" ]; then echo "background-memory-update: CWD missing or not a directory: '$CWD'" >&2 @@ -127,26 +137,29 @@ if ! acquire_lock; then exit 0 fi -# --- Orphan-only auto-clean: if queue has no assistant turn, truncate and exit --- +# --- Orphan-only auto-clean: if queue has no assistant/qa turn, truncate and exit --- # This prevents fabrication-prone LLM runs with only user turns in the queue. +# A qa row (captured Q&A pair) counts as content-bearing here too — it carries +# the same synthesis-worthy signal as an assistant turn (AC-F10). This check +# and the TURNS_TEXT extraction loop below must agree on that. # When neither jq nor node is available (_JSON_AVAILABLE=false) we skip the check # and allow the run to proceed — conservative: better to attempt than to truncate blindly. if [ -f "$QUEUE_FILE" ] && [ -s "$QUEUE_FILE" ] && [ "$_JSON_AVAILABLE" = "true" ]; then if [ "$_HAS_JQ" = "true" ]; then - _HAS_ASSISTANT=$(jq -r 'select(.role=="assistant") | .role' "$QUEUE_FILE" 2>/dev/null | head -1 || echo "") + _HAS_CONTENT=$(jq -r 'select(.role=="assistant" or .role=="qa") | .role' "$QUEUE_FILE" 2>/dev/null | head -1 || echo "") else # SECURITY: pass path via argv, never interpolate into node -e source (avoids shell injection # for repo paths containing quotes or special characters — applies ADR-008 plumbing principle) - _HAS_ASSISTANT=$(node -e ' + _HAS_CONTENT=$(node -e ' const f = process.argv[1]; const lines = require("fs").readFileSync(f, "utf8").split("\n"); for (const l of lines) { - try { const o = JSON.parse(l); if (o.role === "assistant") { process.stdout.write("assistant"); break; } } catch {} + try { const o = JSON.parse(l); if (o.role === "assistant" || o.role === "qa") { process.stdout.write(o.role); break; } } catch {} } ' "$QUEUE_FILE" 2>/dev/null || echo "") fi - if [ -z "$_HAS_ASSISTANT" ]; then - log "User-only queue (no assistant turn) — truncating without LLM run" + if [ -z "$_HAS_CONTENT" ]; then + log "User-only queue (no assistant/qa turn) — truncating without LLM run" rm -f "$QUEUE_FILE" 2>/dev/null || true exit 0 fi @@ -250,6 +263,15 @@ Turn ${TURN_COUNT}: Assistant: ${CONTENT} " fi + elif [ "$ROLE" = "qa" ]; then + # Captured AskUserQuestion Q&A pair — a self-contained stanza, not paired + # with CURRENT_USER (a qa row can arrive mid-turn, between a user prompt + # and its eventual assistant reply; leave CURRENT_USER buffered untouched). + TURN_COUNT=$(( TURN_COUNT + 1 )) + TURNS_TEXT="${TURNS_TEXT} +Turn ${TURN_COUNT}: +Q&A: ${CONTENT} +" fi done </dev/null || true if [ "$CLAUDE_EXIT" -gt 128 ]; then log "FAIL: claude -p killed by watchdog after ${WATCHDOG_SECS}s" - # Leave .processing for crash recovery (dream-recover D56c). - # Retry-count ceiling: this worker is rate-bounded to one spawn per 120s by dream-capture's + # Leave .processing for crash recovery (session-start-memory's D56c cold path). + # Retry-count ceiling: this worker is rate-bounded to one spawn per 120s by memory-worker's # throttle (.working-memory-last-trigger mtime). Persistent failure is surfaced to the user # via session-start-memory State C (stale .last-refresh-ok) rather than a retry counter here, # keeping this plumbing script simple (applies ADR-008). @@ -429,7 +451,7 @@ if [ "$UPDATED" = "true" ]; then touch "$OK_FILE" 2>/dev/null || true log "SUCCESS: queue drained, .last-refresh-ok touched" else - # Leave .processing for dream-recover D56c; do NOT touch .last-refresh-ok + # Leave .processing for session-start-memory's D56c cold path; do NOT touch .last-refresh-ok log "FAIL: verification failed — leaving .processing for recovery" fi diff --git a/scripts/hooks/capture-prompt b/scripts/hooks/capture-prompt new file mode 100755 index 00000000..05293706 --- /dev/null +++ b/scripts/hooks/capture-prompt @@ -0,0 +1,92 @@ +#!/bin/bash + +# Dream System: capture-prompt (UserPromptSubmit Hook) +# Dual-append: writes the user turn to BOTH the memory queue and the dream +# queue, each independently gated by its own feature flag (AC-F4). Delegates +# the actual JSONL-write logic to the shared queue-append helper rather than +# duplicating it inline. + +# Safe no-op fallback: must exist before set -e and before hook-bootstrap is sourced. +dbg() { :; } + +set -e + +# Feedback-loop guard -- before hook-bootstrap to minimize background session +# overhead. The memory worker's own claude -p session fires +# UserPromptSubmit-adjacent hooks on its nested turns; bail out immediately +# rather than double-capturing a background session's own prompts. +if [ "${DEVFLOW_BG_UPDATER:-}" = "1" ]; then dbg "EXIT: bg_updater"; exit 0; fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +source "$SCRIPT_DIR/hook-bootstrap" "capture-prompt" + +source "$SCRIPT_DIR/json-parse" || { echo "capture-prompt: failed to source json-parse" >&2; exit 1; } +if [ "$_JSON_AVAILABLE" = "false" ]; then dbg "EXIT: no json"; exit 0; fi + +INPUT=$(cat) + +FIELDS=$(printf '%s' "$INPUT" | json_extract_cwd_prompt) +CWD="${FIELDS%%$'\001'*}" +PROMPT="${FIELDS#*$'\001'}" + +dbg "CWD=$CWD PROMPT_LENGTH=${#PROMPT}" + +if [ -z "$CWD" ] || [ ! -d "$CWD" ]; then dbg "EXIT: bad CWD"; exit 0; fi + +devflow_debug_set_cwd "$CWD" + +# Anchor .devflow/ to the project root (prevents a stray nested .devflow/ when this +# hook runs with a CWD inside .devflow/...). Empty → fall back to CWD. +source "$SCRIPT_DIR/resolve-project-root" 2>/dev/null || true +PROJECT_ROOT="$(df_resolve_root "$CWD" 2>/dev/null || true)" +[ -n "$PROJECT_ROOT" ] || PROJECT_ROOT="$CWD" + +DEVFLOW_DIR="$PROJECT_ROOT/.devflow" +MEMORY_DIR="$DEVFLOW_DIR/memory" +DREAM_DIR="$DEVFLOW_DIR/dream" + +if [ -z "$PROMPT" ]; then + dbg "EXIT: empty PROMPT" + exit 0 +fi + +# Truncate to 2000 chars (AC-F1) +if [ ${#PROMPT} -gt 2000 ]; then + PROMPT="${PROMPT:0:2000}... [truncated]" +fi + +source "$SCRIPT_DIR/queue-append" || { echo "capture-prompt: failed to source queue-append" >&2; exit 1; } + +# --- AC-P1: exactly ONE config-read fork, fetching both memory + decisions fields --- +queue_read_gates "$DREAM_DIR/config.json" +MEMORY_ENABLED="$_QG_MEMORY" +DECISIONS_ENABLED="$_QG_DECISIONS" + +dbg "MEMORY_ENABLED=$MEMORY_ENABLED DECISIONS_ENABLED=$DECISIONS_ENABLED" + +if [ "$MEMORY_ENABLED" != "true" ] && [ "$DECISIONS_ENABLED" != "true" ]; then + dbg "EXIT: both features disabled" + exit 0 +fi + +# Normal logging (only paid for once we know at least one feature is enabled) +source "$SCRIPT_DIR/hook-log-init" "capture-prompt" + +# Auto-create .devflow/ and ensure .gitignore entries +source "$SCRIPT_DIR/ensure-devflow-init" "$CWD" || exit 0 + +source "$SCRIPT_DIR/get-mtime" || { echo "capture-prompt: failed to source get-mtime" >&2; exit 1; } +source "$SCRIPT_DIR/dream-lock" || { echo "capture-prompt: failed to source dream-lock" >&2; exit 1; } + +mkdir -p "$MEMORY_DIR" "$DREAM_DIR" 2>/dev/null || true + +TS=$(date +%s) +queue_append_both \ + "$MEMORY_DIR/.pending-turns.jsonl" "$DREAM_DIR/.pending-turns.jsonl" \ + "$MEMORY_ENABLED" "$DECISIONS_ENABLED" \ + "user" "$PROMPT" "$TS" + +log "Captured user turn (${#PROMPT} chars)" +dbg "=== HOOK COMPLETE ===" +exit 0 diff --git a/scripts/hooks/capture-question b/scripts/hooks/capture-question new file mode 100755 index 00000000..ca6efbbd --- /dev/null +++ b/scripts/hooks/capture-question @@ -0,0 +1,158 @@ +#!/bin/bash + +# Dream System: capture-question (PostToolUse Hook, matcher: AskUserQuestion) +# Captures each answered question as a {role:"qa"} row into BOTH the memory and +# dream queues -- high decision-signal content that free-running Stop-hook +# turns miss (the model's own paraphrase of an answer is lossy; the raw Q&A +# pair is not). +# +# PAYLOAD SHAPE (pinned empirically -- see .devflow/docs/handoff-*.md for the +# mining method and exact captured samples): +# Envelope (captured via a scratch-project PostToolUse probe against the +# CURRENT harness version): session_id, transcript_path, cwd, prompt_id, +# permission_mode, effort, hook_event_name, tool_name, tool_input, +# tool_response, tool_use_id, duration_ms. Verified: tool_response is +# byte-identical to the toolUseResult recorded in the transcript JSONL for +# the same tool call. +# tool_input.questions: [{question, header, options:[{label,description}], multiSelect}] +# tool_response (success): {questions:[...], answers:{: }, annotations:{}} +# tool_response (validation error, e.g. missing "questions"): a PLAIN STRING, +# e.g. "InputValidationError: [...]" -- a real sample mined from +# ~/.claude/projects. +# No genuine cancelled/interrupted or free-text ("Other") sample was found +# despite an exhaustive machine-wide search; free text would use the SAME +# answers[question]=string shape as a listed option (confirmed structurally +# from the observed schema, not invented), so no special case is needed. +# +# Handling contract (regardless of shape): one qa row per question; cancelled/ +# absent/errored tool_response -> exit 0, zero writes; non-AskUserQuestion +# tool_name -> exit 0 (defensive, even though the hook is matcher-scoped). + +# Safe no-op fallback: must exist before set -e and before hook-bootstrap is sourced. +dbg() { :; } + +set -e + +# Feedback-loop guard -- before hook-bootstrap to minimize background session overhead. +if [ "${DEVFLOW_BG_UPDATER:-}" = "1" ]; then dbg "EXIT: bg_updater"; exit 0; fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +source "$SCRIPT_DIR/hook-bootstrap" "capture-question" + +source "$SCRIPT_DIR/json-parse" || { echo "capture-question: failed to source json-parse" >&2; exit 1; } +if [ "$_JSON_AVAILABLE" = "false" ]; then dbg "EXIT: no json"; exit 0; fi + +INPUT=$(cat) + +TOOL_NAME=$(printf '%s' "$INPUT" | json_field "tool_name" "") +if [ "$TOOL_NAME" != "AskUserQuestion" ]; then + dbg "EXIT: not AskUserQuestion (tool_name=$TOOL_NAME)" + exit 0 +fi + +CWD=$(printf '%s' "$INPUT" | json_field "cwd" "") +if [ -z "$CWD" ] || [ ! -d "$CWD" ]; then dbg "EXIT: bad CWD"; exit 0; fi + +devflow_debug_set_cwd "$CWD" + +source "$SCRIPT_DIR/resolve-project-root" 2>/dev/null || true +PROJECT_ROOT="$(df_resolve_root "$CWD" 2>/dev/null || true)" +[ -n "$PROJECT_ROOT" ] || PROJECT_ROOT="$CWD" + +DEVFLOW_DIR="$PROJECT_ROOT/.devflow" +MEMORY_DIR="$DEVFLOW_DIR/memory" +DREAM_DIR="$DEVFLOW_DIR/dream" + +# --- Parse questions + answers into "questionanswer" rows (one subprocess) --- +# Defensive against: tool_response absent or a plain string (error case), missing +# questions/answers, multiSelect array answers (joined with "; "), non-object +# tool_input. Tabs/newlines within a question or answer are collapsed to spaces +# so the TAB-delimited row format below stays unambiguous (mirrors +# background-memory-update's own gsub("\n";" ") convention for the same reason). +if [ "$_HAS_JQ" = "true" ]; then + QA_ROWS=$(printf '%s' "$INPUT" | jq -r ' + (.tool_response // {}) as $tr + | if ($tr|type) != "object" then + empty + else + (.tool_input.questions // [])[] as $q + | ($tr.answers // {})[$q.question] as $a + | select($a != null) + | (($q.question // "") | gsub("\n";" ") | gsub("\t";" ")) as $qtext + | ((if ($a|type)=="array" then ($a|join("; ")) else ($a|tostring) end) | gsub("\n";" ") | gsub("\t";" ")) as $atext + | $qtext + "\t" + $atext + end + ' 2>/dev/null || echo "") +else + QA_ROWS=$(printf '%s' "$INPUT" | node -e ' + let raw = ""; + try { raw = require("fs").readFileSync("/dev/stdin", "utf8"); } catch { process.exit(0); } + let input; + try { input = JSON.parse(raw); } catch { process.exit(0); } + const tr = input.tool_response; + if (!tr || typeof tr !== "object") process.exit(0); + const questions = Array.isArray(input.tool_input && input.tool_input.questions) ? input.tool_input.questions : []; + const answers = (tr.answers && typeof tr.answers === "object") ? tr.answers : {}; + const clean = (s) => String(s).replace(/\n/g, " ").replace(/\t/g, " "); + const lines = []; + for (const q of questions) { + if (!q || typeof q.question !== "string") continue; + const a = answers[q.question]; + if (a === undefined || a === null) continue; + const aStr = Array.isArray(a) ? a.join("; ") : String(a); + lines.push(clean(q.question) + "\t" + clean(aStr)); + } + process.stdout.write(lines.join("\n")); + ' 2>/dev/null || echo "") +fi + +if [ -z "$QA_ROWS" ]; then + dbg "EXIT: no answered questions found (cancelled/errored/absent tool_response, or non-object shape)" + exit 0 +fi + +source "$SCRIPT_DIR/queue-append" || { echo "capture-question: failed to source queue-append" >&2; exit 1; } + +# --- AC-P1-style: ONE config fork reads both memory + decisions fields --- +queue_read_gates "$DREAM_DIR/config.json" +MEMORY_ENABLED="$_QG_MEMORY" +DECISIONS_ENABLED="$_QG_DECISIONS" + +if [ "$MEMORY_ENABLED" != "true" ] && [ "$DECISIONS_ENABLED" != "true" ]; then + dbg "EXIT: both features disabled" + exit 0 +fi + +source "$SCRIPT_DIR/hook-log-init" "capture-question" +source "$SCRIPT_DIR/ensure-devflow-init" "$CWD" || exit 0 +source "$SCRIPT_DIR/get-mtime" || { echo "capture-question: failed to source get-mtime" >&2; exit 1; } +source "$SCRIPT_DIR/dream-lock" || { echo "capture-question: failed to source dream-lock" >&2; exit 1; } + +mkdir -p "$MEMORY_DIR" "$DREAM_DIR" 2>/dev/null || true + +_QUESTION_COUNT=0 +while IFS=$'\t' read -r Q A; do + [ -z "$Q" ] && continue + + # Q and A truncated INDEPENDENTLY at 1000 chars each so a long question can't + # swallow the answer. + if [ ${#Q} -gt 1000 ]; then Q="${Q:0:1000}... [truncated]"; fi + if [ ${#A} -gt 1000 ]; then A="${A:0:1000}... [truncated]"; fi + + CONTENT="Q: ${Q} +A: ${A}" + TS=$(date +%s) + queue_append_both \ + "$MEMORY_DIR/.pending-turns.jsonl" "$DREAM_DIR/.pending-turns.jsonl" \ + "$MEMORY_ENABLED" "$DECISIONS_ENABLED" \ + "qa" "$CONTENT" "$TS" + _QUESTION_COUNT=$(( _QUESTION_COUNT + 1 )) +done <&2; exit 1; } +if [ "$_JSON_AVAILABLE" = "false" ]; then dbg "EXIT: no json"; exit 0; fi + +INPUT=$(cat) +dbg "INPUT length=${#INPUT}" + +# Resolve project directory and assistant response in one subprocess via the +# shared json_extract_cwd_field helper (json-parse) -- keeps the SOH delimiter +# and jq/node fallback pair defined in exactly one place, shared with capture-prompt. +_FIELDS=$(printf '%s' "$INPUT" | json_extract_cwd_field "last_assistant_message") || { dbg "EXIT: extraction failed"; exit 0; } +CWD="${_FIELDS%%$'\001'*}" +ASSISTANT_MSG="${_FIELDS#*$'\001'}" + +if [ -z "$CWD" ] || [ ! -d "$CWD" ]; then dbg "EXIT: bad CWD"; exit 0; fi + +devflow_debug_set_cwd "$CWD" +dbg "CWD=$CWD" +# SECURITY: Never log ASSISTANT_MSG or INPUT content -- may contain secrets. +dbg "ASSISTANT_MSG length=${#ASSISTANT_MSG}" + +# Anchor .devflow/ to the project root (prevents a stray nested .devflow/ when this +# hook runs with a CWD inside .devflow/...). Empty → fall back to CWD. +source "$SCRIPT_DIR/resolve-project-root" 2>/dev/null || true +PROJECT_ROOT="$(df_resolve_root "$CWD" 2>/dev/null || true)" +[ -n "$PROJECT_ROOT" ] || PROJECT_ROOT="$CWD" + +DEVFLOW_DIR="$PROJECT_ROOT/.devflow" +MEMORY_DIR="$DEVFLOW_DIR/memory" +DREAM_DIR="$DEVFLOW_DIR/dream" + +# Skip if empty response +if [ -z "$ASSISTANT_MSG" ]; then + dbg "EXIT: empty ASSISTANT_MSG" + exit 0 +fi + +# Truncate to 2000 chars +if [ ${#ASSISTANT_MSG} -gt 2000 ]; then + ASSISTANT_MSG="${ASSISTANT_MSG:0:2000}... [truncated]" +fi + +source "$SCRIPT_DIR/queue-append" || { echo "capture-turn: failed to source queue-append" >&2; exit 1; } + +# --- AC-P1: exactly ONE config-read fork, fetching both memory + decisions fields --- +queue_read_gates "$DREAM_DIR/config.json" +MEMORY_ENABLED="$_QG_MEMORY" +DECISIONS_ENABLED="$_QG_DECISIONS" + +dbg "MEMORY_ENABLED=$MEMORY_ENABLED DECISIONS_ENABLED=$DECISIONS_ENABLED" + +# --- Decisions usage scanner (independent of the memory/dream queue gates below) --- +# D29: Grep-first reorder -- cheap in-process citation check gates the scanner call. +SCANNER="$SCRIPT_DIR/decisions-usage-scan.cjs" +if [ -f "$SCANNER" ] && printf '%s' "$ASSISTANT_MSG" | grep -qE 'ADR-[0-9]+|PF-[0-9]+'; then + if [ "$DECISIONS_ENABLED" = "true" ]; then + dbg "Running decisions usage scanner" + printf '%s' "$ASSISTANT_MSG" | node "$SCANNER" --cwd "$PROJECT_ROOT" 2>/dev/null || true + fi +fi + +if [ "$MEMORY_ENABLED" != "true" ] && [ "$DECISIONS_ENABLED" != "true" ]; then + dbg "EXIT: both features disabled" + exit 0 +fi + +# Logging (needs CWD and at-least-one-feature-enabled path — source after the +# gate check so the log-paths subprocess overhead is only paid when needed) +source "$SCRIPT_DIR/hook-log-init" "capture-turn" + +# Auto-create .devflow/ and ensure .gitignore entries +source "$SCRIPT_DIR/ensure-devflow-init" "$CWD" || exit 0 + +source "$SCRIPT_DIR/get-mtime" || { echo "capture-turn: failed to source get-mtime" >&2; exit 1; } +source "$SCRIPT_DIR/dream-lock" || { echo "capture-turn: failed to source dream-lock" >&2; exit 1; } + +mkdir -p "$MEMORY_DIR" "$DREAM_DIR" 2>/dev/null || true + +TS=$(date +%s) +queue_append_both \ + "$MEMORY_DIR/.pending-turns.jsonl" "$DREAM_DIR/.pending-turns.jsonl" \ + "$MEMORY_ENABLED" "$DECISIONS_ENABLED" \ + "assistant" "$ASSISTANT_MSG" "$TS" + +log "Captured assistant turn (${#ASSISTANT_MSG} chars)" +dbg "=== HOOK COMPLETE ===" +exit 0 diff --git a/scripts/hooks/decisions-usage-scan.cjs b/scripts/hooks/decisions-usage-scan.cjs index 358ddea9..fd71a142 100755 --- a/scripts/hooks/decisions-usage-scan.cjs +++ b/scripts/hooks/decisions-usage-scan.cjs @@ -6,7 +6,7 @@ const fs = require('fs'); const path = require('path'); -const { getMemoryDir, getDecisionsDisabledSentinel, getDecisionsUsagePath, getDecisionsUsageLockDir } = require('./lib/project-paths.cjs'); +const { getMemoryDir, getDecisionsUsagePath, getDecisionsUsageLockDir } = require('./lib/project-paths.cjs'); // Parse --cwd argument const cwdIdx = process.argv.indexOf('--cwd'); @@ -27,9 +27,6 @@ const cwd = path.resolve(rawCwd); const memoryDir = getMemoryDir(cwd); if (!fs.existsSync(memoryDir)) process.exit(0); // no .devflow/memory dir — nothing to scan -// Skip if decisions feature is disabled (sentinel file) -if (fs.existsSync(getDecisionsDisabledSentinel(cwd))) process.exit(0); - // Read stdin synchronously let input = ''; try { diff --git a/scripts/hooks/dream-capture b/scripts/hooks/dream-capture deleted file mode 100755 index dc747ad7..00000000 --- a/scripts/hooks/dream-capture +++ /dev/null @@ -1,211 +0,0 @@ -#!/bin/bash - -# Dream System: dream-capture (Stop Hook) -# Captures assistant responses to .devflow/memory/.pending-turns.jsonl queue. -# After the 120s throttle, spawns a detached `claude -p` worker (background-memory-update) -# to rewrite WORKING-MEMORY.md. No dream marker is written for memory — memory refresh -# now happens eagerly in the background, not via the SessionStart Dream subagent. -# On failure: does nothing (stale memory is better than fake data). - -# Safe no-op fallback: must exist before set -e and before hook-bootstrap is sourced. -dbg() { :; } - -set -e - -# Feedback-loop guards — before hook-bootstrap to minimize background session overhead -if [ "${DEVFLOW_BG_UPDATER:-}" = "1" ]; then dbg "EXIT: bg_updater"; exit 0; fi -if [ "${DEVFLOW_BG_LEARNER:-}" = "1" ]; then dbg "EXIT: bg_learner"; exit 0; fi -if [ "${DEVFLOW_BG_KNOWLEDGE_REFRESH:-}" = "1" ]; then dbg "EXIT: bg_knowledge"; exit 0; fi - -# Resolve script directory once -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -source "$SCRIPT_DIR/hook-bootstrap" "dream-capture" - -# JSON parsing (jq with node fallback) -source "$SCRIPT_DIR/json-parse" || { echo "dream-capture: failed to source json-parse" >&2; exit 1; } -if [ "$_JSON_AVAILABLE" = "false" ]; then dbg "EXIT: no json"; exit 0; fi - -dbg "json available, has_jq=$_HAS_JQ" - -INPUT=$(cat) -dbg "INPUT length=${#INPUT}" -if [ "${DEVFLOW_HOOK_DEBUG:-}" = "1" ]; then dbg "INPUT keys=$(printf '%s' "$INPUT" | jq -r 'keys | join(", ")' 2>/dev/null || echo 'jq-failed')"; fi - -# Resolve project directory and assistant response in one subprocess. -# Stop hook fields: cwd, last_assistant_message, session_id, transcript_path, etc. -# Uses ASCII SOH (0x01) as delimiter; split with parameter expansion (bash 3.2 safe). -if [ "$_HAS_JQ" = "true" ]; then - _FIELDS=$(printf '%s' "$INPUT" | jq -r '(.cwd // "") + "" + (.last_assistant_message // "")') || { dbg "EXIT: jq failed"; exit 0; } -else - _FIELDS=$(printf '%s' "$INPUT" | node -e " - const j=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); - process.stdout.write((j.cwd||'')+'\x01'+(j.last_assistant_message||''))") || { dbg "EXIT: node failed"; exit 0; } -fi -# Split on the first SOH with parameter expansion (newline-safe). NOT cut: cut is -# line-oriented, so a multi-line last_assistant_message leaks its body lines into CWD, -# corrupting the -d directory check below (silent drop of every multi-line turn). -# Mirrors dream-dispatch's CWD/PROMPT split. -CWD="${_FIELDS%%$'\001'*}" -ASSISTANT_MSG="${_FIELDS#*$'\001'}" - -if [ -z "$CWD" ] || [ ! -d "$CWD" ]; then dbg "EXIT: bad CWD"; exit 0; fi - -devflow_debug_set_cwd "$CWD" -dbg "CWD=$CWD" -# SECURITY: Never log ASSISTANT_MSG or INPUT content — may contain secrets. -# Only log metadata (length, keys, presence checks). -dbg "ASSISTANT_MSG length=${#ASSISTANT_MSG}" - -# Anchor .devflow/ to the project root (prevents a stray nested .devflow/ when this -# hook runs with a CWD inside .devflow/...). Empty → fall back to CWD (old behavior). -source "$SCRIPT_DIR/resolve-project-root" 2>/dev/null || true -PROJECT_ROOT="$(df_resolve_root "$CWD" 2>/dev/null || true)" -[ -n "$PROJECT_ROOT" ] || PROJECT_ROOT="$CWD" - -DEVFLOW_DIR="$PROJECT_ROOT/.devflow" -MEMORY_DIR="$DEVFLOW_DIR/memory" -DREAM_DIR="$DEVFLOW_DIR/dream" - -# Read dream config — memory:false gates memory-specific sections only. -# The decisions usage scanner runs independently of memory state. -DREAM_CONFIG="$DREAM_DIR/config.json" -MEMORY_ENABLED="true" -if [ -f "$DREAM_CONFIG" ]; then - MEMORY_ENABLED=$(json_field_file "$DREAM_CONFIG" "memory" "true") -fi - -dbg "MEMORY_ENABLED=$MEMORY_ENABLED" - -# Skip if empty response -if [ -z "$ASSISTANT_MSG" ]; then - dbg "EXIT: empty ASSISTANT_MSG" - exit 0 -fi - -dbg "PASSED all checks, proceeding to capture" - -# Truncate to 2000 chars -if [ ${#ASSISTANT_MSG} -gt 2000 ]; then - ASSISTANT_MSG="${ASSISTANT_MSG:0:2000}... [truncated]" -fi - -# --- Decisions usage scanner (independent of memory state) --- -# D29: Grep-first reorder — cheap in-process citation check gates the expensive config parse. -# Dual-signal: skip when decisions is disabled via config OR sentinel (mirror of dream-evaluate). -# On the common case (no ADR/PF citations) the grep exits false and the config subprocess is -# never forked, preserving the D29 optimization (~5-20ms jq / ~50-100ms node per turn saved). -SCANNER="$SCRIPT_DIR/decisions-usage-scan.cjs" -if [ -f "$SCANNER" ] && printf '%s' "$ASSISTANT_MSG" | grep -qE 'ADR-[0-9]+|PF-[0-9]+'; then - _DEC_ENABLED_CAPTURE="true" - if [ -f "$DREAM_CONFIG" ]; then - _DEC_ENABLED_CAPTURE=$(json_field_file "$DREAM_CONFIG" "decisions" "true") - fi - [ -f "$DEVFLOW_DIR/decisions/.disabled" ] && _DEC_ENABLED_CAPTURE="false" - if [ "$_DEC_ENABLED_CAPTURE" = "true" ]; then - dbg "Running decisions usage scanner" - printf '%s' "$ASSISTANT_MSG" | node "$SCANNER" --cwd "$PROJECT_ROOT" 2>/dev/null || true - fi -fi - -# --- Memory-specific sections (gated by memory:false in dream config) --- -if [ "$MEMORY_ENABLED" = "false" ]; then - dbg "EXIT: memory disabled" - exit 0 -fi - -# Logging (needs CWD and memory-enabled path — source after memory gate so log-paths -# subprocess overhead (~15-20ms) is only paid when memory is actually enabled) -source "$SCRIPT_DIR/hook-log-init" "dream-capture" - -# Auto-create .devflow/ and ensure .gitignore entries -source "$SCRIPT_DIR/ensure-devflow-init" "$CWD" || exit 0 - -source "$SCRIPT_DIR/get-mtime" || { echo "dream-capture: failed to source get-mtime" >&2; exit 1; } -source "$SCRIPT_DIR/dream-lock" || { echo "dream-capture: failed to source dream-lock" >&2; exit 1; } - -# --- Append to queue --- -QUEUE_FILE="$MEMORY_DIR/.pending-turns.jsonl" - -# Ensure queue file has restricted permissions (600) if not yet created. -if [ ! -f "$QUEUE_FILE" ]; then - (umask 077 && touch "$QUEUE_FILE") 2>/dev/null || true -fi - -TS=$(date +%s) -if [ "$_HAS_JQ" = "true" ]; then - jq -n -c --arg role "assistant" --arg content "$ASSISTANT_MSG" --argjson ts "$TS" \ - '{role: $role, content: $content, ts: $ts}' >> "$QUEUE_FILE" -else - node -e "process.stdout.write(JSON.stringify({role:'assistant',content:process.argv[1],ts:parseInt(process.argv[2])})+'\n')" \ - -- "$ASSISTANT_MSG" "$TS" >> "$QUEUE_FILE" -fi - -dbg "Appended to queue: QUEUE_FILE=$QUEUE_FILE" - -# Queue overflow safety: if >200 lines, keep last 100 (locked to prevent multi-session race) -if [ -f "$QUEUE_FILE" ]; then - LINE_COUNT=$(wc -l < "$QUEUE_FILE" | tr -d ' ') - if [ "$LINE_COUNT" -gt 200 ]; then - _QUEUE_LOCK="$MEMORY_DIR/.pending-turns.lock" - if dream_lock_acquire "$_QUEUE_LOCK" 2; then - LINE_COUNT=$(wc -l < "$QUEUE_FILE" | tr -d ' ') - if [ "$LINE_COUNT" -gt 200 ]; then - _TRUNC_TMP="$QUEUE_FILE.tmp.$$" && tail -100 "$QUEUE_FILE" > "$_TRUNC_TMP" && mv "$_TRUNC_TMP" "$QUEUE_FILE" || rm -f "$_TRUNC_TMP" - log "Queue overflow: truncated from $LINE_COUNT to 100 lines" - dbg "Queue overflow: truncated from $LINE_COUNT to 100 lines" - fi - dream_lock_release "$_QUEUE_LOCK" - fi - fi -fi - -log "Captured assistant turn (${#ASSISTANT_MSG} chars)" - -# --- Spawn detached worker if throttle expired --- -# Throttle key: .working-memory-last-trigger mtime (120s window). -# We touch it BEFORE spawning to prevent a second concurrent Stop hook from -# double-spawning within the same 120s window. -TRIGGER_FILE="$MEMORY_DIR/.working-memory-last-trigger" -NOW=$(date +%s) -LAST_TRIGGER=0 -if [ -f "$TRIGGER_FILE" ]; then - LAST_TRIGGER=$(get_mtime "$TRIGGER_FILE") -fi -LAST_TRIGGER="${LAST_TRIGGER:-0}" -TRIGGER_AGE=$(( NOW - LAST_TRIGGER )) - -if [ "$TRIGGER_AGE" -lt 120 ]; then - log "Throttled: worker spawned ${TRIGGER_AGE}s ago" - dbg "EXIT: throttled TRIGGER_AGE=${TRIGGER_AGE}s" - exit 0 -fi - -# Resolve worker and claude binary -UPDATER="$SCRIPT_DIR/background-memory-update" -CLAUDE_BIN=$(command -v claude 2>/dev/null || true) - -if [ -z "$CLAUDE_BIN" ]; then - log "SKIP: claude binary not found — worker not spawned (queue intact)" - dbg "EXIT: no claude binary" - exit 0 -fi - -if [ ! -x "$UPDATER" ]; then - log "SKIP: background-memory-update not found or not executable: $UPDATER" - dbg "EXIT: worker missing" - exit 0 -fi - -dbg "Throttle passed: TRIGGER_AGE=${TRIGGER_AGE}s >= 120s — spawning worker" - -# Touch trigger BEFORE spawning (prevents concurrent Stop hooks from double-spawning) -touch "$TRIGGER_FILE" 2>/dev/null || true - -# Spawn detached worker — nohup + disown so it survives the Stop hook process exit -nohup "$UPDATER" "$CWD" >/dev/null 2>&1 & disown - -log "Spawned background-memory-update worker (CWD=$CWD)" -dbg "=== HOOK COMPLETE ===" - -exit 0 diff --git a/scripts/hooks/dream-collect-tasks b/scripts/hooks/dream-collect-tasks deleted file mode 100644 index 6777702a..00000000 --- a/scripts/hooks/dream-collect-tasks +++ /dev/null @@ -1,256 +0,0 @@ -#!/bin/bash -# Dream helper: dream-collect-tasks (sourced helper) -# Collects pending dream task types from .devflow/dream/*.json markers. -# -# Source this file to get the dream_collect_tasks and dream_build_spawn_directive functions. -# -# Function: dream_collect_tasks DREAM_DIR DECISIONS_ENABLED -# -# Inputs (positional args): -# $1 DREAM_DIR — path to .devflow/dream/ -# $2 DECISIONS_ENABLED — "true"/"false" -# -# Outputs (exported variable): -# _DREAM_TASKS — comma-separated, deduped, sorted list of pending task types. -# Empty string if no pending tasks. -# NOTE: "memory" never appears — memory markers are unconditionally swept -# (background-memory-update worker handles refresh from dream-capture). -# -# Behaviour: -# - Skips config.json (reserved). -# - Pass 1: unconditional sweep — deletes learning.* and memory.* markers always -# (both pipelines removed from Dream subagent); deletes disabled decisions -# markers. When decisions is disabled, curation markers are also swept (curation -# depends on decisions data and should not run when decisions is disabled). -# Unknown types pass through unchanged. -# - Pass 1 runs over ALL markers regardless of cap, so no orphan survives by -# being pushed past position 50. -# - COLLECT_LIMIT=50: when >50 kept markers remain after pass 1, orders by -# mtime OLDEST-FIRST (FIFO) and takes the first 50. get_mtime is invoked -# ONLY on this overflow path — zero stat subprocesses for <=50 markers. -# - Markers beyond the cap (overflow path) are RETAINED (never deleted). -# - Requires: get_mtime() (source get-mtime before calling), _HAS_JQ, dbg(), log() -# - Uses JUST_RECOVERED (if set) to preserve .retries files for just-recovered markers. -# -# No set -e: every path exits cleanly. - -# _DREAM_TASKS is the output variable -_DREAM_TASKS="" - -# _derive_marker_type BASENAME — strips .json suffix then returns the component before the first dot. -# Examples: "decisions.abc123.json" → "decisions", "memory.json" → "memory" -# Called from both pass 1 and pass 2 so the naming rule has one source of truth. -_derive_marker_type() { - local _fbn="${1%.json}" - case "$_fbn" in - *.*) printf '%s' "${_fbn%%.*}" ;; - *) printf '%s' "$_fbn" ;; - esac -} - -dream_collect_tasks() { - local dream_dir="${1:?dream_collect_tasks: dream_dir required}" - local dec_enabled="${2:-true}" - - _DREAM_TASKS="" - - # Guard: dream dir must exist - if [ ! -d "$dream_dir" ]; then - dbg "dream_collect_tasks: no dream dir — skip" - return 0 - fi - - # Guard: no .json markers at all — fast exit - if ! compgen -G "$dream_dir/*.json" > /dev/null 2>&1; then - dbg "dream_collect_tasks: no .json markers — skip" - return 0 - fi - - local collect_limit=50 - - # --------------------------------------------------------------------------- - # Pass 1: single scan over all .json markers. - # - Skip config.json. - # - Derive type via parameter expansion (no basename subprocess). - # - Deletion (unconditional sweep — runs regardless of cap): - # learning → rm -f always (orphan sweep, R1) - # memory → rm -f always (no longer a Dream subagent task) - # decisions → rm -f if dec_enabled != true - # curation / unknown → KEEP (pass through; no flag gates them) - # - Kept markers appended to newline-separated `candidates`; count tracked. - # --------------------------------------------------------------------------- - local candidates="" - local count=0 - local _f _base _type _full_basename - - for _f in "$dream_dir"/*.json; do - [ -f "$_f" ] || continue - _base="${_f##*/}" # filename only — no basename subprocess - # Skip config - case "$_base" in config.json) continue ;; esac - # Derive type: "decisions.abc123.json" → "decisions" (via _derive_marker_type) - _type=$(_derive_marker_type "$_base") - - # Deletion sweep (runs for every marker — not capped) - case "$_type" in - learning) - # Learning pipeline removed — delete any orphaned learning markers on sight (R1) - rm -f "$_f" 2>/dev/null || true - dbg "dream_collect_tasks: deleted orphaned learning marker: $_base" - continue - ;; - memory) - # Memory is no longer a Dream subagent task — background-memory-update worker - # handles refresh directly from dream-capture. Delete any stale memory.* markers - # unconditionally (same treatment as learning.*). - rm -f "$_f" 2>/dev/null || true - dbg "dream_collect_tasks: deleted stale memory marker: $_base" - continue - ;; - decisions) - if [ "$dec_enabled" != "true" ]; then - rm -f "$_f" 2>/dev/null || true - dbg "dream_collect_tasks: deleted disabled-feature marker: $_base" - continue - fi - ;; - curation) - # Curation depends on decisions data — sweep when decisions is disabled - # so stray curation markers don't trigger Dream agent spawns when disabled. - if [ "$dec_enabled" != "true" ]; then - rm -f "$_f" 2>/dev/null || true - dbg "dream_collect_tasks: deleted disabled-feature curation marker: $_base" - continue - fi - ;; - # unknown types: pass through unchanged - esac - - # Marker kept — accumulate - if [ -n "$candidates" ]; then - candidates="${candidates} -${_f}" - else - candidates="${_f}" - fi - count=$(( count + 1 )) - done - - if [ "$count" -eq 0 ]; then - dbg "dream_collect_tasks: no enabled markers found" - return 0 - fi - - # --------------------------------------------------------------------------- - # Cap selection: if count <= collect_limit, use all candidates directly. - # get_mtime is invoked ONLY when count > collect_limit (overflow path). - # --------------------------------------------------------------------------- - local selected - if [ "$count" -le "$collect_limit" ]; then - selected="$candidates" - else - # Overflow: compute mtime per candidate, sort oldest-first, take first 50. - # Format: MTIMEFILEPATH → sort -k1,1n → head -n 50 → strip mtime field. - local mtime_list="" _mt - while IFS= read -r _f; do - [ -f "$_f" ] || continue - _mt=$(get_mtime "$_f" 2>/dev/null || true) - _mt="${_mt:-0}" - if [ -n "$mtime_list" ]; then - mtime_list="${mtime_list} -${_mt} ${_f}" - else - mtime_list="${_mt} ${_f}" - fi - done </dev/null || true ;; - esac - - # Accumulate type - if [ -n "$raw_tasks" ]; then - raw_tasks="${raw_tasks} -${_sel_type}" - else - raw_tasks="${_sel_type}" - fi - done </dev/null || printf '%s\n' "$raw_tasks" | sort -u | tr '\n' ',' | sed 's/,$//') - dbg "dream_collect_tasks: _DREAM_TASKS=$_DREAM_TASKS" - return 0 -} - -# _DREAM_DIRECTIVE is the output variable for dream_build_spawn_directive -_DREAM_DIRECTIVE="" - -# dream_build_spawn_directive TASKS -# Builds the DREAM MAINTENANCE per-task spawn directive for a comma-separated -# task list and assigns it to the global _DREAM_DIRECTIVE (empty if no known -# task types are present). Communicates via a global — mirroring the -# dream_collect_tasks → _DREAM_TASKS contract — rather than stdout, so the -# exact directive bytes (including trailing newlines) survive intact; command -# substitution would strip them. -# -# Hardcoded task→model map: decisions=opus, curation=opus. -# decisions+curation co-pending → exactly ONE opus spawn that -# runs decisions THEN curation sequentially (prevents .decisions.lock contention). -# Unknown task types are skipped (belt-and-suspenders — dream_collect_tasks -# should never emit them). -dream_build_spawn_directive() { - local _dbsd_tasks="$1" - _DREAM_DIRECTIVE="" - - local _dbsd_dec="false" _dbsd_cur="false" - case ",$_dbsd_tasks," in *,decisions,*) _dbsd_dec="true" ;; esac - case ",$_dbsd_tasks," in *,curation,*) _dbsd_cur="true" ;; esac - - local _dbsd_lines="" - if [ "$_dbsd_dec" = "true" ] && [ "$_dbsd_cur" = "true" ]; then - _dbsd_lines="${_dbsd_lines}Agent(subagent_type=\"Dream\", model=\"opus\", run_in_background: true, prompt: \"Process pending decisions and curation marker(s): claim each per your plumbing, then load devflow:dream-decisions and follow it, THEN load devflow:dream-curation and follow it (sequentially, never concurrently).\") -" - elif [ "$_dbsd_dec" = "true" ]; then - _dbsd_lines="${_dbsd_lines}Agent(subagent_type=\"Dream\", model=\"opus\", run_in_background: true, prompt: \"Process pending 'decisions' marker(s): claim per your plumbing, then load devflow:dream-decisions and follow it.\") -" - elif [ "$_dbsd_cur" = "true" ]; then - _dbsd_lines="${_dbsd_lines}Agent(subagent_type=\"Dream\", model=\"opus\", run_in_background: true, prompt: \"Process pending 'curation' marker(s): claim per your plumbing, then load devflow:dream-curation and follow it.\") -" - fi - - # No known task types → leave _DREAM_DIRECTIVE empty so the caller skips emission. - [ -z "$_dbsd_lines" ] && return 0 - - _DREAM_DIRECTIVE="--- DREAM MAINTENANCE (pending from previous session) --- -Pending background maintenance: ${_dbsd_tasks}. -Spawn the following background agents (one non-blocking call each, in order). Do not narrate, summarize, or ask — just spawn them, then continue. -${_dbsd_lines}" - return 0 -} diff --git a/scripts/hooks/dream-dispatch b/scripts/hooks/dream-dispatch deleted file mode 100755 index 364e8265..00000000 --- a/scripts/hooks/dream-dispatch +++ /dev/null @@ -1,85 +0,0 @@ -#!/bin/bash - -# Dream System: dream-dispatch (UserPromptSubmit Hook) -# Capture-only: appends the user turn to .devflow/memory/.pending-turns.jsonl. -# Marker collection and directive emission have moved to session-start-context (Section 2). - -# Safe no-op fallback: must exist before set -e and before hook-bootstrap is sourced. -dbg() { :; } - -set -e - -# Feedback-loop guards — before hook-bootstrap to minimize background session overhead -if [ "${DEVFLOW_BG_UPDATER:-}" = "1" ]; then dbg "EXIT: bg_updater"; exit 0; fi -if [ "${DEVFLOW_BG_LEARNER:-}" = "1" ]; then dbg "EXIT: bg_learner"; exit 0; fi -if [ "${DEVFLOW_BG_KNOWLEDGE_REFRESH:-}" = "1" ]; then dbg "EXIT: bg_knowledge"; exit 0; fi - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -source "$SCRIPT_DIR/hook-bootstrap" "dream-dispatch" - -source "$SCRIPT_DIR/json-parse" || { echo "dream-dispatch: failed to source json-parse" >&2; exit 1; } -if [ "$_JSON_AVAILABLE" = "false" ]; then dbg "EXIT: no json"; exit 0; fi - -INPUT=$(cat) - -FIELDS=$(printf '%s' "$INPUT" | json_extract_cwd_prompt) -CWD="${FIELDS%%$'\001'*}" -PROMPT="${FIELDS#*$'\001'}" - -dbg "CWD=$CWD PROMPT_LENGTH=${#PROMPT}" - -if [ -z "$CWD" ] || [ ! -d "$CWD" ]; then dbg "EXIT: bad CWD"; exit 0; fi - -devflow_debug_set_cwd "$CWD" - -# Anchor .devflow/ to the project root (prevents a stray nested .devflow/ when this -# hook runs with a CWD inside .devflow/...). Empty → fall back to CWD (old behavior). -source "$SCRIPT_DIR/resolve-project-root" 2>/dev/null || true -PROJECT_ROOT="$(df_resolve_root "$CWD" 2>/dev/null || true)" -[ -n "$PROJECT_ROOT" ] || PROJECT_ROOT="$CWD" - -DEVFLOW_DIR="$PROJECT_ROOT/.devflow" -MEMORY_DIR="$DEVFLOW_DIR/memory" -DREAM_DIR="$DEVFLOW_DIR/dream" - -# Normal logging -source "$SCRIPT_DIR/hook-log-init" "dream-dispatch" - -# Read dream config — memory:false skips user turn capture -DREAM_CONFIG="$DREAM_DIR/config.json" -MEMORY_ENABLED="true" -if [ -f "$DREAM_CONFIG" ]; then - MEMORY_ENABLED=$(json_field_file "$DREAM_CONFIG" "memory" "true") -fi - -dbg "memory=$MEMORY_ENABLED" - -# --- Capture user turn --- -if [ "$MEMORY_ENABLED" = "true" ] && [ -n "$PROMPT" ]; then - source "$SCRIPT_DIR/ensure-devflow-init" "$CWD" || exit 0 - - # Truncate to 2000 chars - if [ ${#PROMPT} -gt 2000 ]; then - PROMPT="${PROMPT:0:2000}... [truncated]" - fi - - QUEUE_FILE="$MEMORY_DIR/.pending-turns.jsonl" - - # Ensure queue file is created with restricted permissions (600) before first write. - if [ ! -f "$QUEUE_FILE" ]; then - (umask 077 && touch "$QUEUE_FILE") 2>/dev/null || true - fi - - TS=$(date +%s) - if [ "$_HAS_JQ" = "true" ]; then - jq -n -c --arg role "user" --arg content "$PROMPT" --argjson ts "$TS" \ - '{role: $role, content: $content, ts: $ts}' >> "$QUEUE_FILE" - else - node -e "process.stdout.write(JSON.stringify({role:'user',content:process.argv[1],ts:parseInt(process.argv[2])})+'\n')" \ - -- "$PROMPT" "$TS" >> "$QUEUE_FILE" - fi -fi - -dbg "=== HOOK COMPLETE ===" -exit 0 diff --git a/scripts/hooks/dream-evaluate b/scripts/hooks/dream-evaluate deleted file mode 100755 index 1ba11196..00000000 --- a/scripts/hooks/dream-evaluate +++ /dev/null @@ -1,141 +0,0 @@ -#!/bin/bash - -# Dream System: dream-evaluate (SessionEnd Hook) -# Evaluates session-level features: decisions detection and curation. -# Writes marker files to .devflow/dream/ for dream-dispatch to pick up. -# -# Orchestrator: sources eval-helpers + 2 feature modules after shared setup. - -# Safe no-op fallback: must exist before set -e and before hook-bootstrap is sourced. -dbg() { :; } - -set -e - -# Feedback-loop guards — before hook-bootstrap to minimize background session overhead -# Order: UPDATER, LEARNER, KNOWLEDGE — consistent with dream-capture and dream-dispatch. -# dbg() is the pre-bootstrap no-op here; annotations are safe but silent without debug active. -if [ "${DEVFLOW_BG_UPDATER:-}" = "1" ]; then dbg "EXIT: bg_updater"; exit 0; fi -if [ "${DEVFLOW_BG_LEARNER:-}" = "1" ]; then dbg "EXIT: bg_learner"; exit 0; fi -if [ "${DEVFLOW_BG_KNOWLEDGE_REFRESH:-}" = "1" ]; then dbg "EXIT: bg_knowledge"; exit 0; fi - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -source "$SCRIPT_DIR/hook-bootstrap" "dream-evaluate" - -source "$SCRIPT_DIR/json-parse" || { echo "dream-evaluate: failed to source json-parse" >&2; exit 1; } -if [ "$_JSON_AVAILABLE" = "false" ]; then dbg "EXIT: no json"; exit 0; fi - -INPUT=$(cat) - -CWD=$(printf '%s' "$INPUT" | json_field "cwd" "") -if [ -z "$CWD" ] || [ ! -d "$CWD" ]; then dbg "EXIT: bad CWD"; exit 0; fi - -devflow_debug_set_cwd "$CWD" -dbg "CWD=$CWD" - -# Anchor .devflow/ to the project root (prevents a stray nested .devflow/ when this -# hook runs with a CWD inside .devflow/...). Empty → fall back to CWD (old behavior). -source "$SCRIPT_DIR/resolve-project-root" 2>/dev/null || true -PROJECT_ROOT="$(df_resolve_root "$CWD" 2>/dev/null || true)" -[ -n "$PROJECT_ROOT" ] || PROJECT_ROOT="$CWD" - -DEVFLOW_DIR="$PROJECT_ROOT/.devflow" -[ ! -d "$DEVFLOW_DIR" ] && exit 0 - -MEMORY_DIR="$DEVFLOW_DIR/memory" -DREAM_DIR="$DEVFLOW_DIR/dream" -DECISIONS_DIR_DATA="$DEVFLOW_DIR/decisions" - -# Read dream config -DREAM_CONFIG="$DREAM_DIR/config.json" -DECISIONS_ENABLED="true" - -if [ -f "$DREAM_CONFIG" ]; then - DECISIONS_ENABLED=$(json_field_file "$DREAM_CONFIG" "decisions" "true") -fi - -# Dual-signal gate: config OR sentinel disables decisions (and transitively curation). -# This ensures session-start-context and dream-evaluate honour the same signal so -# stray markers are never written when decisions is disabled via the sentinel alone. -[ -f "$DECISIONS_DIR_DATA/.disabled" ] && DECISIONS_ENABLED="false" - -# Log + dream-lock -source "$SCRIPT_DIR/hook-log-init" "dream-evaluate" -source "$SCRIPT_DIR/dream-lock" || { log "failed to source dream-lock"; exit 1; } - -log "SessionEnd hook triggered" -dbg "DECISIONS=$DECISIONS_ENABLED" - -# --- Find transcript --- -ENCODED_CWD=$(echo "$CWD" | sed 's|^/||' | tr '/' '-') -PROJECTS_DIR="$HOME/.claude/projects/-${ENCODED_CWD}" - -SESSION_ID=$(printf '%s' "$INPUT" | json_field "session_id" "") -dbg "SESSION_ID=$SESSION_ID" -TRANSCRIPT="" - -if [ -d "$PROJECTS_DIR" ]; then - # Validate session_id before use in path — guard against path traversal (../ sequences) - if [ -n "$SESSION_ID" ] && echo "$SESSION_ID" | grep -qE '^[a-zA-Z0-9_-]+$' && [ -f "$PROJECTS_DIR/${SESSION_ID}.jsonl" ]; then - TRANSCRIPT="$PROJECTS_DIR/${SESSION_ID}.jsonl" - else - # Fallback: sort all session JSONL files by mtime and pick the newest. - # Assumption: PROJECTS_DIR contains O(hundreds) of files at most — bounded by - # Claude Code's own session retention policy, so ls -t overhead is acceptable. - TRANSCRIPT=$(ls -t "$PROJECTS_DIR"/*.jsonl 2>/dev/null | head -1 || true) - [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ] && TRANSCRIPT="" - [ -n "$TRANSCRIPT" ] && SESSION_ID=$(basename "$TRANSCRIPT" .jsonl) - fi -fi - -# Session depth check (min 3 user turns) -SESSION_DEEP="false" -if [ -n "$TRANSCRIPT" ] && [ -f "$TRANSCRIPT" ]; then - # Use jq for accurate top-level type matching; grep may over-count when the literal appears in content - if [ "$_HAS_JQ" = "true" ]; then - USER_TURNS=$(jq -c 'select(.type == "user")' "$TRANSCRIPT" 2>/dev/null | wc -l | tr -d ' ') - else - USER_TURNS=$(grep -c '"type":"user"' "$TRANSCRIPT" 2>/dev/null || echo "0") - fi - if [ "$USER_TURNS" -ge 3 ]; then - SESSION_DEEP="true" - log "Session depth: $USER_TURNS turns" - dbg "SESSION_DEEP=true USER_TURNS=$USER_TURNS TRANSCRIPT=$TRANSCRIPT" - else - log "Shallow session ($USER_TURNS turns)" - dbg "SESSION_DEEP=false USER_TURNS=$USER_TURNS" - fi -else - log "No transcript found" - dbg "No transcript found" -fi - -mkdir -p "$DREAM_DIR" - -NOW=$(date +%s) -TODAY=$(date '+%Y-%m-%d') -FILTER_LIB="$SCRIPT_DIR/lib/transcript-filter.cjs" -MARKER_SUFFIX="${SESSION_ID:-$$}" - -# --- Feature modules --- -# -# Orchestrator-exported contract: the following shell variables are set before -# eval-* modules are sourced and are consumed by one or more of them. -# NOW — epoch seconds (date +%s) -# TODAY — YYYY-MM-DD (date '+%Y-%m-%d') -# SESSION_ID — transcript session identifier (string) -# TRANSCRIPT — absolute path to session JSONL file (may be empty) -# SESSION_DEEP — "true" if session has ≥3 user turns -# FILTER_LIB — absolute path to transcript-filter.cjs -# MARKER_SUFFIX — suffix for per-session marker filenames (SESSION_ID or $$) -# DREAM_DIR — absolute path to .devflow/dream/ -# DECISIONS_DIR_DATA — absolute path to .devflow/decisions/ -# DECISIONS_ENABLED — "true"/"false" -# _HAS_JQ — "true" if jq is available on PATH -# -source "$SCRIPT_DIR/eval-helpers" -source "$SCRIPT_DIR/eval-decisions" -source "$SCRIPT_DIR/eval-curation" - -dbg "=== HOOK COMPLETE ===" -exit 0 diff --git a/scripts/hooks/dream-lock b/scripts/hooks/dream-lock index a13a7f2b..648de636 100755 --- a/scripts/hooks/dream-lock +++ b/scripts/hooks/dream-lock @@ -1,5 +1,12 @@ #!/bin/bash -# mkdir-based locking for macOS bash 3.2 (no flock) +# mkdir-based locking, portable across BSD (macOS) and GNU (Linux) stat. +# +# Source-order requirement: source get-mtime (scripts/hooks/get-mtime) BEFORE +# sourcing this file so get_mtime() is defined and stale-lock detection uses a +# real mtime. If get_mtime is not yet defined when a lock_dir already exists, +# this file falls back to treating the lock as maximally stale (immediate +# break) rather than crashing on a BSD-only stat flag — safe-by-construction, +# never a hard failure, but source get-mtime first for accurate staleness. dream_lock_acquire() { local lock_dir="$1" @@ -9,8 +16,14 @@ dream_lock_acquire() { # Break stale locks if [ -d "$lock_dir" ]; then - local lock_age - lock_age=$(( $(date +%s) - $(stat -f %m "$lock_dir" 2>/dev/null || echo 0) )) + local lock_age lock_mtime + if declare -F get_mtime >/dev/null 2>&1; then + lock_mtime=$(get_mtime "$lock_dir") + else + lock_mtime="" + fi + lock_mtime="${lock_mtime:-0}" + lock_age=$(( $(date +%s) - lock_mtime )) if [ "$lock_age" -gt "$stale_threshold" ]; then rmdir "$lock_dir" 2>/dev/null || true fi diff --git a/scripts/hooks/dream-recover b/scripts/hooks/dream-recover deleted file mode 100644 index 03058c82..00000000 --- a/scripts/hooks/dream-recover +++ /dev/null @@ -1,185 +0,0 @@ -#!/bin/bash -# Dream helper: dream-recover (sourced helper) -# Recovers stale .processing markers back to .json for retry. -# -# Source this file to get the dream_recover_stale function. -# -# Function: dream_recover_stale DREAM_DIR MEMORY_DIR -# -# Inputs (positional args): -# $1 DREAM_DIR — path to .devflow/dream/ -# $2 MEMORY_DIR — path to .devflow/memory/ (for pending-turns.processing recovery) -# -# Outputs (exported variable): -# JUST_RECOVERED — space-prefixed space-separated list of full basenames (without .processing) -# that were recovered in this invocation. -# Consumed by dream_collect_tasks to preserve .retries files. -# -# Behaviour: -# - PROC_LIMIT=10: cap on .processing files examined per run. -# - MAX_RETRIES=3: on 3rd failed attempt, rename to .failed (do NOT delete). -# - .failed cleanup only when older than 24h. -# - JUST_RECOVERED guard: recovered markers have their .retries preserved (not reset). -# - Per-type stale thresholds: -# memory = 300s (5 minutes) -# decisions = 1800s (30 minutes) -# unknown = 1800s (default) -# - Also recovers orphaned .pending-turns.processing: -# If MEMORY_DIR/.pending-turns.processing exists and is older than 300s, -# rename it back to .pending-turns.jsonl ONLY if .pending-turns.jsonl -# does not already exist. If .jsonl already exists, leave .processing -# for manual/next-run handling (non-destructive). -# - Requires: get_mtime() (source get-mtime before calling), dbg(), log() -# - No set -e: every path exits cleanly. -# -# D56a: Per-type stale thresholds. -# memory = 300s (5 min) because the memory agent is fast — if it is still -# running after 5 minutes the session almost certainly crashed. All other -# types (decisions, curation) run the LLM and may take up to 30 minutes on -# large logs or slow models; 1800s avoids yanking an actively-running Dream agent. -# -# D56b: JUST_RECOVERED guard. -# When dream_recover_stale renames a .processing back to .json, it records -# the basename in JUST_RECOVERED. dream_collect_tasks reads this list and -# preserves the existing .retries counter for recovered markers instead of -# resetting it. Without this guard, a marker that has failed 2/3 times would -# have its counter silently reset to 0 on recovery, defeating MAX_RETRIES. -# -# D56c: Orphaned .pending-turns.processing non-clobber recovery. -# The memory agent renames .pending-turns.jsonl → .pending-turns.processing -# as an atomic claim. If the session crashes mid-processing, .jsonl is gone -# and .processing is orphaned. Recovery renames it back ONLY when .jsonl does -# not already exist — this prevents clobbering a fresh .jsonl that a concurrent -# session may have written while the prior session's .processing was stale. - -# JUST_RECOVERED is the output variable — consumed by dream_collect_tasks -JUST_RECOVERED="" - -# _dream_stale_threshold TYPE → echoes threshold in seconds for that marker type -_dream_stale_threshold() { - case "${1:-}" in - memory) echo "300" ;; - *) echo "1800" ;; - esac -} - -dream_recover_stale() { - local dream_dir="${1:?dream_recover_stale: dream_dir required}" - local memory_dir="${2:?dream_recover_stale: memory_dir required}" - - JUST_RECOVERED="" - - local now - now=$(date +%s) - - # --- Clean up .failed markers older than 24h --- - if compgen -G "$dream_dir/*.failed" > /dev/null 2>&1; then - for _failed_file in "$dream_dir"/*.failed; do - [ -f "$_failed_file" ] || continue - local _failed_mtime - _failed_mtime=$(get_mtime "$_failed_file" 2>/dev/null || true) - _failed_mtime="${_failed_mtime:-0}" - local _failed_age=$(( now - _failed_mtime )) - if [ "$_failed_age" -gt 86400 ]; then - rm -f "$_failed_file" 2>/dev/null || true - dbg "dream_recover_stale: removed old .failed: $(basename "$_failed_file")" - fi - done - fi - - # --- Recover stale .processing markers --- - if ! compgen -G "$dream_dir/*.processing" > /dev/null 2>&1; then - dbg "dream_recover_stale: no .processing markers" - else - local proc_count=0 - local proc_limit=10 - local max_retries=3 - for _proc_file in "$dream_dir"/*.processing; do - [ -f "$_proc_file" ] || continue - proc_count=$(( proc_count + 1 )) - [ "$proc_count" -gt "$proc_limit" ] && break - - local _proc_mtime - _proc_mtime=$(get_mtime "$_proc_file" 2>/dev/null || true) - _proc_mtime="${_proc_mtime:-0}" - local _proc_age=$(( now - _proc_mtime )) - - # Derive type from filename: {type}.{session}.processing - local _proc_basename - _proc_basename=$(basename "$_proc_file" .processing) - local _proc_type - case "$_proc_basename" in - *.*) _proc_type="${_proc_basename%%.*}" ;; - *) _proc_type="$_proc_basename" ;; - esac - - local _threshold - _threshold=$(_dream_stale_threshold "$_proc_type") - - if [ "$_proc_age" -gt "$_threshold" ]; then - local _base="${_proc_file%.processing}" - local _retry_file="${_base}.retries" - local _retry_count=0 - if [ -f "$_retry_file" ]; then - _retry_count=$(cat "$_retry_file" 2>/dev/null | tr -dc '0-9' || true) - _retry_count="${_retry_count:-0}" - fi - - if [ "$_retry_count" -ge "$max_retries" ]; then - # Too many retries — mark as permanently failed - mv "$_proc_file" "${_base}.failed" 2>/dev/null || true - rm -f "$_retry_file" 2>/dev/null || true - dbg "dream_recover_stale: permanently failed after $_retry_count retries: $(basename "$_base")" - log "Stale marker permanently failed after $_retry_count retries: $(basename "$_base")" - else - # Bump retry counter and recover back to .json - echo $(( _retry_count + 1 )) > "$_retry_file" - mv "$_proc_file" "${_base}.json" 2>/dev/null || true - JUST_RECOVERED="${JUST_RECOVERED} ${_proc_basename}" - dbg "dream_recover_stale: recovered (retry $(( _retry_count + 1 ))/$max_retries): $(basename "$_base")" - log "Stale marker recovered (retry $(( _retry_count + 1 ))/$max_retries): $(basename "$_base")" - fi - fi - done - fi - - # --- Recover orphaned .pending-turns.processing --- - local _pt_proc="$memory_dir/.pending-turns.processing" - local _pt_jsonl="$memory_dir/.pending-turns.jsonl" - if [ -f "$_pt_proc" ]; then - local _pt_mtime - _pt_mtime=$(get_mtime "$_pt_proc" 2>/dev/null || true) - _pt_mtime="${_pt_mtime:-0}" - local _pt_age=$(( now - _pt_mtime )) - local _pt_threshold - _pt_threshold=$(_dream_stale_threshold "memory") - if [ "$_pt_age" -gt "$_pt_threshold" ]; then - # Cold-path fallback: recovery is intentionally lock-free (no .working-memory.lock - # acquisition here). The worker (background-memory-update) is the PRIMARY recovery - # owner: it runs under .working-memory.lock and re-merges a leftover .processing - # into its own batch. This fallback fires only when the worker never re-spawned - # (i.e., SessionStart arrives >300s after the crash with no intervening Stop hook). - # - # Safe-by-construction: STALE_THRESHOLD (300s) strictly exceeds the worker's - # watchdog total (WATCHDOG_SECS 120s + WATCHDOG_KILL_GRACE_SECS 5s + margin ≈ 125s). - # By the time we can act, any live worker's claude -p has already been watchdog-killed - # and the worker has exited — the .working-memory.lock is gone. - # - # INVARIANT: if STALE_THRESHOLD is ever raised to ≥ worker-watchdog-total, the - # lock-free assumption breaks and a race window opens between this recovery and - # the worker's merge block. Preserve STALE_THRESHOLD > watchdog-total at all times. - if [ ! -f "$_pt_jsonl" ]; then - # Safe to recover — no .jsonl exists to be clobbered - mv "$_pt_proc" "$_pt_jsonl" 2>/dev/null || true - dbg "dream_recover_stale: recovered orphaned .pending-turns.processing → .pending-turns.jsonl" - log "Recovered orphaned .pending-turns.processing → .pending-turns.jsonl" - else - # .jsonl already exists — leave .processing for manual/next-run handling - dbg "dream_recover_stale: .pending-turns.processing stale but .pending-turns.jsonl exists — leaving for next run" - log "Stale .pending-turns.processing skipped: .pending-turns.jsonl already exists" - fi - fi - fi - - return 0 -} diff --git a/scripts/hooks/ensure-devflow-init b/scripts/hooks/ensure-devflow-init index 67f9d8d1..da99e87f 100755 --- a/scripts/hooks/ensure-devflow-init +++ b/scripts/hooks/ensure-devflow-init @@ -1,7 +1,7 @@ #!/bin/bash # Ensures .devflow/ and all subdirectories exist and the project root .gitignore -# ignores .devflow/. Called from dream-capture, dream-dispatch, and pre-compact-memory. -# Idempotent. +# ignores .devflow/. Called from capture-prompt, capture-turn, capture-question, +# memory-worker, and pre-compact-memory. Idempotent. # Usage: source ensure-devflow-init "$CWD" [ -z "$1" ] && return 1 diff --git a/scripts/hooks/eval-curation b/scripts/hooks/eval-curation deleted file mode 100644 index 663abc1e..00000000 --- a/scripts/hooks/eval-curation +++ /dev/null @@ -1,81 +0,0 @@ -#!/bin/bash -# dream-evaluate: Curation evaluation module. -# Writes a throttled curation marker at most once per 7 days. -# Curation is gated by DECISIONS_ENABLED — when decisions is disabled (via -# dream config or .disabled sentinel), curation is also skipped so that -# disabling decisions does not stamp .curation-last and cause a 7-day -# suppression window after re-enable. -# Source from dream-evaluate orchestrator after eval-helpers. -# -# Requires (from orchestrator namespace): -# DECISIONS_ENABLED, DREAM_DIR, NOW, MARKER_SUFFIX, -# _HAS_JQ, log(), dbg() -# -# D58: 7-day curation throttle. -# Curation (deprecating/merging ADR/PF entries) is expensive LLM work that -# produces at most 5 changes per run. Running it every session would burn -# tokens without meaningful benefit — decisions files rarely need pruning more -# than once a week. The 7-day (604800s) throttle is stored in .curation-last -# (epoch timestamp, atomic write via temp+mv). The throttle is written -# OPTIMISTICALLY at marker-write time (before the LLM actually runs the -# curation), so subsequent sessions in the same window are skipped without -# needing to check whether the LLM task completed. If the LLM task fails, the -# next window will pick it up. This is intentional: a failed curation is less -# costly than repeatedly spawning curation work in a retry storm. - -: "${DECISIONS_ENABLED:?eval-curation: DECISIONS_ENABLED must be set by orchestrator}" -: "${DREAM_DIR:?eval-curation: DREAM_DIR must be set by orchestrator}" -: "${NOW:?eval-curation: NOW must be set by orchestrator}" -: "${MARKER_SUFFIX:?eval-curation: MARKER_SUFFIX must be set by orchestrator}" -: "${_HAS_JQ:?eval-curation: _HAS_JQ must be set by orchestrator}" - -# Gate: curation inherits decisions enabled-state. When decisions is disabled, -# skip immediately — BEFORE reading or writing .curation-last — so disabling -# decisions does not burn a 7-day suppression window. -# Use return (not exit): this file is sourced under set -e in dream-evaluate; -# exit would kill the whole orchestrator process. -if [ "$DECISIONS_ENABLED" != "true" ]; then - log "Curation skipped: decisions disabled" - dbg "eval-curation: DECISIONS_ENABLED=$DECISIONS_ENABLED — returning" - return 0 -fi - -# Throttle: at most once per 7 days (604800 seconds). -_CUR_THROTTLE=604800 -_CUR_LAST_FILE="$DREAM_DIR/.curation-last" - -_CUR_LAST=0 -if [ -f "$_CUR_LAST_FILE" ]; then - _CUR_LAST=$(tr -dc '0-9' < "$_CUR_LAST_FILE" 2>/dev/null || true) - _CUR_LAST="${_CUR_LAST:-0}" -fi -_CUR_AGE=$(( NOW - _CUR_LAST )) - -dbg "Curation: AGE=${_CUR_AGE}s (throttle=${_CUR_THROTTLE}s)" - -if [ "$_CUR_AGE" -lt "$_CUR_THROTTLE" ]; then - log "Curation throttled: last run ${_CUR_AGE}s ago" - dbg "Curation throttled: AGE=${_CUR_AGE}s" -else - # Write curation marker (per-session name for concurrency safety) - _CUR_MARKER="$DREAM_DIR/curation.${MARKER_SUFFIX}.json" - _CUR_TMP="${_CUR_MARKER}.tmp.$$" - - if [ "$_HAS_JQ" = "true" ]; then - jq -n \ - --argjson timestamp "$NOW" \ - '{timestamp: $timestamp}' \ - > "$_CUR_TMP" && mv "$_CUR_TMP" "$_CUR_MARKER" || rm -f "$_CUR_TMP" - else - node -e " - process.stdout.write(JSON.stringify({timestamp: parseInt(process.argv[1])}) + '\n') - " -- "$NOW" > "$_CUR_TMP" && mv "$_CUR_TMP" "$_CUR_MARKER" || rm -f "$_CUR_TMP" - fi - - # Optimistically update throttle marker so subsequent sessions in the same 7-day - # window are skipped — the dream agent does the actual curation work. - printf '%s' "$NOW" > "$_CUR_LAST_FILE" 2>/dev/null || true - - log "Wrote curation marker" - dbg "Wrote curation marker: $_CUR_MARKER" -fi diff --git a/scripts/hooks/eval-decisions b/scripts/hooks/eval-decisions deleted file mode 100644 index f033c19c..00000000 --- a/scripts/hooks/eval-decisions +++ /dev/null @@ -1,79 +0,0 @@ -#!/bin/bash -# dream-evaluate: Decisions evaluation module. -# Extracts dialog pairs from transcript and writes decisions marker. -# Source from dream-evaluate orchestrator after eval-helpers. -# -# Requires (from orchestrator namespace): -# DECISIONS_ENABLED, SESSION_DEEP, DREAM_DIR, DECISIONS_DIR_DATA, -# FILTER_LIB, TRANSCRIPT, NOW, TODAY, MARKER_SUFFIX, -# _HAS_JQ, json_field_file(), log(), dbg(), -# read_daily_cap(), atomic_increment_daily(), load_existing_ids() - -: "${DREAM_DIR:?eval-decisions: DREAM_DIR must be set by orchestrator}" -: "${DECISIONS_DIR_DATA:?eval-decisions: DECISIONS_DIR_DATA must be set by orchestrator}" -: "${NOW:?eval-decisions: NOW must be set by orchestrator}" -: "${TODAY:?eval-decisions: TODAY must be set by orchestrator}" -: "${MARKER_SUFFIX:?eval-decisions: MARKER_SUFFIX must be set by orchestrator}" -: "${_HAS_JQ:?eval-decisions: _HAS_JQ must be set by orchestrator}" - -if [ "$DECISIONS_ENABLED" = "true" ] && [ "$SESSION_DEEP" = "true" ]; then - log "Evaluating decisions..." - - # Daily cap check - _DEC_RUNS_FILE="$DREAM_DIR/.decisions-runs-today" - _DEC_RUNS_TODAY=$(read_daily_cap "$_DEC_RUNS_FILE" 0) - - _DEC_MAX_RUNS=3 - _DEC_CONFIG="$DECISIONS_DIR_DATA/decisions.json" - if [ -f "$_DEC_CONFIG" ]; then - _DEC_MAX_RUNS=$(json_field_file "$_DEC_CONFIG" "max_daily_runs" "3") - fi - _DEC_MAX_RUNS=$(echo "$_DEC_MAX_RUNS" | tr -dc '0-9') - _DEC_MAX_RUNS="${_DEC_MAX_RUNS:-3}" - - dbg "Decisions: RUNS_TODAY=$_DEC_RUNS_TODAY MAX_RUNS=$_DEC_MAX_RUNS" - if [ "$_DEC_RUNS_TODAY" -ge "$_DEC_MAX_RUNS" ]; then - log "Decisions daily cap reached ($_DEC_RUNS_TODAY/$_DEC_MAX_RUNS)" - dbg "Decisions daily cap reached — skipping" - else - # Extract dialog pairs from transcript - _DEC_DIALOG_PAIRS="" - if [ -f "$FILTER_LIB" ] && [ -n "$TRANSCRIPT" ] && [ -f "$TRANSCRIPT" ]; then - _DEC_DIALOG_PAIRS=$(node "$FILTER_LIB" dialog-pairs "$TRANSCRIPT" 2>/dev/null || true) - fi - - if [ -n "$_DEC_DIALOG_PAIRS" ]; then - # Load existing observation IDs - _DEC_EXISTING_IDS=$(load_existing_ids "$DECISIONS_DIR_DATA/decisions-log.jsonl") - - # Write decisions marker (per-session name for concurrency safety) - _DEC_MARKER="$DREAM_DIR/decisions.${MARKER_SUFFIX}.json" - _DEC_TMP="${_DEC_MARKER}.tmp.$$" - if [ "$_HAS_JQ" = "true" ]; then - jq -n \ - --arg dialogPairs "$_DEC_DIALOG_PAIRS" \ - --argjson existingObservationIds "$_DEC_EXISTING_IDS" \ - --argjson timestamp "$NOW" \ - '{dialogPairs: $dialogPairs, existingObservationIds: $existingObservationIds, timestamp: $timestamp}' \ - > "$_DEC_TMP" && mv "$_DEC_TMP" "$_DEC_MARKER" || rm -f "$_DEC_TMP" - else - node -e " - process.stdout.write(JSON.stringify({ - dialogPairs: process.argv[1], - existingObservationIds: JSON.parse(process.argv[2]), - timestamp: parseInt(process.argv[3]) - }) + '\n') - " -- "$_DEC_DIALOG_PAIRS" "$_DEC_EXISTING_IDS" "$NOW" > "$_DEC_TMP" && mv "$_DEC_TMP" "$_DEC_MARKER" || rm -f "$_DEC_TMP" - fi - - # Update daily cap - atomic_increment_daily "$_DEC_RUNS_FILE" "$TODAY" - - log "Wrote decisions marker" - dbg "Wrote decisions marker: $_DEC_MARKER" - else - log "Decisions: no dialog pairs extracted" - dbg "Decisions: no dialog pairs in transcript" - fi - fi -fi diff --git a/scripts/hooks/eval-helpers b/scripts/hooks/eval-helpers deleted file mode 100644 index a7be3dda..00000000 --- a/scripts/hooks/eval-helpers +++ /dev/null @@ -1,70 +0,0 @@ -#!/bin/bash -# Shared utility functions for dream-evaluate feature modules. -# Source this file from the dream-evaluate orchestrator before sourcing eval-* modules. -# -# Exports: read_daily_cap(), atomic_increment_daily(), load_existing_ids(), -# _eval_release_lock() -# -# Requires: $TODAY, dream_lock_acquire/dream_lock_release (from dream-lock), -# $_HAS_JQ, json_field_file (from json-parse). - -# read_daily_cap FILE DEFAULT → echoes today's run count (0 if file absent or dated yesterday) -read_daily_cap() { - local runs_file="$1" default="${2:-0}" - local count=0 - if [ -f "$runs_file" ]; then - local date_field - date_field=$(cut -f1 "$runs_file" 2>/dev/null || true) - if [ "$date_field" = "$TODAY" ]; then - if grep -q "$(printf '\t')" "$runs_file" 2>/dev/null; then - count=$(cut -f2 "$runs_file" 2>/dev/null | tr -dc '0-9') - count="${count:-$default}" - fi - fi - fi - echo "$count" -} - -# atomic_increment_daily RUNS_FILE TODAY_VAL — increment daily counter under lock -atomic_increment_daily() { - local runs_file="$1" today_val="$2" - local lock_dir="${runs_file}.lock" - if dream_lock_acquire "$lock_dir" 3; then - # Re-read under lock for accurate count - local actual - actual=$(read_daily_cap "$runs_file" 0) - printf '%s\t%d\n' "$today_val" "$((actual + 1))" > "$runs_file" - dream_lock_release "$lock_dir" - fi -} - -# load_existing_ids LOG_FILE → echoes JSON array of .id values ([] when absent/empty) -load_existing_ids() { - local log_file="$1" - if [ ! -f "$log_file" ]; then - echo "[]" - return - fi - if [ "$_HAS_JQ" = "true" ]; then - # Stream line-by-line then collect — avoids slurping entire file into memory - jq -c '.id // empty' "$log_file" 2>/dev/null | jq -s '.' 2>/dev/null || echo "[]" - else - node -e " - const readline=require('readline'); - const fs=require('fs'); - const rl=readline.createInterface({input:fs.createReadStream(process.argv[1]),crlfDelay:Infinity}); - const ids=[]; - rl.on('line',l=>{if(!l.trim())return;try{const id=JSON.parse(l).id;if(id)ids.push(id);}catch{}}); - rl.on('close',()=>process.stdout.write(JSON.stringify(ids))); - " -- "$log_file" 2>/dev/null || echo "[]" - fi -} - -# _eval_release_lock LOCK_DIR — shared EXIT trap target for eval-* modules. -# Each module sets: trap '_eval_release_lock "$DREAM_DIR/..lock"' EXIT -# and clears with: trap - EXIT in its normal path. -# Using a shared function prevents the second sourced module from silently -# overwriting the first module's lock-specific trap string. -_eval_release_lock() { - dream_lock_release "${1:-}" -} diff --git a/scripts/hooks/json-helper.cjs b/scripts/hooks/json-helper.cjs index adc260fb..6d6e237c 100755 --- a/scripts/hooks/json-helper.cjs +++ b/scripts/hooks/json-helper.cjs @@ -14,7 +14,7 @@ // construct [--arg k v] Build JSON object with args // update-field [--json] Set field on stdin JSON (--json parses value) // update-fields Apply multiple field updates from stdin JSON -// extract-cwd-prompt Extract cwd + prompt fields, NUL-byte delimited +// extract-cwd-field Extract cwd + arbitrary field, SOH-byte delimited // extract-text-messages Extract text content from Claude message format // merge-evidence Flatten, dedupe, limit to 10 from stdin JSON // slurp-sort [limit] Read JSONL, sort by field desc, limit results @@ -24,9 +24,9 @@ // session-output Build SessionStart output envelope // prompt-output Build UserPromptSubmit output envelope // backup-construct Build pre-compact backup JSON from --arg pairs -// merge-observation Reinforce existing observation by id (D14) -// decisions-usage-scan Scan session context for ADR/PF cite counts -// read-dream Read field from dream JSON (allowed fields only; returns [] on any error) +// assign-anchor Claim next ADR/PF number, render both .md files +// retire-anchor Flip ledger row status, re-render both .md files +// rotate-observations [] [] Archive observing rows older than 30 days 'use strict'; @@ -38,8 +38,6 @@ const args = process.argv.slice(3); const { safePath } = require('./lib/safe-path.cjs'); const { - getDecisionsFilePath, - getPitfallsFilePath, getDecisionsUsagePath, getDecisionsLockDir, getDecisionsLedgerPath, @@ -49,8 +47,6 @@ const { } = require('./lib/project-paths.cjs'); const { initDecisionsContent, - formatDecisionBody, - formatPitfallBody, toLedgerRow, } = require('./lib/decisions-format.cjs'); const { @@ -84,15 +80,6 @@ function parseJsonl(file) { }).filter(Boolean); } -// --- Learning system constants --- -// (Phase 3: deterministic confidence/promotion constants removed. -// The LLM now sets confidence/status/quality_ok fields directly.) - -function learningLog(msg) { - const ts = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); - process.stderr.write(`[${ts}] ${msg}\n`); -} - /** * Strip leading YAML frontmatter from content that the model may have included * despite being told not to. Belt-and-suspenders defense against duplicate frontmatter. @@ -164,26 +151,6 @@ function nextAnchorFromLedger(ledgerRows, type) { return { anchorId: `${prefix}-${nextN}`, nextN }; } -/** - * Count active anchored rows of the given type in the ledger. - * Active = decisions_status is undefined | 'Accepted' | 'Active'. - * - * @param {object[]} ledgerRows - All rows from the ledger - * @param {'decision'|'pitfall'} type - * @returns {number} - */ -function countActiveLedgerRows(ledgerRows, type) { - const INACTIVE = new Set(['Deprecated', 'Superseded', 'Retired']); - let count = 0; - for (const row of ledgerRows) { - if (row.type !== type) continue; - if (!row.anchor_id) continue; - if (row.decisions_status && INACTIVE.has(row.decisions_status)) continue; - count++; - } - return count; -} - /** * Read .decisions-usage.json. Returns {version, entries} or empty default. * @param {string} projectRoot - Path to project root (cwd) @@ -301,12 +268,6 @@ function rotateObservations(logPath, archivePath, nowMs) { return stale.length; } -function mergeEvidence(oldEvidence, newEvidence) { - const flat = [...(oldEvidence || []), ...(newEvidence || [])]; - const unique = [...new Set(flat)]; - return unique.slice(0, 10); -} - function parseArgs(argList) { const result = {}; const jsonArgs = {}; @@ -328,14 +289,6 @@ function parseArgs(argList) { if (require.main === module) { try { - // Route to domain modules first; fall through to the main switch if not handled. - if (op === 'read-dream') { - const dreamOps = require('./lib/dream-ops.cjs'); - if (dreamOps.handle(op, args, process.cwd())) { - process.exit(0); - } - } - switch (op) { case 'get-field': { const input = JSON.parse(readStdin()); @@ -408,15 +361,15 @@ try { break; } - case 'extract-cwd-prompt': { - // Extract cwd and prompt from hook JSON in one pass. - // Outputs: cwd + ASCII SOH (0x01) + prompt (no trailing newline). - // Caller splits with: cut -d$'\001' -f1 and cut -d$'\001' -f2- - // SOH is used (not NUL) for bash 3.2 compatibility with cut. + case 'extract-cwd-field': { + // Extract cwd and an arbitrary top-level field from hook JSON in one pass. + // Outputs: cwd + ASCII SOH (0x01) + field value (no trailing newline). + // Caller splits with bash parameter expansion on $'\001' (bash 3.2 safe). + const field = args[0]; const input = JSON.parse(readStdin()); const cwd = input.cwd || ''; - const prompt = input.prompt || ''; - process.stdout.write(cwd + '\x01' + prompt); + const value = (field && input[field]) || ''; + process.stdout.write(cwd + '\x01' + value); break; } @@ -529,125 +482,6 @@ try { break; } - // ------------------------------------------------------------------------- - // merge-observation - // ID-keyed reinforce op: if the id exists in the log, increment count, merge evidence, - // update last_seen, and store LLM-provided confidence/status/quality_ok verbatim. - // If the id is new, insert a new entry with LLM-provided fields verbatim. - // D11: ID collision with different-type entry → suffix _b to avoid trampling. - // D12: evidence array capped at 10 (FIFO). - // D53: merge-observation is locked EXTERNALLY by the caller (dream agent acquires/ - // releases .devflow/dream/.observations.lock around the Bash subshell call), while - // assign-anchor self-locks INTERNALLY via .decisions.lock. These are two distinct lock - // domains — merge-observation itself never acquires a lock; it relies on the caller to - // serialize concurrent writes. This is intentional: the subshell pattern in the Dream - // agent acquires the lock, invokes this op, and releases — all in a single Bash call. - // ------------------------------------------------------------------------- - case 'merge-observation': { - const logFile = safePath(args[0]); - const newObsJson = args[1]; - let newObs; - try { newObs = JSON.parse(newObsJson); } catch { - process.stderr.write('merge-observation: invalid JSON for new observation\n'); - process.exit(1); - } - - // D54 (E1): self-create parent directory on first write (fresh project, file+dir absent). - // The prior batch-merge op created the dir; merge-observation matches that so callers - // do not need to pre-create the log directory. - const logDir = path.dirname(logFile); - if (!fs.existsSync(logDir)) { - fs.mkdirSync(logDir, { recursive: true }); - } - - let logEntries = []; - if (fs.existsSync(logFile)) { - logEntries = parseJsonl(logFile); - } - const logMap = new Map(logEntries.map(e => [e.id, e])); - const nowIso = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); - - // ID-keyed lookup: match by obs_xxx id only - const existing = logMap.get(newObs.id); - let merged = false; - - if (existing) { - // Reinforce: increment count, merge evidence (FIFO cap 10), update last_seen. - // Store LLM-provided confidence/status/quality_ok verbatim (no recalculation). - const newCount = (existing.observations || 0) + 1; - existing.observations = newCount; - existing.evidence = mergeEvidence(existing.evidence || [], newObs.evidence || []); - if (typeof newObs.confidence === 'number') existing.confidence = newObs.confidence; - if (newObs.status) existing.status = newObs.status; - existing.last_seen = nowIso; - if (newObs.pattern) existing.pattern = newObs.pattern; - if (newObs.details) existing.details = newObs.details; - if (newObs.quality_ok === true) existing.quality_ok = true; - // Passthrough new ledger fields from incoming obs (if LLM sets them) - if (newObs.anchor_id !== undefined) existing.anchor_id = newObs.anchor_id; - if (newObs.date !== undefined) existing.date = newObs.date; - if (newObs.decisions_status !== undefined) existing.decisions_status = newObs.decisions_status; - if (newObs.amendments !== undefined) existing.amendments = newObs.amendments; - if (newObs.raw_body !== undefined) existing.raw_body = newObs.raw_body; - - merged = true; - learningLog(`merge-observation: merged into ${existing.id} (count=${newCount})`); - } else { - // D11: ID collision recovery - let newId = newObs.id; - if (logMap.has(newId)) { - newId = newId + '_b'; - learningLog(`merge-observation: ID collision resolved: ${newObs.id} -> ${newId}`); - } - const entry = { - id: newId, - type: newObs.type, - pattern: newObs.pattern, - confidence: typeof newObs.confidence === 'number' ? newObs.confidence : 0, - observations: 1, - first_seen: nowIso, - last_seen: nowIso, - status: newObs.status || 'observing', - evidence: (newObs.evidence || []).slice(0, 10), - details: newObs.details || '', - quality_ok: newObs.quality_ok === true, - }; - // Passthrough new ledger fields if present on the new obs - if (newObs.anchor_id !== undefined) entry.anchor_id = newObs.anchor_id; - if (newObs.date !== undefined) entry.date = newObs.date; - if (newObs.decisions_status !== undefined) entry.decisions_status = newObs.decisions_status; - if (newObs.amendments !== undefined) entry.amendments = newObs.amendments; - if (newObs.raw_body !== undefined) entry.raw_body = newObs.raw_body; - - logMap.set(newId, entry); - learningLog(`merge-observation: new entry ${newId} confidence=${entry.confidence}`); - } - - writeJsonlAtomic(logFile, Array.from(logMap.values())); - console.log(JSON.stringify({ merged, id: existing ? existing.id : newObs.id })); - break; - } - - // ------------------------------------------------------------------------- - // count-active - // D23: Count active anchored rows from the ledger. - // - // count-active — reads ledger; returns 0 when absent - // - // The legacy .md-file-path calling convention (count-active type) - // has been removed — all projects are now on the ledger model. - // ------------------------------------------------------------------------- - case 'count-active': { - const caArg = safePath(args[0]); - const entryType = args[1]; // 'decision' or 'pitfall' - - const caLedgerPath = getDecisionsLedgerPath(caArg); - const caLedgerRows = parseLedger(caLedgerPath); - const count = countActiveLedgerRows(caLedgerRows, entryType); - console.log(JSON.stringify({ count })); - break; - } - // ------------------------------------------------------------------------- // assign-anchor // AC-A2: Assign next anchor ID for the given type (decision|pitfall) to the @@ -878,7 +712,6 @@ try { // Expose helpers for unit testing (only when required as a module, not run as CLI) if (typeof module !== 'undefined' && module.exports) { module.exports = { - countActiveLedgerRows, readUsageFile, writeUsageFile, registerUsageEntry, diff --git a/scripts/hooks/json-parse b/scripts/hooks/json-parse index 00eb1908..4c2f78a8 100755 --- a/scripts/hooks/json-parse +++ b/scripts/hooks/json-parse @@ -160,22 +160,31 @@ json_array_item() { # --- Multi-field batched extraction --- -# Extract cwd and prompt from stdin JSON in a single subprocess. -# Uses ASCII SOH (U+0001) as delimiter — safe for prompts containing tabs or backslashes, -# and compatible with bash 3.2 parameter expansion (unlike NUL byte). +# Extract cwd and an arbitrary top-level field from stdin JSON in a single +# subprocess. Uses ASCII SOH (U+0001) as delimiter -- safe for field values +# containing tabs or backslashes, and compatible with bash 3.2 parameter +# expansion (unlike NUL byte). Always written as the \u0001 jq escape (never +# a literal control byte) so the delimiter stays visible in source review. # Caller pattern (zero extra subprocesses for the split): -# FIELDS=$(printf '%s' "$INPUT" | json_extract_cwd_prompt) +# FIELDS=$(printf '%s' "$INPUT" | json_extract_cwd_field "last_assistant_message") # CWD="${FIELDS%%$'\001'*}" -# PROMPT="${FIELDS#*$'\001'}" -# This replaces the @tsv/@cut pattern, which corrupts tab chars in prompts to "\t". -json_extract_cwd_prompt() { +# VALUE="${FIELDS#*$'\001'}" +# This replaces the @tsv/@cut pattern, which corrupts tab chars in field values to "\t". +json_extract_cwd_field() { + local field="$1" if [ "$_HAS_JQ" = "true" ]; then - jq -r '(.cwd // "") + "\u0001" + (.prompt // "")' 2>/dev/null + jq -r --arg f "$field" '(.cwd // "") + "\u0001" + (.[$f] // "")' 2>/dev/null else - node "$_JSON_HELPER" extract-cwd-prompt + node "$_JSON_HELPER" extract-cwd-field "$field" fi } +# Thin wrapper over json_extract_cwd_field -- kept as a named function since +# it is the most common call site (capture-prompt, preamble). +json_extract_cwd_prompt() { + json_extract_cwd_field "prompt" +} + # --- Transcript extraction --- # Extract text messages from Claude message JSON. Usage: printf '%s\n' '{"message":...}' | json_extract_messages diff --git a/scripts/hooks/lib/dream-ops.cjs b/scripts/hooks/lib/dream-ops.cjs deleted file mode 100644 index e2cfc20d..00000000 --- a/scripts/hooks/lib/dream-ops.cjs +++ /dev/null @@ -1,54 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const { safePath } = require('./safe-path.cjs'); - -const ALLOWED_FIELDS = new Set(['referencedFiles', 'description']); - -/** - * Handle dream-related operations. - * Returns true if the command was handled, false otherwise. - * - * read-dream : - * Only fields in ALLOWED_FIELDS are permitted. - * Array fields → JSON-stringified array (string elements only) - * String fields → raw string value - * Other/missing/disallowed → '[]' - * - * @param {string} command - * @param {string[]} args - * @param {string} cwd — working directory used as safePath root - * @returns {boolean} - */ -function handle(command, args, cwd) { - if (command !== 'read-dream') return false; - - if (!args[0] || !args[1]) { - console.log('[]'); - return true; - } - - const field = args[1]; - if (!ALLOWED_FIELDS.has(field)) { - console.log('[]'); - return true; - } - - try { - const dreamFile = safePath(args[0], cwd); - const data = JSON.parse(fs.readFileSync(dreamFile, 'utf8')); - const value = data[field]; - if (Array.isArray(value)) { - console.log(JSON.stringify(value.filter(v => typeof v === 'string'))); - } else if (typeof value === 'string') { - console.log(value); - } else { - console.log('[]'); - } - } catch { - console.log('[]'); - } - return true; -} - -module.exports = { handle }; diff --git a/scripts/hooks/lib/project-paths.cjs b/scripts/hooks/lib/project-paths.cjs index 94974fa1..5ad115a2 100644 --- a/scripts/hooks/lib/project-paths.cjs +++ b/scripts/hooks/lib/project-paths.cjs @@ -55,6 +55,16 @@ function getDreamConfigPath(projectRoot) { return path.join(projectRoot, '.devflow', 'dream', 'config.json'); } +/** .devflow/dream/.pending-turns.jsonl — decisions detection queue (dual-write with memory queue) */ +function getDreamPendingTurnsPath(projectRoot) { + return path.join(projectRoot, '.devflow', 'dream', '.pending-turns.jsonl'); +} + +/** .devflow/dream/.pending-turns.processing — atomic claim held by the Dream agent while processing */ +function getDreamPendingTurnsProcessingPath(projectRoot) { + return path.join(projectRoot, '.devflow', 'dream', '.pending-turns.processing'); +} + // --------------------------------------------------------------------------- // Decisions files // --------------------------------------------------------------------------- @@ -69,11 +79,6 @@ function getPitfallsFilePath(projectRoot) { return path.join(projectRoot, '.devflow', 'decisions', 'pitfalls.md'); } -/** .devflow/decisions/.disabled — sentinel that gates decisions sections */ -function getDecisionsDisabledSentinel(projectRoot) { - return path.join(projectRoot, '.devflow', 'decisions', '.disabled'); -} - /** .devflow/decisions/decisions.json — project-level decisions config */ function getDecisionsConfigPath(projectRoot) { return path.join(projectRoot, '.devflow', 'decisions', 'decisions.json'); @@ -124,11 +129,6 @@ function getDecisionsNotificationsPath(projectRoot) { return path.join(projectRoot, '.devflow', 'decisions', '.decisions-notifications.json'); } -/** .devflow/decisions/.decisions-runs-today */ -function getDecisionsRunsTodayPath(projectRoot) { - return path.join(projectRoot, '.devflow', 'decisions', '.decisions-runs-today'); -} - /** .devflow/decisions/.decisions-batch-ids */ function getDecisionsBatchIdsPath(projectRoot) { return path.join(projectRoot, '.devflow', 'decisions', '.decisions-batch-ids'); @@ -213,10 +213,11 @@ module.exports = { getDocsDir, // Dream files getDreamConfigPath, + getDreamPendingTurnsPath, + getDreamPendingTurnsProcessingPath, // Decisions files getDecisionsFilePath, getPitfallsFilePath, - getDecisionsDisabledSentinel, getDecisionsConfigPath, getDecisionsLedgerPath, getDecisionsLogPath, @@ -227,7 +228,6 @@ module.exports = { getDecisionsUsagePath, getDecisionsUsageLockDir, getDecisionsNotificationsPath, - getDecisionsRunsTodayPath, getDecisionsBatchIdsPath, // Memory files getWorkingMemoryPath, diff --git a/scripts/hooks/lib/staleness.cjs b/scripts/hooks/lib/staleness.cjs deleted file mode 100644 index e5f0855e..00000000 --- a/scripts/hooks/lib/staleness.cjs +++ /dev/null @@ -1,115 +0,0 @@ -// scripts/hooks/lib/staleness.cjs -// Staleness detection for learning log entries (D16). -// -// Extracts file path references from an entry's details and evidence fields, -// then checks whether those files still exist on disk. Entries referencing -// missing files are flagged with mayBeStale=true and a staleReason string. -// -// This module is the single source of truth for the staleness algorithm. -// -/** - * D55: Staleness as a processor signal, not a CLI display surface. - * - * Originally this module was wired into the background-learning shell script - * (via `node lib/staleness.cjs`) for direct file annotation, and the CLI - * displayed `⚠ N flagged` counts from per-observation review flags. - * - * After the LLM-driven Dream refactor (Part B): - * - The sole live caller is the SessionStart Dream agent, - * which invokes `node staleness.cjs ` during the learning - * and curation phases to annotate log entries with mayBeStale before - * deciding whether to reinforce or deprecate them. - * - The CLI display surface (the per-observation review flags and `⚠ flagged` - * output lines) has been removed; staleness is now a SIGNAL to the LLM, not - * a user-facing count. - * - The module contract is unchanged: input = JSONL log path + cwd; - * output = in-place file annotation with mayBeStale/staleReason. - */ - -'use strict'; - -const fs = require('fs'); -const path = require('path'); - -// Matches file path tokens ending in recognised source extensions. -// Mirrors the grep pattern in background-learning: -// grep -oE '[A-Za-z0-9_/.-]+\.(ts|tsx|js|cjs|md|sh|py|go|java|rs)' -const FILE_REF_RE = /[A-Za-z0-9_/.-]+\.(ts|tsx|js|cjs|md|sh|py|go|java|rs)/g; - -/** - * Apply staleness detection to an array of log entries. - * - * @param {Record[]} entries - parsed learning-log entries - * @param {string} cwd - project root; relative refs are resolved against this - * @returns {Record[]} entries with mayBeStale/staleReason added where applicable - */ -function checkStaleEntries(entries, cwd) { - return entries.map(entry => { - const combined = `${entry.details || ''} ${(entry.evidence || []).join(' ')}`; - const refs = combined.match(FILE_REF_RE) || []; - const uniqueRefs = [...new Set(refs)]; - - let staleRef = null; - for (const ref of uniqueRefs) { - const absPath = ref.startsWith('/') ? ref : path.join(cwd, ref); - if (!fs.existsSync(absPath)) { - staleRef = ref; - break; - } - } - - if (staleRef !== null) { - return { - ...entry, - mayBeStale: true, - staleReason: `code-ref-missing:${staleRef}`, - }; - } - return entry; - }); -} - -// CLI interface: invoked by background-learning as -// node lib/staleness.cjs -// Reads the JSONL log, applies staleness check, writes updated lines back. -// Exits 0 always (staleness failures are non-fatal). -if (require.main === module) { - const [, , logFile, cwd] = process.argv; - - if (!logFile || !cwd) { - process.stderr.write('Usage: node lib/staleness.cjs \n'); - process.exit(1); - } - - let raw; - try { - raw = fs.readFileSync(logFile, 'utf8'); - } catch { - // Log file missing — nothing to do - process.exit(0); - } - - const lines = raw.split('\n').filter(l => l.trim()); - if (lines.length === 0) process.exit(0); - - let entries; - try { - entries = lines.map(l => JSON.parse(l)); - } catch (err) { - process.stderr.write(`staleness.cjs: failed to parse log: ${err.message}\n`); - process.exit(0); - } - - const updated = checkStaleEntries(entries, cwd); - - const flagged = updated.filter(e => e.mayBeStale).length; - if (flagged > 0) { - const out = updated.map(e => JSON.stringify(e)).join('\n') + '\n'; - fs.writeFileSync(logFile, out, 'utf8'); - process.stdout.write(`Staleness pass: ${flagged} entries flagged\n`); - } - - process.exit(0); -} - -module.exports = { checkStaleEntries, FILE_REF_RE }; diff --git a/scripts/hooks/lib/transcript-filter.cjs b/scripts/hooks/lib/transcript-filter.cjs deleted file mode 100644 index 51afbeb6..00000000 --- a/scripts/hooks/lib/transcript-filter.cjs +++ /dev/null @@ -1,207 +0,0 @@ -// scripts/hooks/lib/transcript-filter.cjs -// -// Channel-based transcript filter for the self-learning pipeline. -// -// DESIGN: D1 — two-channel filter separates USER_SIGNALS (workflow/procedural detection) -// from DIALOG_PAIRS (decision/pitfall detection). These two channels serve different -// upstream purposes: USER_SIGNALS need only clean user text; DIALOG_PAIRS need both -// the preceding assistant context AND the user correction to identify pitfalls and -// decisions with rationale. -// -// DESIGN: D2 — filter rules reject five classes of pollution: -// 1. isMeta:true — hook/system messages -// 2. sourceToolUseID / toolUseResult — tool invocation scaffolding -// 3. Wrapped framework noise (, , etc.) -// 4. tool_result content items in user turns -// 5. Empty turns (<5 chars after trim) -// -// DESIGN: D10 — this module is pure data transformation (no I/O). Called once per batch. -// Kept in a separate testable CJS module so unit tests can import it directly -// without spawning a shell process. - -'use strict'; - -/** - * Regex for framework-injected XML wrappers we must reject. - * Covers: , , , - */ -const FRAMEWORK_NOISE_RE = /^\s*<(command-|local-command-|system-reminder|example)/; - -const CAP_TURNS = 80; -const CAP_TEXT_CHARS = 1200; -const MIN_TEXT_CHARS = 5; - -/** - * Returns true if a string contains framework-injected noise. - * @param {string} text - * @returns {boolean} - */ -function isNoisyText(text) { - return FRAMEWORK_NOISE_RE.test(text); -} - -/** - * Cap text to the per-turn character limit. - * @param {string} text - * @returns {string} - */ -function capText(text) { - return text.length > CAP_TEXT_CHARS ? text.slice(0, CAP_TEXT_CHARS) : text; -} - -/** - * Cleans text content from a user turn. - * For string content: reject if noisy. - * For array content: filter out tool_result items and noisy text items, join remainder. - * - * @param {unknown} content - raw content field from transcript JSON - * @returns {{ ok: boolean, text: string }} - */ -function cleanContent(content) { - if (typeof content === 'string') { - if (isNoisyText(content)) return { ok: false, text: '' }; - const trimmed = content.trim(); - if (trimmed.length < MIN_TEXT_CHARS) return { ok: false, text: '' }; - return { ok: true, text: trimmed }; - } - - if (Array.isArray(content)) { - // Reject entire turn if any item is a tool_result - if (content.some(item => item && item.type === 'tool_result')) { - return { ok: false, text: '' }; - } - // Join text items, excluding noisy ones - const texts = content - .filter(item => item && item.type === 'text' && typeof item.text === 'string') - .map(item => item.text) - .filter(t => !isNoisyText(t)) - .join('\n') - .trim(); - - if (texts.length < MIN_TEXT_CHARS) return { ok: false, text: '' }; - return { ok: true, text: texts }; - } - - return { ok: false, text: '' }; -} - -/** - * Checks whether a transcript line represents a polluted source we should reject. - * DESIGN: D2 — pollution sources listed here must be kept in sync with the spec. - * - * @param {object} entry - parsed JSONL entry - * @returns {boolean} true if the entry should be skipped entirely - */ -function isRejectedEntry(entry) { - if (!entry || typeof entry !== 'object') return true; - // Reject meta/system lines - if (entry.isMeta === true) return true; - // Reject tool scaffolding - if (entry.sourceToolUseID != null) return true; - if (entry.toolUseResult != null) return true; - return false; -} - -/** - * extractChannels — main export. - * - * Parses JSONL transcript content and returns two channels: - * - userSignals: clean user-turn texts (for workflow/procedural detection) - * - dialogPairs: [{prior, user}] tuples (for decision/pitfall detection) - * - * Processing: - * 1. Parse each JSONL line, reject polluted entries (D2) - * 2. Collect user/assistant turns with clean text content - * 3. Cap to last 80 turns, 1200 chars per turn text - * 4. Build USER_SIGNALS from user turns only - * 5. Build DIALOG_PAIRS from (assistant, user) adjacent pairs in the tail - * - * @param {string} jsonlContent - raw JSONL string from transcript file(s) - * @returns {{ userSignals: string[], dialogPairs: Array<{prior: string, user: string}> }} - */ -function extractChannels(jsonlContent) { - const lines = jsonlContent.split('\n').filter(line => line.trim().length > 0); - - /** @type {Array<{role: 'user'|'assistant', text: string, turnId: number}>} */ - const turns = []; - let turnId = 0; - - for (const line of lines) { - let entry; - try { - entry = JSON.parse(line); - } catch { - continue; - } - - if (isRejectedEntry(entry)) continue; - - // Extract the actual message from transcript envelope format - // Transcripts may have: { type, message: { role, content } } - // or direct: { type, content } - const messageType = entry.type; - const message = entry.message || entry; - const role = message.role || messageType; - const content = message.content; - - if (role === 'user') { - const { ok, text } = cleanContent(content); - if (!ok) continue; - turns.push({ role: 'user', text: capText(text), turnId: ++turnId }); - } else if (role === 'assistant') { - // For assistant turns: accept string content or text-array content - const { ok, text } = cleanContent(content); - if (!ok) continue; - // Assistant turn inherits current turnId (not incremented) - turns.push({ role: 'assistant', text: capText(text), turnId }); - } - } - - // Cap to last 80 turns - const tail = turns.length > CAP_TURNS ? turns.slice(turns.length - CAP_TURNS) : turns; - - // Build USER_SIGNALS: texts from user turns only - const userSignals = tail.filter(t => t.role === 'user').map(t => t.text); - - // Build DIALOG_PAIRS: adjacent (assistant, user) pairs in tail - /** @type {Array<{prior: string, user: string}>} */ - const dialogPairs = []; - for (let i = 1; i < tail.length; i++) { - if (tail[i].role === 'user' && tail[i - 1].role === 'assistant') { - dialogPairs.push({ prior: tail[i - 1].text, user: tail[i].text }); - } - } - - return { userSignals, dialogPairs }; -} - -module.exports = { extractChannels }; - -if (require.main === module) { - const argv = process.argv.slice(2); - const subcommand = argv[0]; - const filePath = argv[1]; - - if (!subcommand || !filePath || !['user-signals', 'dialog-pairs'].includes(subcommand)) { - process.stderr.write('Usage: node transcript-filter.cjs \n'); - process.exit(1); - } - - const fs = require('fs'); - let content = ''; - try { - content = fs.readFileSync(filePath, 'utf8'); - } catch { - // Output nothing so shell [ -n "$VAR" ] emptiness checks work correctly. - process.exit(0); - } - - const { userSignals, dialogPairs } = extractChannels(content); - - // Output nothing when results are empty so shell [ -n "$VAR" ] checks work correctly. - // JSON.stringify([]) produces "[]" which is a non-empty string that fools shell emptiness checks. - const results = subcommand === 'user-signals' ? userSignals : dialogPairs; - if (results.length > 0) { - process.stdout.write(JSON.stringify(results) + '\n'); - } -} diff --git a/scripts/hooks/memory-worker b/scripts/hooks/memory-worker new file mode 100755 index 00000000..246cac85 --- /dev/null +++ b/scripts/hooks/memory-worker @@ -0,0 +1,102 @@ +#!/bin/bash + +# Dream System: memory-worker (Stop Hook) +# Owns the 120s-throttle + nohup-spawn logic for background-memory-update. +# Registered AFTER capture-turn in the Stop hook array so append-before-spawn +# ordering is preserved by array position. This hook does NOT append to any +# queue itself -- capture-turn already did that earlier in the same Stop event. + +# Safe no-op fallback: must exist before set -e and before hook-bootstrap is sourced. +dbg() { :; } + +set -e + +if [ "${DEVFLOW_BG_UPDATER:-}" = "1" ]; then dbg "EXIT: bg_updater"; exit 0; fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +source "$SCRIPT_DIR/hook-bootstrap" "memory-worker" + +source "$SCRIPT_DIR/json-parse" || { echo "memory-worker: failed to source json-parse" >&2; exit 1; } +if [ "$_JSON_AVAILABLE" = "false" ]; then dbg "EXIT: no json"; exit 0; fi + +INPUT=$(cat) + +CWD=$(printf '%s' "$INPUT" | json_field "cwd" "") +if [ -z "$CWD" ] || [ ! -d "$CWD" ]; then dbg "EXIT: bad CWD"; exit 0; fi + +devflow_debug_set_cwd "$CWD" +dbg "CWD=$CWD" + +source "$SCRIPT_DIR/resolve-project-root" 2>/dev/null || true +PROJECT_ROOT="$(df_resolve_root "$CWD" 2>/dev/null || true)" +[ -n "$PROJECT_ROOT" ] || PROJECT_ROOT="$CWD" + +DEVFLOW_DIR="$PROJECT_ROOT/.devflow" +MEMORY_DIR="$DEVFLOW_DIR/memory" +DREAM_DIR="$DEVFLOW_DIR/dream" + +# Read dream config -- memory:false gates this hook entirely. +DREAM_CONFIG="$DREAM_DIR/config.json" +MEMORY_ENABLED="true" +if [ -f "$DREAM_CONFIG" ]; then + MEMORY_ENABLED=$(json_field_file "$DREAM_CONFIG" "memory" "true") +fi + +dbg "MEMORY_ENABLED=$MEMORY_ENABLED" + +if [ "$MEMORY_ENABLED" != "true" ]; then + dbg "EXIT: memory disabled" + exit 0 +fi + +source "$SCRIPT_DIR/hook-log-init" "memory-worker" +source "$SCRIPT_DIR/ensure-devflow-init" "$CWD" || exit 0 +source "$SCRIPT_DIR/get-mtime" || { echo "memory-worker: failed to source get-mtime" >&2; exit 1; } + +# --- Spawn detached worker if throttle expired --- +# Throttle key: .working-memory-last-trigger mtime (120s window). We touch it +# BEFORE spawning to prevent a second concurrent Stop hook from double-spawning +# within the same 120s window. +TRIGGER_FILE="$MEMORY_DIR/.working-memory-last-trigger" +NOW=$(date +%s) +LAST_TRIGGER=0 +if [ -f "$TRIGGER_FILE" ]; then + LAST_TRIGGER=$(get_mtime "$TRIGGER_FILE") +fi +LAST_TRIGGER="${LAST_TRIGGER:-0}" +TRIGGER_AGE=$(( NOW - LAST_TRIGGER )) + +if [ "$TRIGGER_AGE" -lt 120 ]; then + log "Throttled: worker spawned ${TRIGGER_AGE}s ago" + dbg "EXIT: throttled TRIGGER_AGE=${TRIGGER_AGE}s" + exit 0 +fi + +# Resolve worker and claude binary +UPDATER="$SCRIPT_DIR/background-memory-update" +CLAUDE_BIN=$(command -v claude 2>/dev/null || true) + +if [ -z "$CLAUDE_BIN" ]; then + log "SKIP: claude binary not found — worker not spawned (queue intact)" + dbg "EXIT: no claude binary" + exit 0 +fi + +if [ ! -x "$UPDATER" ]; then + log "SKIP: background-memory-update not found or not executable: $UPDATER" + dbg "EXIT: worker missing" + exit 0 +fi + +dbg "Throttle passed: TRIGGER_AGE=${TRIGGER_AGE}s >= 120s — spawning worker" + +# Touch trigger BEFORE spawning (prevents concurrent Stop hooks from double-spawning) +touch "$TRIGGER_FILE" 2>/dev/null || true + +# Spawn detached worker — nohup + disown so it survives the Stop hook process exit +nohup "$UPDATER" "$CWD" >/dev/null 2>&1 & disown + +log "Spawned background-memory-update worker (CWD=$CWD)" +dbg "=== HOOK COMPLETE ===" +exit 0 diff --git a/scripts/hooks/pre-compact-memory b/scripts/hooks/pre-compact-memory index 98b80d57..4fb5bf57 100644 --- a/scripts/hooks/pre-compact-memory +++ b/scripts/hooks/pre-compact-memory @@ -12,6 +12,11 @@ dbg() { :; } set -e +# Re-entrancy guard — before hook-bootstrap to minimize background session +# overhead. The memory worker's own claude -p session fires hooks too +# (PreCompact can fire mid-session on a long-running detached worker). +if [ "${DEVFLOW_BG_UPDATER:-}" = "1" ]; then dbg "EXIT: bg_updater"; exit 0; fi + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" source "$SCRIPT_DIR/hook-bootstrap" "pre-compact-memory" @@ -32,7 +37,7 @@ devflow_debug_set_cwd "$CWD" dbg "CWD=$CWD" # Anchor .devflow/ to the project root (prevents a stray nested .devflow/ when this -# hook runs with a CWD inside .devflow/...). Empty → fall back to CWD (old behavior). +# hook runs with a CWD inside .devflow/...). Empty → fall back to CWD. source "$SCRIPT_DIR/resolve-project-root" 2>/dev/null || true PROJECT_ROOT="$(df_resolve_root "$CWD" 2>/dev/null || true)" [ -n "$PROJECT_ROOT" ] || PROJECT_ROOT="$CWD" @@ -44,8 +49,7 @@ DREAM_DIR="$DEVFLOW_DIR/dream" # Normal logging source "$SCRIPT_DIR/hook-log-init" "pre-compact-memory" -# Check dream config — single source of truth for memory enabled/disabled (ADR-001 clean break). -# The legacy .working-memory-disabled sentinel is not checked: dream config supersedes it. +# Check dream config — single source of truth for memory enabled/disabled (ADR-001). DREAM_CONFIG="$DREAM_DIR/config.json" if [ -f "$DREAM_CONFIG" ]; then MEMORY_ENABLED=$(json_field_file "$DREAM_CONFIG" "memory" "true") diff --git a/scripts/hooks/preamble b/scripts/hooks/preamble index ee9c4465..4f0ff129 100755 --- a/scripts/hooks/preamble +++ b/scripts/hooks/preamble @@ -62,9 +62,9 @@ esac shopt -u nocasematch # Fire when the first word is a workflow keyword followed by at least one more word. -# (The former trailing-'?' guard was removed: it produced false negatives on -# command-style prompts that close with a clarifying/rhetorical question, e.g. -# "implement X … should I use Redis?" or "Explore the ambient mode … could it be?".) +# Prompts that end with a question mark still count — command-style prompts often +# close with a clarifying question ("implement X … should I use Redis?"), so a +# trailing-'?' exclusion would produce false negatives. if [[ -n "$SKILL" && -n "$REST" ]]; then dbg "WORKFLOW_KEYWORD detected: $SKILL — injecting directive" json_prompt_output "The user's prompt begins with the \`$SKILL\` keyword, which signals the \`devflow:$SKILL\` workflow. In one short sentence, tell the user you're invoking \`devflow:$SKILL\`. Then immediately invoke it with the Skill tool, passing the user's full request (everything after the leading \`$SKILL\` keyword) as the skill input. Do not pause to ask whether to proceed." diff --git a/scripts/hooks/queue-append b/scripts/hooks/queue-append new file mode 100755 index 00000000..a075d615 --- /dev/null +++ b/scripts/hooks/queue-append @@ -0,0 +1,120 @@ +#!/bin/bash +# queue-append -- shared queue-append helper (sourced, not executed). +# +# Single implementation of: umask-077 file creation, JSONL emit (jq --arg / node +# JSON.stringify -- never string concatenation), and 200->100 overflow truncation +# under a lock. Used by capture-prompt, capture-turn, and capture-question so the +# per-feature dual-queue-append logic exists in exactly one place. +# +# Source-order requirement: source json-parse (for _HAS_JQ) and dream-lock (for +# dream_lock_acquire/dream_lock_release, which itself requires get-mtime) BEFORE +# calling queue_append_row or queue_append_both. Sourcing this file itself only +# defines functions -- no side effects until they are called. +# +# queue_append_row +# Appends one JSONL row {role, content, ts} to . Creates the file +# with mode 0600 (umask 077) if absent. After appending, truncates from 200 to +# the newest 100 lines under a lock (dream_lock_acquire on ".lock", +# 2s timeout). The append itself is intentionally lock-free (accepted-class +# race shared with the pre-existing memory design -- see the design doc's +# "Append-vs-claim race" note). +# +# queue_append_both +# Calls queue_append_row for each queue whose *_enabled flag is "true". Each +# queue is gated independently -- callers compute memory_enabled/dream_enabled +# from dream config themselves (see queue_read_gates below for the one-fork +# combined read that keeps this to a single config subprocess per hook). +# +# queue_read_gates +# Reads BOTH the "memory" and "decisions" fields from dream config.json in a +# SINGLE subprocess fork (AC-P1 -- exactly one config-read fork per capture +# hook, not two). Sets _QG_MEMORY and _QG_DECISIONS ("true"/"false") in the +# caller's scope. Missing config file -> both default "true". The two values +# are newline-separated rather than using a control-character delimiter: +# they are always the literal strings "true"/"false", never arbitrary +# content, so a plain newline split is unambiguous and easy to review (no +# invisible bytes hiding in the source). + +queue_append_row() { + local _qar_file="$1" _qar_role="$2" _qar_content="$3" _qar_ts="$4" + + if [ ! -f "$_qar_file" ]; then + (umask 077 && touch "$_qar_file") 2>/dev/null || true + fi + + if [ "$_HAS_JQ" = "true" ]; then + jq -n -c --arg role "$_qar_role" --arg content "$_qar_content" --argjson ts "$_qar_ts" \ + '{role: $role, content: $content, ts: $ts}' >> "$_qar_file" + else + node -e "process.stdout.write(JSON.stringify({role:process.argv[1],content:process.argv[2],ts:parseInt(process.argv[3])})+String.fromCharCode(10))" \ + -- "$_qar_role" "$_qar_content" "$_qar_ts" >> "$_qar_file" + fi + + # Overflow safety: >200 lines -> keep newest 100, locked to prevent a multi-session race. + if [ -f "$_qar_file" ]; then + local _qar_lines + _qar_lines=$(wc -l < "$_qar_file" | tr -d ' ') + if [ "$_qar_lines" -gt 200 ]; then + local _qar_lock="${_qar_file}.lock" + if dream_lock_acquire "$_qar_lock" 2; then + _qar_lines=$(wc -l < "$_qar_file" | tr -d ' ') + if [ "$_qar_lines" -gt 200 ]; then + local _qar_tmp="${_qar_file}.tmp.$$" + tail -100 "$_qar_file" > "$_qar_tmp" && mv "$_qar_tmp" "$_qar_file" || rm -f "$_qar_tmp" + log "Queue overflow: truncated from $_qar_lines to 100 lines ($(basename "$_qar_file"))" + dbg "Queue overflow: truncated from $_qar_lines to 100 lines ($_qar_file)" + fi + dream_lock_release "$_qar_lock" + fi + fi + fi +} + +queue_append_both() { + local _qab_memory_queue="$1" _qab_dream_queue="$2" + local _qab_memory_enabled="$3" _qab_dream_enabled="$4" + local _qab_role="$5" _qab_content="$6" _qab_ts="$7" + + if [ "$_qab_memory_enabled" = "true" ]; then + queue_append_row "$_qab_memory_queue" "$_qab_role" "$_qab_content" "$_qab_ts" + fi + if [ "$_qab_dream_enabled" = "true" ]; then + queue_append_row "$_qab_dream_queue" "$_qab_role" "$_qab_content" "$_qab_ts" + fi +} + +queue_read_gates() { + local _qg_config="$1" + _QG_MEMORY="true" + _QG_DECISIONS="true" + + if [ -f "$_qg_config" ]; then + local _qg_fields + if [ "$_HAS_JQ" = "true" ]; then + # if/then/else (not //) preserves an explicit `false` -- mirrors json_field_file's + # own documented rationale (jq's // would replace a real `false` with the default). + # The comma produces two raw-output lines (newline-separated) in one jq process. + _qg_fields=$(jq -r ' + (if (.memory | type) == "null" then "true" else (.memory | tostring) end), + (if (.decisions | type) == "null" then "true" else (.decisions | tostring) end) + ' "$_qg_config" 2>/dev/null) || _qg_fields="" + else + _qg_fields=$(node -e " + const j = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')); + const m = j.memory === undefined ? 'true' : String(j.memory); + const d = j.decisions === undefined ? 'true' : String(j.decisions); + process.stdout.write(m + String.fromCharCode(10) + d); + " -- "$_qg_config" 2>/dev/null) || _qg_fields="" + fi + if [ -n "$_qg_fields" ]; then + _QG_MEMORY="${_qg_fields%%$'\n'*}" + _QG_DECISIONS="${_qg_fields#*$'\n'}" + fi + fi + + # Explicit return: this function's meaning is "populate _QG_* outputs," not a + # success/failure signal, so its exit status must never leak the truthiness of + # the last internal test into a `set -e` caller that invokes this function as + # a plain statement. + return 0 +} diff --git a/scripts/hooks/run-hook b/scripts/hooks/run-hook index d61b10cd..5b9437e4 100755 --- a/scripts/hooks/run-hook +++ b/scripts/hooks/run-hook @@ -20,4 +20,12 @@ CMDBLOCK SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" HOOK_NAME="$1" shift +# Exit 0 (never fail the host event) when the named script is absent — covers the +# init upgrade-swap window and hand-edited settings.json entries pointing at a +# hook that was renamed or removed (e.g. an old dream-* registration surviving +# past a cutover before `devflow init` re-runs and sweeps it via LEGACY_HOOK_FILES). +if [ ! -f "$SCRIPT_DIR/${HOOK_NAME}" ]; then + echo "run-hook: '${HOOK_NAME}' not found in $SCRIPT_DIR — skipping" >&2 + exit 0 +fi exec bash "$SCRIPT_DIR/${HOOK_NAME}" "$@" diff --git a/scripts/hooks/session-start-context b/scripts/hooks/session-start-context index a7192e38..5ef8dcd2 100755 --- a/scripts/hooks/session-start-context +++ b/scripts/hooks/session-start-context @@ -1,17 +1,27 @@ #!/bin/bash # SessionStart Hook: Cross-Feature Context Injection -# Always-on hook that injects decisions TL;DR as additionalContext. -# Sections are independently gated by feature sentinels — this hook itself is never disabled. +# Always-on hook that injects decisions context as additionalContext. Sections +# are gated by the `decisions` field in dream config (config-only, ADR-001) — +# this hook itself is never disabled. # -# Section 1.5: Decisions TL;DR (skipped if .devflow/decisions/.disabled) -# Section 2: Dream pending-work (emits directive when pending tasks detected) +# Section 1: Project decisions TL;DR (decisions.md / pitfalls.md header lines). +# Section 2: Dream maintenance directive — when captured turns are pending in +# the dream queue (or a crashed run left a stale .processing batch), instructs +# the main model to spawn the background Dream agent with the resolved model. +# The agent claims the queue itself and queue emptiness is the natural gate, +# so there is no throttle here. # Safe no-op fallback: must exist before hook-bootstrap is sourced. dbg() { :; } -# set -e intentionally omitted: sections 1.5 and 2 are independent — -# a failure in one must not prevent the others from running. +# set -e intentionally omitted: a failure in this section must not crash the hook. + +# Re-entrancy guard — before hook-bootstrap to minimize background session +# overhead. The memory worker's own claude -p session fires SessionStart hooks; +# without this guard the nested session would re-inject its own context (and +# receive a Dream spawn directive it must never act on). +if [ "${DEVFLOW_BG_UPDATER:-}" = "1" ]; then dbg "EXIT: bg_updater"; exit 0; fi # JSON parsing (jq with node fallback) — silently no-op if neither available SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -33,7 +43,7 @@ devflow_debug_set_cwd "$CWD" dbg "CWD=$CWD" # Anchor .devflow/ to the project root (prevents a stray nested .devflow/ when this -# hook runs with a CWD inside .devflow/...). Empty → fall back to CWD (old behavior). +# hook runs with a CWD inside .devflow/...). Empty → fall back to CWD. source "$SCRIPT_DIR/resolve-project-root" 2>/dev/null || true PROJECT_ROOT="$(df_resolve_root "$CWD" 2>/dev/null || true)" [ -n "$PROJECT_ROOT" ] || PROJECT_ROOT="$CWD" @@ -48,16 +58,21 @@ PROJECT_ROOT="$(df_resolve_root "$CWD" 2>/dev/null || true)" CONTEXT="" DEVFLOW_DIR="$PROJECT_ROOT/.devflow" -MEMORY_DIR="$DEVFLOW_DIR/memory" DREAM_DIR="$DEVFLOW_DIR/dream" DECISIONS_CONTENT_DIR="$DEVFLOW_DIR/decisions" # Normal logging source "$SCRIPT_DIR/hook-log-init" "session-start-context" -# --- Section 1.5: Project Decisions TL;DR --- -# Skipped if decisions feature is disabled via sentinel. -if [ ! -f "$DECISIONS_CONTENT_DIR/.disabled" ]; then +# --- Decisions gate (config-only) --- +DREAM_CONFIG="$DREAM_DIR/config.json" +DECISIONS_ENABLED="true" +if [ -f "$DREAM_CONFIG" ]; then + DECISIONS_ENABLED=$(json_field_file "$DREAM_CONFIG" "decisions" "true") +fi + +# --- Section 1: Project Decisions TL;DR --- +if [ "$DECISIONS_ENABLED" = "true" ]; then DECISIONS_DIR="$DECISIONS_CONTENT_DIR" # Heal older installs that have .devflow/ but not .devflow/decisions/ if [ -d "$DEVFLOW_DIR" ] && [ ! -d "$DECISIONS_DIR" ]; then @@ -88,117 +103,62 @@ ${DECISIONS_SECTION}" fi fi -# --- Section 2: Dream Pending-Work --- -# Detects pending dream tasks and emits a directive to spawn a background Dream agent. -# Guards: processor-spawn throttle (120s window) prevents duplicate emissions on rapid -# SessionStart events (e.g., /clear or compact). -# Requires: dream-recover + dream-collect-tasks helpers (sourced inline below). -# No set -e: any failure must not affect prior sections. -# -# D57a: Per-session .processing suffix (collision fix). -# Each eval-* module writes marker files as {type}.{session_id}.json, where -# session_id is the Claude session ID passed to the hook. When the Dream agent -# claims a marker it renames it to {type}.{session_id}.processing. This suffix -# prevents two concurrent Dream agent instances (from two simultaneous SessionStart -# events) from racing on the same marker file — each session only claims its own -# markers. -# -# D57b: 120s processor-spawn throttle. -# SessionStart fires on /clear, compact, and every new window open. Without -# a throttle, three rapid /clear commands would spawn three background agents -# all processing the same pending markers. The 120s window (.processor-spawned-at) -# ensures only one Dream agent is spawned per burst. The throttle is written -# atomically (temp+mv) so concurrent SessionStart events read a consistent value. -# -# D57c: Heartbeat rationale. -# The Dream agent touches each in-flight .processing file at the start of -# every phase. dream-recover uses the mtime to determine staleness (1800s -# threshold for non-memory types). The touch refreshes the mtime so a legitimate -# long-running Dream agent is not yanked mid-phase. Pairs with D56a thresholds. - -# Source helpers — soft-fail if absent (e.g., on older installs before rebuild). -# All sourced helpers follow the no-set-e discipline and exit cleanly on failure. -_SC2_HELPERS_OK="false" -if [ -f "$SCRIPT_DIR/get-mtime" ] && [ -f "$SCRIPT_DIR/dream-recover" ] && [ -f "$SCRIPT_DIR/dream-collect-tasks" ]; then - source "$SCRIPT_DIR/get-mtime" 2>/dev/null || true - source "$SCRIPT_DIR/dream-lock" 2>/dev/null || true - source "$SCRIPT_DIR/dream-recover" 2>/dev/null || true - source "$SCRIPT_DIR/dream-collect-tasks" 2>/dev/null || true - _SC2_HELPERS_OK="true" -fi - -if [ "$_SC2_HELPERS_OK" = "true" ]; then - # Determine feature enabled-state. - # Primary: dream/config.json fields; secondary: runtime sentinel for decisions. - # Only decisions is gated here (per ADR-016; avoids PF-009). - _SC2_DEC_EN="true" - - _SC2_DREAM_CONFIG="$DREAM_DIR/config.json" - if [ -f "$_SC2_DREAM_CONFIG" ]; then - _SC2_DEC_EN=$(json_field_file "$_SC2_DREAM_CONFIG" "decisions" "true") - fi - # Sentinel override for decisions - [ -f "$DEVFLOW_DIR/decisions/.disabled" ] && _SC2_DEC_EN="false" - - dbg "Section2: dec=$_SC2_DEC_EN" - - # Step 1: recover stale .processing markers so they become collectable in step 2 - if [ -d "$DREAM_DIR" ]; then - dream_recover_stale "$DREAM_DIR" "$MEMORY_DIR" || true +# --- Section 2: Dream maintenance directive --- +# Emitted when captured turns are waiting: queue non-empty, or a leftover +# .processing batch whose owner crashed (stale mtime, 900s threshold — same +# family the Dream agent itself uses to discriminate live from crashed). A +# FRESH .processing means a live Dream agent already owns the batch, so the +# directive is suppressed even if new turns queued since its claim. +if [ "$DECISIONS_ENABLED" = "true" ]; then + QUEUE_FILE="$DREAM_DIR/.pending-turns.jsonl" + PROCESSING_FILE="$DREAM_DIR/.pending-turns.processing" + PROCESSING_STALE_SECS=900 + + DREAM_WORK="" + if [ -f "$PROCESSING_FILE" ]; then + source "$SCRIPT_DIR/get-mtime" 2>/dev/null || true + _SC_PROC_MTIME=$(get_mtime "$PROCESSING_FILE" 2>/dev/null || true) + _SC_NOW=$(date +%s) + if [ -n "$_SC_PROC_MTIME" ] && [ $(( _SC_NOW - _SC_PROC_MTIME )) -ge "$PROCESSING_STALE_SECS" ]; then + DREAM_WORK="stale-processing" + else + dbg "dream directive suppressed: fresh .processing (live agent owns the batch)" + fi + elif [ -s "$QUEUE_FILE" ]; then + DREAM_WORK="queue" fi - # Step 2: collect pending marker task types - dream_collect_tasks "$DREAM_DIR" "$_SC2_DEC_EN" || true - - # Emit directive if tasks pending, subject to processor-spawn throttle. - if [ -n "$_DREAM_TASKS" ]; then - _SC2_SPAWNED_AT_FILE="$DREAM_DIR/.processor-spawned-at" - _SC2_THROTTLE=120 - _SC2_EMIT="true" - _SC2_NOW=$(date +%s) - - if [ -f "$_SC2_SPAWNED_AT_FILE" ]; then - _SC2_LAST_SPAWN=$(cat "$_SC2_SPAWNED_AT_FILE" 2>/dev/null | tr -dc '0-9' || true) - _SC2_LAST_SPAWN="${_SC2_LAST_SPAWN:-0}" - _SC2_SPAWN_AGE=$(( _SC2_NOW - _SC2_LAST_SPAWN )) - if [ "$_SC2_SPAWN_AGE" -lt "$_SC2_THROTTLE" ]; then - _SC2_EMIT="false" - dbg "Section2: processor-spawn throttled (spawned ${_SC2_SPAWN_AGE}s ago)" - log "Dream processor-spawn throttled (${_SC2_SPAWN_AGE}s < ${_SC2_THROTTLE}s): tasks=$_DREAM_TASKS" - fi + if [ -n "$DREAM_WORK" ]; then + # Model resolution: project decisions.json → global ~/.devflow/decisions.json → opus + DREAM_MODEL="" + if [ -f "$DECISIONS_CONTENT_DIR/decisions.json" ]; then + DREAM_MODEL=$(json_field_file "$DECISIONS_CONTENT_DIR/decisions.json" "model" "") fi - - if [ "$_SC2_EMIT" = "true" ]; then - # Write/refresh .processor-spawned-at atomically - _SC2_SPAWN_TMP="$DREAM_DIR/.processor-spawned-at.tmp.$$" - printf '%s' "$_SC2_NOW" > "$_SC2_SPAWN_TMP" 2>/dev/null && mv "$_SC2_SPAWN_TMP" "$_SC2_SPAWNED_AT_FILE" 2>/dev/null || rm -f "$_SC2_SPAWN_TMP" 2>/dev/null || true - - # Build the per-task spawn directive (one Agent() call per pending task with a - # hardcoded task→model map). Logic lives in dream_build_spawn_directive - # (sourced from dream-collect-tasks); it sets _DREAM_DIRECTIVE, mirroring the - # dream_collect_tasks → _DREAM_TASKS contract. Empty result → no known types. - dream_build_spawn_directive "$_DREAM_TASKS" - - if [ -n "$_DREAM_DIRECTIVE" ]; then - if [ -n "$CONTEXT" ]; then - CONTEXT="${CONTEXT} - -${_DREAM_DIRECTIVE}" - else - CONTEXT="$_DREAM_DIRECTIVE" - fi - dbg "Section2: emitted per-task dream directive (tasks=$_DREAM_TASKS)" - log "Dream per-task directive emitted: tasks=$_DREAM_TASKS" - else - dbg "Section2: tasks present but no known types to spawn — skipping directive" - log "Dream directive skipped: no known task types in tasks=$_DREAM_TASKS" - fi + if [ -z "$DREAM_MODEL" ] && [ -f "$HOME/.devflow/decisions.json" ]; then + DREAM_MODEL=$(json_field_file "$HOME/.devflow/decisions.json" "model" "") + fi + DREAM_MODEL="${DREAM_MODEL:-opus}" + # Allowlist before interpolating into the injected directive (defense in depth -- + # decisions.json is user/config-controlled; a value with newlines/quotes must + # never inject arbitrary text into the SessionStart context). Fallback matches + # decisions-config.ts DEFAULTS.model (duplicated-by-design, see feature KB). + case "$DREAM_MODEL" in + opus|sonnet|haiku) ;; + *) DREAM_MODEL="opus" ;; + esac + + dbg "dream directive emitted (reason=$DREAM_WORK model=$DREAM_MODEL)" + DREAM_SECTION="--- DREAM MAINTENANCE --- +Captured turns are pending in the decisions queue. Spawn the background Dream agent (one non-blocking call). Do not narrate, summarize, or ask — just spawn it, then continue with the user's request. +Agent(subagent_type=\"Dream\", model=\"$DREAM_MODEL\", run_in_background: true, prompt: \"Process the pending decisions queue per your agent instructions. Project root: $PROJECT_ROOT\")" + if [ -n "$CONTEXT" ]; then + CONTEXT="${CONTEXT} + +${DREAM_SECTION}" + else + CONTEXT="$DREAM_SECTION" fi - else - dbg "Section2: no pending dream tasks" fi -else - dbg "Section2: helpers not available — skip" fi # --- Output --- diff --git a/scripts/hooks/session-start-memory b/scripts/hooks/session-start-memory index cbf98967..8eb20ad4 100644 --- a/scripts/hooks/session-start-memory +++ b/scripts/hooks/session-start-memory @@ -12,6 +12,11 @@ dbg() { :; } set -e +# Re-entrancy guard — before hook-bootstrap to minimize background session +# overhead. The memory worker's own claude -p session fires SessionStart hooks +# too (SessionStart fires on every session, including a detached worker's). +if [ "${DEVFLOW_BG_UPDATER:-}" = "1" ]; then dbg "EXIT: bg_updater"; exit 0; fi + # JSON parsing (jq with node fallback) — silently no-op if neither available SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -32,7 +37,7 @@ devflow_debug_set_cwd "$CWD" dbg "CWD=$CWD" # Anchor .devflow/ to the project root (prevents a stray nested .devflow/ when this -# hook runs with a CWD inside .devflow/...). Empty → fall back to CWD (old behavior). +# hook runs with a CWD inside .devflow/...). Empty → fall back to CWD. source "$SCRIPT_DIR/resolve-project-root" 2>/dev/null || true PROJECT_ROOT="$(df_resolve_root "$CWD" 2>/dev/null || true)" [ -n "$PROJECT_ROOT" ] || PROJECT_ROOT="$CWD" @@ -54,6 +59,35 @@ if [ -f "$DREAM_CONFIG" ]; then fi fi +# --- D56c cold-path recovery: orphaned .pending-turns.processing --- +# Self-contained (not sourced from a shared helper): applies a 300s stale-age +# gate to .pending-turns.processing. background-memory-update is the PRIMARY +# recovery owner: it re-merges a leftover .processing under its own lock on +# every spawn. This is the COLD-PATH fallback for when the worker never +# re-spawns (memory disabled mid-flight, host offline, etc.): a SessionStart +# arriving >300s after the crash with no intervening Stop hook. +# Non-clobber: only recovers when .pending-turns.jsonl does NOT already exist, +# so a concurrent session's fresh queue is never overwritten. +source "$SCRIPT_DIR/get-mtime" || { echo "session-start-memory: failed to source get-mtime" >&2; exit 1; } +_SSM_PT_PROC="$MEMORY_DIR/.pending-turns.processing" +_SSM_PT_JSONL="$MEMORY_DIR/.pending-turns.jsonl" +if [ -f "$_SSM_PT_PROC" ]; then + _SSM_PT_MTIME=$(get_mtime "$_SSM_PT_PROC" 2>/dev/null || true) + _SSM_PT_MTIME="${_SSM_PT_MTIME:-0}" + _SSM_NOW_PT=$(date +%s) + _SSM_PT_AGE=$(( _SSM_NOW_PT - _SSM_PT_MTIME )) + if [ "$_SSM_PT_AGE" -gt 300 ]; then + if [ ! -f "$_SSM_PT_JSONL" ]; then + mv "$_SSM_PT_PROC" "$_SSM_PT_JSONL" 2>/dev/null || true + dbg "Recovered orphaned .pending-turns.processing (age ${_SSM_PT_AGE}s)" + log "Recovered orphaned .pending-turns.processing → .pending-turns.jsonl (cold path)" + else + dbg "Stale .pending-turns.processing skipped — .pending-turns.jsonl already exists" + log "Stale .pending-turns.processing skipped (cold path): .pending-turns.jsonl already exists" + fi + fi +fi + CONTEXT="" # --- Section 1: Working Memory with git-reconciled header --- diff --git a/shared/agents/dream.md b/shared/agents/dream.md index 4e33acdd..eb025e72 100644 --- a/shared/agents/dream.md +++ b/shared/agents/dream.md @@ -1,7 +1,7 @@ --- name: Dream -description: Background maintenance agent — processes ONE pending task type named in its prompt (decisions, curation). Spawned per-task by session-start-context; loads the matching per-task skill via the Skill tool. -model: sonnet +description: Background decisions maintenance agent — claims the pending dream queue, detects architectural decisions and pitfalls from captured turns, and curates the decisions ledger. Spawned as a background agent by the session-start directive when the queue is non-empty. +model: opus tools: - Read - Bash @@ -11,79 +11,189 @@ tools: - Grep skills: - devflow:apply-decisions - - devflow:dream-decisions - - devflow:dream-curation --- # Dream Agent -You are spawned for the ONE task named in your prompt (or "decisions then curation" for the -combined Opus spawn). Your role: claim markers atomically, do real LLM work via the matching -per-task skill, write results through plumbing ops, clean up. +You process the pending decisions queue for one project: claim it atomically, detect +decision/pitfall patterns worth keeping, curate the existing ledger, and delete the claimed +queue as your final act. You read and edit the data files directly — no script reads, +validates, or applies anything on your behalf. The only executables you call are the three +ledger ops below. -> **Model note**: The `model: sonnet` frontmatter is the fallback default. In practice -> `session-start-context` overrides the model per spawn: opus for the combined -> `decisions then curation` spawn. When you see "Opus spawn" in this document, -> that refers to the model assigned by the orchestrator at spawn time. +## Iron Law + +> **assign-anchor OWNS NUMBERING; render OWNS THE .md; NEVER HAND-EDIT decisions.md or pitfalls.md** +> +> ADR and PF numbers are assigned exclusively by `assign-anchor`. The `.md` files are written +> exclusively by `render-decisions.cjs` (invoked internally by `assign-anchor`/`retire-anchor`). +> One `assign-anchor` invocation claims one number and re-renders both files atomically. To +> deprecate, supersede, or retire an entry, call `retire-anchor ` — never +> edit the `.md` files directly. ## Environment -Installed scripts live at `$HOME/.devflow/scripts/hooks/`. -All node invocations use these paths: -- `node "$HOME/.devflow/scripts/hooks/json-helper.cjs" ...` -- `node "$HOME/.devflow/scripts/hooks/lib/decisions-index.cjs" ...` +Your prompt names the project root — run every command from it; all `.devflow/` paths below +are relative to it. The ledger ops live at `$HOME/.devflow/scripts/hooks/json-helper.cjs`: + +- `assign-anchor ` — claims the next ADR/PF number and re-renders both `.md` files +- `retire-anchor ` — flips a ledger row's rendered status and re-renders +- `rotate-observations` — archives `observing` log rows older than 30 days + +Each op self-locks internally. Call them plainly — never wrap them in a lock of your own, +never hold anything across calls. + +## Step 0 — Claim the queue + +Queue: `.devflow/dream/.pending-turns.jsonl`. Claim file: `.devflow/dream/.pending-turns.processing`. + +1. If the claim file exists, check its age (now minus mtime): + - **Fresh (younger than 900s)** — another Dream agent is live. Exit silently; change nothing. + - **Stale (900s or older)** — a previous run crashed. Re-claim it: `touch` the claim file + (your heartbeat), then fold in any new queue: + `cat .devflow/dream/.pending-turns.jsonl >> .devflow/dream/.pending-turns.processing && rm -f .devflow/dream/.pending-turns.jsonl` + (skip the fold-in if there is no queue file). +2. Otherwise claim atomically — one winner even across concurrent sessions: + `mv .devflow/dream/.pending-turns.jsonl .devflow/dream/.pending-turns.processing` + If the `mv` fails, another agent claimed first — exit silently. +3. No queue and no claim file: report "no pending decisions work" and finish. + +**Heartbeat**: `touch` the claim file once more at the Part 1 → Part 2 boundary so a long run +is never mistaken for a crashed one. + +**Vanished inputs**: if the claim file or `.devflow/decisions/` disappears mid-run (the user +disabled or cleared the feature), stop without further writes. Never recreate them. + +## Inputs (read directly with your Read tool) + +- `.devflow/dream/.pending-turns.processing` — the claimed turns (`user`/`assistant`/`qa` rows) +- `.devflow/decisions/decisions-log.jsonl` — full observation history (for dedup and recurrence) +- `.devflow/decisions/decisions.md` and `pitfalls.md` — the rendered, currently-active entries +- `.devflow/decisions/.decisions-usage.json` — citation counts keyed by anchor ID (`ADR-NNN`/`PF-NNN`) + +## Part 1 — Decision & pitfall detection + +Read the claimed turns in full (cap at the last 30 dialog-worthy entries if the file is very +large). Read `decisions-log.jsonl` in full for dedup. + +**LLM judgment — creation bar (abstain-by-default)**: + +Most runs produce nothing. If unsure, record nothing. Only capture what a future contributor +would need and could not reconstruct from the code. + +**NOT a decision**: bug fix, one-off UX tweak, routine refactor, applying an existing pattern, +dependency bump, or anything already covered by an existing ADR in the log. -Project root is your current working directory (`.`). All `.devflow/` paths are relative to it. +**NOT a pitfall**: typo, transient flake, mistake with no general lesson, or a problem fully +prevented by existing tooling. -## Step 0 — Identify your task +**Positive bar**: +- Decision = a deliberate architectural choice or trade-off with rationale that constrains + future work. It must be a real fork in the road, not an obvious choice. +- Pitfall = a non-obvious failure mode with a transferable lesson that the next contributor + cannot recover from the code alone. -Your prompt names the task type(s) to process: `decisions`, `curation`, -or `decisions then curation` (the combined Opus spawn). Process ONLY the task(s) named. +**ADR-XOR-PF (hard rule)**: one incident yields exactly one of an ADR or a PF — never both. +Concrete failure → PF; forward-looking architectural choice → ADR. -## Step 1 — Claim markers atomically +**Dedup before creating (read the log first)**: if an existing row (any status, including +Retired) already covers this concern, reinforce that row instead of creating a new one. +Duplication is worse than silence. + +**Writing observations** — you edit `decisions-log.jsonl` yourself, one row at a time; never +rewrite the whole file: + +- **New observation** — append exactly one JSONL line (heredoc keeps quoting safe): + + ```bash + mkdir -p .devflow/decisions + cat >> .devflow/decisions/decisions-log.jsonl <<'EOF' + {"id":"obs_","type":"decision","pattern":"...","confidence":0.8,"observations":1,"first_seen":"","last_seen":"","status":"observing","evidence":["..."],"details":"context: X; decision: Y; rationale: Z","quality_ok":true} + EOF + ``` + + Keep every field — downstream readers (`assign-anchor`, `rotate-observations`, + `devflow decisions --list/--status`) depend on this shape. `type` is `decision` or + `pitfall`; pitfall `details` read `"area: X; issue: Y; impact: Z; resolution: W"`; + timestamps are UTC ISO (`date -u +%Y-%m-%dT%H:%M:%SZ`). Estimate `confidence` honestly — + it is curation metadata only, NOT a gate; do not inflate it. + +- **Reinforce an existing row** — use the Edit tool to replace that row's single line: + increment `observations`, union `evidence` (dedupe, cap 10), update `last_seen`, and + refresh `pattern`/`details`/`confidence` only where the new evidence sharpens them. + +**If promoting** (quality_ok=true, pattern recurs or is clearly significant after clearing the +creation bar above): -List markers for your task type(s): ```bash -ls .devflow/dream/*.json 2>/dev/null | grep -v config.json +node "$HOME/.devflow/scripts/hooks/json-helper.cjs" assign-anchor "decision" "obs_xxx" +node "$HOME/.devflow/scripts/hooks/json-helper.cjs" assign-anchor "pitfall" "obs_xxx" ``` -For each marker `{type}.{session}.json` matching your task, claim via atomic rename — preserving -the session suffix: +NEVER hand-edit `decisions.md` or `pitfalls.md`. NEVER invent an ADR-NNN/PF-NNN number +yourself — `assign-anchor` is the only source of numbering. + +## Part 2 — Curation + +Periodic housekeeping of the ledger and rendered `.md` files. Bounds: **≤5 curation changes +per run**. **7-day protection window** — never touch any entry whose `date` field in the +ledger (`.devflow/decisions/decisions-ledger.jsonl`) is within the past 7 days. The window key +is the ledger row's `date` field (YYYY-MM-DD), not anything in the `.md` file. + +Ground yourself first, all by direct reads: +- Active entries and counts: `decisions.md` / `pitfalls.md` — what is rendered is what is active. +- Cite counts: `.decisions-usage.json`. +- Stale code references: for entries whose `details`/`evidence` mention file paths, check + those files still exist (Glob). An entry whose referenced files are gone is a preferred + retirement candidate — a signal to prefer, not an automatic retirement. + +**Rotate stale observations first** (before selecting curation candidates): + ```bash -mv ".devflow/dream/{type}.{session}.json" ".devflow/dream/{type}.{session}.processing" 2>/dev/null +node "$HOME/.devflow/scripts/hooks/json-helper.cjs" rotate-observations ``` -If `mv` fails (another Dream agent already claimed it), skip that marker. -If ALL markers for a task type fail to claim, skip that task entirely. +This archives `observing` rows older than 30 days to `decisions-log.archive.jsonl` +(gitignored). It never touches anchored (`anchor_id` set) or `created`/`ready` rows. -**Heartbeat rule**: At the start of every phase, `touch` each in-flight `.processing` file to -refresh its mtime. This prevents dream-recover from reclaiming a file that is actively -being processed (recovery threshold is 1800s; a long decisions or curation pass can take time). +**LLM judgment — identify entries to retire or merge**: -**Concurrency rule**: Never hold a lock across tool calls. All log/decisions writes go through -the plumbing ops in the per-task skills — they hold locks internally for one atomic read-modify-write. +Retire an entry when it is: +- Superseded by a newer, more precise entry on the same topic +- Contradicted by evidence in recent sessions +- Never cited (0 cites) AND older than 30 days AND low-confidence in the log -**Multi-marker merge**: When multiple `{type}.{session}.processing` files exist for one type, -read them all, then union/concat their payloads before processing: -- `decisions`: concatenate `dialogPairs` strings; union `existingObservationIds` arrays -- `curation`: single marker only +**ADR-XOR-PF awareness**: if curation finds two entries covering the same incident (one ADR, +one PF), consolidate to the more accurate type and retire the other. -**Input cap**: Process only the last **30** dialog-pairs (truncate oldest if more). -This bounds token cost and keeps each run predictable. +**Dedup awareness**: before retiring, check whether two near-duplicate entries could be +consolidated. Retire the less specific one and update the surviving entry's `pattern` to +absorb the key insight from the retired entry. -## Step 2 — Process each task via its skill +**RETIRE BY STATUS — never hand-edit the .md**: + +```bash +node "$HOME/.devflow/scripts/hooks/json-helper.cjs" retire-anchor +# status ∈ Deprecated | Superseded | Retired +``` -For each task type you claimed markers for, load the matching skill and follow its procedure: +`retire-anchor` is atomic and idempotent. Call it once per entry. -- **decisions** → load `devflow:dream-decisions` via the Skill tool and follow its procedure exactly. -- **curation** → load `devflow:dream-curation` via the Skill tool and follow its procedure exactly. +**Citation preservation**: if an entry being retired has inbound `applies ADR-NNN` citations +in other entries, update those entries' `pattern`/`details` to reference the surviving entry +instead — edit those ledger rows directly (one line at a time), then re-render via +`node "$HOME/.devflow/scripts/hooks/lib/render-decisions.cjs" render "$(pwd)"`. -For the combined "decisions then curation" spawn: run decisions fully (claim + process + cleanup) -THEN run curation fully (claim + process + cleanup). Sequential — never concurrent. +**Cap enforcement**: stop after 5 changes regardless of remaining candidates. -## Error discipline +## Finishing -- On success for any task: delete all `.processing` files for that task type. -- On failure for any task: leave `.processing` files in place — dream-recover will retry them. -- Never delete `.processing` files for a task that did not fully complete. -- Each task type is independent — a failure in one must not prevent processing of others. +1. Run `rotate-observations` if you have not already this run (Part 2 covers it — never run + it twice). +2. Delete the claim file as your FINAL act, strictly after every other write: + `rm -f .devflow/dream/.pending-turns.processing` + Crashing before this line leaves the claim file for the next run's stale-merge recovery — + the correct outcome for a partial run. +3. End with a 1–3 line summary: what you created, reinforced, promoted, retired, or merged — + or one line saying nothing cleared the bar. Your final message is the run's only + visibility surface; there is no status file to write or touch. diff --git a/shared/skills/docs-framework/SKILL.md b/shared/skills/docs-framework/SKILL.md index d8674299..7545ebf6 100644 --- a/shared/skills/docs-framework/SKILL.md +++ b/shared/skills/docs-framework/SKILL.md @@ -176,7 +176,7 @@ This framework is used by: - **Review agents**: Creates review reports - **Bug analysis agents**: Creates bug analysis reports - **Working Memory hooks**: Auto-maintains `.devflow/memory/WORKING-MEMORY.md` -- **Dream agent**: background LLM agent (spawned at SessionStart) promotes observations to ADRs/PFs via `assign-anchor`, which renders `decisions.md` / `pitfalls.md` +- **Dream agent**: background LLM agent (spawned via the session-start directive) promotes observations to ADRs/PFs via `assign-anchor`, which renders `decisions.md` / `pitfalls.md` All persisting agents should load this skill to ensure consistent documentation. diff --git a/shared/skills/dream-curation/SKILL.md b/shared/skills/dream-curation/SKILL.md deleted file mode 100644 index 15d5125e..00000000 --- a/shared/skills/dream-curation/SKILL.md +++ /dev/null @@ -1,139 +0,0 @@ ---- -name: dream-curation -description: "Dream agent per-task procedure for the 'curation' task. Loaded EXPLICITLY by the Dream agent via the Skill tool when the agent is spawned for a curation task — not auto-activated. Handles periodic housekeeping of the decisions ledger and observation log." -allowed-tools: Read, Bash, Write, Edit, Glob, Grep ---- - -# Dream Task: curation - -## Iron Law - -> **RETIRE BY STATUS — THE LEDGER IS THE SOURCE OF TRUTH** -> -> The `.md` files are rendered views of the ledger — they are never hand-edited. -> To deprecate, supersede, or retire an entry, call `retire-anchor `. -> That op flips `decisions_status` on the ledger and re-renders both `.md` files -> automatically. Numbers are never reused; retired entries are recoverable. - -This skill is loaded by the Dream agent after it has claimed the curation marker. -The agent has already done: claim (mv .json → .processing). Curation uses a single marker only. - -`assign-anchor` adds new entries; curation flips status only — never creates entries. - -## Procedure - -Touch the claimed `.devflow/dream/curation.{session}.processing` file. - -This task performs periodic housekeeping of the decisions ledger and rendered `.md` files. -Bounds: **≤5 changes per run**. **7-day protection window** — never touch any entry whose -`date` field in the ledger is within the past 7 days. The window key is the ledger row's -`date` field (YYYY-MM-DD), not anything in the `.md` file. - -Read all inputs: -```bash -# Active entry counts from the ledger (preferred) -node "$HOME/.devflow/scripts/hooks/json-helper.cjs" count-active \ - "decision" -node "$HOME/.devflow/scripts/hooks/json-helper.cjs" count-active \ - "pitfall" -``` - -Also read `.devflow/decisions/decisions-ledger.jsonl`, -`.devflow/decisions/decisions-log.jsonl`, and `.devflow/decisions/.decisions-usage.json`. - -Cite counts come from `.decisions-usage.json` — read it directly. Each entry is keyed by -anchor ID (`ADR-NNN` / `PF-NNN`) with `{ cites, last_cited, created }`. There is no separate -"scan" step here: `decisions-usage-scan.cjs` is a write-path tool that increments cite counts -from session text, not a reporter — do not call it from the curation task. - -**Rotate stale observations first** (before selecting curation candidates): - -Run under `.observations.lock` — never hold `.decisions.lock` and `.observations.lock` -simultaneously (ADR-017: if you need both, take `.decisions.lock` as the outer and complete -your observation reads before acquiring the inner — but in curation only rotation needs -`.observations.lock` and it is a self-contained step): - -```bash -node "$HOME/.devflow/scripts/hooks/json-helper.cjs" rotate-observations -``` - -This archives `observing` rows older than 30 days to `decisions-log.archive.jsonl` -(gitignored), keeping the writer's recurrence read bounded. It never touches anchored -(`anchor_id` set) or `created`/`ready` rows. After rotation, the live log is a clean -working set. - -**Staleness signal** (run once per curation task, before selecting candidates): - -```bash -node "$HOME/.devflow/scripts/hooks/lib/staleness.cjs" \ - ".devflow/decisions/decisions-log.jsonl" \ - "$(pwd)" -``` - -Entries flagged `mayBeStale: true` in the log (their referenced files no longer exist) are -**preferred retirement candidates**, WITHIN the existing 7-day protection window and ≤5-changes -bounds. This is a signal to prefer — not an automatic retirement. Apply normal LLM judgment: -a stale-referenced entry that is otherwise heavily cited should survive over one that is -uncited and stale. - -**LLM judgment — identify entries to retire or merge**: - -Retire an entry when it is: -- Superseded by a newer, more precise entry on the same topic -- Contradicted by evidence in recent sessions -- Never cited (0 cites) AND older than 30 days AND low-confidence in the log - -**ADR-XOR-PF awareness**: one incident yields exactly one of an ADR or a PF — never both. -If curation finds two entries covering the same incident (one ADR, one PF), consolidate to -the more accurate type and retire the other. Concrete failure mode → PF; forward-looking -architectural choice → ADR. - -**Dedup awareness**: before retiring, check whether two near-duplicate entries could be -consolidated. Retire the less specific one and update the surviving entry's `pattern` -description to absorb the key insight from the retired entry. - -**RETIRE BY STATUS — never hand-edit the .md** (rendered render invariant): - -To deprecate/supersede/retire an entry, call `retire-anchor` — this flips `decisions_status` -on the ledger row and re-renders both `.md` files atomically: - -```bash -# Single retirement — self-locking (acquires and releases .decisions.lock internally) -node "$HOME/.devflow/scripts/hooks/json-helper.cjs" \ - retire-anchor -# status ∈ Deprecated | Superseded | Retired -``` - -`retire-anchor` holds `.decisions.lock` across the full ledger-write + render critical -section. It is atomic and idempotent — calling it twice with the same status is safe. -The entry vanishes from the rendered `.md` but survives in the committed ledger; -numbers are never reused. - -**Batch retirement**: call `retire-anchor` once per entry — each call self-locks atomically. -Do NOT attempt to hold `.decisions.lock` across multiple `retire-anchor` invocations; that -would deadlock against `retire-anchor`'s own lock acquisition. - -**Recoverability**: to re-activate a retired entry (AC-F6), edit its row in -`decisions-ledger.jsonl` directly — set `decisions_status` back to `Accepted` (decisions) -or `Active` (pitfalls) — then re-render. (`retire-anchor` only accepts retiring statuses, -and `merge-observation` writes the raw observation log, not the ledger, so neither -re-activates an entry.) - -```bash -node "$HOME/.devflow/scripts/hooks/lib/render-decisions.cjs" render "$(pwd)" -``` - -**Citation preservation**: if an entry being retired has inbound `applies ADR-NNN` citations -in other entries, update those entries' `pattern` or `details` to reference the surviving -entry instead (edit those ledger rows directly, then re-render). - -**Cap enforcement**: stop after 5 changes regardless of remaining candidates. - -`.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. - -Delete the claimed `.processing` marker on success. - -**On any failure**: leave `.processing` files in place (dream-recover will retry them). diff --git a/shared/skills/dream-decisions/SKILL.md b/shared/skills/dream-decisions/SKILL.md deleted file mode 100644 index 2cca4dce..00000000 --- a/shared/skills/dream-decisions/SKILL.md +++ /dev/null @@ -1,116 +0,0 @@ ---- -name: dream-decisions -description: "Dream agent per-task procedure for the 'decisions' task. Loaded EXPLICITLY by the Dream agent via the Skill tool when the agent is spawned for a decisions task — not auto-activated. Handles decision/pitfall detection from dialog pairs and materialization via assign-anchor." -allowed-tools: Read, Bash, Write, Edit, Glob, Grep ---- - -# Dream Task: decisions - -## Iron Law - -> **assign-anchor OWNS NUMBERING; render OWNS THE .md; NEVER HAND-EDIT** -> -> ADR and PF numbers are assigned exclusively by `assign-anchor`. The `.md` files are -> written exclusively by `render-decisions.cjs`. Never write, edit, or infer an ADR-NNN -> or PF-NNN number directly into decisions.md or pitfalls.md. Never call `decisions-append`. -> One `assign-anchor` invocation claims one number and re-renders both files atomically. - -This skill is loaded by the Dream agent after it has claimed the decisions marker(s). -The agent has already done: claim (mv .json → .processing) and multi-marker merge -(concatenate dialogPairs strings; union existingObservationIds arrays). -Cap at the last 30 dialog-pairs before proceeding. - -## Procedure - -Touch all claimed `.devflow/dream/decisions.{session}.processing` files. - -Read the merged `dialogPairs`. Cap at last 30 pairs. -Read `.devflow/decisions/decisions-log.jsonl` in full (for dedup and recurrence patterns). - -**LLM judgment — creation bar (abstain-by-default)**: - -Most sessions produce nothing. If unsure, record nothing. Only capture what a future -contributor would need and could not reconstruct from the code. - -**NOT a decision**: bug fix, one-off UX tweak, routine refactor, applying an existing -pattern, dependency bump, or anything already covered by an existing ADR in the log. - -**NOT a pitfall**: typo, transient flake, mistake with no general lesson, or a problem -fully prevented by existing tooling. - -**Positive bar**: -- Decision = a deliberate architectural choice or trade-off with rationale that - constrains future work. It must be a real fork in the road, not an obvious choice. -- Pitfall = a non-obvious failure mode with a transferable lesson that the next - contributor cannot recover from the code alone. - -**ADR-XOR-PF (hard rule)**: one incident yields exactly one of an ADR or a PF — never -both. Concrete failure → PF; forward-looking architectural choice → ADR. - -**Dedup before creating**: read the log first. If an existing row (any status, including -Retired) already covers this concern, reinforce it (reuse its `obs_` id via -`merge-observation`) instead of creating a new entry. Duplication is worse than silence. - -For each pattern that clears the creation bar: -1. Scan the log for a matching existing entry. REUSE its `obs_` id if found. -2. Estimate `confidence` honestly — this is curation metadata only, NOT a gate. Estimate - what the evidence actually supports; do not inflate it. -3. Author full `details` string: `"context: X; decision: Y; rationale: Z"` (decision) or - `"area: X; issue: Y; impact: Z; resolution: W"` (pitfall). - -Write (or reinforce) each observation using bounded retry+backoff on `.observations.lock` -(explicit cap: 9 attempts, ~47s total backoff; on exhaustion leave `.processing` for retry): - -```bash -( - LOCK=".devflow/dream/.observations.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 "dream-decisions: failed to acquire .observations.lock after 9 attempts — leaving .processing for retry" >&2 - exit 1 - fi - node "$HOME/.devflow/scripts/hooks/json-helper.cjs" merge-observation \ - ".devflow/decisions/decisions-log.jsonl" \ - '{"id":"obs_xxx","type":"decision","pattern":"...","evidence":["..."],"details":"context: ...; decision: ...; rationale: ...","confidence":0.8,"status":"observing","quality_ok":true}' - rmdir "$LOCK" 2>/dev/null || true -) -``` - -Replace the JSON with actual LLM-authored observation data (full fields shown above). - -**If promoting** (quality_ok=true, pattern recurs or is clearly significant after clearing -the creation bar above): promote via `assign-anchor`: - -```bash -node "$HOME/.devflow/scripts/hooks/json-helper.cjs" assign-anchor \ - "decision" \ - "obs_xxx" -``` - -For pitfalls: -```bash -node "$HOME/.devflow/scripts/hooks/json-helper.cjs" assign-anchor \ - "pitfall" \ - "obs_xxx" -``` - -`assign-anchor` scans the ledger for the current max anchor number (including Retired), -assigns max+1 as a zero-padded 3-digit ID, writes an anchored row to -`decisions-ledger.jsonl`, marks the log row as `created`, registers usage, and re-renders -both `decisions.md` and `pitfalls.md` — all atomically under `.decisions.lock`. - -NEVER call `decisions-append`. NEVER hand-edit `decisions.md` or `pitfalls.md`. - -`.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/src/cli/commands/capture.ts b/src/cli/commands/capture.ts new file mode 100644 index 00000000..0e0a3f88 --- /dev/null +++ b/src/cli/commands/capture.ts @@ -0,0 +1,166 @@ +import * as path from 'path'; +import type { Settings, HookMatcher } from '../utils/hooks.js'; + +// ─── Capture hook utilities ──────────────────────────────────────────────── +// +// The capture bundle (capture-prompt, capture-turn, capture-question) is +// always-on, like session-start-context (context.ts) — registered +// unconditionally by init, removed by uninstall. There is no per-feature +// toggle: capture hooks only append to the memory/dream queues, gated +// per-queue internally by each script's own dream-config read (see +// queue-append's queue_read_gates). Follows the context.ts add/remove/has +// pattern rather than memory.ts's toggle pattern. +// +// IMPORTANT — Stop-array ordering contract: capture-turn MUST be registered +// BEFORE memory-worker in the Stop hook array (see memory.ts). Settings.json +// hook arrays run in array order, and memory-worker's throttle/spawn decision +// assumes the current turn has already been appended to the queue by +// capture-turn earlier in the same Stop event (append-before-spawn). This +// module only ever pushes capture-turn; callers (init.ts) must register the +// capture bundle before the memory bundle to preserve this ordering. + +const CAPTURE_PROMPT_MARKER = 'capture-prompt'; +const CAPTURE_TURN_MARKER = 'capture-turn'; +const CAPTURE_QUESTION_MARKER = 'capture-question'; +const CAPTURE_QUESTION_MATCHER = 'AskUserQuestion'; + +/** + * Map of hook event type → filename marker for the capture hooks. + * Three hooks total: UserPromptSubmit, Stop, PostToolUse (matcher-scoped). + */ +const CAPTURE_HOOK_CONFIG: Record = { + UserPromptSubmit: CAPTURE_PROMPT_MARKER, + Stop: CAPTURE_TURN_MARKER, + PostToolUse: CAPTURE_QUESTION_MARKER, +}; + +/** + * Add all 3 capture hooks (UserPromptSubmit, Stop, PostToolUse) to settings JSON. + * Idempotent — skips hooks that already exist. Returns unchanged JSON if all 3 present. + * The PostToolUse entry is scoped with `matcher: "AskUserQuestion"` so it only fires + * for AskUserQuestion tool calls. + */ +export function addCaptureHooks(settingsJson: string, devflowDir: string): string { + const settings: Settings = JSON.parse(settingsJson); + + if (hasCaptureHooks(settings)) { + return settingsJson; + } + + if (!settings.hooks) { + settings.hooks = {}; + } + + for (const [hookType, marker] of Object.entries(CAPTURE_HOOK_CONFIG)) { + const existing = settings.hooks[hookType] ?? []; + const alreadyPresent = existing.some((matcher) => + matcher.hooks.some((h) => h.command.includes(marker)), + ); + + if (!alreadyPresent) { + const hookCommand = path.join(devflowDir, 'scripts', 'hooks', 'run-hook') + ` ${marker}`; + const newEntry: HookMatcher = { + hooks: [ + { + type: 'command', + command: hookCommand, + timeout: 10, + }, + ], + }; + + if (hookType === 'PostToolUse') { + newEntry.matcher = CAPTURE_QUESTION_MATCHER; + } + + if (!settings.hooks[hookType]) { + settings.hooks[hookType] = []; + } + + settings.hooks[hookType].push(newEntry); + } + } + + return JSON.stringify(settings, null, 2) + '\n'; +} + +/** + * Remove all capture hooks (UserPromptSubmit, Stop, PostToolUse) from settings JSON. + * Accepts either a JSON string or a parsed Settings object. + * Idempotent — returns unchanged JSON if no capture hooks present. + * Preserves non-capture hooks (e.g. ambient preamble on UserPromptSubmit, memory-worker on Stop). + */ +export function removeCaptureHooks(input: string | Settings): string { + // No-change return must match the formatted style of the mutating return + // below (2-space indent + trailing newline) so object callers can't + // observe a different representation depending on whether a change + // happened. String input is returned byte-identical (no re-formatting). + const settingsJson = + typeof input === 'string' ? input : JSON.stringify(input, null, 2) + '\n'; + const settings: Settings = typeof input === 'string' ? JSON.parse(input) : structuredClone(input); + + if (!settings.hooks) { + return settingsJson; + } + + let changed = false; + + for (const [hookType, marker] of Object.entries(CAPTURE_HOOK_CONFIG)) { + if (!settings.hooks[hookType]) { + continue; + } + + const before = settings.hooks[hookType].length; + settings.hooks[hookType] = settings.hooks[hookType].filter( + (matcher) => !matcher.hooks.some((h) => h.command.includes(marker)), + ); + + if (settings.hooks[hookType].length !== before) { + changed = true; + } + + if (settings.hooks[hookType].length === 0) { + delete settings.hooks[hookType]; + } + } + + if (settings.hooks && Object.keys(settings.hooks).length === 0) { + delete settings.hooks; + } + + if (!changed) { + return settingsJson; + } + + return JSON.stringify(settings, null, 2) + '\n'; +} + +/** + * Check if ALL 3 capture hooks are registered in settings JSON or parsed Settings object. + */ +export function hasCaptureHooks(input: string | Settings): boolean { + return countCaptureHooks(input) === Object.keys(CAPTURE_HOOK_CONFIG).length; +} + +/** + * Count how many of the 3 capture hooks are present (0-3). + * Accepts either a JSON string or a parsed Settings object. + */ +export function countCaptureHooks(input: string | Settings): number { + const settings: Settings = typeof input === 'string' ? JSON.parse(input) : input; + + if (!settings.hooks) { + return 0; + } + + let count = 0; + + for (const [hookType, marker] of Object.entries(CAPTURE_HOOK_CONFIG)) { + const matchers = settings.hooks[hookType] ?? []; + if (matchers.some((matcher) => matcher.hooks.some((h) => h.command.includes(marker)))) { + count++; + } + } + + return count; +} diff --git a/src/cli/commands/decisions.ts b/src/cli/commands/decisions.ts index ec167f96..19636cd5 100644 --- a/src/cli/commands/decisions.ts +++ b/src/cli/commands/decisions.ts @@ -12,14 +12,15 @@ import { getDecisionsManifestPath, getDecisionsLockDir, getDecisionsNotificationsPath, - getDecisionsRunsTodayPath, getDecisionsBatchIdsPath, - getDecisionsDisabledSentinel, + getDreamPendingTurnsPath, + getDreamPendingTurnsProcessingPath, } from '../utils/project-paths.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'; +import { sweepLegacyDreamMarkers, drainDreamQueue } from '../utils/dream-cleanup.js'; import { type DecisionsEntryStatus, } from '../utils/observations.js'; @@ -34,6 +35,298 @@ import { */ export type { DecisionsEntryStatus }; +// --------------------------------------------------------------------------- +// Sub-command handlers +// --------------------------------------------------------------------------- + +function printUsage(): void { + p.intro(color.bgCyan(color.black(' Decisions Learning '))); + p.note( + `${color.cyan('devflow decisions --enable')} Enable decisions detection\n` + + `${color.cyan('devflow decisions --disable')} Disable decisions detection (drains queue)\n` + + `${color.cyan('devflow decisions --status')} Show decisions status\n` + + `${color.cyan('devflow decisions --list')} Show all observations\n` + + `${color.cyan('devflow decisions --configure')} Configuration wizard\n` + + `${color.cyan('devflow decisions --clear')} Truncate decisions log\n` + + `${color.cyan('devflow decisions --reset')} Remove all state files`, + 'Usage', + ); + p.outro(color.dim('Detects architectural decisions and known pitfalls from your sessions')); +} + +/** + * Resolve the git root for a state-mutating subcommand, warning and + * returning null if the caller isn't inside a git project. `actionSuffix` + * completes "Could not resolve git root — {actionSuffix}". + */ +async function requireGitRoot(actionSuffix: string): Promise { + const gitRoot = await getGitRoot(); + if (!gitRoot) { + p.log.warn(`Could not resolve git root — ${actionSuffix}`); + } + return gitRoot; +} + +async function handleStatus(): Promise { + const gitRoot = await getGitRoot(); + if (!gitRoot) { + p.log.info('Decisions learning: disabled (not in a git project)'); + return; + } + const logPath = getDecisionsLogPath(gitRoot); + const enabled = await isFeatureEnabled(gitRoot, 'decisions'); + const { observations, invalidCount } = await readObservations(logPath); + + const decisionObs = observations.filter(o => o.type === 'decision' || o.type === 'pitfall'); + const decisions = observations.filter(o => o.type === 'decision'); + const pitfalls = observations.filter(o => o.type === 'pitfall'); + const created = decisionObs.filter(o => o.status === 'created'); + const ready = decisionObs.filter(o => o.status === 'ready'); + const observing = decisionObs.filter(o => o.status === 'observing'); + const deprecated = decisionObs.filter(o => o.status === 'deprecated'); + + const lines: string[] = [`Decisions learning: ${enabled ? 'enabled' : 'disabled'}`]; + if (decisionObs.length === 0) { + lines.push('Observations: none'); + } else { + lines.push(`Observations: ${decisionObs.length} total`); + lines.push(` Decisions: ${decisions.length}, Pitfalls: ${pitfalls.length}`); + lines.push(` Status: ${observing.length} observing, ${ready.length} ready, ${created.length} promoted, ${deprecated.length} deprecated`); + } + p.log.info(lines.join('\n')); + warnIfInvalid(invalidCount); +} + +async function handleList(): Promise { + // Resolve the log from the git root (matches --status, --clear, --reset, + // --disable) so `--list` run from a subdirectory finds the real log + // instead of a nonexistent one under process.cwd(). Falls back to cwd + // when not in a git project, preserving the prior behavior for that case. + const gitRoot = await getGitRoot(); + const logPath = getDecisionsLogPath(gitRoot ?? process.cwd()); + + let logExists = true; + try { + await fs.access(logPath); + } catch { + logExists = false; + } + + if (!logExists) { + p.log.info('No observations yet. Decisions log not found.'); + return; + } + + const { observations, invalidCount } = await readObservations(logPath); + const filtered = observations.filter(o => o.type === 'decision' || o.type === 'pitfall'); + + if (filtered.length === 0) { + p.log.info('No decision/pitfall observations recorded yet.'); + return; + } + + // Sort by confidence descending + filtered.sort((a, b) => b.confidence - a.confidence); + + p.intro(color.bgCyan(color.black(' Decisions Observations '))); + for (const obs of filtered) { + const typeIcon = obs.type === 'decision' ? 'D' : 'F'; + const statusIcon = obs.status === 'created' ? color.green('created') + : obs.status === 'ready' ? color.yellow('ready') + : obs.status === 'deprecated' ? color.dim('deprecated') + : color.dim('observing'); + const conf = (obs.confidence * 100).toFixed(0); + p.log.info( + `[${typeIcon}] ${color.cyan(obs.pattern)} (${conf}% | ${obs.observations}x | ${statusIcon})`, + ); + } + warnIfInvalid(invalidCount); + p.outro(color.dim(`${filtered.length} observation(s) total`)); +} + +async function handleConfigure(): Promise { + p.intro(color.bgCyan(color.black(' Decisions Configuration '))); + + const model = await p.select({ + message: 'Model for decision detection', + options: [ + { value: 'opus', label: 'Opus', hint: 'Recommended — highest quality for detection + curation judgment' }, + { value: 'sonnet', label: 'Sonnet', hint: 'Good balance of quality and speed' }, + { value: 'haiku', label: 'Haiku', hint: 'Fastest, lowest cost' }, + ], + }); + if (p.isCancel(model)) { + p.cancel('Configuration cancelled.'); + return; + } + + const debugMode = await p.confirm({ + message: 'Enable debug logging? (logs session content excerpts to ~/.devflow/logs/)', + initialValue: false, + }); + if (p.isCancel(debugMode)) { + p.cancel('Configuration cancelled.'); + return; + } + + const scope = await p.select({ + message: 'Configuration scope', + options: [ + { value: 'project', label: 'Project', hint: 'This project only (.devflow/decisions/decisions.json)' }, + { value: 'global', label: 'Global', hint: 'All projects (~/.devflow/decisions.json)' }, + ], + }); + if (p.isCancel(scope)) { + p.cancel('Configuration cancelled.'); + return; + } + + const config = { + model, + debug: debugMode, + }; + + const configJson = JSON.stringify(config, null, 2) + '\n'; + + if (scope === 'global') { + const globalDir = path.join(process.env.HOME || '~', '.devflow'); + await fs.mkdir(globalDir, { recursive: true }); + await fs.writeFile(path.join(globalDir, 'decisions.json'), configJson, 'utf-8'); + p.log.success(`Global config written to ${color.dim(path.join(globalDir, 'decisions.json'))}`); + } else { + const memoryDir = getMemoryDir(process.cwd()); + await fs.mkdir(memoryDir, { recursive: true }); + const projectConfigPath = getDecisionsConfigPath(process.cwd()); + await fs.writeFile(projectConfigPath, configJson, 'utf-8'); + p.log.success(`Project config written to ${color.dim(projectConfigPath)}`); + } + + p.outro(color.green('Configuration saved.')); +} + +async function handleReset(): Promise { + const gitRoot = await requireGitRoot('reset not performed'); + if (!gitRoot) return; + + const lockDir = getDecisionsLockDir(gitRoot); + + // Acquire lock to prevent conflict with a concurrent `devflow decisions` invocation. + try { + await fs.mkdir(lockDir); + } catch { + p.log.error('Decisions system is currently running. Try again in a moment.'); + return; + } + + try { + const stateFilePaths = [ + getDecisionsLogPath(gitRoot), + getDecisionsManifestPath(gitRoot), + getDecisionsNotificationsPath(gitRoot), + getDecisionsBatchIdsPath(gitRoot), + getDecisionsConfigPath(gitRoot), + getDreamPendingTurnsPath(gitRoot), + getDreamPendingTurnsProcessingPath(gitRoot), + ]; + + if (process.stdin.isTTY) { + const confirm = await p.confirm({ + message: 'Remove all decisions-specific state files? This cannot be undone.', + initialValue: false, + }); + if (p.isCancel(confirm) || !confirm) { + p.log.info('Reset cancelled.'); + return; + } + } + + let removed = 0; + for (const filePath of stateFilePaths) { + try { + await fs.unlink(filePath); + removed++; + } catch { /* may not exist */ } + } + + // Remove the decisions directory if present (rendered files, ledger, config). + try { + await fs.rm(getDecisionsDir(gitRoot), { recursive: true, force: true }); + } catch { /* best effort */ } + + // Clean legacy dream marker-pipeline stamps from old installs + // (config.json and the queue files are handled above/never touched here). + // Best-effort: reset must still finish (and release its lock) even if the + // dream directory is inaccessible. + try { + await sweepLegacyDreamMarkers(getDreamDir(gitRoot)); + } catch { /* best effort */ } + + p.log.success(`Reset complete — removed ${removed} file(s).`); + } finally { + try { await fs.rmdir(lockDir); } catch { /* already cleaned */ } + } +} + +async function handleClear(): Promise { + const gitRoot = await requireGitRoot('clear not performed'); + if (!gitRoot) return; + + const decisionsLogPath = getDecisionsLogPath(gitRoot); + try { + await fs.access(decisionsLogPath); + } catch { + p.log.info('No decisions log to clear.'); + return; + } + + if (process.stdin.isTTY) { + const confirm = await p.confirm({ + message: 'Clear all decision/pitfall observations? This cannot be undone.', + initialValue: false, + }); + if (p.isCancel(confirm) || !confirm) { + p.log.info('Clear cancelled.'); + return; + } + } + + await fs.writeFile(decisionsLogPath, '', 'utf-8'); + + // Drain the dream (decisions-detection) queue so stale turns don't process + // on the next session — mirrors memory.ts's drain-on-disable behavior for + // the sibling memory queue. A mid-run Dream agent whose claimed batch + // vanishes aborts without changes — the desired outcome of clearing. + await drainDreamQueue(gitRoot); + + p.log.success('Decisions log cleared.'); +} + +async function handleEnable(): Promise { + const gitRoot = await requireGitRoot('configuration not updated'); + if (!gitRoot) return; + + await updateFeature(gitRoot, 'decisions', true); + await syncManifestFeature(getDevFlowDirectory(), 'decisions', true); + p.log.success('Decisions learning enabled — configuration updated'); + p.log.info(color.dim('Architectural decisions and pitfalls will be detected from your sessions')); +} + +async function handleDisable(): Promise { + const gitRoot = await requireGitRoot('configuration not updated'); + if (!gitRoot) return; + + await updateFeature(gitRoot, 'decisions', false); + + // Drain the dream (decisions-detection) queue so stale turns don't process + // on re-enable — mirrors memory.ts's drain-on-disable behavior for the + // sibling memory queue. Unconditional: a mid-run Dream agent whose claimed + // batch vanishes aborts without changes — the desired outcome of disabling. + await drainDreamQueue(gitRoot); + + await syncManifestFeature(getDevFlowDirectory(), 'decisions', false); + p.log.success('Decisions learning disabled — configuration updated'); +} + // --------------------------------------------------------------------------- // Command // --------------------------------------------------------------------------- @@ -64,316 +357,39 @@ export const decisionsCommand = new Command('decisions') ]; const hasFlag = knownFlags.some((f) => options[f]); if (!hasFlag) { - p.intro(color.bgCyan(color.black(' Decisions Learning '))); - p.note( - `${color.cyan('devflow decisions --enable')} Register decisions hook\n` + - `${color.cyan('devflow decisions --disable')} Remove decisions hook\n` + - `${color.cyan('devflow decisions --status')} Show decisions status\n` + - `${color.cyan('devflow decisions --list')} Show all observations\n` + - `${color.cyan('devflow decisions --configure')} Configuration wizard\n` + - `${color.cyan('devflow decisions --clear')} Truncate decisions log\n` + - `${color.cyan('devflow decisions --reset')} Remove all state files`, - 'Usage', - ); - p.outro(color.dim('Detects architectural decisions and known pitfalls from your sessions')); + printUsage(); return; } - const memoryDir = getMemoryDir(process.cwd()); - - // Shared log path for --status, --list, --clear - const logPath = getDecisionsLogPath(process.cwd()); - - // --- --status --- + // Thin router — dispatch order matches the precedence of the original + // inline implementation (status, list, configure, reset, clear, enable, + // disable). Each handler owns its own path resolution and I/O. if (options.status) { - const gitRoot = await getGitRoot(); - if (!gitRoot) { - p.log.info('Decisions learning: disabled (not in a git project)'); - return; - } - const enabled = await isFeatureEnabled(gitRoot, 'decisions'); - const { observations, invalidCount } = await readObservations(logPath); - - const decisionObs = observations.filter(o => o.type === 'decision' || o.type === 'pitfall'); - const decisions = observations.filter(o => o.type === 'decision'); - const pitfalls = observations.filter(o => o.type === 'pitfall'); - const created = decisionObs.filter(o => o.status === 'created'); - const ready = decisionObs.filter(o => o.status === 'ready'); - const observing = decisionObs.filter(o => o.status === 'observing'); - const deprecated = decisionObs.filter(o => o.status === 'deprecated'); - - const lines: string[] = [`Decisions learning: ${enabled ? 'enabled' : 'disabled'}`]; - if (decisionObs.length === 0) { - lines.push('Observations: none'); - } else { - lines.push(`Observations: ${decisionObs.length} total`); - lines.push(` Decisions: ${decisions.length}, Pitfalls: ${pitfalls.length}`); - lines.push(` Status: ${observing.length} observing, ${ready.length} ready, ${created.length} promoted, ${deprecated.length} deprecated`); - } - p.log.info(lines.join('\n')); - warnIfInvalid(invalidCount); + await handleStatus(); return; } - - // --- --list --- if (options.list) { - let logExists = true; - try { - await fs.access(logPath); - } catch { - logExists = false; - } - - if (!logExists) { - p.log.info('No observations yet. Decisions log not found.'); - return; - } - - const { observations, invalidCount } = await readObservations(logPath); - const filtered = observations.filter(o => o.type === 'decision' || o.type === 'pitfall'); - - if (filtered.length === 0) { - p.log.info('No decision/pitfall observations recorded yet.'); - return; - } - - // Sort by confidence descending - filtered.sort((a, b) => b.confidence - a.confidence); - - p.intro(color.bgCyan(color.black(' Decisions Observations '))); - for (const obs of filtered) { - const typeIcon = obs.type === 'decision' ? 'D' : 'F'; - const statusIcon = obs.status === 'created' ? color.green('created') - : obs.status === 'ready' ? color.yellow('ready') - : obs.status === 'deprecated' ? color.dim('deprecated') - : color.dim('observing'); - const conf = (obs.confidence * 100).toFixed(0); - p.log.info( - `[${typeIcon}] ${color.cyan(obs.pattern)} (${conf}% | ${obs.observations}x | ${statusIcon})`, - ); - } - warnIfInvalid(invalidCount); - p.outro(color.dim(`${filtered.length} observation(s) total`)); + await handleList(); return; } - - // --- --configure --- if (options.configure) { - p.intro(color.bgCyan(color.black(' Decisions Configuration '))); - - const maxRuns = await p.text({ - message: 'Maximum background runs per day', - placeholder: '3', - defaultValue: '3', - validate: (v) => { - const n = Number(v); - if (isNaN(n) || n < 1 || n > 50) return 'Enter a number between 1 and 50'; - return undefined; - }, - }); - if (p.isCancel(maxRuns)) { - p.cancel('Configuration cancelled.'); - return; - } - - const throttle = await p.text({ - message: 'Throttle interval (minutes between runs)', - placeholder: '5', - defaultValue: '5', - validate: (v) => { - const n = Number(v); - if (isNaN(n) || n < 1 || n > 60) return 'Enter a number between 1 and 60'; - return undefined; - }, - }); - if (p.isCancel(throttle)) { - p.cancel('Configuration cancelled.'); - return; - } - - const model = await p.select({ - message: 'Model for decision detection', - options: [ - { value: 'sonnet', label: 'Sonnet', hint: 'Recommended — good balance of quality and speed' }, - { value: 'haiku', label: 'Haiku', hint: 'Fastest, lowest cost' }, - { value: 'opus', label: 'Opus', hint: 'Highest quality, highest cost' }, - ], - }); - if (p.isCancel(model)) { - p.cancel('Configuration cancelled.'); - return; - } - - const debugMode = await p.confirm({ - message: 'Enable debug logging? (logs session content excerpts to ~/.devflow/logs/)', - initialValue: false, - }); - if (p.isCancel(debugMode)) { - p.cancel('Configuration cancelled.'); - return; - } - - const scope = await p.select({ - message: 'Configuration scope', - options: [ - { value: 'project', label: 'Project', hint: 'This project only (.devflow/decisions/decisions.json)' }, - { value: 'global', label: 'Global', hint: 'All projects (~/.devflow/decisions.json)' }, - ], - }); - if (p.isCancel(scope)) { - p.cancel('Configuration cancelled.'); - return; - } - - const config = { - max_daily_runs: Number(maxRuns), - throttle_minutes: Number(throttle), - model, - debug: debugMode, - }; - - const configJson = JSON.stringify(config, null, 2) + '\n'; - - if (scope === 'global') { - const globalDir = path.join(process.env.HOME || '~', '.devflow'); - await fs.mkdir(globalDir, { recursive: true }); - await fs.writeFile(path.join(globalDir, 'decisions.json'), configJson, 'utf-8'); - p.log.success(`Global config written to ${color.dim(path.join(globalDir, 'decisions.json'))}`); - } else { - await fs.mkdir(memoryDir, { recursive: true }); - const projectConfigPath = getDecisionsConfigPath(process.cwd()); - await fs.writeFile(projectConfigPath, configJson, 'utf-8'); - p.log.success(`Project config written to ${color.dim(projectConfigPath)}`); - } - - p.outro(color.green('Configuration saved.')); + await handleConfigure(); return; } - - // --- --reset --- if (options.reset) { - const lockDir = getDecisionsLockDir(process.cwd()); - - // Acquire lock to prevent conflict with running background decisions agent. - try { - await fs.mkdir(lockDir); - } catch { - p.log.error('Decisions system is currently running. Try again in a moment.'); - return; - } - - try { - const stateFilePaths = [ - getDecisionsLogPath(process.cwd()), - getDecisionsManifestPath(process.cwd()), - getDecisionsNotificationsPath(process.cwd()), - getDecisionsRunsTodayPath(process.cwd()), - getDecisionsBatchIdsPath(process.cwd()), - getDecisionsConfigPath(process.cwd()), - ]; - - if (process.stdin.isTTY) { - const confirm = await p.confirm({ - message: 'Remove all decisions-specific state files? This cannot be undone.', - initialValue: false, - }); - if (p.isCancel(confirm) || !confirm) { - p.log.info('Reset cancelled.'); - return; - } - } - - let removed = 0; - for (const filePath of stateFilePaths) { - try { - await fs.unlink(filePath); - removed++; - } catch { /* may not exist */ } - } - - // Remove decisions sentinel directory if present (may contain .disabled or other files). - try { - await fs.rm(getDecisionsDir(process.cwd()), { recursive: true, force: true }); - } catch { /* best effort */ } - - // Clean dream state files - const dreamDir = getDreamDir(process.cwd()); - for (const f of ['.decisions-runs-today']) { - try { await fs.unlink(path.join(dreamDir, f)); } catch { /* may not exist */ } - } - // Clean dream decisions markers - try { - const dreamFiles = await fs.readdir(dreamDir); - for (const f of dreamFiles) { - if (f.startsWith('decisions.') && f.endsWith('.json')) { - try { await fs.unlink(path.join(dreamDir, f)); } catch { /* ignore */ } - } - } - } catch { /* dream dir may not exist */ } - - p.log.success(`Reset complete — removed ${removed} file(s).`); - } finally { - try { await fs.rmdir(lockDir); } catch { /* already cleaned */ } - } + await handleReset(); return; } - - // --- --clear --- if (options.clear) { - try { - await fs.access(logPath); - } catch { - p.log.info('No decisions log to clear.'); - return; - } - - if (process.stdin.isTTY) { - const confirm = await p.confirm({ - message: 'Clear all decision/pitfall observations? This cannot be undone.', - initialValue: false, - }); - if (p.isCancel(confirm) || !confirm) { - p.log.info('Clear cancelled.'); - return; - } - } - - await fs.writeFile(logPath, '', 'utf-8'); - p.log.success('Decisions log cleared.'); + await handleClear(); return; } - - // --- --enable / --disable --- if (options.enable) { - const gitRoot = await getGitRoot(); - if (gitRoot) { - await updateFeature(gitRoot, 'decisions', true); - // Remove decisions/.disabled sentinel if present (kept for session-start-context gating) - try { - await fs.unlink(getDecisionsDisabledSentinel(gitRoot)); - } catch { /* may not exist */ } - await syncManifestFeature(getDevFlowDirectory(), 'decisions', true); - p.log.success('Decisions learning enabled — configuration updated'); - p.log.info(color.dim('Architectural decisions and pitfalls will be detected from your sessions')); - } else { - p.log.warn('Could not resolve git root — configuration not updated'); - } + await handleEnable(); return; } - if (options.disable) { - const gitRoot = await getGitRoot(); - if (gitRoot) { - await updateFeature(gitRoot, 'decisions', false); - // Create decisions/.disabled sentinel (gates session-start-context decisions section) - const decisionsDir = getDecisionsDir(gitRoot); - await fs.mkdir(decisionsDir, { recursive: true }); - await fs.writeFile(getDecisionsDisabledSentinel(gitRoot), '', 'utf-8'); - await syncManifestFeature(getDevFlowDirectory(), 'decisions', false); - p.log.success('Decisions learning disabled — configuration updated'); - } else { - p.log.warn('Could not resolve git root — configuration not updated'); - } + await handleDisable(); return; } }); diff --git a/src/cli/commands/dream.ts b/src/cli/commands/dream.ts new file mode 100644 index 00000000..47983ea0 --- /dev/null +++ b/src/cli/commands/dream.ts @@ -0,0 +1,54 @@ +import type { Settings, HookMatcher } from '../utils/hooks.js'; + +// ─── Dream worker hook cleanup ────────────────────────────────────────────── +// +// The spawn-dream-worker SessionStart hook belonged to the retired detached +// dream worker. Decisions processing runs as the directive-spawned Dream agent +// (session-start-context Section 2), which needs no hook registration of its +// own. remove/has exist for upgrade cleanup: init and uninstall strip any +// stale entry left in settings.json by a prior install. + +const SPAWN_DREAM_WORKER_MARKER = 'spawn-dream-worker'; + +/** + * Remove the spawn-dream-worker hook from settings JSON. + * Idempotent — returns unchanged JSON if hook not present. + * Preserves all other SessionStart hooks (session-start-memory, session-start-context). + */ +export function removeDreamHook(settingsJson: string): string { + const settings: Settings = JSON.parse(settingsJson); + + if (!settings.hooks?.SessionStart) { + return settingsJson; + } + + const before = settings.hooks.SessionStart.length; + settings.hooks.SessionStart = settings.hooks.SessionStart.filter( + (matcher: HookMatcher) => !matcher.hooks.some((h) => h.command.includes(SPAWN_DREAM_WORKER_MARKER)), + ); + + if (settings.hooks.SessionStart.length === before) { + return settingsJson; + } + + if (settings.hooks.SessionStart.length === 0) { + delete settings.hooks.SessionStart; + } + + if (Object.keys(settings.hooks).length === 0) { + delete settings.hooks; + } + + return JSON.stringify(settings, null, 2) + '\n'; +} + +/** + * Check if the spawn-dream-worker hook is present in settings JSON. + * Accepts either a JSON string or a parsed Settings object. + */ +export function hasDreamHook(input: string | Settings): boolean { + const settings: Settings = typeof input === 'string' ? JSON.parse(input) : input; + return settings.hooks?.SessionStart?.some( + (matcher) => matcher.hooks.some((h) => h.command.includes(SPAWN_DREAM_WORKER_MARKER)), + ) ?? false; +} diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index fbaed2ab..56880add 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -32,22 +32,25 @@ import { detectPlatform, detectShell, getProfilePath, getSafeDeleteInfo, hasSafe import { generateSafeDeleteBlock, installToProfile, removeFromProfile, getInstalledVersion, SAFE_DELETE_BLOCK_VERSION } from '../utils/safe-delete-install.js'; import { addAmbientHook, removeAmbientHook } from './ambient.js'; import { addMemoryHooks, removeMemoryHooks } from './memory.js'; +import { addCaptureHooks, removeCaptureHooks } from './capture.js'; +import { removeDreamHook } from './dream.js'; // Settings/HookMatcher types used by hook utilities — each in their own module import { addHudStatusLine, removeHudStatusLine } from './hud.js'; import { loadConfig as loadHudConfig, saveConfig as saveHudConfig } from '../hud/config.js'; import { readManifest, writeManifest, resolvePluginList, detectUpgrade } from '../utils/manifest.js'; import { getDefaultFlags, applyFlags, stripFlags, applyViewMode, stripViewMode, FLAG_REGISTRY, ViewMode, VIEW_MODES } from '../utils/flags.js'; import { addContextHook, removeContextHook, hasContextHook } from './context.js'; -import { manageSentinel } from '../utils/sentinel.js'; import { writeFileAtomicExclusive } from '../utils/fs-atomic.js'; import { writeConfig as writeDreamConfig } from '../utils/dream-config.js'; -import { getDecisionsDisabledSentinel, getPendingTurnsPath, getPendingTurnsProcessingPath } from '../utils/project-paths.js'; +import { getPendingTurnsPath, getPendingTurnsProcessingPath } from '../utils/project-paths.js'; import * as os from 'os'; // Re-export pure functions for tests (canonical source is post-install.ts) export { substituteSettingsTemplate, computeGitignoreAppend, mergeDenyList, discoverProjectGitRoots } from '../utils/post-install.js'; export { addAmbientHook, removeAmbientHook, hasAmbientHook } from './ambient.js'; export { addMemoryHooks, removeMemoryHooks, hasMemoryHooks } from './memory.js'; +export { addCaptureHooks, removeCaptureHooks, hasCaptureHooks } from './capture.js'; +export { removeDreamHook, hasDreamHook } from './dream.js'; export { addHudStatusLine, removeHudStatusLine, hasHudStatusLine } from './hud.js'; // Re-export migrateShadowOverrides under its original name for backward compatibility export { migrateShadowOverridesRegistry as migrateShadowOverrides } from '../utils/shadow-overrides-migration.js'; @@ -1092,19 +1095,16 @@ export const initCommand = new Command('init') } catch { /* ignore */ } } - // Clean up legacy hook scripts and lib files (paths relative to hooksDir) + // Clean up legacy hook scripts and lib files left by prior installs + // (paths relative to hooksDir; copyDirectory is additive, so stale files + // must be actively swept on init or they linger after upgrade) const LEGACY_HOOK_FILES = [ 'ambient-prompt', - // Ambient simplification: session-start-classification removed (plan detection + commands rule) 'session-start-classification', - // kb → knowledge rename: hook scripts replaced by session-end-knowledge-refresh / background-knowledge-refresh 'session-end-kb-refresh', 'background-kb-refresh', - // kb → knowledge rename: CJS module (now removed — knowledge pipeline is write-through) 'lib/feature-kb.cjs', - // decisions agent decoupling: background-learning replaced by TypeScript CLI (devflow learn --run-background) 'background-learning', - // Pre-sidecar hooks replaced by the dream hooks (dream-capture/dispatch/evaluate) 'prompt-capture-memory', 'stop-update-memory', 'stop-update-learning', @@ -1112,9 +1112,22 @@ export const initCommand = new Command('init') 'session-end-decisions', 'session-end-knowledge-refresh', 'background-knowledge-refresh', - // Learning pipeline removed: eval-learning/eval-reinforce no longer sourced by dream-evaluate 'eval-learning', 'eval-reinforce', + 'dream-capture', + 'dream-dispatch', + 'dream-evaluate', + 'eval-helpers', + 'eval-decisions', + 'eval-curation', + 'dream-collect-tasks', + 'dream-recover', + 'lib/transcript-filter.cjs', + 'lib/dream-ops.cjs', + 'spawn-dream-worker', + 'background-dream-update', + 'dream-procedure.md', + 'lib/staleness.cjs', ]; const hooksDir = path.join(devflowDir, 'scripts', 'hooks'); for (const legacy of LEGACY_HOOK_FILES) { @@ -1138,9 +1151,18 @@ export const initCommand = new Command('init') const cleanedForAmbient = await removeAmbientHook(content); content = ambientEnabled ? await addAmbientHook(cleanedForAmbient, devflowDir) : cleanedForAmbient; - // Memory hooks — always remove-then-add to upgrade hook format (e.g., .sh → run-hook) - // Memory hooks include the unified dream hooks (dream-dispatch, dream-capture, - // dream-evaluate) which handle memory and decisions in the background. + // Capture hooks — always-on (like the context hook below), remove-then-add for + // upgrade safety. Queue-append only (capture-prompt/capture-turn/capture-question); + // each script gates its own per-queue write internally via dream config, so there + // is no CLI-level enable/disable toggle here. MUST run before addMemoryHooks below + // so capture-turn lands before memory-worker in the Stop array (AC-C2 ordering: + // append-before-spawn). + const cleanedForCapture = removeCaptureHooks(content); + content = addCaptureHooks(cleanedForCapture, devflowDir); + + // Memory hooks — always remove-then-add to upgrade hook format (e.g., .sh → run-hook). + // Three hooks: Stop (memory-worker), SessionStart (session-start-memory), PreCompact. + // Decisions detection/curation no longer live here — see the dream hook below. // Knowledge is handled in-command via write-through (knowledge_writeback MDS partial). const cleaned = removeMemoryHooks(content); content = memoryEnabled ? addMemoryHooks(cleaned, devflowDir) : cleaned; @@ -1154,6 +1176,11 @@ export const initCommand = new Command('init') const cleanedForContext = removeContextHook(content); content = addContextHook(cleanedForContext, devflowDir); + // Dream hook upgrade cleanup — the spawn-dream-worker SessionStart hook is + // retired (the session-start-context directive spawns the Dream agent); + // strip any stale entry left in settings.json by a prior install. + content = removeDreamHook(content); + // Claude Code flags — strip all managed keys, then re-apply selected flags content = stripFlags(content); content = applyFlags(content, enabledFlags); @@ -1183,12 +1210,6 @@ export const initCommand = new Command('init') } } catch { /* settings.json may not exist yet */ } - - // Manage runtime-disable sentinel for decisions gating. - if (gitRoot) { - await manageSentinel(getDecisionsDisabledSentinel(gitRoot), decisionsEnabled); - } - // Write dream config.json to manage per-feature enable/disable at runtime. // 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 diff --git a/src/cli/commands/memory.ts b/src/cli/commands/memory.ts index 71480144..ce619456 100644 --- a/src/cli/commands/memory.ts +++ b/src/cli/commands/memory.ts @@ -17,30 +17,38 @@ import type { HookMatcher, Settings } from '../utils/hooks.js'; import { updateFeature, isFeatureEnabled } from '../utils/dream-config.js'; /** - * Map of hook event type → filename marker for the dream hooks. - * Five hooks total: UserPromptSubmit, Stop, SessionEnd, SessionStart, PreCompact. + * Map of hook event type → filename marker for the memory hooks. + * Three hooks total: Stop, SessionStart, PreCompact. + * + * UserPromptSubmit and SessionEnd are not memory.ts's concern: prompt/turn capture + * lives in capture.ts (capture-prompt, capture-turn — always-on, not feature-gated + * at the hook-registration level), and decisions detection is a SessionStart-spawned + * detached worker rather than a SessionEnd hook (see dream.ts). + * + * Stop-array ordering contract: memory-worker MUST be registered AFTER capture-turn + * in the Stop hook array (append-before-spawn — memory-worker's throttle/spawn + * decision assumes the current turn was already appended by capture-turn earlier + * in the same Stop event). Enforced by init.ts's registration order, not here. */ const MEMORY_HOOK_CONFIG: Record = { - UserPromptSubmit: 'dream-dispatch', - Stop: 'dream-capture', - SessionEnd: 'dream-evaluate', + Stop: 'memory-worker', SessionStart: 'session-start-memory', PreCompact: 'pre-compact-memory', }; /** - * Legacy hook filename markers from the pre-sidecar 8-hook system and the v3 sidecar→dream rename. + * Legacy hook filename markers from prior architectures. * Used by removeMemoryHooks to clean up hooks from upgrading users. */ const LEGACY_HOOK_MARKERS: Record = { - UserPromptSubmit: ['prompt-capture-memory', 'sidecar-dispatch'], - Stop: ['stop-update-memory', 'stop-update-learning', 'sidecar-capture'], - SessionEnd: ['session-end-learning', 'session-end-decisions', 'session-end-knowledge-refresh', 'sidecar-evaluate'], + UserPromptSubmit: ['prompt-capture-memory', 'sidecar-dispatch', 'dream-dispatch'], + Stop: ['stop-update-memory', 'stop-update-learning', 'sidecar-capture', 'dream-capture'], + SessionEnd: ['session-end-learning', 'session-end-decisions', 'session-end-knowledge-refresh', 'sidecar-evaluate', 'dream-evaluate'], }; /** - * Add all 5 memory hooks (UserPromptSubmit, Stop, SessionEnd, SessionStart, PreCompact) to settings JSON. - * Idempotent — skips hooks that already exist. Returns unchanged JSON if all 5 present. + * Add all 3 memory hooks (Stop, SessionStart, PreCompact) to settings JSON. + * Idempotent — skips hooks that already exist. Returns unchanged JSON if all 3 present. */ export function addMemoryHooks(settingsJson: string, devflowDir: string): string { const settings: Settings = JSON.parse(settingsJson); @@ -140,14 +148,14 @@ export function removeMemoryHooks(input: string | Settings): string { } /** - * Check if ALL 5 memory hooks are registered in settings JSON or parsed Settings object. + * Check if ALL 3 memory hooks are registered in settings JSON or parsed Settings object. */ export function hasMemoryHooks(input: string | Settings): boolean { return countMemoryHooks(input) === Object.keys(MEMORY_HOOK_CONFIG).length; } /** - * Count how many of the 5 memory hooks are present (0-5). + * Count how many of the 3 memory hooks are present (0-3). * Accepts either a JSON string or a parsed Settings object. */ export function countMemoryHooks(input: string | Settings): number { diff --git a/src/cli/commands/uninstall.ts b/src/cli/commands/uninstall.ts index 4c066106..4a6794a1 100644 --- a/src/cli/commands/uninstall.ts +++ b/src/cli/commands/uninstall.ts @@ -11,6 +11,8 @@ import { isClaudeCliAvailable } from '../utils/cli.js'; import { DEVFLOW_PLUGINS, getAllSkillNames, LEGACY_SKILL_NAMES, prefixSkillName, type PluginDefinition } from '../plugins.js'; import { removeAmbientHook } from './ambient.js'; import { removeMemoryHooks } from './memory.js'; +import { removeCaptureHooks } from './capture.js'; +import { removeDreamHook } from './dream.js'; import { removeHudStatusLine } from './hud.js'; import { removeContextHook } from './context.js'; import { listShadowed } from './skills.js'; @@ -399,6 +401,8 @@ export const uninstallCommand = new Command('uninstall') // Remove all Devflow hooks and flags in one pass (idempotent) let settingsContent = await removeAmbientHook(originalContent); settingsContent = removeMemoryHooks(settingsContent); + settingsContent = removeCaptureHooks(settingsContent); + settingsContent = removeDreamHook(settingsContent); settingsContent = removeHudStatusLine(settingsContent); settingsContent = removeContextHook(settingsContent); settingsContent = stripFlags(settingsContent); diff --git a/src/cli/hud/components/decisions-counts.ts b/src/cli/hud/components/decisions-counts.ts new file mode 100644 index 00000000..07c611b1 --- /dev/null +++ b/src/cli/hud/components/decisions-counts.ts @@ -0,0 +1,99 @@ +import * as fs from 'node:fs'; +import type { ComponentResult, GatherContext, DecisionsCountsData } from '../types.js'; +import { dim } from '../colors.js'; +import { getDecisionsLedgerPath } from '../../utils/project-paths.js'; + +/** + * @devflow-design-decision D309 + * Counts come from decisions-ledger.jsonl (the render source of truth), NOT + * the rendered decisions.md/pitfalls.md, so the HUD never couples to markdown + * format. Active-row semantics mirror render-decisions.cjs exactly: a row + * counts when anchor_id is set and decisions_status is absent or outside + * INACTIVE_STATUSES — so the numbers always equal the entries visible in the + * rendered files. + */ +const INACTIVE_STATUSES = new Set(['Deprecated', 'Superseded', 'Retired']); + +interface LedgerCountRow { + type: 'decision' | 'pitfall'; + anchor_id: string; + decisions_status?: string; +} + +function isLedgerCountRow(val: unknown): val is LedgerCountRow { + if (typeof val !== 'object' || val === null) return false; + const o = val as Record; + if (o.type !== 'decision' && o.type !== 'pitfall') return false; + if (typeof o.anchor_id !== 'string' || o.anchor_id.length === 0) return false; + return o.decisions_status === undefined || typeof o.decisions_status === 'string'; +} + +function isActive(row: LedgerCountRow): boolean { + if (!row.decisions_status) return true; + return !INACTIVE_STATUSES.has(row.decisions_status); +} + +/** + * Read .devflow/decisions/decisions-ledger.jsonl and count active anchored + * rows by type. Returns null if the ledger is missing or holds no valid rows + * (graceful fallback). Exported for use by the main HUD entry point. + */ +export function gatherDecisionsCounts(cwd: string): DecisionsCountsData | null { + let content: string; + try { + content = fs.readFileSync(getDecisionsLedgerPath(cwd), 'utf-8'); + } catch { + return null; + } + + const counts: DecisionsCountsData = { decisions: 0, pitfalls: 0 }; + let parsedAny = false; + + for (const rawLine of content.split('\n')) { + const line = rawLine.trim(); + if (!line) continue; + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + // Skip malformed lines — graceful + continue; + } + + if (!isLedgerCountRow(parsed)) continue; + parsedAny = true; + + if (!isActive(parsed)) continue; + if (parsed.type === 'decision') counts.decisions++; + else counts.pitfalls++; + } + + return parsedAny ? counts : null; +} + +/** + * HUD component: decisions/pitfalls counts. + * Shows how many active ADR/PF entries the project has accumulated. + * Returns null when no ledger exists or every entry is retired. + */ +export default async function decisionsCounts( + ctx: GatherContext, +): Promise { + const data = ctx.decisionsCounts; + if (!data) return null; + + const { decisions, pitfalls } = data; + if (decisions + pitfalls === 0) return null; + + const parts: string[] = []; + if (decisions > 0) parts.push(`${decisions} decision${decisions !== 1 ? 's' : ''}`); + if (pitfalls > 0) parts.push(`${pitfalls} pitfall${pitfalls !== 1 ? 's' : ''}`); + + // Intentionally one dimmed clause (unlike config-counts, which dims each + // part and joins with a middot for a list of independent facts): "Learning: + // N decisions, M pitfalls" reads as a single sentence, so the whole string + // is dimmed once and parts are comma-joined rather than middot-separated. + const raw = `Learning: ${parts.join(', ')}`; + return { text: dim(raw), raw }; +} diff --git a/src/cli/hud/config.ts b/src/cli/hud/config.ts index 4af50df2..fb4f94fc 100644 --- a/src/cli/hud/config.ts +++ b/src/cli/hud/config.ts @@ -22,6 +22,7 @@ export const HUD_COMPONENTS: readonly ComponentId[] = [ 'usageQuota', 'todoProgress', 'configCounts', + 'decisionsCounts', 'notifications', ]; diff --git a/src/cli/hud/index.ts b/src/cli/hud/index.ts index f7586079..285d2843 100644 --- a/src/cli/hud/index.ts +++ b/src/cli/hud/index.ts @@ -7,6 +7,7 @@ import { gatherGitStatus } from './git.js'; import { parseTranscript } from './transcript.js'; import { persistSessionCost, aggregateCosts } from './cost-history.js'; import { gatherConfigCounts } from './components/config-counts.js'; +import { gatherDecisionsCounts } from './components/decisions-counts.js'; import { getActiveNotification } from './notifications.js'; import { render } from './render.js'; import type { GatherContext, StdinData, UsageData } from './types.js'; @@ -86,6 +87,7 @@ async function run(): Promise { components.has('todoProgress') || components.has('configCounts'); const needsConfigCounts = components.has('configCounts'); + const needsDecisionsCounts = components.has('decisionsCounts'); const needsNotifications = components.has('notifications'); const needsSessionCost = components.has('sessionCost'); @@ -114,6 +116,11 @@ async function run(): Promise { ? gatherConfigCounts(cwd) : null; + // Decisions/pitfalls counts (fast, synchronous filesystem read) + const decisionsCountsData = needsDecisionsCounts + ? gatherDecisionsCounts(cwd) + : null; + // D24: Notification data (fast, synchronous filesystem read) const notificationsData = needsNotifications ? getActiveNotification(cwd) @@ -140,6 +147,7 @@ async function run(): Promise { transcript, usage, configCounts: configCountsData, + decisionsCounts: decisionsCountsData, notifications: notificationsData, costHistory, config: { ...config, components: resolved } as GatherContext['config'], diff --git a/src/cli/hud/render.ts b/src/cli/hud/render.ts index 8e811a41..975b6fec 100644 --- a/src/cli/hud/render.ts +++ b/src/cli/hud/render.ts @@ -17,6 +17,7 @@ import sessionDuration from './components/session-duration.js'; import usageQuota from './components/usage-quota.js'; import todoProgress from './components/todo-progress.js'; import configCounts from './components/config-counts.js'; +import decisionsCounts from './components/decisions-counts.js'; import sessionCost from './components/session-cost.js'; import releaseInfo from './components/release-info.js'; import worktreeCount from './components/worktree-count.js'; @@ -34,6 +35,7 @@ const COMPONENT_MAP: Record = { usageQuota, todoProgress, configCounts, + decisionsCounts, sessionCost, releaseInfo, worktreeCount, @@ -43,17 +45,12 @@ const COMPONENT_MAP: Record = { /** * Line groupings for smart layout. * Components are assigned to lines and only rendered if enabled. - * null entries denote section breaks (blank line between sections). */ -const LINE_GROUPS: (ComponentId[] | null)[] = [ - // Section 1: Info (3 lines) +const LINE_GROUPS: ComponentId[][] = [ ['directory', 'gitBranch', 'gitAheadBehind', 'releaseInfo', 'worktreeCount', 'diffStats'], - ['contextUsage', 'usageQuota'], + ['contextUsage', 'usageQuota', 'todoProgress'], ['model', 'configCounts', 'sessionCost'], - // --- section break --- - null, - // Section 2: Activity - ['todoProgress'], + ['decisionsCounts'], ['notifications'], ['versionBadge'], ]; @@ -62,7 +59,8 @@ const SEPARATOR = dim(' \u00B7 '); /** * Render all enabled components into a multi-line HUD string. - * Components that return null are excluded. Empty lines are skipped. + * Components that return null are excluded. Lines with no rendered + * components are skipped. */ export async function render(ctx: GatherContext): Promise { const enabled = new Set(ctx.config.components); @@ -87,25 +85,15 @@ export async function render(ctx: GatherContext): Promise { await Promise.all(promises); - // Assemble lines using smart layout with section breaks + // Assemble lines using smart layout const lines: string[] = []; - let pendingBreak = false; for (const entry of LINE_GROUPS) { - if (entry === null) { - if (lines.length > 0) pendingBreak = true; - continue; - } - const lineResults = entry .filter((id) => enabled.has(id) && results.has(id)) .map((id) => results.get(id)!); if (lineResults.length > 0) { - if (pendingBreak) { - lines.push(''); - pendingBreak = false; - } // Separate multi-line results (containing newlines) from single-line const singleLine: string[] = []; for (const r of lineResults) { diff --git a/src/cli/hud/types.ts b/src/cli/hud/types.ts index 11fbda31..161d0cd7 100644 --- a/src/cli/hud/types.ts +++ b/src/cli/hud/types.ts @@ -33,6 +33,7 @@ export type ComponentId = | 'usageQuota' | 'todoProgress' | 'configCounts' + | 'decisionsCounts' | 'sessionCost' | 'releaseInfo' | 'worktreeCount' @@ -114,6 +115,14 @@ export interface ConfigCountsData { hooks: number; } +/** + * Decisions/pitfalls counts data for the decisionsCounts component. + */ +export interface DecisionsCountsData { + decisions: number; + pitfalls: number; +} + /** * D24: Notification data for the HUD notifications component. */ @@ -134,6 +143,7 @@ export interface GatherContext { transcript: TranscriptData | null; usage: UsageData | null; configCounts: ConfigCountsData | null; + decisionsCounts: DecisionsCountsData | null; notifications?: NotificationData | null; costHistory: CostAggregation | null; config: HudConfig & { components: ComponentId[] }; diff --git a/src/cli/plugins.ts b/src/cli/plugins.ts index 5eb2cd0e..21da5b0c 100644 --- a/src/cli/plugins.ts +++ b/src/cli/plugins.ts @@ -48,15 +48,8 @@ export const DEVFLOW_PLUGINS: PluginDefinition[] = [ name: 'devflow-core-skills', description: 'Auto-activating quality enforcement skills - foundation layer for all Devflow plugins', commands: [], - // The Dream agent lives here (always-installed foundation plugin) because the - // session-start-context hook spawns Agent(subagent_type="Dream") unconditionally - // for the decisions/knowledge/curation subsystems — independent of whether - // the ambient plugin is installed. Declaring it only in devflow-ambient would break - // the dream subsystem under `devflow init --no-ambient` (agents install per selected - // plugin, unlike skills which install universally). Predecessor was the universally - // installed `devflow:sidecar` skill; core-skills preserves that guarantee. agents: ['dream'], - skills: ['apply-decisions', 'apply-feature-knowledge', 'software-design', 'docs-framework', 'git', 'boundary-validation', 'test-driven-development', 'testing', 'dependency-research', 'dream-decisions', 'dream-curation'], + skills: ['apply-decisions', 'apply-feature-knowledge', 'software-design', 'docs-framework', 'git', 'boundary-validation', 'test-driven-development', 'testing', 'dependency-research'], rules: ['security', 'engineering', 'quality', 'reliability'], }, { @@ -535,22 +528,15 @@ const LEGACY_SKILLS_V2X: string[] = [ 'devflow:pipeline:orch', 'devflow:research:orch', 'devflow:release:orch', - // v3.x dream per-task skills: bare names for pre-namespace installs. - // NOTE: dream-decisions and dream-curation are STILL-ACTIVE skills (declared in - // DEVFLOW_PLUGINS, installed at the namespaced path devflow:dream-*). - // These bare entries exist solely to clean up pre-namespace V2.x installs where - // skills were written without the devflow: prefix. On current installs the post-install - // fs.rm targets a bare path (e.g. ~/.claude/skills/dream-decisions) that does not exist - // — a harmless no-op. Do NOT remove these entries: upgrading users from V2.x need the - // stale bare-name dirs swept. (applies ADR-016; avoids PF-009) + // v3.x dream per-task skills: bare and namespaced names for cleanup 'dream-memory', 'dream-knowledge', 'dream-decisions', 'dream-curation', - // v3.x dream-memory + dream-knowledge removal: namespaced names for cleanup of the - // installed devflow:dream-memory / devflow:dream-knowledge skills (both fully removed). 'devflow:dream-memory', 'devflow:dream-knowledge', + 'devflow:dream-decisions', + 'devflow:dream-curation', // v3.x agent-teams removal: namespaced name for cleanup of installed devflow:agent-teams skill 'devflow:agent-teams', // v2.x ambient refinements: devflow:-prefixed triage/guided/router names for cleanup diff --git a/src/cli/utils/decisions-config.ts b/src/cli/utils/decisions-config.ts index 6d09d860..c693cb8e 100644 --- a/src/cli/utils/decisions-config.ts +++ b/src/cli/utils/decisions-config.ts @@ -6,30 +6,29 @@ import { getDecisionsConfigPath } from './project-paths.js'; /** * Merged decisions agent configuration from global and project-level config files. * - * Mirrors the shape of LearningConfig in learn.ts — decisions agent is a sibling - * pipeline with the same runtime knobs (model, throttle, daily cap, debug). + * The Dream agent has no daily-run cap or throttle: session-start-context emits + * its spawn directive only when the dream queue is non-empty (or a stale + * .processing batch exists), so queue emptiness is the natural gate. + * session-start-context reads these config files directly (same project → + * global → default precedence) when resolving the model for the directive. */ export interface DecisionsConfig { - /** Maximum number of background runs per day. Default: 3 */ - max_daily_runs: number; - /** Minimum minutes between consecutive runs. Default: 5 */ - throttle_minutes: number; - /** Model alias for the background Dream agent. Default: 'sonnet' */ + /** Model alias for the Dream agent. Default: 'opus' */ model: string; /** Emit verbose logs when true. Default: false */ debug: boolean; } const DEFAULTS: DecisionsConfig = { - max_daily_runs: 3, - throttle_minutes: 5, - model: 'sonnet', + model: 'opus', debug: false, }; /** * Apply a single JSON config layer onto a DecisionsConfig, returning a new object. * Skips fields with wrong types. Swallows parse errors — callers see defaults. + * Unknown fields (e.g. an old config still on disk with extra knobs) are + * silently ignored, not an error. */ export function applyDecisionsConfigLayer( config: DecisionsConfig, @@ -38,14 +37,6 @@ export function applyDecisionsConfigLayer( try { const raw = JSON.parse(json) as Record; return { - max_daily_runs: - typeof raw.max_daily_runs === 'number' - ? raw.max_daily_runs - : config.max_daily_runs, - throttle_minutes: - typeof raw.throttle_minutes === 'number' - ? raw.throttle_minutes - : config.throttle_minutes, model: typeof raw.model === 'string' ? raw.model : config.model, debug: diff --git a/src/cli/utils/dream-cleanup.ts b/src/cli/utils/dream-cleanup.ts new file mode 100644 index 00000000..6bde163e --- /dev/null +++ b/src/cli/utils/dream-cleanup.ts @@ -0,0 +1,99 @@ +/** + * @file dream-cleanup.ts + * + * Shared cleanup helpers for `.devflow/dream/` — used by both the + * `purge-dream-marker-pipeline-v1` migration and `devflow decisions --reset` + * (legacy marker sweep), and by `devflow decisions --clear`/`--disable` + * (live queue drain). Centralizing these predicates keeps the two call + * sites of each behavior byte-identical instead of hand-copied. + */ + +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { + getDreamPendingTurnsPath, + getDreamPendingTurnsProcessingPath, +} from './project-paths.js'; + +// --------------------------------------------------------------------------- +// Legacy marker-pipeline sweep +// --------------------------------------------------------------------------- + +/** Fixed-name stamps left by the retired dream marker pipeline. */ +const LEGACY_FIXED_STAMPS = ['.decisions-runs-today', '.curation-last', '.processor-spawned-at']; + +/** Per-session marker variants: (decisions|curation).*.{json,processing,retries,failed} */ +function isLegacyPerSessionMarker(name: string): boolean { + return ( + (name.startsWith('decisions.') || name.startsWith('curation.')) && + (name.endsWith('.json') || name.endsWith('.processing') || name.endsWith('.retries') || name.endsWith('.failed')) + ); +} + +/** + * Sweep legacy marker-pipeline files from a `.devflow/dream/` directory: + * the fixed-name stamps above, plus per-session `decisions.*`/`curation.*` + * markers. Never touches `config.json` or the live + * `.pending-turns.jsonl`/`.pending-turns.processing` queue files. + * + * ENOENT-idempotent (missing dream dir or already-removed files are not + * errors). Non-ENOENT errors are rethrown — callers that need best-effort + * semantics (e.g. `--reset`, which must still finish releasing its lock) + * should wrap the call in their own try/catch. + * + * @returns number of files removed + */ +export async function sweepLegacyDreamMarkers(dreamDir: string): Promise { + let removed = 0; + + for (const name of LEGACY_FIXED_STAMPS) { + try { + await fs.unlink(path.join(dreamDir, name)); + removed++; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw err; + } + } + + try { + const entries = await fs.readdir(dreamDir); + for (const entry of entries) { + if (isLegacyPerSessionMarker(entry)) { + try { + await fs.unlink(path.join(dreamDir, entry)); + removed++; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw err; + } + } + } + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw err; + } + + return removed; +} + +// --------------------------------------------------------------------------- +// Live queue drain +// --------------------------------------------------------------------------- + +/** + * Drain the dream (decisions-detection) pending-turns queue so stale turns + * don't process later — used by both `--clear` and `--disable`. A mid-run + * Dream agent whose claimed batch vanishes aborts without changes, which is + * the desired outcome in both cases. ENOENT-tolerant; other errors propagate. + */ +export async function drainDreamQueue(gitRoot: string): Promise { + await Promise.all([ + fs.unlink(getDreamPendingTurnsPath(gitRoot)).catch((e: NodeJS.ErrnoException) => { + if (e.code !== 'ENOENT') throw e; + }), + fs.unlink(getDreamPendingTurnsProcessingPath(gitRoot)).catch((e: NodeJS.ErrnoException) => { + if (e.code !== 'ENOENT') throw e; + }), + ]); +} diff --git a/src/cli/utils/flags.ts b/src/cli/utils/flags.ts index 4b317c41..46d306cf 100644 --- a/src/cli/utils/flags.ts +++ b/src/cli/utils/flags.ts @@ -66,6 +66,30 @@ export const FLAG_REGISTRY: readonly ClaudeCodeFlag[] = [ target: { type: 'setting', key: 'showClearContextOnPlanAccept', value: true }, defaultEnabled: true, }, + { + id: 'disable-bundled-skills', + label: 'Disable bundled skills', + description: "Remove Claude Code's built-in skills and workflows (devflow provides its own)", + hint: 'Cleaner skill list', + target: { type: 'setting', key: 'disableBundledSkills', value: true }, + defaultEnabled: true, + }, + { + id: 'pin-sonnet-4-6', + label: 'Pin Sonnet to 4.6', + description: 'Pin the default Sonnet model to claude-sonnet-4-6', + hint: 'Stable, deterministic Sonnet version', + target: { type: 'env', key: 'ANTHROPIC_DEFAULT_SONNET_MODEL', value: 'claude-sonnet-4-6' }, + defaultEnabled: true, + }, + { + id: 'disable-mouse-clicks', + label: 'Disable mouse clicks', + description: 'Disable mouse click/drag/hover in fullscreen (keeps wheel scroll)', + hint: 'Prevents accidental clicks', + target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_MOUSE_CLICKS', value: '1' }, + defaultEnabled: true, + }, // === Optional (default OFF) — skip these if you're unsure === { id: 'brief', diff --git a/src/cli/utils/hooks.ts b/src/cli/utils/hooks.ts index ed1c328c..7ab7419a 100644 --- a/src/cli/utils/hooks.ts +++ b/src/cli/utils/hooks.ts @@ -13,6 +13,8 @@ export interface HookEntry { } export interface HookMatcher { + /** Tool-name filter for PostToolUse/PreToolUse hooks (e.g. "AskUserQuestion"). Absent = matches all. */ + matcher?: string; hooks: HookEntry[]; } diff --git a/src/cli/utils/migrations.ts b/src/cli/utils/migrations.ts index ebb6dfe5..bdaf49ef 100644 --- a/src/cli/utils/migrations.ts +++ b/src/cli/utils/migrations.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import * as os from 'os'; import { writeFileAtomicExclusive } from './fs-atomic.js'; import { getMemoryDir, getFeaturesDir } from './project-paths.js'; +import { sweepLegacyDreamMarkers } from './dream-cleanup.js'; // --------------------------------------------------------------------------- // consolidate-to-devflow-dir helpers @@ -958,6 +959,93 @@ const MIGRATION_PURGE_DEAD_WORKING_MEMORY_SENTINEL: Migration<'per-project'> = { }, }; +/** + * Per-project: remove stale marker-pipeline files (`decisions.*`/`curation.*` + * markers and their fixed-name stamps) from `.devflow/dream/`. + * + * MUST NOT touch: config.json (shared multi-feature config — memory/decisions/knowledge + * keys) or the `.pending-turns.jsonl`/`.pending-turns.processing` queue files (live + * inputs of the Dream agent). Worker-era state files are owned by + * purge-dream-worker-state-v1 below. + * + * `.decisions-runs-today` historically lived at `.devflow/dream/.decisions-runs-today` + * (via $DREAM_DIR, NOT under `.devflow/decisions/` despite the similar name) — swept + * from its real location here. + * + * Mirrors purge-stale-memory-markers-v1 in shape. + */ +const MIGRATION_PURGE_DREAM_MARKER_PIPELINE: Migration<'per-project'> = { + id: 'purge-dream-marker-pipeline-v1', + description: 'Remove stale decisions.*/curation.* markers and legacy stamps from the retired dream marker pipeline', + scope: 'per-project', + async run(ctx: PerProjectMigrationContext): Promise { + const dreamDir = path.join(ctx.projectRoot, '.devflow', 'dream'); + const removed = await sweepLegacyDreamMarkers(dreamDir); + + const infos: string[] = []; + if (removed > 0) { + infos.push(`Removed ${removed} stale dream marker-pipeline file(s)`); + } + + return { infos, warnings: [] }; + }, +}; + +/** + * Per-project: remove inert state files left by the retired detached dream + * worker (background-dream-update). Decisions processing now runs as the + * directive-spawned Dream agent, whose only state is the queue itself: + * - .devflow/decisions/.disabled — runtime sentinel (gate is config-only now) + * - .devflow/dream/.last-dream-ok — worker success stamp + * - .devflow/dream/last-run-summary — inject-once summary file + * - .devflow/dream/.worker.lock/ — worker concurrency lock (directory) + * + * MUST NOT touch: config.json or the `.pending-turns.jsonl`/`.processing` + * queue files — both are live inputs of the current architecture. + * + * Mirrors purge-stale-memory-markers-v1 in shape. + */ +const MIGRATION_PURGE_DREAM_WORKER_STATE: Migration<'per-project'> = { + id: 'purge-dream-worker-state-v1', + description: 'Remove .disabled sentinel, .last-dream-ok, last-run-summary, and .worker.lock left by the retired detached dream worker', + scope: 'per-project', + async run(ctx: PerProjectMigrationContext): Promise { + const devflowDir = path.join(ctx.projectRoot, '.devflow'); + let removed = 0; + + const files = [ + path.join(devflowDir, 'decisions', '.disabled'), + path.join(devflowDir, 'dream', '.last-dream-ok'), + path.join(devflowDir, 'dream', 'last-run-summary'), + ]; + for (const filePath of files) { + try { + await fs.unlink(filePath); + removed++; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw err; // unexpected — surface to runner + } + } + + // Lock is a directory (mkdir-based) — remove recursively. + try { + await fs.rm(path.join(devflowDir, 'dream', '.worker.lock'), { recursive: true }); + removed++; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw err; // unexpected — surface to runner + } + + const infos: string[] = []; + if (removed > 0) { + infos.push(`Removed ${removed} inert dream-worker state file(s)`); + } + + return { infos, warnings: [] }; + }, +}; + export const MIGRATIONS: readonly Migration[] = [ MIGRATION_SHADOW_OVERRIDES, MIGRATION_PURGE_LEGACY_KNOWLEDGE, @@ -975,6 +1063,8 @@ export const MIGRATIONS: readonly Migration[] = [ MIGRATION_PURGE_TEAMMATE_MODE_PER_PROJECT, MIGRATION_DECISIONS_LEDGER_UNIFY, MIGRATION_PURGE_DEAD_WORKING_MEMORY_SENTINEL, + MIGRATION_PURGE_DREAM_MARKER_PIPELINE, + MIGRATION_PURGE_DREAM_WORKER_STATE, ]; const MIGRATIONS_FILE = 'migrations.json'; diff --git a/src/cli/utils/observation-io.ts b/src/cli/utils/observation-io.ts index 72b8cf85..d6a58a70 100644 --- a/src/cli/utils/observation-io.ts +++ b/src/cli/utils/observation-io.ts @@ -9,12 +9,10 @@ import { type LearningObservation, loadAndCountObservations } from './observatio * File I/O for observations and user-facing warnings. * Bridges the pure data module (observations.ts) with the filesystem. * - * NOTE: `updateDecisionsStatus` was removed in Phase 6 of the decisions-ledger-render - * refactor. The `.md` files are now a pure render of the decisions ledger — they must - * not be edited directly. To change the status of a decision or pitfall, use the - * `retire-anchor` op in `json-helper.cjs`, which flips `decisions_status` on the - * ledger row and re-renders both `.md` files atomically. At the time of removal, - * `updateDecisionsStatus` had zero callers in the TypeScript codebase. + * The `.md` files are a pure render of the decisions ledger — never edit them + * directly. To change the status of a decision or pitfall, use the + * `retire-anchor` op in `json-helper.cjs`, which flips `decisions_status` on + * the ledger row and re-renders both `.md` files atomically. */ /** diff --git a/src/cli/utils/project-paths.ts b/src/cli/utils/project-paths.ts index c9fa7c77..e9123a07 100644 --- a/src/cli/utils/project-paths.ts +++ b/src/cli/utils/project-paths.ts @@ -56,6 +56,16 @@ export function getDreamConfigPath(projectRoot: string): string { return path.join(projectRoot, '.devflow', 'dream', 'config.json'); } +/** .devflow/dream/.pending-turns.jsonl — decisions detection queue (dual-write with memory queue) */ +export function getDreamPendingTurnsPath(projectRoot: string): string { + return path.join(projectRoot, '.devflow', 'dream', '.pending-turns.jsonl'); +} + +/** .devflow/dream/.pending-turns.processing — atomic claim held by the Dream agent while processing */ +export function getDreamPendingTurnsProcessingPath(projectRoot: string): string { + return path.join(projectRoot, '.devflow', 'dream', '.pending-turns.processing'); +} + // --------------------------------------------------------------------------- // Decisions files // --------------------------------------------------------------------------- @@ -70,11 +80,6 @@ export function getPitfallsFilePath(projectRoot: string): string { return path.join(projectRoot, '.devflow', 'decisions', 'pitfalls.md'); } -/** .devflow/decisions/.disabled — sentinel that gates decisions sections */ -export function getDecisionsDisabledSentinel(projectRoot: string): string { - return path.join(projectRoot, '.devflow', 'decisions', '.disabled'); -} - /** .devflow/decisions/decisions.json — project-level decisions config */ export function getDecisionsConfigPath(projectRoot: string): string { return path.join(projectRoot, '.devflow', 'decisions', 'decisions.json'); @@ -125,11 +130,6 @@ export function getDecisionsNotificationsPath(projectRoot: string): string { return path.join(projectRoot, '.devflow', 'decisions', '.decisions-notifications.json'); } -/** .devflow/decisions/.decisions-runs-today */ -export function getDecisionsRunsTodayPath(projectRoot: string): string { - return path.join(projectRoot, '.devflow', 'decisions', '.decisions-runs-today'); -} - /** .devflow/decisions/.decisions-batch-ids */ export function getDecisionsBatchIdsPath(projectRoot: string): string { return path.join(projectRoot, '.devflow', 'decisions', '.decisions-batch-ids'); diff --git a/src/cli/utils/sentinel.ts b/src/cli/utils/sentinel.ts deleted file mode 100644 index 2758ee14..00000000 --- a/src/cli/utils/sentinel.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { promises as fs } from 'fs'; -import * as path from 'path'; - -/** - * Manage a runtime-disable sentinel file. - * - * When `enabled` is true the sentinel is removed (no-op if absent). - * When `enabled` is false the sentinel is created (parent directory created if needed). - * - * Removal is best-effort (swallows ENOENT). Creation propagates real I/O errors. - * - * @param sentinelPath Absolute path to the sentinel file. - * @param enabled Whether the feature is being enabled (true) or disabled (false). - */ -export async function manageSentinel( - sentinelPath: string, - enabled: boolean, -): Promise { - if (enabled) { - try { await fs.unlink(sentinelPath); } catch { /* sentinel didn't exist — that's fine */ } - } else { - await fs.mkdir(path.dirname(sentinelPath), { recursive: true }); - await fs.writeFile(sentinelPath, '', 'utf-8'); - } -} diff --git a/src/templates/settings.json b/src/templates/settings.json index bbaf790d..818ae8e0 100644 --- a/src/templates/settings.json +++ b/src/templates/settings.json @@ -4,12 +4,44 @@ "command": "${DEVFLOW_DIR}/scripts/hud.sh" }, "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "${DEVFLOW_DIR}/scripts/hooks/run-hook capture-prompt", + "timeout": 10 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "AskUserQuestion", + "hooks": [ + { + "type": "command", + "command": "${DEVFLOW_DIR}/scripts/hooks/run-hook capture-question", + "timeout": 10 + } + ] + } + ], "Stop": [ { "hooks": [ { "type": "command", - "command": "${DEVFLOW_DIR}/scripts/hooks/run-hook stop-update-memory", + "command": "${DEVFLOW_DIR}/scripts/hooks/run-hook capture-turn", + "timeout": 10 + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "${DEVFLOW_DIR}/scripts/hooks/run-hook memory-worker", "timeout": 10 } ] @@ -24,6 +56,15 @@ "timeout": 10 } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "${DEVFLOW_DIR}/scripts/hooks/run-hook session-start-context", + "timeout": 10 + } + ] } ], "PreCompact": [ diff --git a/tests/capture-hooks.test.ts b/tests/capture-hooks.test.ts new file mode 100644 index 00000000..2679b80e --- /dev/null +++ b/tests/capture-hooks.test.ts @@ -0,0 +1,565 @@ +/** + * tests/capture-hooks.test.ts + * + * Tests for the capture layer of the dream system: capture-prompt, + * capture-turn, capture-question, and memory-worker. A dedicated file + * (mirroring eager-memory-refresh.test.ts's precedent for a redesign-scoped + * test file) rather than adding to the already-large shell-hooks.test.ts. + * + * Harness idioms follow eager-memory-refresh.test.ts: fake-claude PATH shim, + * temp dirs, DEVFLOW_BG_WATCHDOG_SECS override, execSync + JSON stdin. Real + * `claude` is never invoked from these tests. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const HOOKS_DIR = path.resolve(__dirname, '..', 'scripts', 'hooks'); +const CAPTURE_PROMPT = path.join(HOOKS_DIR, 'capture-prompt'); +const CAPTURE_TURN = path.join(HOOKS_DIR, 'capture-turn'); +const CAPTURE_QUESTION = path.join(HOOKS_DIR, 'capture-question'); +const MEMORY_WORKER = path.join(HOOKS_DIR, 'memory-worker'); + +// --------------------------------------------------------------------------- +// Harness helpers (mirrors eager-memory-refresh.test.ts) +// --------------------------------------------------------------------------- + +function runHook( + hookPath: string, + input: object, + homeDir: string, + extraEnv: Record = {}, +): { stdout: string; stderr: string; exitCode: number } { + try { + const result = execSync(`bash "${hookPath}"`, { + input: JSON.stringify(input), + env: { ...process.env, HOME: homeDir, ...extraEnv }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { stdout: result.toString(), stderr: '', exitCode: 0 }; + } catch (e: unknown) { + const err = e as { stdout?: Buffer; stderr?: Buffer; status?: number }; + return { stdout: err.stdout?.toString() ?? '', stderr: err.stderr?.toString() ?? '', exitCode: err.status ?? 1 }; + } +} + +function runHookWithPath( + hookPath: string, + input: object, + homeDir: string, + shimDir: string, + extraEnv: Record = {}, +): { stdout: string; stderr: string; exitCode: number } { + return runHook(hookPath, input, homeDir, { + PATH: `${shimDir}:${process.env.PATH ?? '/usr/bin:/bin'}`, + ...extraEnv, + }); +} + +function createFakeClaudeShim(shimDir: string, memFile: string): void { + const bin = path.join(shimDir, 'claude'); + fs.writeFileSync( + bin, + `#!/bin/bash +echo "" > "${memFile}" +echo "## Now" >> "${memFile}" +exit 0 +`, + ); + fs.chmodSync(bin, 0o755); +} + +function backdateMtime(filePath: string, secondsAgo: number): void { + const past = new Date(Date.now() - secondsAgo * 1000); + fs.utimesSync(filePath, past, past); +} + +function readJsonl(file: string): Record[] { + if (!fs.existsSync(file)) return []; + return fs.readFileSync(file, 'utf-8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l)); +} + +function writeDreamConfig(projectDir: string, fields: Record): void { + const dir = path.join(projectDir, '.devflow', 'dream'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify(fields)); +} + +function workerLogPath(projectDir: string, homeDir: string, hookName: string): string { + const slug = projectDir.replace(/^\//, '').replace(/\//g, '-'); + return path.join(homeDir, '.devflow', 'logs', slug, `.${hookName}.log`); +} + +// ============================================================================= +// capture-prompt +// ============================================================================= +describe('capture-prompt', () => { + let projectDir: string; + let homeDir: string; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-prompt-')); + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-prompt-home-')); + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + fs.rmSync(homeDir, { recursive: true, force: true }); + }); + + it('AC-F1: both features enabled (no config) -> one {role:"user"} row to BOTH queues', () => { + runHook(CAPTURE_PROMPT, { cwd: projectDir, prompt: 'hello world' }, homeDir); + const mem = readJsonl(path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl')); + const dream = readJsonl(path.join(projectDir, '.devflow', 'dream', '.pending-turns.jsonl')); + expect(mem).toEqual([{ role: 'user', content: 'hello world', ts: expect.any(Number) }]); + expect(dream).toEqual([{ role: 'user', content: 'hello world', ts: expect.any(Number) }]); + }); + + it('AC-F1: content truncated at 2000 chars', () => { + const longPrompt = 'x'.repeat(2500); + runHook(CAPTURE_PROMPT, { cwd: projectDir, prompt: longPrompt }, homeDir); + const mem = readJsonl(path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl')); + expect((mem[0].content as string).length).toBe(2000 + '... [truncated]'.length); + }); + + it('empty prompt -> zero appends, no .devflow scaffolding', () => { + runHook(CAPTURE_PROMPT, { cwd: projectDir, prompt: '' }, homeDir); + expect(fs.existsSync(path.join(projectDir, '.devflow'))).toBe(false); + }); + + it('AC-F4: memory:false -> no memory-queue append (dream append unaffected)', () => { + writeDreamConfig(projectDir, { memory: false }); + runHook(CAPTURE_PROMPT, { cwd: projectDir, prompt: 'test' }, homeDir); + expect(fs.existsSync(path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl'))).toBe(false); + expect(readJsonl(path.join(projectDir, '.devflow', 'dream', '.pending-turns.jsonl'))).toHaveLength(1); + }); + + it('AC-F4: decisions disabled via config field -> no dream-queue append (memory unaffected)', () => { + writeDreamConfig(projectDir, { decisions: false }); + runHook(CAPTURE_PROMPT, { cwd: projectDir, prompt: 'test' }, homeDir); + expect(readJsonl(path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl'))).toHaveLength(1); + expect(fs.existsSync(path.join(projectDir, '.devflow', 'dream', '.pending-turns.jsonl'))).toBe(false); + }); + + it('both disabled -> zero appends, no scaffolding', () => { + writeDreamConfig(projectDir, { memory: false, decisions: false }); + runHook(CAPTURE_PROMPT, { cwd: projectDir, prompt: 'test' }, homeDir); + expect(fs.existsSync(path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl'))).toBe(false); + expect(fs.existsSync(path.join(projectDir, '.devflow', 'dream', '.pending-turns.jsonl'))).toBe(false); + }); + + it('AC-F14: DEVFLOW_BG_UPDATER=1 -> exit 0, zero filesystem writes', () => { + const { exitCode } = runHook(CAPTURE_PROMPT, { cwd: projectDir, prompt: 'test' }, homeDir, { DEVFLOW_BG_UPDATER: '1' }); + expect(exitCode).toBe(0); + expect(fs.existsSync(path.join(projectDir, '.devflow'))).toBe(false); + }); +}); + +// ============================================================================= +// capture-turn +// ============================================================================= +describe('capture-turn', () => { + let projectDir: string; + let homeDir: string; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-turn-')); + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-turn-home-')); + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + fs.rmSync(homeDir, { recursive: true, force: true }); + }); + + it('AC-F2: last_assistant_message present -> one {role:"assistant"} row to both queues', () => { + runHook(CAPTURE_TURN, { cwd: projectDir, session_id: 't', last_assistant_message: 'response text' }, homeDir); + expect(readJsonl(path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl'))).toEqual([ + { role: 'assistant', content: 'response text', ts: expect.any(Number) }, + ]); + expect(readJsonl(path.join(projectDir, '.devflow', 'dream', '.pending-turns.jsonl'))).toEqual([ + { role: 'assistant', content: 'response text', ts: expect.any(Number) }, + ]); + }); + + it('AC-F2: empty message -> zero appends', () => { + runHook(CAPTURE_TURN, { cwd: projectDir, session_id: 't', last_assistant_message: '' }, homeDir); + expect(fs.existsSync(path.join(projectDir, '.devflow'))).toBe(false); + }); + + it('AC-F5: capture-turn NEVER spawns a process', () => { + // No claude shim on PATH at all, and no trigger/throttle files involved. + // If capture-turn tried to spawn anything, .working-memory.lock/ or a + // worker log line would appear; assert their total absence. + fs.mkdirSync(path.join(projectDir, '.devflow', 'memory'), { recursive: true }); + runHook(CAPTURE_TURN, { cwd: projectDir, session_id: 't', last_assistant_message: 'hi' }, homeDir, { + PATH: '/usr/bin:/bin', // deliberately excludes any claude shim + }); + expect(fs.existsSync(path.join(projectDir, '.devflow', 'memory', '.working-memory.lock'))).toBe(false); + expect(fs.existsSync(path.join(projectDir, '.devflow', 'memory', '.working-memory-last-trigger'))).toBe(false); + const logFile = workerLogPath(projectDir, homeDir, 'background-memory-update'); + expect(fs.existsSync(logFile)).toBe(false); + }); + + it('capture-turn never spawns even with a stale trigger + populated queue + no claude shim', () => { + fs.mkdirSync(path.join(projectDir, '.devflow', 'memory'), { recursive: true }); + const triggerFile = path.join(projectDir, '.devflow', 'memory', '.working-memory-last-trigger'); + fs.writeFileSync(triggerFile, ''); + backdateMtime(triggerFile, 600); // "throttle expired" — would matter for memory-worker, not capture-turn + const beforeMtime = fs.statSync(triggerFile).mtimeMs; + + runHook(CAPTURE_TURN, { cwd: projectDir, session_id: 't', last_assistant_message: 'hello' }, homeDir); + + // capture-turn must not touch the trigger file (that's memory-worker's job) + expect(fs.statSync(triggerFile).mtimeMs).toBe(beforeMtime); + const logFile = workerLogPath(projectDir, homeDir, 'background-memory-update'); + expect(fs.existsSync(logFile)).toBe(false); + }); + + it('AC-F4: gating independent per queue (memory:false, decisions enabled)', () => { + writeDreamConfig(projectDir, { memory: false }); + runHook(CAPTURE_TURN, { cwd: projectDir, session_id: 't', last_assistant_message: 'x' }, homeDir); + expect(fs.existsSync(path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl'))).toBe(false); + expect(readJsonl(path.join(projectDir, '.devflow', 'dream', '.pending-turns.jsonl'))).toHaveLength(1); + }); + + it('decisions usage scanner still runs when memory is disabled', () => { + writeDreamConfig(projectDir, { memory: false }); + // decisions-usage-scan.cjs itself no-ops when .devflow/memory/ is absent + // (its own guard) — pre-create it, matching config-disable-guards.test.ts's + // mkMemoryDir convention. + fs.mkdirSync(path.join(projectDir, '.devflow', 'memory'), { recursive: true }); + fs.mkdirSync(path.join(projectDir, '.devflow', 'decisions'), { recursive: true }); + const usagePath = path.join(projectDir, '.devflow', 'decisions', '.decisions-usage.json'); + fs.writeFileSync(usagePath, JSON.stringify({ version: 1, entries: { 'ADR-001': { cites: 0, last_cited: null } } })); + runHook(CAPTURE_TURN, { cwd: projectDir, session_id: 't', last_assistant_message: 'applies ADR-001' }, homeDir); + const updated = JSON.parse(fs.readFileSync(usagePath, 'utf-8')); + expect(updated.entries['ADR-001'].cites).toBe(1); + }); + + it('AC-F14: DEVFLOW_BG_UPDATER=1 -> exit 0, zero filesystem writes', () => { + const { exitCode } = runHook( + CAPTURE_TURN, + { cwd: projectDir, session_id: 't', last_assistant_message: 'hi' }, + homeDir, + { DEVFLOW_BG_UPDATER: '1' }, + ); + expect(exitCode).toBe(0); + expect(fs.existsSync(path.join(projectDir, '.devflow'))).toBe(false); + }); + + it('content truncated at 2000 chars', () => { + const longMessage = 'b'.repeat(5000); + runHook(CAPTURE_TURN, { cwd: projectDir, session_id: 't', last_assistant_message: longMessage }, homeDir); + const mem = readJsonl(path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl')); + expect((mem[0].content as string).length).toBe(2000 + '... [truncated]'.length); + }); + + it('ignores legacy response_text field — only last_assistant_message gates capture', () => { + // Regression guard (PF-006 lineage): a payload carrying only the old field name + // (response_text) and not last_assistant_message must produce zero appends. + runHook(CAPTURE_TURN, { cwd: projectDir, session_id: 't', response_text: 'this should be ignored' }, homeDir); + expect(fs.existsSync(path.join(projectDir, '.devflow'))).toBe(false); + }); + + it('creates its own log file tagged [capture-turn] on successful capture', () => { + runHook(CAPTURE_TURN, { cwd: projectDir, session_id: 't', last_assistant_message: 'hello' }, homeDir); + const logFile = workerLogPath(projectDir, homeDir, 'capture-turn'); + expect(fs.existsSync(logFile)).toBe(true); + expect(fs.readFileSync(logFile, 'utf-8')).toContain('[capture-turn]'); + }); +}); + +// ============================================================================= +// capture-question +// ============================================================================= +describe('capture-question', () => { + let projectDir: string; + let homeDir: string; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-question-')); + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-question-home-')); + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + fs.rmSync(homeDir, { recursive: true, force: true }); + }); + + // Fixture mined from a real transcript (~/.claude/projects/-Users-dean-Sandbox-devflow), + // structurally identical to the captured toolUseResult shape (verified byte-identical + // to the PostToolUse tool_response field via a scratch-project probe). + const REAL_MULTI_QUESTION_PAYLOAD = { + session_id: 'test', + hook_event_name: 'PostToolUse', + tool_name: 'AskUserQuestion', + tool_input: { + questions: [ + { + question: 'How should I handle the Phase 5 Scrutinizer review?', + header: 'Scrutinizer', + multiSelect: false, + options: [{ label: 'Re-run, inert probes only' }, { label: 'Skip Scrutinizer entirely' }], + }, + { + question: 'Going forward, how do you want me to handle subagents?', + header: 'Shell policy', + multiSelect: false, + options: [{ label: 'Flag before running' }], + }, + ], + }, + tool_response: { + questions: [], + answers: { + 'How should I handle the Phase 5 Scrutinizer review?': 'Re-run, inert probes only', + 'Going forward, how do you want me to handle subagents?': 'Flag before running', + }, + annotations: {}, + }, + }; + + // Real errored sample mined from ~/.claude/projects (a different project's + // transcript): an AskUserQuestion call with a missing required `questions` param. + const REAL_ERRORED_PAYLOAD = { + session_id: 'test', + hook_event_name: 'PostToolUse', + tool_name: 'AskUserQuestion', + tool_input: {}, + tool_response: + 'InputValidationError: [\n {\n "expected": "array",\n "code": "invalid_type",\n "path": [\n "questions"\n ],\n "message": "Invalid input: expected array, received undefined"\n }\n]', + }; + + it('AC-F3: one {role:"qa"} row PER QUESTION, to both queues', () => { + runHook(CAPTURE_QUESTION, { ...REAL_MULTI_QUESTION_PAYLOAD, cwd: projectDir }, homeDir); + const mem = readJsonl(path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl')); + const dream = readJsonl(path.join(projectDir, '.devflow', 'dream', '.pending-turns.jsonl')); + expect(mem).toHaveLength(2); + expect(dream).toHaveLength(2); + expect(mem[0]).toMatchObject({ + role: 'qa', + content: 'Q: How should I handle the Phase 5 Scrutinizer review?\nA: Re-run, inert probes only', + }); + expect(mem[1]).toMatchObject({ + role: 'qa', + content: 'Q: Going forward, how do you want me to handle subagents?\nA: Flag before running', + }); + }); + + it('non-AskUserQuestion tool_name -> zero appends (even with a matcher, defensively)', () => { + runHook( + CAPTURE_QUESTION, + { cwd: projectDir, tool_name: 'Read', tool_input: { file_path: '/tmp/x' }, tool_response: { type: 'text' } }, + homeDir, + ); + expect(fs.existsSync(path.join(projectDir, '.devflow'))).toBe(false); + }); + + it('malformed/errored tool_response (real InputValidationError sample) -> exit 0, zero writes', () => { + const { exitCode } = runHook(CAPTURE_QUESTION, { ...REAL_ERRORED_PAYLOAD, cwd: projectDir }, homeDir); + expect(exitCode).toBe(0); + expect(fs.existsSync(path.join(projectDir, '.devflow'))).toBe(false); + }); + + it('absent tool_response -> exit 0, zero writes', () => { + const { exitCode } = runHook( + CAPTURE_QUESTION, + { cwd: projectDir, tool_name: 'AskUserQuestion', tool_input: { questions: [{ question: 'q?' }] } }, + homeDir, + ); + expect(exitCode).toBe(0); + expect(fs.existsSync(path.join(projectDir, '.devflow'))).toBe(false); + }); + + it('multiSelect array answer -> joined with "; "', () => { + runHook( + CAPTURE_QUESTION, + { + cwd: projectDir, + tool_name: 'AskUserQuestion', + tool_input: { questions: [{ question: 'Pick colors', multiSelect: true, options: [{ label: 'Red' }, { label: 'Blue' }] }] }, + tool_response: { answers: { 'Pick colors': ['Red', 'Blue'] } }, + }, + homeDir, + ); + const mem = readJsonl(path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl')); + expect(mem[0].content).toBe('Q: Pick colors\nA: Red; Blue'); + }); + + it('truncation: Q and A are capped at 1000 chars INDEPENDENTLY', () => { + const longQ = 'Q'.repeat(1500); + const longA = 'A'.repeat(1500); + runHook( + CAPTURE_QUESTION, + { + cwd: projectDir, + tool_name: 'AskUserQuestion', + tool_input: { questions: [{ question: longQ, multiSelect: false, options: [] }] }, + tool_response: { answers: { [longQ]: longA } }, + }, + homeDir, + ); + const mem = readJsonl(path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl')); + const content = mem[0].content as string; + const [qPart, aPart] = content.split('\nA: '); + expect(qPart.replace('Q: ', '')).toHaveLength(1000 + '... [truncated]'.length); + expect(aPart).toHaveLength(1000 + '... [truncated]'.length); + }); + + it('hostile answer content is safely escaped (quotes, $(...), newlines collapsed)', () => { + const hostileAnswer = 'yes "quoted" $(rm -rf /) `backtick`'; + runHook( + CAPTURE_QUESTION, + { + cwd: projectDir, + tool_name: 'AskUserQuestion', + tool_input: { questions: [{ question: 'proceed?', multiSelect: false, options: [] }] }, + tool_response: { answers: { 'proceed?': hostileAnswer } }, + }, + homeDir, + ); + const mem = readJsonl(path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl')); + expect(mem[0].content).toBe(`Q: proceed?\nA: ${hostileAnswer}`); + }); + + it('AC-F4: gating independent per queue (decisions disabled, memory enabled)', () => { + writeDreamConfig(projectDir, { decisions: false }); + runHook(CAPTURE_QUESTION, { ...REAL_MULTI_QUESTION_PAYLOAD, cwd: projectDir }, homeDir); + expect(readJsonl(path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl'))).toHaveLength(2); + expect(fs.existsSync(path.join(projectDir, '.devflow', 'dream', '.pending-turns.jsonl'))).toBe(false); + }); + + it('AC-F14: DEVFLOW_BG_UPDATER=1 -> exit 0, zero writes', () => { + const r1 = runHook(CAPTURE_QUESTION, { ...REAL_MULTI_QUESTION_PAYLOAD, cwd: projectDir }, homeDir, { DEVFLOW_BG_UPDATER: '1' }); + expect(r1.exitCode).toBe(0); + expect(fs.existsSync(path.join(projectDir, '.devflow'))).toBe(false); + }); +}); + +// ============================================================================= +// memory-worker +// ============================================================================= +describe('memory-worker', () => { + let projectDir: string; + let homeDir: string; + let shimDir: string; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mem-worker-')); + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mem-worker-home-')); + shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mem-worker-shim-')); + fs.mkdirSync(path.join(projectDir, '.devflow', 'memory'), { recursive: true }); + fs.mkdirSync(path.join(projectDir, '.devflow', 'dream'), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(shimDir, { recursive: true, force: true }); + }); + + it('120s throttle honored: fresh trigger -> no spawn', () => { + const triggerFile = path.join(projectDir, '.devflow', 'memory', '.working-memory-last-trigger'); + fs.writeFileSync(triggerFile, ''); + const beforeMtime = fs.statSync(triggerFile).mtimeMs; + + runHookWithPath(MEMORY_WORKER, { cwd: projectDir }, homeDir, shimDir); + + expect(fs.statSync(triggerFile).mtimeMs).toBe(beforeMtime); + }); + + it('touch-before-spawn: stale trigger -> trigger touched, worker spawned', () => { + const memFile = path.join(projectDir, '.devflow', 'memory', 'WORKING-MEMORY.md'); + createFakeClaudeShim(shimDir, memFile); + const triggerFile = path.join(projectDir, '.devflow', 'memory', '.working-memory-last-trigger'); + fs.writeFileSync(triggerFile, ''); + backdateMtime(triggerFile, 600); + + runHookWithPath(MEMORY_WORKER, { cwd: projectDir }, homeDir, shimDir); + + expect(fs.statSync(triggerFile).mtimeMs).toBeGreaterThan(Date.now() - 15000); + }); + + it('spawn happens: worker log shows Starting (fake claude shim on PATH)', async () => { + const memFile = path.join(projectDir, '.devflow', 'memory', 'WORKING-MEMORY.md'); + createFakeClaudeShim(shimDir, memFile); + fs.writeFileSync( + path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl'), + JSON.stringify({ role: 'user', content: 'hi', ts: 1 }) + '\n' + JSON.stringify({ role: 'assistant', content: 'hey', ts: 2 }) + '\n', + ); + const triggerFile = path.join(projectDir, '.devflow', 'memory', '.working-memory-last-trigger'); + fs.writeFileSync(triggerFile, ''); + backdateMtime(triggerFile, 600); + + runHookWithPath(MEMORY_WORKER, { cwd: projectDir }, homeDir, shimDir); + + // Poll briefly for the detached worker's log (nohup-spawned, async) + const logFile = workerLogPath(projectDir, homeDir, 'background-memory-update'); + const deadline = Date.now() + 5000; + while (Date.now() < deadline && !fs.existsSync(logFile)) { + await new Promise((r) => setTimeout(r, 100)); + } + expect(fs.existsSync(logFile)).toBe(true); + expect(fs.readFileSync(logFile, 'utf-8')).toContain('Starting (CWD='); + }); + + it('BG_UPDATER guard prevents spawn', () => { + const memFile = path.join(projectDir, '.devflow', 'memory', 'WORKING-MEMORY.md'); + createFakeClaudeShim(shimDir, memFile); + const triggerFile = path.join(projectDir, '.devflow', 'memory', '.working-memory-last-trigger'); + fs.writeFileSync(triggerFile, ''); + backdateMtime(triggerFile, 600); + + runHookWithPath(MEMORY_WORKER, { cwd: projectDir }, homeDir, shimDir, { DEVFLOW_BG_UPDATER: '1' }); + // Trigger must still be stale — guard fired before the throttle check even ran + const age = Date.now() - fs.statSync(triggerFile).mtimeMs; + expect(age).toBeGreaterThan(590 * 1000); + }); + + it('memory:false -> no spawn attempted, no trigger touch', () => { + writeDreamConfig(projectDir, { memory: false }); + const triggerFile = path.join(projectDir, '.devflow', 'memory', '.working-memory-last-trigger'); + fs.writeFileSync(triggerFile, ''); + backdateMtime(triggerFile, 600); + + runHookWithPath(MEMORY_WORKER, { cwd: projectDir }, homeDir, shimDir); + + const age = Date.now() - fs.statSync(triggerFile).mtimeMs; + expect(age).toBeGreaterThan(590 * 1000); + }); +}); + +// ============================================================================= +// capture-prompt + capture-turn integration +// ============================================================================= +describe('capture-prompt + capture-turn integration', () => { + let projectDir: string; + let homeDir: string; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-integ-')); + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-integ-home-')); + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + fs.rmSync(homeDir, { recursive: true, force: true }); + }); + + it('user turn then assistant turn are appended in order to both queues', () => { + runHook(CAPTURE_PROMPT, { cwd: projectDir, session_id: 'integ', prompt: 'implement feature X' }, homeDir); + runHook(CAPTURE_TURN, { cwd: projectDir, session_id: 'integ', last_assistant_message: 'done' }, homeDir); + + for (const queue of ['memory', 'dream'] as const) { + const rows = readJsonl(path.join(projectDir, '.devflow', queue, '.pending-turns.jsonl')); + expect(rows).toEqual([ + { role: 'user', content: 'implement feature X', ts: expect.any(Number) }, + { role: 'assistant', content: 'done', ts: expect.any(Number) }, + ]); + } + }); +}); diff --git a/tests/capture.test.ts b/tests/capture.test.ts new file mode 100644 index 00000000..82590da7 --- /dev/null +++ b/tests/capture.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect } from 'vitest'; +import { + addCaptureHooks, + removeCaptureHooks, + hasCaptureHooks, + countCaptureHooks, +} from '../src/cli/commands/capture.js'; + +describe('addCaptureHooks', () => { + it('adds all 3 capture hook types to empty settings', () => { + const result = addCaptureHooks('{}', '/home/user/.devflow'); + const settings = JSON.parse(result); + + expect(settings.hooks.UserPromptSubmit).toHaveLength(1); + expect(settings.hooks.Stop).toHaveLength(1); + expect(settings.hooks.PostToolUse).toHaveLength(1); + expect(settings.hooks.UserPromptSubmit[0].hooks[0].command).toContain('capture-prompt'); + expect(settings.hooks.Stop[0].hooks[0].command).toContain('capture-turn'); + expect(settings.hooks.PostToolUse[0].hooks[0].command).toContain('capture-question'); + }); + + it('scopes the PostToolUse entry with matcher: "AskUserQuestion"', () => { + const result = addCaptureHooks('{}', '/home/user/.devflow'); + const settings = JSON.parse(result); + expect(settings.hooks.PostToolUse[0].matcher).toBe('AskUserQuestion'); + }); + + it('matcher persists through a JSON settings round-trip', () => { + const first = addCaptureHooks('{}', '/home/user/.devflow'); + // Simulate a settings.json read/write cycle (JSON.stringify -> disk -> JSON.parse) + const roundTripped = JSON.stringify(JSON.parse(first)); + const parsed = JSON.parse(roundTripped); + expect(parsed.hooks.PostToolUse[0].matcher).toBe('AskUserQuestion'); + }); + + it('is idempotent — calling twice returns identical JSON', () => { + const first = addCaptureHooks('{}', '/home/user/.devflow'); + const second = addCaptureHooks(first, '/home/user/.devflow'); + expect(second).toBe(first); + }); + + it('adds only missing hooks when partial state (1 hook missing)', () => { + const input = JSON.stringify({ + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: '/path/capture-prompt', timeout: 10 }] }], + Stop: [{ hooks: [{ type: 'command', command: '/path/capture-turn', timeout: 10 }] }], + }, + }); + const result = addCaptureHooks(input, '/home/user/.devflow'); + const settings = JSON.parse(result); + + expect(settings.hooks.UserPromptSubmit).toHaveLength(1); + expect(settings.hooks.Stop).toHaveLength(1); + expect(settings.hooks.PostToolUse).toHaveLength(1); + expect(settings.hooks.PostToolUse[0].hooks[0].command).toContain('capture-question'); + expect(settings.hooks.PostToolUse[0].matcher).toBe('AskUserQuestion'); + }); + + it('preserves existing ambient preamble hook on UserPromptSubmit when adding capture-prompt', () => { + const input = JSON.stringify({ + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: '/path/run-hook preamble' }] }], + }, + }); + const result = addCaptureHooks(input, '/home/user/.devflow'); + const settings = JSON.parse(result); + + expect(settings.hooks.UserPromptSubmit).toHaveLength(2); + const commands = settings.hooks.UserPromptSubmit.map((m: { hooks: { command: string }[] }) => m.hooks[0].command); + expect(commands.some((c: string) => c.includes('preamble'))).toBe(true); + expect(commands.some((c: string) => c.includes('capture-prompt'))).toBe(true); + }); + + it('creates hooks object if missing', () => { + const input = JSON.stringify({ statusLine: { type: 'command' } }); + const result = addCaptureHooks(input, '/home/user/.devflow'); + const settings = JSON.parse(result); + + expect(settings.hooks).toBeDefined(); + expect(settings.hooks.Stop).toHaveLength(1); + }); + + it('uses correct devflowDir path in command via run-hook wrapper', () => { + const result = addCaptureHooks('{}', '/custom/path/.devflow'); + const settings = JSON.parse(result); + + expect(settings.hooks.UserPromptSubmit[0].hooks[0].command).toContain('/custom/path/.devflow/scripts/hooks/run-hook'); + expect(settings.hooks.Stop[0].hooks[0].command).toContain('run-hook'); + expect(settings.hooks.PostToolUse[0].hooks[0].command).toContain('run-hook'); + }); + + it('preserves other settings (statusLine, env)', () => { + const input = JSON.stringify({ + statusLine: { type: 'command', command: 'statusline.sh' }, + env: { SOME_VAR: '1' }, + }); + const result = addCaptureHooks(input, '/home/user/.devflow'); + const settings = JSON.parse(result); + + expect(settings.statusLine.command).toBe('statusline.sh'); + expect(settings.env.SOME_VAR).toBe('1'); + }); + + it('sets timeout to 10 for all hooks', () => { + const result = addCaptureHooks('{}', '/home/user/.devflow'); + const settings = JSON.parse(result); + + expect(settings.hooks.UserPromptSubmit[0].hooks[0].timeout).toBe(10); + expect(settings.hooks.Stop[0].hooks[0].timeout).toBe(10); + expect(settings.hooks.PostToolUse[0].hooks[0].timeout).toBe(10); + }); +}); + +describe('removeCaptureHooks', () => { + it('removes all 3 capture hook types', () => { + const withHooks = addCaptureHooks('{}', '/home/user/.devflow'); + const result = removeCaptureHooks(withHooks); + const settings = JSON.parse(result); + + expect(settings.hooks).toBeUndefined(); + }); + + it('preserves ambient preamble on UserPromptSubmit when removing capture-prompt', () => { + const input = JSON.stringify({ + hooks: { + UserPromptSubmit: [ + { hooks: [{ type: 'command', command: 'preamble' }] }, + { hooks: [{ type: 'command', command: '/path/capture-prompt' }] }, + ], + }, + }); + const result = removeCaptureHooks(input); + const settings = JSON.parse(result); + + expect(settings.hooks.UserPromptSubmit).toHaveLength(1); + expect(settings.hooks.UserPromptSubmit[0].hooks[0].command).toBe('preamble'); + }); + + it('preserves memory-worker on Stop when removing capture-turn (adjacent hook, different module)', () => { + const input = JSON.stringify({ + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: '/path/capture-turn' }] }, + { hooks: [{ type: 'command', command: '/path/memory-worker' }] }, + ], + }, + }); + const result = removeCaptureHooks(input); + const settings = JSON.parse(result); + + expect(settings.hooks.Stop).toHaveLength(1); + expect(settings.hooks.Stop[0].hooks[0].command).toContain('memory-worker'); + }); + + it('does not touch legacy dream-dispatch/dream-capture entries — that sweep belongs to memory.ts', () => { + // capture.ts is a pure always-on registrar (context.ts pattern) with no legacy + // awareness of its own; the dream-dispatch/dream-capture/dream-evaluate legacy + // sweep is memory.ts's LEGACY_HOOK_MARKERS responsibility (see memory.test.ts). + // This test proves removeCaptureHooks coexists safely: it removes only its own + // current markers and leaves legacy entries untouched for memory.ts to sweep. + const input = JSON.stringify({ + hooks: { + UserPromptSubmit: [ + { hooks: [{ type: 'command', command: '/path/run-hook dream-dispatch', timeout: 10 }] }, + { hooks: [{ type: 'command', command: '/path/run-hook capture-prompt', timeout: 10 }] }, + ], + Stop: [ + { hooks: [{ type: 'command', command: '/path/run-hook dream-capture', timeout: 10 }] }, + { hooks: [{ type: 'command', command: '/path/run-hook capture-turn', timeout: 10 }] }, + ], + }, + }); + const result = removeCaptureHooks(input); + const settings = JSON.parse(result); + + // Only the current capture-* markers are removed + expect(settings.hooks.UserPromptSubmit).toHaveLength(1); + expect(settings.hooks.UserPromptSubmit[0].hooks[0].command).toContain('dream-dispatch'); + expect(settings.hooks.Stop).toHaveLength(1); + expect(settings.hooks.Stop[0].hooks[0].command).toContain('dream-capture'); + }); + + it('is idempotent — safe to call when not present', () => { + const input = JSON.stringify({ + hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'other.sh' }] }] }, + }); + const result = removeCaptureHooks(input); + + expect(result).toBe(input); + }); + + it('cleans empty hooks object when all arrays removed', () => { + const withHooks = addCaptureHooks('{}', '/home/user/.devflow'); + const result = removeCaptureHooks(withHooks); + const settings = JSON.parse(result); + + expect(settings.hooks).toBeUndefined(); + }); + + it('accepts a parsed Settings object and does not mutate the original', () => { + const settings = { + hooks: { + Stop: [{ hooks: [{ type: 'command' as const, command: '/path/capture-turn', timeout: 10 }] }], + }, + }; + const result = removeCaptureHooks(settings); + const parsed = JSON.parse(result); + expect(parsed.hooks).toBeUndefined(); + // Original must be unchanged + expect(settings.hooks.Stop).toHaveLength(1); + }); + + it('formats the no-change return identically for object input regardless of whether a change occurred', () => { + // No capture hooks present, so this is a no-op — but the object-input + // return must still be pretty-printed with a trailing newline, matching + // the format used when a change does occur (see the mutating-path test + // above, which parses via JSON.parse and would mask a formatting drift). + const settings = { hooks: { Stop: [{ hooks: [{ type: 'command' as const, command: '/path/other-hook', timeout: 10 }] }] } }; + const result = removeCaptureHooks(settings); + + expect(result).toBe(JSON.stringify(settings, null, 2) + '\n'); + }); + + it('toggle cycle: enable → disable → enable produces clean state', () => { + const enabled = addCaptureHooks('{}', '/home/user/.devflow'); + const disabled = removeCaptureHooks(enabled); + const reEnabled = addCaptureHooks(disabled, '/home/user/.devflow'); + const settings = JSON.parse(reEnabled); + + expect(settings.hooks.UserPromptSubmit).toHaveLength(1); + expect(settings.hooks.Stop).toHaveLength(1); + expect(settings.hooks.PostToolUse).toHaveLength(1); + expect(settings.hooks.PostToolUse[0].matcher).toBe('AskUserQuestion'); + }); +}); + +describe('hasCaptureHooks / countCaptureHooks', () => { + it('hasCaptureHooks returns true when all 3 present', () => { + const withHooks = addCaptureHooks('{}', '/home/user/.devflow'); + expect(hasCaptureHooks(withHooks)).toBe(true); + expect(countCaptureHooks(withHooks)).toBe(3); + }); + + it('hasCaptureHooks returns false when none present', () => { + expect(hasCaptureHooks('{}')).toBe(false); + expect(countCaptureHooks('{}')).toBe(0); + }); + + it('returns false/partial-count when only 2 of 3 present', () => { + const input = JSON.stringify({ + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: '/path/capture-prompt' }] }], + Stop: [{ hooks: [{ type: 'command', command: '/path/capture-turn' }] }], + }, + }); + expect(hasCaptureHooks(input)).toBe(false); + expect(countCaptureHooks(input)).toBe(2); + }); + + it('accepts a parsed Settings object (not just JSON string)', () => { + const settings = { + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'command' as const, command: '/path/capture-prompt', timeout: 10 }] }], + Stop: [{ hooks: [{ type: 'command' as const, command: '/path/capture-turn', timeout: 10 }] }], + PostToolUse: [{ matcher: 'AskUserQuestion', hooks: [{ type: 'command' as const, command: '/path/capture-question', timeout: 10 }] }], + }, + }; + expect(countCaptureHooks(settings)).toBe(3); + expect(hasCaptureHooks(settings)).toBe(true); + }); +}); diff --git a/tests/sentinel.test.ts b/tests/config-disable-guards.test.ts similarity index 58% rename from tests/sentinel.test.ts rename to tests/config-disable-guards.test.ts index 1d7a1e70..8f937166 100644 --- a/tests/sentinel.test.ts +++ b/tests/config-disable-guards.test.ts @@ -1,6 +1,7 @@ /** - * Tests for sentinel-based disable guards across memory and learning hooks, - * the new session-start-context hook, CLI sentinel management, and decisions scanner. + * Tests for config-based disable guards across memory and decisions hooks, + * the session-start-context hook, hook registration utilities, and the + * decisions usage scanner. * * Test order follows TDD RED-GREEN-REFACTOR. */ @@ -9,14 +10,13 @@ import { execSync } from 'child_process'; import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; -import { manageSentinel } from '../src/cli/utils/sentinel.js'; const HOOKS_DIR = path.resolve(__dirname, '..', 'scripts', 'hooks'); // ─── Helpers ──────────────────────────────────────────────────────────────── function mkTmpDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-sentinel-test-')); + return fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-config-disable-guards-test-')); } function mkMemoryDir(base: string): void { @@ -26,12 +26,6 @@ function mkMemoryDir(base: string): void { fs.mkdirSync(path.join(base, '.devflow', 'learning'), { recursive: true }); } -function writeDisabledSentinel(sentinelPath: string): void { - const dir = path.dirname(sentinelPath); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(sentinelPath, '', 'utf-8'); -} - function sessionInput(tmpDir: string, extra: Record = {}): string { return JSON.stringify({ cwd: tmpDir, session_id: 'test-session', ...extra }); } @@ -52,125 +46,7 @@ function parseHookOutput(rawOutput: string): string { return hookOutput.additionalContext as string; } -// ─── Part B: Sentinel guards for dream capture hooks ────────────────────── - -describe('sentinel guard: dream-dispatch', () => { - const HOOK = path.join(HOOKS_DIR, 'dream-dispatch'); - let tmpDir: string; - - beforeEach(() => { tmpDir = mkTmpDir(); }); - afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - - it('captures user prompt normally when memory enabled (no config)', () => { - mkMemoryDir(tmpDir); - const input = sessionInput(tmpDir, { prompt: 'test prompt' }); - execSync(`bash "${HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - expect(fs.existsSync(path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'))).toBe(true); - }); - - it('exits early and writes nothing when config has memory: false', () => { - mkMemoryDir(tmpDir); - const dreamDir = path.join(tmpDir, '.devflow', 'dream'); - fs.mkdirSync(dreamDir, { recursive: true }); - fs.writeFileSync(path.join(dreamDir, 'config.json'), JSON.stringify({ memory: false })); - const input = sessionInput(tmpDir, { prompt: 'test prompt' }); - execSync(`bash "${HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - expect(fs.existsSync(path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'))).toBe(false); - }); -}); - -describe('sentinel guard: dream-capture', () => { - const HOOK = path.join(HOOKS_DIR, 'dream-capture'); - let tmpDir: string; - - beforeEach(() => { tmpDir = mkTmpDir(); }); - afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - - it('appends assistant turn normally when memory enabled (no config)', () => { - mkMemoryDir(tmpDir); - // Write stale WORKING-MEMORY.md so throttle passes - const memFile = path.join(tmpDir, '.devflow', 'memory', 'WORKING-MEMORY.md'); - fs.writeFileSync(memFile, '## Now\n- testing'); - const tenMinutesAgo = new Date(Date.now() - 600 * 1000); - fs.utimesSync(memFile, tenMinutesAgo, tenMinutesAgo); - const input = sessionInput(tmpDir, { last_assistant_message: 'hello' }); - execSync(`bash "${HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - expect(fs.existsSync(path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'))).toBe(true); - }); - - it('exits early when config has memory: false', () => { - mkMemoryDir(tmpDir); - const dreamDir = path.join(tmpDir, '.devflow', 'dream'); - fs.mkdirSync(dreamDir, { recursive: true }); - fs.writeFileSync(path.join(dreamDir, 'config.json'), JSON.stringify({ memory: false })); - const input = sessionInput(tmpDir, { last_assistant_message: 'hello' }); - execSync(`bash "${HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - expect(fs.existsSync(path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'))).toBe(false); - }); - - it('captures when stop_reason is "tool_use" and last_assistant_message is present', () => { - // Regression: the old code filtered on stop_reason=end_turn, which meant tool_use - // stops with a valid last_assistant_message were silently dropped. After the rename, - // stop_reason is ignored — only last_assistant_message presence gates capture. - mkMemoryDir(tmpDir); - const memFile = path.join(tmpDir, '.devflow', 'memory', 'WORKING-MEMORY.md'); - fs.writeFileSync(memFile, '## Now\n- testing'); - const tenMinutesAgo = new Date(Date.now() - 600 * 1000); - fs.utimesSync(memFile, tenMinutesAgo, tenMinutesAgo); - const input = sessionInput(tmpDir, { - stop_reason: 'tool_use', - last_assistant_message: 'response with tool call', - }); - execSync(`bash "${HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - // Capture must proceed regardless of stop_reason value - expect(fs.existsSync(path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'))).toBe(true); - }); - - it('ignores legacy response_text field (avoids PF-006)', () => { - // Regression guard: when input carries only the old field name (response_text) - // and NOT the new field (last_assistant_message), ASSISTANT_MSG is empty and - // the hook must exit without creating the queue file. - mkMemoryDir(tmpDir); - const memFile = path.join(tmpDir, '.devflow', 'memory', 'WORKING-MEMORY.md'); - fs.writeFileSync(memFile, '## Now\n- testing'); - const tenMinutesAgo = new Date(Date.now() - 600 * 1000); - fs.utimesSync(memFile, tenMinutesAgo, tenMinutesAgo); - const input = sessionInput(tmpDir, { - stop_reason: 'end_turn', - response_text: 'this should be ignored', - }); - execSync(`bash "${HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - // Queue must NOT be created — old field name carries no capture value - expect(fs.existsSync(path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'))).toBe(false); - }); - - it('creates log file on successful capture', () => { - mkMemoryDir(tmpDir); - const memFile = path.join(tmpDir, '.devflow', 'memory', 'WORKING-MEMORY.md'); - fs.writeFileSync(memFile, '## Now\n- testing'); - const tenMinutesAgo = new Date(Date.now() - 600 * 1000); - fs.utimesSync(memFile, tenMinutesAgo, tenMinutesAgo); - const input = sessionInput(tmpDir, { last_assistant_message: 'hello' }); - // Create a temp home so log writes land in our temp dir - const tmpHome = fs.mkdtempSync(os.tmpdir() + '/devflow-log-home-'); - try { - execSync(`bash "${HOOK}"`, { - input, - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, HOME: tmpHome }, - }); - const slug = tmpDir.replace(/^\//, '').replace(/\//g, '-'); - const logFile = path.join(tmpHome, '.devflow', 'logs', slug, '.dream-capture.log'); - expect(fs.existsSync(logFile)).toBe(true); - const content = fs.readFileSync(logFile, 'utf-8'); - expect(content).toContain('[dream-capture]'); - } finally { - fs.rmSync(tmpHome, { recursive: true, force: true }); - } - }); -}); - -describe('sentinel guard: pre-compact-memory', () => { +describe('config guard: pre-compact-memory', () => { const HOOK = path.join(HOOKS_DIR, 'pre-compact-memory'); let tmpDir: string; @@ -190,7 +66,7 @@ describe('sentinel guard: pre-compact-memory', () => { expect(fs.existsSync(path.join(tmpDir, '.devflow', 'memory', 'backup.json'))).toBe(false); }); - it('writes backup.json when sentinel absent', () => { + it('writes backup.json when disable guard absent', () => { mkMemoryDir(tmpDir); const input = sessionInput(tmpDir); expect(() => { @@ -201,7 +77,7 @@ describe('sentinel guard: pre-compact-memory', () => { }); }); -describe('sentinel guard: session-start-memory', () => { +describe('config guard: session-start-memory', () => { const HOOK = path.join(HOOKS_DIR, 'session-start-memory'); let tmpDir: string; @@ -219,7 +95,7 @@ describe('sentinel guard: session-start-memory', () => { expect(output).toBe(''); }); - it('outputs context when sentinel absent and WORKING-MEMORY.md exists', () => { + it('outputs context when disable guard absent and WORKING-MEMORY.md exists', () => { mkMemoryDir(tmpDir); fs.writeFileSync(path.join(tmpDir, '.devflow', 'memory', 'WORKING-MEMORY.md'), '## Now\n- testing'); const input = sessionInput(tmpDir); @@ -231,59 +107,16 @@ describe('sentinel guard: session-start-memory', () => { }); }); -// ─── Part C: Sentinel guard for dream-evaluate hook ──────────────────────── - -describe('sentinel guard: dream-evaluate', () => { - const HOOK = path.join(HOOKS_DIR, 'dream-evaluate'); - let tmpDir: string; - - beforeEach(() => { tmpDir = mkTmpDir(); }); - afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - - it('exits cleanly when no session_id is provided', () => { - mkMemoryDir(tmpDir); - const input = JSON.stringify({ cwd: tmpDir }); - expect(() => { - execSync(`bash "${HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - }).not.toThrow(); - }); - - it('exits cleanly for DEVFLOW_BG_LEARNER=1 (feedback loop guard)', () => { - expect(() => { - execSync(`DEVFLOW_BG_LEARNER=1 bash "${HOOK}"`, { stdio: 'ignore' }); - }).not.toThrow(); - }); - - it('exits cleanly when .devflow/memory/ does not exist (no markers written)', () => { - const input = sessionInput(tmpDir); - expect(() => { - execSync(`bash "${HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - }).not.toThrow(); - // Should not create any dream marker files - expect(fs.existsSync(path.join(tmpDir, '.devflow', 'dream'))).toBe(false); - }); -}); - // ─── Part D: Decisions scanner fix ────────────────────────────────────────── -describe('sentinel guard: decisions-usage-scan.cjs', () => { +describe('decisions-usage-scan.cjs', () => { const SCANNER = path.join(HOOKS_DIR, 'decisions-usage-scan.cjs'); let tmpDir: string; beforeEach(() => { tmpDir = mkTmpDir(); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it('exits 0 when decisions/.disabled exists', () => { - mkMemoryDir(tmpDir); - writeDisabledSentinel(path.join(tmpDir, '.devflow', 'decisions', '.disabled')); - // Even with ADR-001 in input, scanner should skip - const response = 'applies ADR-001 and avoids PF-001'; - expect(() => { - execSync(`printf '%s' "${response}" | node "${SCANNER}" --cwd "${tmpDir}"`, { stdio: ['pipe', 'pipe', 'pipe'] }); - }).not.toThrow(); - }); - - it('processes citations when decisions/.disabled absent', () => { + it('processes citations (gating lives in the caller, not the scanner)', () => { mkMemoryDir(tmpDir); // Create usage file with a known entry const usagePath = path.join(tmpDir, '.devflow', 'decisions', '.decisions-usage.json'); @@ -298,24 +131,19 @@ describe('sentinel guard: decisions-usage-scan.cjs', () => { }); }); -describe('sentinel guard: dream-capture decisions scanner gating', () => { - const HOOK = path.join(HOOKS_DIR, 'dream-capture'); +describe('config guard: capture-turn decisions scanner gating', () => { + const HOOK = path.join(HOOKS_DIR, 'capture-turn'); let tmpDir: string; beforeEach(() => { tmpDir = mkTmpDir(); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - function writeStaleWorkingMemory(dir: string): void { - const memFile = path.join(dir, '.devflow', 'memory', 'WORKING-MEMORY.md'); - fs.writeFileSync(memFile, '## Now\n- stale memory'); - const tenMinutesAgo = new Date(Date.now() - 600 * 1000); - fs.utimesSync(memFile, tenMinutesAgo, tenMinutesAgo); - } - - it('does NOT run scanner when decisions/.disabled exists', () => { + it('does NOT run scanner when dream config has decisions: false', () => { mkMemoryDir(tmpDir); - writeStaleWorkingMemory(tmpDir); - writeDisabledSentinel(path.join(tmpDir, '.devflow', 'decisions', '.disabled')); + fs.writeFileSync( + path.join(tmpDir, '.devflow', 'dream', 'config.json'), + JSON.stringify({ decisions: false }), + ); // Create usage file to detect if scanner would have run const usagePath = path.join(tmpDir, '.devflow', 'decisions', '.decisions-usage.json'); fs.writeFileSync(usagePath, JSON.stringify({ @@ -329,9 +157,8 @@ describe('sentinel guard: dream-capture decisions scanner gating', () => { expect(updated.entries['ADR-001'].cites).toBe(0); }); - it('runs scanner when decisions/.disabled absent', () => { + it('runs scanner when decisions enabled (config absent defaults true)', () => { mkMemoryDir(tmpDir); - writeStaleWorkingMemory(tmpDir); // Create usage file to detect scanner run const usagePath = path.join(tmpDir, '.devflow', 'decisions', '.decisions-usage.json'); fs.writeFileSync(usagePath, JSON.stringify({ @@ -348,7 +175,7 @@ describe('sentinel guard: dream-capture decisions scanner gating', () => { // ─── Part A: session-start-context hook ────────────────────────────────────── -describe('sentinel guard: session-start-context', () => { +describe('config guard: session-start-context', () => { const HOOK = path.join(HOOKS_DIR, 'session-start-context'); let tmpDir: string; @@ -385,11 +212,14 @@ describe('sentinel guard: session-start-context', () => { expect(additionalContext).toContain('PROJECT DECISIONS'); }); - it('skips decisions TL;DR when decisions/.disabled exists', () => { + it('skips decisions TL;DR when dream config has decisions: false', () => { mkMemoryDir(tmpDir); const decisionsDir = path.join(tmpDir, '.devflow', 'decisions'); fs.writeFileSync(path.join(decisionsDir, 'decisions.md'), '\n# Decisions\n'); - writeDisabledSentinel(path.join(decisionsDir, '.disabled')); + fs.writeFileSync( + path.join(tmpDir, '.devflow', 'dream', 'config.json'), + JSON.stringify({ decisions: false }), + ); const input = sessionInput(tmpDir); const output = execSync(`bash "${HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim(); // No output (nothing else to inject in this minimal test) @@ -495,53 +325,137 @@ describe('context hook registration', () => { }); }); -// ─── Part E: manageSentinel utility ───────────────────────────────────────── +// ─── Part A: dream hook upgrade cleanup (spawn-dream-worker) ──────────────── +// +// The spawn-dream-worker SessionStart hook belonged to the retired detached +// dream worker. removeDreamHook/hasDreamHook exist for upgrade cleanup only: +// init and uninstall strip any stale entry left by a prior install. + +describe('dream hook upgrade cleanup', () => { + const SETTINGS_WITH_DREAM_HOOK = JSON.stringify({ + hooks: { + SessionStart: [ + { hooks: [{ type: 'command', command: '/path/run-hook session-start-memory' }] }, + { hooks: [{ type: 'command', command: '/path/run-hook session-start-context' }] }, + { hooks: [{ type: 'command', command: '/path/run-hook spawn-dream-worker', timeout: 10 }] }, + ], + }, + }); + + it('removeDreamHook removes a stale spawn-dream-worker entry from settings', async () => { + const { removeDreamHook } = await import('../src/cli/commands/init.js'); + const removed = removeDreamHook(SETTINGS_WITH_DREAM_HOOK); + const settings = JSON.parse(removed); + const hookPresent = settings.hooks?.SessionStart?.some( + (m: { hooks: { command: string }[] }) => + m.hooks.some((h: { command: string }) => h.command.includes('spawn-dream-worker')), + ) ?? false; + expect(hookPresent).toBe(false); + }); + + it('removeDreamHook preserves other SessionStart hooks (session-start-memory, session-start-context)', async () => { + const { removeDreamHook } = await import('../src/cli/commands/init.js'); + const removed = removeDreamHook(SETTINGS_WITH_DREAM_HOOK); + const settings = JSON.parse(removed); + expect(settings.hooks?.SessionStart).toHaveLength(2); + const commands = settings.hooks.SessionStart.map((m: { hooks: { command: string }[] }) => m.hooks[0].command); + expect(commands.some((c: string) => c.includes('session-start-memory'))).toBe(true); + expect(commands.some((c: string) => c.includes('session-start-context'))).toBe(true); + }); + + it('removeDreamHook returns settings unchanged when no stale entry exists', async () => { + const { removeDreamHook } = await import('../src/cli/commands/init.js'); + const input = JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: '/path/run-hook session-start-context' }] }], + }, + }); + expect(removeDreamHook(input)).toBe(input); + expect(removeDreamHook('{}')).toBe('{}'); + }); + + it('hasDreamHook detects a stale entry and its absence', async () => { + const { hasDreamHook } = await import('../src/cli/commands/init.js'); + expect(hasDreamHook(SETTINGS_WITH_DREAM_HOOK)).toBe(true); + expect(hasDreamHook('{}')).toBe(false); + }); +}); + +// ─── Part F: DEVFLOW_BG_UPDATER re-entrancy guards (AC-F14) ───────────────── +// +// The memory worker's own nested claude -p session fires SessionStart and +// PreCompact hooks too — session-start-context, session-start-memory, and +// pre-compact-memory must all bail out before any read or write. +// capture-prompt, capture-turn, and capture-question carry the equivalent +// guard and are covered in tests/capture-hooks.test.ts. -describe('manageSentinel utility', () => { +describe('re-entrancy guard: session-start-context DEVFLOW_BG_UPDATER', () => { + const HOOK = path.join(HOOKS_DIR, 'session-start-context'); let tmpDir: string; beforeEach(() => { tmpDir = mkTmpDir(); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it('creates sentinel file when disabled=false', async () => { - const sentinelPath = path.join(tmpDir, '.devflow', 'decisions', '.disabled'); - await manageSentinel(sentinelPath, false); - expect(fs.existsSync(sentinelPath)).toBe(true); + it('outputs nothing when DEVFLOW_BG_UPDATER=1, even with a decisions TL;DR present', () => { + mkMemoryDir(tmpDir); + const decisionsDir = path.join(tmpDir, '.devflow', 'decisions'); + fs.writeFileSync(path.join(decisionsDir, 'decisions.md'), '\n# Decisions\n'); + const input = sessionInput(tmpDir); + const output = execSync(`DEVFLOW_BG_UPDATER=1 bash "${HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim(); + expect(output).toBe(''); }); - it('creates parent directories when they do not exist', async () => { - const sentinelPath = path.join(tmpDir, '.devflow', 'decisions', '.disabled'); - await manageSentinel(sentinelPath, false); - expect(fs.existsSync(sentinelPath)).toBe(true); + it('DEVFLOW_BG_UPDATER=1 makes zero filesystem writes (no .gitignore/.devflow scaffolding)', () => { + // A fresh tmpDir with no .devflow/ at all — the guard must fire before + // ensure-root-gitignore or any other write-side-effect runs. + const input = sessionInput(tmpDir); + execSync(`DEVFLOW_BG_UPDATER=1 bash "${HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); + expect(fs.existsSync(path.join(tmpDir, '.devflow'))).toBe(false); }); +}); - it('removes sentinel file when enabled=true', async () => { - const sentinelPath = path.join(tmpDir, '.devflow', 'memory', '.learning-disabled'); - writeDisabledSentinel(sentinelPath); - await manageSentinel(sentinelPath, true); - expect(fs.existsSync(sentinelPath)).toBe(false); - }); +describe('re-entrancy guard: session-start-memory DEVFLOW_BG_UPDATER', () => { + const HOOK = path.join(HOOKS_DIR, 'session-start-memory'); + let tmpDir: string; + + beforeEach(() => { tmpDir = mkTmpDir(); }); + afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it('is idempotent when enabling with no sentinel present', async () => { - const sentinelPath = path.join(tmpDir, '.devflow', 'decisions', '.disabled'); - // No sentinel exists — enabling again should not throw - await expect(manageSentinel(sentinelPath, true)).resolves.toBeUndefined(); - expect(fs.existsSync(sentinelPath)).toBe(false); + it('outputs nothing when DEVFLOW_BG_UPDATER=1, even with WORKING-MEMORY.md present', () => { + mkMemoryDir(tmpDir); + fs.writeFileSync(path.join(tmpDir, '.devflow', 'memory', 'WORKING-MEMORY.md'), '## Now\n- testing'); + const input = sessionInput(tmpDir); + const output = execSync(`DEVFLOW_BG_UPDATER=1 bash "${HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim(); + expect(output).toBe(''); }); - it('is idempotent when disabling with sentinel already present', async () => { - const sentinelPath = path.join(tmpDir, '.devflow', 'decisions', '.disabled'); - writeDisabledSentinel(sentinelPath); - await expect(manageSentinel(sentinelPath, false)).resolves.toBeUndefined(); - expect(fs.existsSync(sentinelPath)).toBe(true); + it('DEVFLOW_BG_UPDATER=1 does not recover a stale .pending-turns.processing (guard fires before the cold-path check)', () => { + mkMemoryDir(tmpDir); + const proc = path.join(tmpDir, '.devflow', 'memory', '.pending-turns.processing'); + fs.writeFileSync(proc, JSON.stringify({ role: 'user', content: 'x', ts: 1 }) + '\n'); + const old = new Date(Date.now() - 600 * 1000); + fs.utimesSync(proc, old, old); + const input = sessionInput(tmpDir); + execSync(`DEVFLOW_BG_UPDATER=1 bash "${HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); + expect(fs.existsSync(proc)).toBe(true); + expect(fs.existsSync(path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'))).toBe(false); }); +}); + +describe('re-entrancy guard: pre-compact-memory DEVFLOW_BG_UPDATER', () => { + const HOOK = path.join(HOOKS_DIR, 'pre-compact-memory'); + let tmpDir: string; - it('disable then enable removes the sentinel', async () => { - const sentinelPath = path.join(tmpDir, '.devflow', 'decisions', '.disabled'); - await manageSentinel(sentinelPath, false); - expect(fs.existsSync(sentinelPath)).toBe(true); - await manageSentinel(sentinelPath, true); - expect(fs.existsSync(sentinelPath)).toBe(false); + beforeEach(() => { tmpDir = mkTmpDir(); }); + afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + + it('does not write backup.json when DEVFLOW_BG_UPDATER=1', () => { + mkMemoryDir(tmpDir); + const input = sessionInput(tmpDir); + expect(() => { + execSync(`DEVFLOW_BG_UPDATER=1 bash "${HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); + }).not.toThrow(); + expect(fs.existsSync(path.join(tmpDir, '.devflow', 'memory', 'backup.json'))).toBe(false); }); }); diff --git a/tests/decisions/cli-subcommands.test.ts b/tests/decisions/cli-subcommands.test.ts index a25b17ce..bc0238e8 100644 --- a/tests/decisions/cli-subcommands.test.ts +++ b/tests/decisions/cli-subcommands.test.ts @@ -16,9 +16,7 @@ import * as os from 'os'; vi.mock('../../src/cli/utils/decisions-config.js', () => ({ loadDecisionsConfig: vi.fn(() => ({ - max_daily_runs: 3, - throttle_minutes: 5, - model: 'sonnet', + model: 'opus', debug: false, })), })); @@ -28,6 +26,10 @@ vi.mock('../../src/cli/utils/paths.js', () => ({ getDevFlowDirectory: vi.fn(() => '/home/user/.devflow'), })); +vi.mock('../../src/cli/utils/git.js', () => ({ + getGitRoot: vi.fn(), +})); + vi.mock('@clack/prompts', () => ({ intro: vi.fn(), outro: vi.fn(), @@ -49,6 +51,16 @@ import { loadAndCountObservations, type LearningObservation, } from '../../src/cli/utils/observations.js'; +import { getGitRoot } from '../../src/cli/utils/git.js'; +import { decisionsCommand } from '../../src/cli/commands/decisions.js'; +import * as p from '@clack/prompts'; +import { + getDreamPendingTurnsPath, + getDreamPendingTurnsProcessingPath, + getDreamConfigPath, + getPendingTurnsPath, + getDecisionsLogPath, +} from '../../src/cli/utils/project-paths.js'; // --------------------------------------------------------------------------- // Helpers @@ -274,12 +286,11 @@ describe('decisions --clear log truncation', () => { // --------------------------------------------------------------------------- describe('decisions --reset target files', () => { - it('reset targets decisions-specific state files only', () => { - const resetFiles = [ + it('reset targets decisions-specific state files (.devflow/decisions/)', () => { + const decisionsStateFiles = [ 'decisions-log.jsonl', '.decisions-manifest.json', '.decisions-notifications.json', - '.decisions-runs-today', '.decisions-batch-ids', 'decisions.json', ]; @@ -291,7 +302,7 @@ describe('decisions --reset target files', () => { 'WORKING-MEMORY.md', ]; - for (const f of resetFiles) { + for (const f of decisionsStateFiles) { expect( f.includes('decision') || f.includes('decisions'), `Expected "${f}" to contain "decision" or "decisions"`, @@ -299,34 +310,69 @@ describe('decisions --reset target files', () => { } for (const f of preservedFiles) { - expect(resetFiles).not.toContain(f); + expect(decisionsStateFiles).not.toContain(f); + } + }); + + it('reset also targets the dream (decisions-detection) queue (.devflow/dream/)', () => { + // Not decision-prefixed by name — these live in .devflow/dream/, the queue the + // Dream agent claims from. Reset must drain them too so a re-enable doesn't + // process stale pre-reset turns. + const dreamQueueFiles = [ + '.pending-turns.jsonl', + '.pending-turns.processing', + ]; + + const preservedDreamFiles = [ + 'config.json', // shared multi-feature config — reset must never touch this + ]; + + for (const f of preservedDreamFiles) { + expect(dreamQueueFiles).not.toContain(f); } }); }); // --------------------------------------------------------------------------- -// --reset dream cleanup: verify dream state files are targeted +// --reset dream cleanup: verify legacy marker-pipeline state files are targeted // --------------------------------------------------------------------------- describe('decisions --reset dream state cleanup', () => { - it('dream cleanup targets .decisions-runs-today and decisions.*.json markers', () => { + it('dream cleanup targets legacy stamp files (.decisions-runs-today, .curation-last, .processor-spawned-at)', () => { const dreamFilesToClean = [ '.decisions-runs-today', + '.curation-last', + '.processor-spawned-at', ]; - const dreamMarkerPattern = /^decisions\..+\.json$/; for (const f of dreamFilesToClean) { - expect(f).toContain('decisions'); + expect(f.startsWith('.')).toBe(true); } + }); - expect(dreamMarkerPattern.test('decisions.abc123.json')).toBe(true); - expect(dreamMarkerPattern.test('decisions.session-xyz.json')).toBe(true); - expect(dreamMarkerPattern.test('learning.abc123.json')).toBe(false); - expect(dreamMarkerPattern.test('decisions.json')).toBe(false); + it('dream cleanup targets legacy decisions.*/curation.* markers across all 4 suffixes', () => { + const dreamMarkerPattern = /^(decisions|curation)\..+\.(json|processing|retries|failed)$/; + + for (const f of [ + 'decisions.abc123.json', + 'decisions.session-xyz.processing', + 'decisions.abc123.retries', + 'decisions.abc123.failed', + 'curation.abc123.json', + 'curation.abc123.processing', + ]) { + expect(dreamMarkerPattern.test(f)).toBe(true); + } + + // Never touches: learning markers (pipeline removed separately), the shared + // config.json, or the new .pending-turns.jsonl/.processing queue files. + for (const f of ['learning.abc123.json', 'decisions.json', 'config.json', '.pending-turns.jsonl', '.pending-turns.processing']) { + expect(dreamMarkerPattern.test(f)).toBe(false); + } }); it('dream cleanup does not target learning state files', () => { - const decisionsDreamFiles = ['.decisions-runs-today']; + const decisionsDreamFiles = ['.decisions-runs-today', '.curation-last', '.processor-spawned-at']; const learningFiles = ['.learning-runs-today', '.learning-sessions']; for (const lf of learningFiles) { @@ -334,3 +380,148 @@ describe('decisions --reset dream state cleanup', () => { } }); }); + +// --------------------------------------------------------------------------- +// --disable drains the dream (decisions-detection) pending-turns queue — +// mirrors memory.ts's drain-on-disable behavior for the sibling memory queue. +// Unconditional: a mid-run Dream agent whose claimed batch vanishes aborts +// without changes, which is the desired outcome of disabling. +// --------------------------------------------------------------------------- + +describe('decisions --disable drains the dream pending-turns queue', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + vi.mocked(getGitRoot).mockResolvedValue(tmpDir); + // Commander retains _optionValues across repeated parseAsync() calls on the + // same Command instance (no built-in reset between calls). Production always + // starts a fresh process per invocation, so clear state here to match that + // reality and keep these tests order-independent. + (decisionsCommand as unknown as { _optionValues: Record })._optionValues = {}; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeDreamQueueFiles(root: string): void { + fs.mkdirSync(path.join(root, '.devflow', 'dream'), { recursive: true }); + fs.writeFileSync(getDreamPendingTurnsPath(root), '{"role":"user"}\n'); + fs.writeFileSync(getDreamPendingTurnsProcessingPath(root), '{"role":"user"}\n'); + } + + it('deletes queue + processing files and flips config (memory queue untouched)', async () => { + writeDreamQueueFiles(tmpDir); + fs.mkdirSync(path.join(tmpDir, '.devflow', 'memory'), { recursive: true }); + fs.writeFileSync(getPendingTurnsPath(tmpDir), '{"role":"user"}\n'); + + await decisionsCommand.parseAsync(['--disable'], { from: 'user' }); + + expect(fs.existsSync(getDreamPendingTurnsPath(tmpDir))).toBe(false); + expect(fs.existsSync(getDreamPendingTurnsProcessingPath(tmpDir))).toBe(false); + + const config = JSON.parse(fs.readFileSync(getDreamConfigPath(tmpDir), 'utf-8')); + expect(config.decisions).toBe(false); + + // The sibling memory queue is never touched by decisions --disable + expect(fs.existsSync(getPendingTurnsPath(tmpDir))).toBe(true); + }); + + it('does not create a .disabled sentinel (gate is config-only)', async () => { + writeDreamQueueFiles(tmpDir); + + await decisionsCommand.parseAsync(['--disable'], { from: 'user' }); + + expect(fs.existsSync(path.join(tmpDir, '.devflow', 'decisions', '.disabled'))).toBe(false); + }); + + it('drains unconditionally — a leftover .worker.lock dir from an old install does not block it', async () => { + writeDreamQueueFiles(tmpDir); + fs.mkdirSync(path.join(tmpDir, '.devflow', 'dream', '.worker.lock'), { recursive: true }); + + await decisionsCommand.parseAsync(['--disable'], { from: 'user' }); + + expect(fs.existsSync(getDreamPendingTurnsPath(tmpDir))).toBe(false); + expect(fs.existsSync(getDreamPendingTurnsProcessingPath(tmpDir))).toBe(false); + + const config = JSON.parse(fs.readFileSync(getDreamConfigPath(tmpDir), 'utf-8')); + expect(config.decisions).toBe(false); + }); + + it('does not delete anything on --enable', async () => { + writeDreamQueueFiles(tmpDir); + + await decisionsCommand.parseAsync(['--enable'], { from: 'user' }); + + expect(fs.existsSync(getDreamPendingTurnsPath(tmpDir))).toBe(true); + expect(fs.existsSync(getDreamPendingTurnsProcessingPath(tmpDir))).toBe(true); + }); + + it('drains the resolved git-root paths, not process.cwd() (regression for the cwd class)', async () => { + writeDreamQueueFiles(tmpDir); + // getGitRoot already resolves to tmpDir regardless of the real cwd (exactly as + // `git rev-parse --show-toplevel` would from any subdirectory). Point cwd at a + // decoy path to prove the drain never falls back to process.cwd() instead of + // the resolved gitRoot. + vi.spyOn(process, 'cwd').mockReturnValue('/nonexistent-cwd-decoy-path'); + + await decisionsCommand.parseAsync(['--disable'], { from: 'user' }); + + expect(fs.existsSync(getDreamPendingTurnsPath(tmpDir))).toBe(false); + expect(fs.existsSync(getDreamPendingTurnsProcessingPath(tmpDir))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// --list resolves the log from the git root, not process.cwd() — regression +// for the class of bug where --list run from a subdirectory of the repo +// would look for a decisions log under the (nonexistent) subdirectory path +// instead of the real one at the git root. +// --------------------------------------------------------------------------- + +describe('decisions --list resolves log path from git root, not process.cwd()', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + vi.mocked(getGitRoot).mockResolvedValue(tmpDir); + (decisionsCommand as unknown as { _optionValues: Record })._optionValues = {}; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('finds the decisions log at the git root even when cwd is a subdirectory', async () => { + const logPath = getDecisionsLogPath(tmpDir); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.writeFileSync(logPath, makeDecisionLog([ + makeDecisionObs({ id: 'obs_decision_001', type: 'decision', pattern: 'Use Result types' }), + ])); + + // getGitRoot is mocked to resolve to tmpDir regardless of the real cwd + // (exactly as `git rev-parse --show-toplevel` would from a subdirectory). + // Point cwd at a decoy path to prove --list never falls back to + // process.cwd() instead of the resolved gitRoot. + vi.spyOn(process, 'cwd').mockReturnValue('/nonexistent-cwd-decoy-path'); + + await decisionsCommand.parseAsync(['--list'], { from: 'user' }); + + expect(p.log.info).not.toHaveBeenCalledWith('No observations yet. Decisions log not found.'); + }); + + it('falls back to process.cwd() when not in a git project', async () => { + vi.mocked(getGitRoot).mockResolvedValue(null); + const cwdLogPath = getDecisionsLogPath(tmpDir); + fs.mkdirSync(path.dirname(cwdLogPath), { recursive: true }); + fs.writeFileSync(cwdLogPath, makeDecisionLog([ + makeDecisionObs({ id: 'obs_decision_001', type: 'decision', pattern: 'Use Result types' }), + ])); + vi.spyOn(process, 'cwd').mockReturnValue(tmpDir); + + await decisionsCommand.parseAsync(['--list'], { from: 'user' }); + + expect(p.log.info).not.toHaveBeenCalledWith('No observations yet. Decisions log not found.'); + }); +}); diff --git a/tests/decisions/config.test.ts b/tests/decisions/config.test.ts index 3a9abc9a..76b64d4d 100644 --- a/tests/decisions/config.test.ts +++ b/tests/decisions/config.test.ts @@ -31,21 +31,10 @@ function ensureDir(dir: string): void { describe('applyDecisionsConfigLayer', () => { const base: DecisionsConfig = { - max_daily_runs: 3, - throttle_minutes: 5, - model: 'sonnet', + model: 'opus', debug: false, }; - it('overrides individual numeric fields', () => { - const result = applyDecisionsConfigLayer( - base, - JSON.stringify({ max_daily_runs: 10 }), - ); - expect(result.max_daily_runs).toBe(10); - expect(result.throttle_minutes).toBe(5); // unchanged - }); - it('overrides model string field', () => { const result = applyDecisionsConfigLayer(base, JSON.stringify({ model: 'haiku' })); expect(result.model).toBe('haiku'); @@ -56,14 +45,6 @@ describe('applyDecisionsConfigLayer', () => { expect(result.debug).toBe(true); }); - it('ignores non-numeric max_daily_runs', () => { - const result = applyDecisionsConfigLayer( - base, - JSON.stringify({ max_daily_runs: 'lots' }), - ); - expect(result.max_daily_runs).toBe(3); - }); - it('ignores non-boolean debug', () => { const result = applyDecisionsConfigLayer(base, JSON.stringify({ debug: 'yes' })); expect(result.debug).toBe(false); @@ -71,14 +52,14 @@ describe('applyDecisionsConfigLayer', () => { it('ignores non-string model', () => { const result = applyDecisionsConfigLayer(base, JSON.stringify({ model: 42 })); - expect(result.model).toBe('sonnet'); + expect(result.model).toBe('opus'); }); it('returns a new object — does not mutate input', () => { const input: DecisionsConfig = { ...base }; - const result = applyDecisionsConfigLayer(input, JSON.stringify({ max_daily_runs: 99 })); - expect(result.max_daily_runs).toBe(99); - expect(input.max_daily_runs).toBe(3); // not mutated + const result = applyDecisionsConfigLayer(input, JSON.stringify({ model: 'sonnet' })); + expect(result.model).toBe('sonnet'); + expect(input.model).toBe('opus'); // not mutated expect(result).not.toBe(input); }); @@ -92,6 +73,18 @@ describe('applyDecisionsConfigLayer', () => { const result = applyDecisionsConfigLayer(base, '{}'); expect(result).toEqual(base); }); + + it('ignores dropped legacy fields (max_daily_runs/throttle_minutes) without error', () => { + // On-disk configs from before the dream-system simplification may still carry + // these fields — they must load without error and be silently ignored. + const result = applyDecisionsConfigLayer( + base, + JSON.stringify({ max_daily_runs: 7, throttle_minutes: 15, model: 'haiku' }), + ); + expect(result).toEqual({ model: 'haiku', debug: false }); + expect((result as Record).max_daily_runs).toBeUndefined(); + expect((result as Record).throttle_minutes).toBeUndefined(); + }); }); // --------------------------------------------------------------------------- @@ -128,39 +121,37 @@ describe('loadDecisionsConfig', () => { it('returns all defaults when no config files exist', () => { const config = loadDecisionsConfig(projectCwd); - expect(config.max_daily_runs).toBe(3); - expect(config.throttle_minutes).toBe(5); - expect(config.model).toBe('sonnet'); + expect(config.model).toBe('opus'); expect(config.debug).toBe(false); }); it('global config overrides defaults', () => { - writeJson(devflowDir, 'decisions.json', { max_daily_runs: 7 }); + writeJson(devflowDir, 'decisions.json', { model: 'haiku' }); const config = loadDecisionsConfig(projectCwd); - expect(config.max_daily_runs).toBe(7); - expect(config.throttle_minutes).toBe(5); // default preserved + expect(config.model).toBe('haiku'); + expect(config.debug).toBe(false); // default preserved }); it('project config overrides global config', () => { writeJson(devflowDir, 'decisions.json', { - max_daily_runs: 7, model: 'haiku', + debug: true, }); writeJson(path.join(projectCwd, '.devflow', 'decisions'), 'decisions.json', { - max_daily_runs: 2, + model: 'sonnet', }); const config = loadDecisionsConfig(projectCwd); - expect(config.max_daily_runs).toBe(2); // project wins - expect(config.model).toBe('haiku'); // global preserved when project doesn't set + expect(config.model).toBe('sonnet'); // project wins + expect(config.debug).toBe(true); // global preserved when project doesn't set }); it('project config alone overrides defaults', () => { writeJson(path.join(projectCwd, '.devflow', 'decisions'), 'decisions.json', { - model: 'opus', + model: 'sonnet', }); const config = loadDecisionsConfig(projectCwd); - expect(config.model).toBe('opus'); - expect(config.max_daily_runs).toBe(3); // default + expect(config.model).toBe('sonnet'); + expect(config.debug).toBe(false); // default }); it('invalid JSON in global config returns defaults without crashing', () => { @@ -170,35 +161,46 @@ describe('loadDecisionsConfig', () => { 'utf-8', ); const config = loadDecisionsConfig(projectCwd); - expect(config.max_daily_runs).toBe(3); - expect(config.model).toBe('sonnet'); + expect(config.model).toBe('opus'); }); it('invalid JSON in project config falls back to global + defaults', () => { - writeJson(devflowDir, 'decisions.json', { max_daily_runs: 7 }); + writeJson(devflowDir, 'decisions.json', { model: 'haiku' }); fs.writeFileSync( path.join(projectCwd, '.devflow', 'decisions', 'decisions.json'), 'bad json', 'utf-8', ); const config = loadDecisionsConfig(projectCwd); - expect(config.max_daily_runs).toBe(7); // global applied - expect(config.model).toBe('sonnet'); // default + expect(config.model).toBe('haiku'); // global applied }); it('partial project override preserves global fields', () => { writeJson(devflowDir, 'decisions.json', { - throttle_minutes: 15, model: 'haiku', }); writeJson(path.join(projectCwd, '.devflow', 'decisions'), 'decisions.json', { debug: true, }); const config = loadDecisionsConfig(projectCwd); - expect(config.throttle_minutes).toBe(15); // from global expect(config.model).toBe('haiku'); // from global expect(config.debug).toBe(true); // from project - expect(config.max_daily_runs).toBe(3); // default }); + it('AC-C5: model defaults to opus (not sonnet) when nothing configures it', () => { + const config = loadDecisionsConfig(projectCwd); + expect(config.model).toBe('opus'); + }); + + it('on-disk config still containing dropped max_daily_runs/throttle_minutes loads without error', () => { + writeJson(path.join(projectCwd, '.devflow', 'decisions'), 'decisions.json', { + max_daily_runs: 3, + throttle_minutes: 5, + model: 'haiku', + }); + const config = loadDecisionsConfig(projectCwd); + expect(config.model).toBe('haiku'); + expect((config as Record).max_daily_runs).toBeUndefined(); + expect((config as Record).throttle_minutes).toBeUndefined(); + }); }); diff --git a/tests/decisions/decisions-format.test.ts b/tests/decisions/decisions-format.test.ts index 2bda1477..6db3b972 100644 --- a/tests/decisions/decisions-format.test.ts +++ b/tests/decisions/decisions-format.test.ts @@ -244,10 +244,11 @@ describe('buildTldrLine', () => { // --------------------------------------------------------------------------- // json-helper.cjs byte-compat: assign-anchor delegates to decisions-format // --------------------------------------------------------------------------- -// We verify this by running merge-observation + assign-anchor via the CLI and -// checking the output matches what formatDecisionBody/formatPitfallBody would -// produce. This ensures the write path delegates to decisions-format.cjs -// correctly (AC-A8: decisions-append is removed; assign-anchor is the writer). +// We verify this by seeding an observation row directly (as the Dream agent +// appends it), promoting via assign-anchor, and checking the output matches +// what formatDecisionBody/formatPitfallBody would produce. This ensures the +// write path delegates to decisions-format.cjs correctly (AC-A8: assign-anchor +// is the sole writer). import { execSync } from 'child_process'; import * as fs from 'fs'; @@ -277,11 +278,9 @@ describe('json-helper.cjs assign-anchor delegates to decisions-format', () => { }); try { - // Write observation to log, then promote via assign-anchor - execSync( - `node "${JSON_HELPER}" merge-observation "${logFile}" '${obs}'`, - { cwd: tmpDir, encoding: 'utf8' } - ); + // Seed the observation directly (one JSONL row, as the Dream agent + // appends it), then promote via assign-anchor + fs.writeFileSync(logFile, obs + '\n', 'utf8'); execSync( `node "${JSON_HELPER}" assign-anchor decision obs_formattest1`, { cwd: tmpDir, encoding: 'utf8' } @@ -322,11 +321,9 @@ describe('json-helper.cjs assign-anchor delegates to decisions-format', () => { }); try { - // Write observation to log, then promote via assign-anchor - execSync( - `node "${JSON_HELPER}" merge-observation "${logFile}" '${obs}'`, - { cwd: tmpDir, encoding: 'utf8' } - ); + // Seed the observation directly (one JSONL row, as the Dream agent + // appends it), then promote via assign-anchor + fs.writeFileSync(logFile, obs + '\n', 'utf8'); execSync( `node "${JSON_HELPER}" assign-anchor pitfall obs_pfformattest1`, { cwd: tmpDir, encoding: 'utf8' } @@ -360,68 +357,70 @@ describe('json-helper.cjs assign-anchor delegates to decisions-format', () => { }); // --------------------------------------------------------------------------- -// dream-decisions SKILL.md content-presence assertions (AC-F1, AC-F2) +// Dream agent content-presence assertions (AC-F1, AC-F2) // --------------------------------------------------------------------------- -// These lightweight checks verify that the SKILL contains the creation-bar -// elements required by the plan. They do not test LLM judgment — that is -// validated by the Tester agent via scenarios. They lock the prose contract -// so that the SKILL cannot accidentally regress on the key phrases. +// These lightweight checks verify that the Dream agent instructions +// (shared/agents/dream.md) contain the required creation-bar elements. They do +// not test LLM judgment — that is validated by the Tester agent via scenarios. +// They lock the prose contract so the agent cannot accidentally regress on the +// key phrases. -describe('dream-decisions SKILL.md creation-bar contract', () => { - const SKILL_PATH = path.join(ROOT, 'shared/skills/dream-decisions/SKILL.md'); +describe('Dream agent creation-bar contract', () => { + const AGENT_PATH = path.join(ROOT, 'shared/agents/dream.md'); - let skillContent: string; + let agentContent: string; beforeAll(() => { - skillContent = fs.readFileSync(SKILL_PATH, 'utf8'); + agentContent = fs.readFileSync(AGENT_PATH, 'utf8'); }); it('contains abstain-by-default stance', () => { - expect(skillContent).toContain('Most sessions produce nothing'); - expect(skillContent).toContain('If unsure, record nothing'); + expect(agentContent).toContain('Most runs produce nothing'); + expect(agentContent).toContain('If unsure, record nothing'); }); it('contains ADR-XOR-PF hard rule', () => { - expect(skillContent).toContain('ADR-XOR-PF'); + expect(agentContent).toContain('ADR-XOR-PF'); // "never both" may span a line break — check both forms - expect(skillContent).toMatch(/never\s+both/); - expect(skillContent).toContain('Concrete failure'); - expect(skillContent).toContain('forward-looking'); + expect(agentContent).toMatch(/never\s+both/); + expect(agentContent).toContain('Concrete failure'); + expect(agentContent).toContain('forward-looking'); }); it('contains dedup-before-create rule', () => { - expect(skillContent).toContain('Dedup before creating'); - expect(skillContent).toContain('reinforce it'); + expect(agentContent).toContain('Dedup before creating'); + expect(agentContent).toContain('reinforce that row'); }); - it('instructs agent to use assign-anchor and prohibits decisions-append', () => { - // The SKILL must instruct the agent to use assign-anchor for promotion - expect(skillContent).toContain('assign-anchor'); - // The SKILL must prohibit decisions-append (mentioning it only to forbid it) - expect(skillContent).toContain('NEVER call `decisions-append`'); - // Must NOT contain a positive instruction to call decisions-append - expect(skillContent).not.toMatch(/\bjson-helper\.cjs\b.*\bdecisions-append\b/); + it('instructs agent to use assign-anchor for promotion, never invents numbers itself', () => { + // The agent must be instructed to use assign-anchor for promotion + expect(agentContent).toContain('assign-anchor'); + // decisions-append is retired tooling and is not mentioned at all (nothing + // positively instructs calling it — there is no lingering reference to forbid). + expect(agentContent).not.toMatch(/\bjson-helper\.cjs\b.*\bdecisions-append\b/); + expect(agentContent).not.toContain('decisions-append'); + expect(agentContent).toContain('NEVER invent an ADR-NNN/PF-NNN number'); }); it('has no numeric confidence gate (ADR-008)', () => { // Must not contain a numeric confidence threshold that acts as a gate - expect(skillContent).not.toMatch(/confidence\s*[>=]+\s*0\.\d+/); - expect(skillContent).not.toContain('0.65'); - expect(skillContent).not.toContain('0.95'); + expect(agentContent).not.toMatch(/confidence\s*[>=]+\s*0\.\d+/); + expect(agentContent).not.toContain('0.65'); + expect(agentContent).not.toContain('0.95'); }); it('states confidence is metadata, not a gate', () => { - expect(skillContent).toContain('NOT a gate'); + expect(agentContent).toContain('NOT a gate'); }); it('Iron Law references assign-anchor and render, not decisions-append', () => { // Verify Iron Law line - expect(skillContent).toContain('assign-anchor OWNS NUMBERING'); - expect(skillContent).toContain('render OWNS THE .md'); - expect(skillContent).toContain('NEVER HAND-EDIT'); + expect(agentContent).toContain('assign-anchor OWNS NUMBERING'); + expect(agentContent).toContain('render OWNS THE .md'); + expect(agentContent).toContain('NEVER HAND-EDIT'); }); it('negative examples list both NOT-a-decision and NOT-a-pitfall', () => { - expect(skillContent).toContain('NOT a decision'); - expect(skillContent).toContain('NOT a pitfall'); + expect(agentContent).toContain('NOT a decision'); + expect(agentContent).toContain('NOT a pitfall'); }); }); diff --git a/tests/decisions/dream-curation.test.ts b/tests/decisions/dream-curation.test.ts index 4ebde87b..9090b723 100644 --- a/tests/decisions/dream-curation.test.ts +++ b/tests/decisions/dream-curation.test.ts @@ -113,100 +113,86 @@ function readDecisionsMd(dir: string): string { } // --------------------------------------------------------------------------- -// dream-curation SKILL.md content-presence assertions +// Dream agent content-presence assertions (AC-C3) +// +// The Dream agent (shared/agents/dream.md) is the sole decisions processor: +// it claims the queue, reads the data files directly, and writes through the +// three ledger ops. These describe pins hold the curation contract strings in +// place — the same Iron-Law contract the ledger ops enforce at runtime. // --------------------------------------------------------------------------- -describe('dream-curation SKILL.md curation contract (Phase 6)', () => { - const SKILL_PATH = path.join(ROOT, 'shared/skills/dream-curation/SKILL.md'); - let skillContent: string; +describe('Dream agent curation contract (AC-C3)', () => { + const AGENT_PATH = path.join(ROOT, 'shared/agents/dream.md'); + let agentContent: string; beforeAll(() => { - skillContent = fs.readFileSync(SKILL_PATH, 'utf8'); - }); - - it('Iron Law says "RETIRE BY STATUS — THE LEDGER IS THE SOURCE OF TRUTH"', () => { - expect(skillContent).toContain('RETIRE BY STATUS'); - expect(skillContent).toContain('THE LEDGER IS THE SOURCE OF TRUTH'); + agentContent = fs.readFileSync(AGENT_PATH, 'utf8'); }); - it('states .md files are rendered views, never hand-edited', () => { - expect(skillContent).toContain('never hand-edited'); - // Or equivalent phrasing - expect(skillContent).toMatch(/rendered|pure render/i); + it('Iron Law says assign-anchor owns numbering, render owns the .md, never hand-edit', () => { + expect(agentContent).toContain('assign-anchor OWNS NUMBERING'); + expect(agentContent).toContain('render OWNS THE .md'); + expect(agentContent).toContain('NEVER HAND-EDIT'); }); - it('instructs to call retire-anchor for deprecation/retirement', () => { - expect(skillContent).toContain('retire-anchor'); - expect(skillContent).toContain('decisions_status'); + it('instructs to call retire-anchor for deprecation/retirement, never hand-edit the .md', () => { + expect(agentContent).toContain('retire-anchor'); + expect(agentContent).toContain('RETIRE BY STATUS'); + expect(agentContent).toContain('never hand-edit the .md'); }); - it('does NOT contain the old 3-call lock/Edit dance instruction', () => { - // The old SKILL had explicit bash acquire/release around an Edit tool call - expect(skillContent).not.toContain('_ACQUIRED=false'); - expect(skillContent).not.toContain('NEVER re-acquire it inside this window'); - // Old step: "Edit tool call: flip the Status line" - expect(skillContent).not.toMatch(/Edit tool call.*flip/); + it('names the inputs the agent reads directly', () => { + expect(agentContent).toContain('Inputs (read directly with your Read tool)'); }); - it('does NOT instruct direct .md editing for status changes', () => { - // The old instruction was to edit "- **Status**: Deprecated" directly - expect(skillContent).not.toContain('Flip its status to `- **Status**: Deprecated`'); - expect(skillContent).not.toContain('directly editing two lines'); + it('routes all ledger writes through assign-anchor/retire-anchor/rotate-observations', () => { + expect(agentContent).toContain('assign-anchor'); + expect(agentContent).toContain('retire-anchor'); + expect(agentContent).toContain('rotate-observations'); }); - it('does NOT reference decisions-append positively', () => { - // decisions-append is removed; SKILL must not instruct to use it - // (it may mention it only in a prohibition context — but the old positive "decisions-append adds" is gone) - expect(skillContent).not.toContain('decisions-append adds'); - expect(skillContent).not.toContain('decisions-append` adds'); + it('deletes the claim file as the final act (consume-then-delete)', () => { + expect(agentContent).toContain('.devflow/dream/.pending-turns.processing'); + expect(agentContent).toContain('FINAL act'); }); - it('references assign-anchor as the writer (for new entries)', () => { - // SKILL may reference assign-anchor in the context of how retire-anchor relates to assign-anchor - // OR in the count-active command description — either way the concept is present - expect(skillContent).toContain('assign-anchor'); + it('run visibility is the final message — no status file', () => { + expect(agentContent).toMatch(/final message is the run's only\s+visibility surface/); + expect(agentContent).toMatch(/no status file/); }); - it('contains rotation step under .observations.lock', () => { - expect(skillContent).toContain('rotate-observations'); - expect(skillContent).toContain('.observations.lock'); - }); - - it('rotation step is for archiving stale observing rows (AC-F9)', () => { - expect(skillContent).toContain('observing'); - expect(skillContent).toMatch(/30 days|30-day/); - // never archives anchored rows - expect(skillContent).toMatch(/never touch.*anchor|never.*anchor.*archived/i); + it('contains the abstain-by-default creation bar', () => { + expect(agentContent).toContain('abstain-by-default'); + expect(agentContent).toMatch(/most runs produce nothing/i); }); it('contains ADR-XOR-PF awareness note', () => { - expect(skillContent).toContain('ADR-XOR-PF'); - // forward-looking / concrete failure distinction - expect(skillContent).toContain('forward-looking'); - expect(skillContent).toContain('Concrete failure'); + expect(agentContent).toContain('ADR-XOR-PF'); + expect(agentContent).toContain('forward-looking'); + expect(agentContent).toContain('Concrete failure'); }); it('contains dedup awareness note', () => { - expect(skillContent).toMatch(/dedup|near-duplicate/i); + expect(agentContent).toMatch(/dedup|near-duplicate/i); }); - it('7-day window is keyed off the ledger date field', () => { - // Not the .md file content - expect(skillContent).toContain("ledger row's"); - expect(skillContent).toContain('date` field'); + it('bounds curation to at most 5 changes per run', () => { + // \s+ tolerates an incidental mid-sentence line wrap in the markdown source + // (a literal newline between "curation" and "changes", not just a space). + expect(agentContent).toMatch(/≤5\s+curation\s+changes/); + expect(agentContent).toContain('stop after 5 changes'); }); - it('describes recoverability: re-activating a retired entry + render restores it (AC-F6)', () => { - expect(skillContent).toContain('Recoverability'); - expect(skillContent).toMatch(/re-activat|re.render/i); + it('7-day protection window is keyed off the ledger date field', () => { + expect(agentContent).toContain('7-day protection window'); + expect(agentContent).toContain("ledger row's"); + expect(agentContent).toContain('date` field'); }); - it('never hold both .decisions.lock and .observations.lock simultaneously (ADR-017)', () => { - expect(skillContent).toContain('ADR-017'); - expect(skillContent).not.toContain('hold both'); - // The statement is actually "never hold both" — check the SKILL advises separation - expect(skillContent).toContain('.observations.lock'); - expect(skillContent).toContain('.decisions.lock'); + it('rotation step is for archiving stale observing rows (AC-F9)', () => { + expect(agentContent).toContain('observing'); + expect(agentContent).toMatch(/30 days|30-day/); + expect(agentContent).toMatch(/never touches anchored|never touch.*anchor/i); }); }); @@ -434,39 +420,38 @@ describe('AC-F6: retired entry is recoverable — re-activate + render restores }); // --------------------------------------------------------------------------- -// AC-F9: rotation step — curation SKILL contract +// AC-F9: rotation step — Dream agent contract // Already tested at the op level in ledger-ops.test.ts; here we verify -// the curation SKILL wires it correctly (contract-level check). +// the agent instructions wire it correctly (contract-level check). // --------------------------------------------------------------------------- describe('AC-F9: rotation step wired into curation (contract check)', () => { - const SKILL_PATH = path.join(ROOT, 'shared/skills/dream-curation/SKILL.md'); - let skillContent: string; + const AGENT_PATH = path.join(ROOT, 'shared/agents/dream.md'); + let agentContent: string; beforeAll(() => { - skillContent = fs.readFileSync(SKILL_PATH, 'utf8'); + agentContent = fs.readFileSync(AGENT_PATH, 'utf8'); }); - it('SKILL calls rotate-observations before selecting curation candidates', () => { - // The rotation step must appear BEFORE "LLM judgment" in the SKILL - const rotateIdx = skillContent.indexOf('rotate-observations'); - const judgmentIdx = skillContent.indexOf('LLM judgment'); + it('agent runs rotate-observations before selecting curation retire/merge candidates', () => { + // The Part 2 rotation step must appear BEFORE the Part 2 "LLM judgment" + // (retire/merge candidate selection) — use lastIndexOf on both since the + // Environment section and Part 1 have earlier mentions. + const rotateIdx = agentContent.lastIndexOf('Rotate stale observations first'); + const judgmentIdx = agentContent.lastIndexOf('LLM judgment'); expect(rotateIdx).toBeGreaterThan(-1); expect(judgmentIdx).toBeGreaterThan(-1); expect(rotateIdx).toBeLessThan(judgmentIdx); }); - it('SKILL says rotation runs under .observations.lock', () => { - // The relevant paragraph must mention .observations.lock near rotate-observations - const rotateIdx = skillContent.indexOf('rotate-observations'); - // Check within 400 chars of the rotate-observations mention - const context = skillContent.slice(Math.max(0, rotateIdx - 400), rotateIdx + 400); - expect(context).toContain('.observations.lock'); + it('agent must call the ops plainly — they self-lock; no external lock allowed', () => { + expect(agentContent).toMatch(/self-locks? internally/i); + expect(agentContent).toMatch(/never wrap them in a lock/i); }); - it('SKILL states rotation archives stale observing rows and never touches anchored rows', () => { - expect(skillContent).toContain('anchored'); - expect(skillContent).toContain('archive'); + it('agent states rotation archives stale observing rows and never touches anchored rows', () => { + expect(agentContent).toContain('anchored'); + expect(agentContent).toContain('archive'); }); it('rotateObservations internal function: anchored rows never archived (AC-F9 contract)', () => { diff --git a/tests/decisions/ledger-ops.test.ts b/tests/decisions/ledger-ops.test.ts index 8c77f4ac..e7e97a9d 100644 --- a/tests/decisions/ledger-ops.test.ts +++ b/tests/decisions/ledger-ops.test.ts @@ -29,7 +29,6 @@ const jsonHelper = require( path.join(ROOT, 'scripts/hooks/json-helper.cjs') ) as { nextAnchorFromLedger: (rows: Record[], type: 'decision' | 'pitfall') => { anchorId: string; nextN: string }; - countActiveLedgerRows: (rows: Record[], type: 'decision' | 'pitfall') => number; rotateObservations: (logPath: string, archivePath: string, nowMs: number) => number; registerUsageEntry: (projectRoot: string, anchorId: string) => void; writeJsonlAtomic: (file: string, entries: object[]) => void; @@ -198,51 +197,6 @@ describe('nextAnchorFromLedger', () => { }); }); -// --------------------------------------------------------------------------- -// countActiveLedgerRows — unit tests -// --------------------------------------------------------------------------- - -describe('countActiveLedgerRows', () => { - it('counts Accepted decisions', () => { - const rows = [ - makeLedgerRow({ anchor_id: 'ADR-001', decisions_status: 'Accepted' }), - makeLedgerRow({ anchor_id: 'ADR-002', id: 'obs_002', decisions_status: 'Accepted' }), - ]; - expect(jsonHelper.countActiveLedgerRows(rows, 'decision')).toBe(2); - }); - - it('excludes Retired decisions', () => { - const rows = [ - makeLedgerRow({ anchor_id: 'ADR-001', decisions_status: 'Accepted' }), - makeLedgerRow({ anchor_id: 'ADR-002', id: 'obs_002', decisions_status: 'Retired' }), - ]; - expect(jsonHelper.countActiveLedgerRows(rows, 'decision')).toBe(1); - }); - - it('excludes Deprecated decisions', () => { - const rows = [ - makeLedgerRow({ anchor_id: 'ADR-001', decisions_status: 'Deprecated' }), - ]; - expect(jsonHelper.countActiveLedgerRows(rows, 'decision')).toBe(0); - }); - - it('excludes unanchored rows', () => { - const rows = [ - makeObsRow({ type: 'decision' }), // no anchor_id - ]; - expect(jsonHelper.countActiveLedgerRows(rows, 'decision')).toBe(0); - }); - - it('counts only matching type', () => { - const rows = [ - makeLedgerRow({ anchor_id: 'ADR-001', type: 'decision', decisions_status: 'Accepted' }), - { ...makeLedgerRow({ anchor_id: 'PF-001', id: 'obs_pf', decisions_status: 'Active' }), type: 'pitfall' }, - ]; - expect(jsonHelper.countActiveLedgerRows(rows, 'decision')).toBe(1); - expect(jsonHelper.countActiveLedgerRows(rows, 'pitfall')).toBe(1); - }); -}); - // --------------------------------------------------------------------------- // assign-anchor CLI op // --------------------------------------------------------------------------- diff --git a/tests/dream-agent.test.ts b/tests/dream-agent.test.ts new file mode 100644 index 00000000..7afc62b3 --- /dev/null +++ b/tests/dream-agent.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const AGENT_PATH = path.resolve(__dirname, '../shared/agents/dream.md'); + +/** Extract the raw frontmatter block from a markdown agent file */ +function parseFrontmatter(content: string): string { + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + return fmMatch ? fmMatch[1] : ''; +} + +/** Extract a YAML-list field (e.g. tools:, skills:) from frontmatter */ +function parseYamlList(frontmatter: string, field: string): string[] { + const re = new RegExp(`^${field}:\\n((?: - .+\\n?)+)`, 'm'); + const match = frontmatter.match(re); + if (!match) return []; + return match[1] + .split('\n') + .map(l => l.replace(/^ {2}- /, '').trim()) + .filter(Boolean); +} + +describe('dream agent', () => { + let content: string; + let frontmatter: string; + + beforeAll(async () => { + content = await fs.readFile(AGENT_PATH, 'utf-8'); + frontmatter = parseFrontmatter(content); + }); + + describe('frontmatter', () => { + it('is named Dream with model opus', () => { + expect(frontmatter).toMatch(/^name: Dream$/m); + expect(frontmatter).toMatch(/^model: opus$/m); + }); + + it('has the file-work tool set (Read, Bash, Write, Edit, Glob, Grep)', () => { + const tools = parseYamlList(frontmatter, 'tools'); + expect(tools.sort()).toEqual(['Bash', 'Edit', 'Glob', 'Grep', 'Read', 'Write']); + }); + + it('references only the apply-decisions skill', () => { + const skills = parseYamlList(frontmatter, 'skills'); + expect(skills).toEqual(['devflow:apply-decisions']); + }); + }); + + describe('queue claim contract', () => { + it('claims the queue via atomic mv to .processing', () => { + expect(content).toContain( + 'mv .devflow/dream/.pending-turns.jsonl .devflow/dream/.pending-turns.processing', + ); + }); + + it('exits silently when the claim is lost or .processing is fresh', () => { + expect(content).toMatch(/mv.*fails.*exit silently/is); + expect(content).toMatch(/Fresh \(younger than 900s\).*Exit silently/s); + }); + + it('merges and re-claims a stale .processing leftover', () => { + expect(content).toMatch(/Stale \(900s or older\)/); + expect(content).toContain( + 'cat .devflow/dream/.pending-turns.jsonl >> .devflow/dream/.pending-turns.processing', + ); + }); + + it('heartbeats the claim file at the detection→curation boundary', () => { + expect(content).toMatch(/Heartbeat.*touch.*Part 1 → Part 2 boundary/s); + }); + + it('deletes the claim file as the final act (consume-then-delete)', () => { + expect(content).toMatch(/FINAL act.*rm -f \.devflow\/dream\/\.pending-turns\.processing/s); + }); + + it('aborts without writes when inputs vanish mid-run', () => { + expect(content).toMatch(/Vanished inputs.*stop without further writes/s); + }); + }); + + describe('ledger op contract', () => { + it('keeps the Iron Law (assign-anchor owns numbering, render owns the .md)', () => { + expect(content).toContain('assign-anchor OWNS NUMBERING'); + expect(content).toContain('NEVER HAND-EDIT decisions.md or pitfalls.md'); + }); + + it('calls assign-anchor, retire-anchor, and rotate-observations via json-helper', () => { + expect(content).toMatch(/json-helper\.cjs" assign-anchor/); + expect(content).toMatch(/json-helper\.cjs" retire-anchor/); + expect(content).toMatch(/json-helper\.cjs" rotate-observations/); + }); + + it('keeps the curation bounds (≤5 changes, 7-day protection window)', () => { + expect(content).toContain('≤5 curation changes'); + expect(content).toContain('7-day protection window'); + }); + + it('keeps the ADR-XOR-PF hard rule', () => { + expect(content).toContain('ADR-XOR-PF (hard rule)'); + }); + }); + + describe('direct file access (no worker-era script reads)', () => { + it('appends new observations one JSONL line at a time, never whole-file rewrites', () => { + expect(content).toContain('cat >> .devflow/decisions/decisions-log.jsonl'); + expect(content).toMatch(/never\s+rewrite the whole file/); + }); + + it('does not reference count-active (reads rendered files directly)', () => { + expect(content).not.toContain('count-active'); + }); + + it('does not reference staleness.cjs (checks file references itself)', () => { + expect(content).not.toContain('staleness.cjs'); + }); + + it('does not reference merge-observation (edits log rows directly)', () => { + expect(content).not.toContain('merge-observation'); + }); + + it('does not reference the .last-dream-ok success stamp', () => { + expect(content).not.toContain('.last-dream-ok'); + }); + + it('does not reference the last-run-summary file (summary is the final message)', () => { + expect(content).not.toContain('last-run-summary'); + }); + }); +}); diff --git a/tests/eager-memory-refresh.test.ts b/tests/eager-memory-refresh.test.ts index b07e4ad3..2f4c22c8 100644 --- a/tests/eager-memory-refresh.test.ts +++ b/tests/eager-memory-refresh.test.ts @@ -5,11 +5,13 @@ * Covers AC-F1/F2/F3, AC-F4 (injection states), AC-F5/F6/F7, AC-C2/C3/C4, * AC-P3 (double-spawn), and no-regression scenarios. * - * Design constraint: tests that exercise dream-capture with a stale trigger MUST - * supply a fake claude shim on PATH (prepended before the system PATH). This - * prevents the nohup-spawned background-memory-update worker from invoking the - * real claude binary and hanging for 120s. Tests that only check queue-write - * behavior use a fresh trigger file so the 120s throttle prevents any spawn. + * The capture/spawn split: queue-append lives in capture-turn (never throttles, + * never spawns — see tests/capture-hooks.test.ts for its dedicated coverage), + * while the 120s throttle + detached background-memory-update spawn lives in + * memory-worker. Design constraint: tests that exercise memory-worker with a + * stale trigger MUST supply a fake claude shim on PATH (prepended before the + * system PATH). This prevents the nohup-spawned background-memory-update worker + * from invoking the real claude binary and hanging for 120s. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; @@ -19,9 +21,9 @@ import * as path from 'path'; import * as os from 'os'; const HOOKS_DIR = path.resolve(__dirname, '..', 'scripts', 'hooks'); -const CAPTURE_HOOK = path.join(HOOKS_DIR, 'dream-capture'); +const CAPTURE_TURN_HOOK = path.join(HOOKS_DIR, 'capture-turn'); +const MEMORY_WORKER_HOOK = path.join(HOOKS_DIR, 'memory-worker'); const SESSION_START_MEMORY_HOOK = path.join(HOOKS_DIR, 'session-start-memory'); -const SESSION_START_CONTEXT_HOOK = path.join(HOOKS_DIR, 'session-start-context'); const BACKGROUND_UPDATER = path.join(HOOKS_DIR, 'background-memory-update'); // --------------------------------------------------------------------------- @@ -186,7 +188,7 @@ function initGitRepo(dir: string): void { // ============================================================================= // S1 — AC-F2/F3/C3: background-memory-update happy path // -// We run background-memory-update DIRECTLY (not via dream-capture) to avoid +// We run background-memory-update DIRECTLY (not via memory-worker) to avoid // the nohup-detach complexity. The fake claude shim writes a deterministic file. // ============================================================================= describe('S1: end-to-end happy path — background-memory-update worker (AC-F2/F3/C3)', () => { @@ -241,68 +243,6 @@ describe('S1: end-to-end happy path — background-memory-update worker (AC-F2/F }); }); -// ============================================================================= -// S1b — AC-F1: dream-capture touches trigger before spawning (throttle gate) -// -// We use a fake claude shim on PATH so the nohup-spawned worker does not -// invoke the real claude binary. -// ============================================================================= -describe('S1b: AC-F1 — dream-capture touches .working-memory-last-trigger', () => { - let projectDir: string; - let homeDir: string; - let shimDir: string; - - beforeEach(() => { - projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emr-s1b-')); - homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emr-s1b-home-')); - shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emr-s1b-shim-')); - fs.mkdirSync(path.join(projectDir, '.devflow', 'memory'), { recursive: true }); - fs.mkdirSync(path.join(projectDir, '.devflow', 'dream'), { recursive: true }); - const memFile = path.join(projectDir, '.devflow', 'memory', 'WORKING-MEMORY.md'); - createFakeClaudeShim(shimDir, memFile); - seedQueue(projectDir); - }); - - afterEach(() => { - fs.rmSync(projectDir, { recursive: true, force: true }); - fs.rmSync(homeDir, { recursive: true, force: true }); - fs.rmSync(shimDir, { recursive: true, force: true }); - }); - - it('trigger file is touched (mtime refreshed) after throttle expires', () => { - const triggerFile = path.join(projectDir, '.devflow', 'memory', '.working-memory-last-trigger'); - fs.writeFileSync(triggerFile, ''); - backdateMtime(triggerFile, 600); // 10 minutes ago — throttle expired - - runHookWithFakeClaude( - CAPTURE_HOOK, - { cwd: projectDir, session_id: 'test', last_assistant_message: 'hello' }, - homeDir, - shimDir - ); - - const triggerStat = fs.statSync(triggerFile); - expect(triggerStat.mtimeMs).toBeGreaterThan(Date.now() - 15000); - }); - - it('no memory.json or memory.processing marker created by dream-capture (AC-C3)', () => { - const triggerFile = path.join(projectDir, '.devflow', 'memory', '.working-memory-last-trigger'); - fs.writeFileSync(triggerFile, ''); - backdateMtime(triggerFile, 600); - - runHookWithFakeClaude( - CAPTURE_HOOK, - { cwd: projectDir, session_id: 'test', last_assistant_message: 'hello' }, - homeDir, - shimDir - ); - - const dreamDir = path.join(projectDir, '.devflow', 'dream'); - const memMarkers = fs.readdirSync(dreamDir).filter((f) => f.startsWith('memory')); - expect(memMarkers).toHaveLength(0); - }); -}); - // ============================================================================= // S2 — AC-F4: Injection state rendering (session-start-memory) // ============================================================================= @@ -438,15 +378,14 @@ describe('S2: AC-F4 — session-start-memory injection states', () => { }); // ============================================================================= -// S3 — AC-F6: PATH without claude binary +// S3 — AC-F6: capture-turn is throttle-agnostic (queue-append only) // -// We keep trigger file FRESH (no backdating) so dream-capture throttles and -// does NOT try to spawn the worker. This lets us verify exit-0 and queue -// behavior without PATH manipulation worries. -// We test the capture-hook's "no claude found" exit path in S3b by explicitly -// using the fake shim approach with the trigger backdated. +// capture-turn never reads the trigger file at all — it only appends to the +// queue. These tests verify exit-0 and queue-append behavior regardless of +// trigger-file state. We test memory-worker's "no claude found" exit path +// separately in S3b. // ============================================================================= -describe('S3: AC-F6 — capture hook behavior when claude binary absent on PATH', () => { +describe('S3: AC-F6 — capture-turn queue-append is unaffected by trigger/throttle state', () => { let projectDir: string; let homeDir: string; @@ -456,7 +395,8 @@ describe('S3: AC-F6 — capture hook behavior when claude binary absent on PATH' fs.mkdirSync(path.join(projectDir, '.devflow', 'memory'), { recursive: true }); fs.mkdirSync(path.join(projectDir, '.devflow', 'dream'), { recursive: true }); seedQueue(projectDir); - // Fresh trigger (< 120s) — throttle will prevent spawn attempt + // Fresh trigger (< 120s) — irrelevant to capture-turn, present only to prove + // capture-turn ignores it entirely (that's memory-worker's concern). fs.writeFileSync( path.join(projectDir, '.devflow', 'memory', '.working-memory-last-trigger'), '' ); @@ -467,18 +407,18 @@ describe('S3: AC-F6 — capture hook behavior when claude binary absent on PATH' fs.rmSync(homeDir, { recursive: true, force: true }); }); - it('dream-capture exits 0 even when throttled (queue append only)', () => { + it('capture-turn exits 0 regardless of trigger-file state (queue append only)', () => { const { exitCode } = runHook( - CAPTURE_HOOK, + CAPTURE_TURN_HOOK, { cwd: projectDir, session_id: 'test', last_assistant_message: 'hello world' }, homeDir ); expect(exitCode).toBe(0); }); - it('queue still receives new turn (throttle does not block capture)', () => { + it('queue still receives new turn (capture-turn never consults the trigger file)', () => { runHook( - CAPTURE_HOOK, + CAPTURE_TURN_HOOK, { cwd: projectDir, session_id: 'test', last_assistant_message: 'hello world' }, homeDir ); @@ -488,9 +428,9 @@ describe('S3: AC-F6 — capture hook behavior when claude binary absent on PATH' expect(lines.length).toBeGreaterThan(0); }); - it('AC-F4/State C: after throttled capture, session-start-memory shows REFRESH FAILING', () => { + it('AC-F4/State C: after a capture with no worker run, session-start-memory shows REFRESH FAILING', () => { runHook( - CAPTURE_HOOK, + CAPTURE_TURN_HOOK, { cwd: projectDir, session_id: 'test', last_assistant_message: 'hello world' }, homeDir ); @@ -513,12 +453,12 @@ describe('S3: AC-F6 — capture hook behavior when claude binary absent on PATH' // S3b — AC-F6 source code: "no claude" path exits 0, logs skip message // ============================================================================= describe('S3b: AC-F6 source code — no-claude exit logged correctly', () => { - it('dream-capture code logs SKIP when claude binary not found', () => { - const captureSrc = fs.readFileSync(CAPTURE_HOOK, 'utf-8'); - expect(captureSrc).toContain('claude binary not found'); - expect(captureSrc).toContain('worker not spawned (queue intact)'); + it('memory-worker code logs SKIP when claude binary not found', () => { + const workerSrc = fs.readFileSync(MEMORY_WORKER_HOOK, 'utf-8'); + expect(workerSrc).toContain('claude binary not found'); + expect(workerSrc).toContain('worker not spawned (queue intact)'); // Exit must be 0 (not a hard failure) - expect(captureSrc).toContain('exit 0'); + expect(workerSrc).toContain('exit 0'); }); }); @@ -581,7 +521,7 @@ describe('S4: AC-F3/P3 — watchdog and failure path', () => { const okFile = path.join(projectDir, '.devflow', 'memory', '.last-refresh-ok'); expect(fs.existsSync(okFile)).toBe(false); - // .processing must be retained for dream-recover crash recovery. + // .processing must be retained for session-start-memory's own cold-path crash recovery (S19). const processingFile = path.join(projectDir, '.devflow', 'memory', '.pending-turns.processing'); expect(fs.existsSync(processingFile)).toBe(true); }, 20000); // 20s timeout: 2s watchdog sleep + 5s SIGTERM grace + margin @@ -702,41 +642,6 @@ describe('S6: AC-F5 — user-only queue skips LLM', () => { }); }); -// ============================================================================= -// S7 — AC-F7: memory:false in config -// ============================================================================= -describe('S7: AC-F7 — memory:false in dream config gates dream-capture', () => { - let projectDir: string; - let homeDir: string; - - beforeEach(() => { - projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emr-s7-')); - homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emr-s7-home-')); - writeDreamConfig(projectDir, { memory: false }); - fs.mkdirSync(path.join(projectDir, '.devflow', 'memory'), { recursive: true }); - }); - - afterEach(() => { - fs.rmSync(projectDir, { recursive: true, force: true }); - fs.rmSync(homeDir, { recursive: true, force: true }); - }); - - it('memory:false — no queue append, no trigger touch, no dream marker', () => { - runHook( - CAPTURE_HOOK, - { cwd: projectDir, session_id: 'test', last_assistant_message: 'hello' }, - homeDir - ); - - expect(fs.existsSync(path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl'))).toBe(false); - expect(fs.existsSync(path.join(projectDir, '.devflow', 'memory', '.working-memory-last-trigger'))).toBe(false); - - const dreamDir = path.join(projectDir, '.devflow', 'dream'); - const markers = fs.readdirSync(dreamDir).filter((f) => f.startsWith('memory')); - expect(markers).toHaveLength(0); - }); -}); - // ============================================================================= // S8 — AC-C2/C4: Security — prompt via STDIN, DEVFLOW_BG_UPDATER, feedback loop // ============================================================================= @@ -791,24 +696,6 @@ describe('S8: AC-C2/C4 — security constraints', () => { fs.rmSync(shimDir, { recursive: true, force: true }); } }); - - it('dream-capture with DEVFLOW_BG_UPDATER=1 exits early — no queue write', () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emr-s8-')); - const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emr-s8-home-')); - fs.mkdirSync(path.join(tmpDir, '.devflow', 'memory'), { recursive: true }); - try { - runHook( - CAPTURE_HOOK, - { cwd: tmpDir, session_id: 'test', last_assistant_message: 'hello' }, - homeDir, - { DEVFLOW_BG_UPDATER: '1' } - ); - expect(fs.existsSync(path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'))).toBe(false); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - fs.rmSync(homeDir, { recursive: true, force: true }); - } - }); }); // ============================================================================= @@ -861,75 +748,11 @@ describe('S9: AC-C4 — .devflow/ local-by-default with the feature-knowledge ca }); // ============================================================================= -// S10 — No-regression: session-start-context decisions/knowledge agents -// ============================================================================= -describe('S10: No-regression — session-start-context decisions/knowledge unaffected', () => { - let projectDir: string; - let homeDir: string; - - beforeEach(() => { - projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emr-s10-')); - homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emr-s10-home-')); - fs.mkdirSync(path.join(projectDir, '.devflow', 'dream'), { recursive: true }); - fs.mkdirSync(path.join(projectDir, '.devflow', 'decisions'), { recursive: true }); - initGitRepo(projectDir); - }); - - afterEach(() => { - fs.rmSync(projectDir, { recursive: true, force: true }); - fs.rmSync(homeDir, { recursive: true, force: true }); - }); - - it('decisions.json marker → DREAM MAINTENANCE directive emitted, not memory', () => { - fs.writeFileSync( - path.join(projectDir, '.devflow', 'dream', 'decisions.test123.json'), - JSON.stringify({ type: 'decisions', session_id: 'test123' }) - ); - - const { stdout } = runHook(SESSION_START_CONTEXT_HOOK, { cwd: projectDir }, homeDir); - const parsed = JSON.parse(stdout.trim()) as { hookSpecificOutput?: { additionalContext?: string } }; - const ctx = parsed?.hookSpecificOutput?.additionalContext ?? ''; - - expect(ctx).toContain('DREAM MAINTENANCE'); - expect(ctx).toContain('decisions'); - expect(ctx).not.toMatch(/Agent\(.*memory.*\)/i); - }); - - it('stale memory.json marker deleted unconditionally (NOT in Dream directive)', () => { - const markerPath = path.join(projectDir, '.devflow', 'dream', 'memory.json'); - fs.writeFileSync(markerPath, JSON.stringify({ type: 'memory' })); - - runHook(SESSION_START_CONTEXT_HOOK, { cwd: projectDir }, homeDir); - - expect(fs.existsSync(markerPath)).toBe(false); - }); - - it('decisions usage scanner exits 0 when ADR-1/PF-1 appear in assistant message', () => { - // Use a FRESH trigger file (< 120s) so dream-capture throttles before spawning the worker - const memDir = path.join(projectDir, '.devflow', 'memory'); - fs.mkdirSync(memDir, { recursive: true }); - fs.writeFileSync(path.join(memDir, '.working-memory-last-trigger'), ''); - - fs.writeFileSync( - path.join(projectDir, '.devflow', 'decisions', 'decisions-log.jsonl'), '' - ); - - const { exitCode } = runHook( - CAPTURE_HOOK, - { cwd: projectDir, session_id: 'test', last_assistant_message: 'I applied ADR-1 and also PF-1 here' }, - homeDir - ); - - expect(exitCode).toBe(0); - }); -}); - -// ============================================================================= -// S11 — AC-C3: No memory.* marker in .devflow/dream/ after dream-capture +// S11 — AC-C3: No memory.* marker in .devflow/dream/ after a memory-worker spawn // // Uses fake claude on PATH so the nohup-spawned worker uses the shim, not real claude. // ============================================================================= -describe('S11: AC-C3 — no memory.* marker in .devflow/dream/ after dream-capture', () => { +describe('S11: AC-C3 — no memory.* marker in .devflow/dream/ after a memory-worker spawn', () => { let projectDir: string; let homeDir: string; let shimDir: string; @@ -956,10 +779,10 @@ describe('S11: AC-C3 — no memory.* marker in .devflow/dream/ after dream-captu fs.rmSync(shimDir, { recursive: true, force: true }); }); - it('no memory.* file in .devflow/dream/ after dream-capture (no marker created)', () => { + it('no memory.* file in .devflow/dream/ after memory-worker spawns the updater (no marker created)', () => { runHookWithFakeClaude( - CAPTURE_HOOK, - { cwd: projectDir, session_id: 'test', last_assistant_message: 'some response' }, + MEMORY_WORKER_HOOK, + { cwd: projectDir }, homeDir, shimDir ); @@ -1464,3 +1287,245 @@ describe('S12: install survival — background-memory-update not deleted by init expect(mode & 0o100).toBeGreaterThan(0); }); }); + +// ============================================================================= +// S18 — AC-F10: qa rows flow into memory synthesis (orphan gate + TURNS_TEXT agree) +// +// A qa row (captured AskUserQuestion Q&A pair) must count as content-bearing for +// the orphan-only auto-clean guard — the same way an assistant row does — and +// must appear in the prompt fed to claude as its own "Q&A:" stanza. +// ============================================================================= +describe('S18: AC-F10 — qa rows in background-memory-update (orphan gate + TURNS_TEXT)', () => { + let projectDir: string; + let homeDir: string; + let shimDir: string; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emr-s18-')); + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emr-s18-home-')); + shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emr-s18-shim-')); + fs.mkdirSync(path.join(projectDir, '.devflow', 'memory'), { recursive: true }); + fs.mkdirSync(path.join(projectDir, '.devflow', 'dream'), { recursive: true }); + initGitRepo(projectDir); + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(shimDir, { recursive: true, force: true }); + }); + + it('user + qa (no assistant) is NOT truncated as user-only — a real run is attempted', () => { + const memFile = path.join(projectDir, '.devflow', 'memory', 'WORKING-MEMORY.md'); + createFakeClaudeShim(shimDir, memFile); + + const ts = Math.floor(Date.now() / 1000); + fs.writeFileSync( + path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl'), + [ + JSON.stringify({ role: 'user', content: 'what should I pick?', ts }), + JSON.stringify({ role: 'qa', content: 'Q: pick one\nA: option B', ts: ts + 1 }), + ].join('\n') + '\n' + ); + + runWorker(projectDir, homeDir, shimDir); + + // WORKING-MEMORY.md written proves the orphan gate did NOT truncate the queue + // (the "no assistant turn" auto-clean path never invokes claude at all). + expect(fs.existsSync(memFile)).toBe(true); + const processingFile = path.join(projectDir, '.devflow', 'memory', '.pending-turns.processing'); + expect(fs.existsSync(processingFile)).toBe(false); + }); + + it('qa content appears in the prompt fed to claude as a "Q&A:" stanza', () => { + const memFile = path.join(projectDir, '.devflow', 'memory', 'WORKING-MEMORY.md'); + const stdinCapture = path.join(shimDir, 'stdin-captured.txt'); + const claudeBin = path.join(shimDir, 'claude'); + fs.writeFileSync( + claudeBin, + `#!/bin/bash +cat > "${stdinCapture}" +echo "" > "${memFile}" +echo "## Now" >> "${memFile}" +exit 0 +` + ); + fs.chmodSync(claudeBin, 0o755); + + const ts = Math.floor(Date.now() / 1000); + fs.writeFileSync( + path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl'), + [ + JSON.stringify({ role: 'user', content: 'need a decision', ts }), + JSON.stringify({ role: 'qa', content: 'Q: ship now or wait?\nA: ship now', ts: ts + 1 }), + ].join('\n') + '\n' + ); + + const { exitCode } = runWorker(projectDir, homeDir, shimDir); + expect(exitCode).toBe(0); + + expect(fs.existsSync(stdinCapture)).toBe(true); + const capturedStdin = fs.readFileSync(stdinCapture, 'utf-8'); + expect(capturedStdin).toContain('Q&A:'); + expect(capturedStdin).toContain('ship now or wait?'); + expect(capturedStdin).toContain('ship now'); + }); + + it('regression: pure user-only queue (no qa, no assistant) is STILL truncated without an LLM run', () => { + const memFile = path.join(projectDir, '.devflow', 'memory', 'WORKING-MEMORY.md'); + createFakeClaudeShim(shimDir, memFile); + + const ts = Math.floor(Date.now() / 1000); + fs.writeFileSync( + path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl'), + JSON.stringify({ role: 'user', content: 'just a question', ts }) + '\n' + ); + + runWorker(projectDir, homeDir, shimDir); + + // Orphan gate must still fire for a genuinely user-only queue — claude never invoked. + expect(fs.existsSync(memFile)).toBe(false); + expect(fs.existsSync(path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl'))).toBe(false); + }); + + it('qa-only queue (no user, no assistant) is NOT truncated as user-only', () => { + const memFile = path.join(projectDir, '.devflow', 'memory', 'WORKING-MEMORY.md'); + createFakeClaudeShim(shimDir, memFile); + + const ts = Math.floor(Date.now() / 1000); + fs.writeFileSync( + path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl'), + JSON.stringify({ role: 'qa', content: 'Q: only question\nA: only answer', ts }) + '\n' + ); + + runWorker(projectDir, homeDir, shimDir); + + expect(fs.existsSync(memFile)).toBe(true); + }); +}); + +// ============================================================================= +// S19 — session-start-memory cold-path recovery for orphaned .pending-turns.processing +// +// session-start-memory owns this recovery itself (self-contained, no external +// helper dependency). Age >300s + no existing .jsonl -> recovered; .jsonl +// present -> left alone (non-clobber). +// ============================================================================= +describe('S19: session-start-memory cold-path .pending-turns.processing recovery', () => { + let projectDir: string; + let homeDir: string; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emr-s19-')); + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emr-s19-home-')); + fs.mkdirSync(path.join(projectDir, '.devflow', 'memory'), { recursive: true }); + fs.mkdirSync(path.join(projectDir, '.devflow', 'dream'), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + fs.rmSync(homeDir, { recursive: true, force: true }); + }); + + it('stale (>300s) orphaned .processing with no .jsonl present is recovered', () => { + const proc = path.join(projectDir, '.devflow', 'memory', '.pending-turns.processing'); + fs.writeFileSync(proc, JSON.stringify({ role: 'user', content: 'orphaned', ts: 1 }) + '\n'); + backdateMtime(proc, 600); + + runHook(SESSION_START_MEMORY_HOOK, { cwd: projectDir }, homeDir); + + expect(fs.existsSync(proc)).toBe(false); + const jsonl = path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl'); + expect(fs.existsSync(jsonl)).toBe(true); + expect(fs.readFileSync(jsonl, 'utf-8')).toContain('orphaned'); + }); + + it('fresh (<300s) .processing is left alone (not yet stale)', () => { + const proc = path.join(projectDir, '.devflow', 'memory', '.pending-turns.processing'); + fs.writeFileSync(proc, JSON.stringify({ role: 'user', content: 'fresh', ts: 1 }) + '\n'); + // No backdate — mtime is "now" + + runHook(SESSION_START_MEMORY_HOOK, { cwd: projectDir }, homeDir); + + expect(fs.existsSync(proc)).toBe(true); + expect(fs.existsSync(path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl'))).toBe(false); + }); + + it('non-clobber: stale .processing is left in place when .pending-turns.jsonl already exists', () => { + const proc = path.join(projectDir, '.devflow', 'memory', '.pending-turns.processing'); + fs.writeFileSync(proc, JSON.stringify({ role: 'user', content: 'orphaned', ts: 1 }) + '\n'); + backdateMtime(proc, 600); + const jsonl = path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl'); + fs.writeFileSync(jsonl, JSON.stringify({ role: 'user', content: 'fresh-queue', ts: 2 }) + '\n'); + + runHook(SESSION_START_MEMORY_HOOK, { cwd: projectDir }, homeDir); + + expect(fs.existsSync(proc)).toBe(true); + expect(fs.readFileSync(jsonl, 'utf-8')).toContain('fresh-queue'); + expect(fs.readFileSync(jsonl, 'utf-8')).not.toContain('orphaned'); + }); + + it('no .processing at all — hook proceeds normally (no error, no spurious .jsonl)', () => { + const { exitCode } = runHook(SESSION_START_MEMORY_HOOK, { cwd: projectDir }, homeDir); + expect(exitCode).toBe(0); + expect(fs.existsSync(path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl'))).toBe(false); + }); + + it('recovery is skipped entirely when memory is disabled via dream config', () => { + writeDreamConfig(projectDir, { memory: false }); + const proc = path.join(projectDir, '.devflow', 'memory', '.pending-turns.processing'); + fs.writeFileSync(proc, JSON.stringify({ role: 'user', content: 'orphaned', ts: 1 }) + '\n'); + backdateMtime(proc, 600); + + runHook(SESSION_START_MEMORY_HOOK, { cwd: projectDir }, homeDir); + + // memory:false gates the whole hook (including the new recovery block) — .processing untouched + expect(fs.existsSync(proc)).toBe(true); + }); +}); + +// ============================================================================= +// S20 — Worker self-guard: background-memory-update's DEVFLOW_BG_UPDATER guard +// must fire before any filesystem interaction (queue claim, memory write, or +// even hook-log-init) so a nested worker session can never cascade a refresh. +// ============================================================================= +describe('S20: DEVFLOW_BG_UPDATER self-guard (worker re-entrancy)', () => { + let projectDir: string; + let homeDir: string; + let shimDir: string; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emr-s20-')); + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emr-s20-home-')); + shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emr-s20-shim-')); + fs.mkdirSync(path.join(projectDir, '.devflow', 'memory'), { recursive: true }); + fs.mkdirSync(path.join(projectDir, '.devflow', 'dream'), { recursive: true }); + initGitRepo(projectDir); + const memFile = path.join(projectDir, '.devflow', 'memory', 'WORKING-MEMORY.md'); + createFakeClaudeShim(shimDir, memFile); + seedQueue(projectDir); + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(shimDir, { recursive: true, force: true }); + }); + + it('DEVFLOW_BG_UPDATER=1: exits 0 before claiming the queue — queue untouched, no memory write, no log', () => { + const queueFile = path.join(projectDir, '.devflow', 'memory', '.pending-turns.jsonl'); + const memFile = path.join(projectDir, '.devflow', 'memory', 'WORKING-MEMORY.md'); + + const { exitCode } = runWorker(projectDir, homeDir, shimDir, { DEVFLOW_BG_UPDATER: '1' }); + + expect(exitCode).toBe(0); + // Guard fires before the "claim queue atomically" step — queue is neither + // renamed to .processing nor deleted. + expect(fs.existsSync(queueFile)).toBe(true); + expect(fs.existsSync(path.join(projectDir, '.devflow', 'memory', '.pending-turns.processing'))).toBe(false); + // The fake claude shim (which writes memFile) must never run. + expect(fs.existsSync(memFile)).toBe(false); + // Guard fires before hook-log-init is sourced — no log file at all. + expect(fs.existsSync(workerLogPath(projectDir, homeDir))).toBe(false); + }); +}); diff --git a/tests/flags.test.ts b/tests/flags.test.ts index dbc8f93b..b1b0aeb4 100644 --- a/tests/flags.test.ts +++ b/tests/flags.test.ts @@ -283,6 +283,81 @@ describe('agent-teams flag', () => { }); }); +describe('disable-bundled-skills flag', () => { + it('is registered in FLAG_REGISTRY', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'disable-bundled-skills'); + expect(flag).toBeDefined(); + }); + + it('is defaultEnabled: true', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'disable-bundled-skills')!; + expect(flag.defaultEnabled).toBe(true); + }); + + it('maps to disableBundledSkills setting = true', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'disable-bundled-skills')!; + expect(flag.target.type).toBe('setting'); + if (flag.target.type === 'setting') { + expect(flag.target.key).toBe('disableBundledSkills'); + expect(flag.target.value).toBe(true); + } + }); + + it('is in getDefaultFlags() (on by default)', () => { + expect(getDefaultFlags()).toContain('disable-bundled-skills'); + }); +}); + +describe('pin-sonnet-4-6 flag', () => { + it('is registered in FLAG_REGISTRY', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'pin-sonnet-4-6'); + expect(flag).toBeDefined(); + }); + + it('is defaultEnabled: true', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'pin-sonnet-4-6')!; + expect(flag.defaultEnabled).toBe(true); + }); + + it('maps to ANTHROPIC_DEFAULT_SONNET_MODEL env var = claude-sonnet-4-6', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'pin-sonnet-4-6')!; + expect(flag.target.type).toBe('env'); + if (flag.target.type === 'env') { + expect(flag.target.key).toBe('ANTHROPIC_DEFAULT_SONNET_MODEL'); + expect(flag.target.value).toBe('claude-sonnet-4-6'); + } + }); + + it('is in getDefaultFlags() (on by default)', () => { + expect(getDefaultFlags()).toContain('pin-sonnet-4-6'); + }); +}); + +describe('disable-mouse-clicks flag', () => { + it('is registered in FLAG_REGISTRY', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'disable-mouse-clicks'); + expect(flag).toBeDefined(); + }); + + it('is defaultEnabled: true', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'disable-mouse-clicks')!; + expect(flag.defaultEnabled).toBe(true); + }); + + it('maps to CLAUDE_CODE_DISABLE_MOUSE_CLICKS env var = 1', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'disable-mouse-clicks')!; + expect(flag.target.type).toBe('env'); + if (flag.target.type === 'env') { + expect(flag.target.key).toBe('CLAUDE_CODE_DISABLE_MOUSE_CLICKS'); + expect(flag.target.value).toBe('1'); + } + }); + + it('is in getDefaultFlags() (on by default)', () => { + expect(getDefaultFlags()).toContain('disable-mouse-clicks'); + }); +}); + describe('applyViewMode', () => { it('sets viewMode to verbose', () => { const input = JSON.stringify({ hooks: {} }, null, 2); diff --git a/tests/hud-components.test.ts b/tests/hud-components.test.ts index f01d2fdb..271a6234 100644 --- a/tests/hud-components.test.ts +++ b/tests/hud-components.test.ts @@ -24,6 +24,7 @@ function makeCtx(overrides: Partial = {}): GatherContext { transcript: null, usage: null, configCounts: null, + decisionsCounts: null, costHistory: null, config: { enabled: true, detail: false, components: [] }, devflowDir: '/test/.devflow', diff --git a/tests/hud-decisions-counts.test.ts b/tests/hud-decisions-counts.test.ts new file mode 100644 index 00000000..538eb990 --- /dev/null +++ b/tests/hud-decisions-counts.test.ts @@ -0,0 +1,215 @@ +// tests/hud-decisions-counts.test.ts +// Tests for the HUD decisions/pitfalls counts component (D309). +// Validates active-row counting from decisions-ledger.jsonl, inactive-status +// exclusion, and graceful fallback when the ledger is missing or unreadable. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createRequire } from 'node:module'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import decisionsCounts, { + gatherDecisionsCounts, +} from '../src/cli/hud/components/decisions-counts.js'; +import { stripAnsi } from '../src/cli/hud/colors.js'; +import type { DecisionsCountsData, GatherContext } from '../src/cli/hud/types.js'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const require = createRequire(import.meta.url); +const { isActive: cjsIsActive } = require( + path.join(ROOT, 'scripts/hooks/lib/render-decisions.cjs'), +) as { isActive: (row: Record) => boolean }; + +// Helper: build a minimal ledger JSONL row with the given fields +function makeRow(type: string, extra: Record = {}): string { + return JSON.stringify({ + id: `obs_${Math.random().toString(36).slice(2)}`, + type, + pattern: 'test pattern', + details: 'test details', + anchor_id: type === 'pitfall' ? 'PF-001' : 'ADR-001', + decisions_status: 'Accepted', + ...extra, + }); +} + +function makeCtx(data: DecisionsCountsData | null): GatherContext { + return { + stdin: {}, + git: null, + transcript: null, + usage: null, + configCounts: null, + decisionsCounts: data, + costHistory: null, + config: { enabled: true, detail: false, components: [] }, + devflowDir: '/test/.devflow', + sessionStartTime: null, + terminalWidth: 120, + }; +} + +describe('gatherDecisionsCounts', () => { + let tmpDir: string; + let ledgerPath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hud-decisions-counts-')); + fs.mkdirSync(path.join(tmpDir, '.devflow', 'decisions'), { recursive: true }); + ledgerPath = path.join(tmpDir, '.devflow', 'decisions', 'decisions-ledger.jsonl'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('counts active rows by type', () => { + const lines = [ + makeRow('decision', { anchor_id: 'ADR-001' }), + makeRow('decision', { anchor_id: 'ADR-002' }), + makeRow('decision', { anchor_id: 'ADR-003' }), + makeRow('pitfall', { anchor_id: 'PF-001' }), + ]; + fs.writeFileSync(ledgerPath, lines.join('\n') + '\n'); + + expect(gatherDecisionsCounts(tmpDir)).toEqual({ decisions: 3, pitfalls: 1 }); + }); + + it('treats absent decisions_status and Active as active', () => { + const lines = [ + makeRow('decision', { anchor_id: 'ADR-001', decisions_status: undefined }), + makeRow('decision', { anchor_id: 'ADR-002', decisions_status: 'Active' }), + ]; + fs.writeFileSync(ledgerPath, lines.join('\n') + '\n'); + + expect(gatherDecisionsCounts(tmpDir)).toEqual({ decisions: 2, pitfalls: 0 }); + }); + + it('excludes Deprecated, Superseded, and Retired rows', () => { + const lines = [ + makeRow('decision', { anchor_id: 'ADR-001', decisions_status: 'Deprecated' }), + makeRow('decision', { anchor_id: 'ADR-002', decisions_status: 'Superseded' }), + makeRow('pitfall', { anchor_id: 'PF-001', decisions_status: 'Retired' }), + makeRow('pitfall', { anchor_id: 'PF-002' }), + ]; + fs.writeFileSync(ledgerPath, lines.join('\n') + '\n'); + + expect(gatherDecisionsCounts(tmpDir)).toEqual({ decisions: 0, pitfalls: 1 }); + }); + + it('skips rows without anchor_id', () => { + const lines = [ + makeRow('decision', { anchor_id: undefined }), + makeRow('decision', { anchor_id: 'ADR-001' }), + ]; + fs.writeFileSync(ledgerPath, lines.join('\n') + '\n'); + + expect(gatherDecisionsCounts(tmpDir)).toEqual({ decisions: 1, pitfalls: 0 }); + }); + + it('skips malformed JSON lines', () => { + const content = `not json at all\n${makeRow('pitfall', { anchor_id: 'PF-001' })}\n{truncated\n`; + fs.writeFileSync(ledgerPath, content); + + expect(gatherDecisionsCounts(tmpDir)).toEqual({ decisions: 0, pitfalls: 1 }); + }); + + it('returns null when the ledger file is missing', () => { + expect(gatherDecisionsCounts(tmpDir)).toBeNull(); + }); + + it('returns null when the ledger holds no valid rows', () => { + fs.writeFileSync(ledgerPath, 'garbage\n\n{"type":"decision"}\n'); + + expect(gatherDecisionsCounts(tmpDir)).toBeNull(); + }); + + it('returns zero counts (not null) when every row is inactive', () => { + fs.writeFileSync( + ledgerPath, + makeRow('decision', { anchor_id: 'ADR-001', decisions_status: 'Retired' }) + '\n', + ); + + expect(gatherDecisionsCounts(tmpDir)).toEqual({ decisions: 0, pitfalls: 0 }); + }); +}); + +describe('decisionsCounts component', () => { + it('returns null when no data was gathered', async () => { + expect(await decisionsCounts(makeCtx(null))).toBeNull(); + }); + + it('returns null when counts are all zero', async () => { + expect(await decisionsCounts(makeCtx({ decisions: 0, pitfalls: 0 }))).toBeNull(); + }); + + it('renders decisions and pitfalls with singular/plural forms', async () => { + const result = await decisionsCounts(makeCtx({ decisions: 1, pitfalls: 2 })); + + expect(result).not.toBeNull(); + expect(result!.raw).toBe('Learning: 1 decision, 2 pitfalls'); + }); + + it('omits zero-count parts', async () => { + const result = await decisionsCounts(makeCtx({ decisions: 3, pitfalls: 0 })); + + expect(result).not.toBeNull(); + expect(result!.raw).toBe('Learning: 3 decisions'); + }); + + it('dims the rendered text without altering content', async () => { + const result = await decisionsCounts(makeCtx({ decisions: 2, pitfalls: 1 })); + + expect(result).not.toBeNull(); + expect(stripAnsi(result!.text)).toBe(result!.raw); + }); +}); + +// --------------------------------------------------------------------------- +// Contract test (D309): the HUD's active-row semantics must mirror +// render-decisions.cjs exactly, or the counts shown by the HUD would drift +// from the entries visible in decisions.md/pitfalls.md. This pins the +// mirror by comparing gatherDecisionsCounts' active/inactive determination +// (via count presence) against the cjs renderer's own isActive() for the +// full status matrix, rather than duplicating INACTIVE_STATUSES here. +// --------------------------------------------------------------------------- +describe('mirrors render-decisions.cjs active-row semantics (D309)', () => { + let tmpDir: string; + let ledgerPath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hud-decisions-mirror-')); + fs.mkdirSync(path.join(tmpDir, '.devflow', 'decisions'), { recursive: true }); + ledgerPath = path.join(tmpDir, '.devflow', 'decisions', 'decisions-ledger.jsonl'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + const statusMatrix: Array = [ + undefined, + 'Accepted', + 'Active', + 'Deprecated', + 'Superseded', + 'Retired', + 'SomeFutureStatus', + ]; + + it.each(statusMatrix)( + 'agrees with render-decisions.cjs isActive() for decisions_status=%s', + (status) => { + const row: Record = { type: 'decision', anchor_id: 'ADR-001' }; + if (status !== undefined) row.decisions_status = status; + + fs.writeFileSync(ledgerPath, JSON.stringify(row) + '\n'); + + const expectedActive = cjsIsActive(row); + const counts = gatherDecisionsCounts(tmpDir); + const actualActive = counts !== null && counts.decisions === 1; + + expect(actualActive).toBe(expectedActive); + }, + ); +}); diff --git a/tests/hud-render.test.ts b/tests/hud-render.test.ts index 00952af7..8a645262 100644 --- a/tests/hud-render.test.ts +++ b/tests/hud-render.test.ts @@ -37,6 +37,7 @@ function makeCtx( transcript: null, usage: null, configCounts: null, + decisionsCounts: null, costHistory: null, config: { enabled: true, @@ -86,7 +87,7 @@ describe('render', () => { expect(raw).toContain('30%'); }); - it('shows activity section with todos and config counts', async () => { + it('renders todos at the end of the capacity line', async () => { const ctx = makeCtx({ sessionStartTime: Date.now() - 5 * 60 * 1000, usage: { fiveHourPercent: 20, sevenDayPercent: null, fiveHourResetsAt: null, sevenDayResetsAt: null }, @@ -104,17 +105,18 @@ describe('render', () => { }, }); const output = await render(ctx); - const lines = output.split('\n').filter((l) => l.length > 0); + const lines = output.split('\n').map((l) => stripAnsi(l)); - // 3 info lines + blank + todo line = 4+ - expect(lines.length).toBeGreaterThanOrEqual(4); + expect(lines).toHaveLength(3); + const capacityLine = lines.find((l) => l.includes('Context')); + expect(capacityLine).toBeDefined(); + expect(capacityLine).toContain('5h'); + expect(capacityLine!.endsWith('2/4 todos')).toBe(true); const raw = stripAnsi(output); - expect(raw).toContain('2/4 todos'); expect(raw).toContain('2 CLAUDE.md'); expect(raw).toContain('3 rules'); expect(raw).toContain('1 MCPs'); expect(raw).toContain('4 hooks'); - expect(raw).toContain('5h'); }); it('components that return null are excluded', async () => { @@ -140,7 +142,7 @@ describe('render', () => { expect(raw).not.toContain('feat/test'); }); - it('inserts blank line between info and activity sections', async () => { + it('emits no blank lines between groups', async () => { const ctx = makeCtx({ sessionStartTime: Date.now() - 5 * 60 * 1000, usage: { fiveHourPercent: 20, sevenDayPercent: null, fiveHourResetsAt: null, sevenDayResetsAt: null }, @@ -150,28 +152,14 @@ describe('render', () => { todos: { completed: 1, total: 3 }, skills: [], }, + decisionsCounts: { decisions: 3, pitfalls: 1 }, }); const output = await render(ctx); const lines = output.split('\n'); - // Should contain an empty line between info and activity sections - expect(lines).toContain(''); - // Empty line should be between non-empty lines - const emptyIdx = lines.indexOf(''); - expect(emptyIdx).toBeGreaterThan(0); - expect(emptyIdx).toBeLessThan(lines.length - 1); - }); - - it('no blank line when activity section is empty', async () => { - const ctx = makeCtx({ - sessionStartTime: Date.now() - 5 * 60 * 1000, - usage: { fiveHourPercent: 20, sevenDayPercent: null, fiveHourResetsAt: null, sevenDayResetsAt: null }, - }); - const output = await render(ctx); - const lines = output.split('\n'); - - // No empty lines — no activity components have data + expect(lines.length).toBeGreaterThanOrEqual(4); expect(lines.every((l) => l.length > 0)).toBe(true); + expect(stripAnsi(lines[lines.length - 1])).toBe('Learning: 3 decisions, 1 pitfall'); }); }); @@ -203,7 +191,7 @@ describe('config', () => { expect(resolveComponents(config)).toEqual(['versionBadge']); }); - it('HUD_COMPONENTS has 14 components (sessionDuration retained but omitted from defaults; learningCounts removed)', () => { - expect(HUD_COMPONENTS).toHaveLength(14); + it('HUD_COMPONENTS has 15 components (sessionDuration retained but omitted from defaults)', () => { + expect(HUD_COMPONENTS).toHaveLength(15); }); }); diff --git a/tests/init-logic.test.ts b/tests/init-logic.test.ts index 18ee28cc..12912a32 100644 --- a/tests/init-logic.test.ts +++ b/tests/init-logic.test.ts @@ -163,6 +163,60 @@ describe('substituteSettingsTemplate', () => { }); }); +// AC-C2 end-state shape: the shipped settings.json template must seed all 5 +// always-on hooks (UserPromptSubmit/PostToolUse/Stop capture bundle + the +// SessionStart/PreCompact memory-dream bundle) exactly, not rely on init's +// addCaptureHooks runtime healing to fill gaps. +describe('settings.json template: AC-C2 complete hook seed shape', () => { + const TEMPLATE_PATH = path.resolve(__dirname, '..', 'src', 'templates', 'settings.json'); + + async function loadHooks(): Promise>> { + const raw = await fs.readFile(TEMPLATE_PATH, 'utf-8'); + return JSON.parse(raw).hooks; + } + + it('seeds all 5 always-on hook event types', async () => { + const hooks = await loadHooks(); + expect(Object.keys(hooks).sort()).toEqual( + ['PostToolUse', 'PreCompact', 'SessionStart', 'Stop', 'UserPromptSubmit'].sort(), + ); + }); + + it('UserPromptSubmit seeds capture-prompt', async () => { + const hooks = await loadHooks(); + expect(hooks.UserPromptSubmit).toHaveLength(1); + expect(hooks.UserPromptSubmit[0].hooks[0].command).toContain('run-hook capture-prompt'); + }); + + it('PostToolUse seeds capture-question scoped to matcher: "AskUserQuestion"', async () => { + const hooks = await loadHooks(); + expect(hooks.PostToolUse).toHaveLength(1); + expect(hooks.PostToolUse[0].matcher).toBe('AskUserQuestion'); + expect(hooks.PostToolUse[0].hooks[0].command).toContain('run-hook capture-question'); + }); + + it('Stop keeps the AC-C2 order: [capture-turn, memory-worker]', async () => { + const hooks = await loadHooks(); + const commands = hooks.Stop.map((m) => m.hooks[0].command); + expect(commands[0]).toContain('run-hook capture-turn'); + expect(commands[1]).toContain('run-hook memory-worker'); + }); + + it('SessionStart keeps the AC-C2 order: [session-start-memory, session-start-context]', async () => { + const hooks = await loadHooks(); + const commands = hooks.SessionStart.map((m) => m.hooks[0].command); + expect(commands).toHaveLength(2); + expect(commands[0]).toContain('run-hook session-start-memory'); + expect(commands[1]).toContain('run-hook session-start-context'); + }); + + it('PreCompact seeds pre-compact-memory', async () => { + const hooks = await loadHooks(); + expect(hooks.PreCompact).toHaveLength(1); + expect(hooks.PreCompact[0].hooks[0].command).toContain('run-hook pre-compact-memory'); + }); +}); + describe('computeGitignoreAppend', () => { it('returns all entries when gitignore is empty', () => { const result = computeGitignoreAppend('', ['.claude/', '.devflow/']); diff --git a/tests/learning/filter.test.ts b/tests/learning/filter.test.ts deleted file mode 100644 index 4534e3f7..00000000 --- a/tests/learning/filter.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -// tests/learning/filter.test.ts -// Tests for the channel-based transcript filter (D1, D2). -// Validates pollution rejection, channel population, and cap behaviour. - -import { describe, it, expect } from 'vitest'; -import { createRequire } from 'module'; -import * as path from 'path'; -import * as url from 'url'; - -const __filename = url.fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const require = createRequire(import.meta.url); - -// Load the CJS module under test -const { extractChannels } = require( - path.resolve(__dirname, '../../scripts/hooks/lib/transcript-filter.cjs') -) as { extractChannels: (jsonl: string) => { userSignals: string[]; dialogPairs: { prior: string; user: string }[] } }; - -// Helper: build a JSONL line in the transcript envelope format used by Claude Code -function line(entry: Record): string { - return JSON.stringify(entry); -} -function userMsg(text: string, extra: Record = {}): string { - return line({ type: 'user', message: { role: 'user', content: text }, ...extra }); -} -function assistantMsg(text: string): string { - return line({ type: 'assistant', message: { role: 'assistant', content: text } }); -} -function userArrayMsg(items: unknown[]): string { - return line({ type: 'user', message: { role: 'user', content: items } }); -} - -describe('extractChannels — pollution rejection (D2)', () => { - it('rejects entries where isMeta is true', () => { - const input = [ - line({ type: 'user', isMeta: true, message: { role: 'user', content: 'some user text here' } }), - userMsg('keep this valid user message ok'), - ].join('\n'); - - const { userSignals } = extractChannels(input); - expect(userSignals).toHaveLength(1); - expect(userSignals[0]).toBe('keep this valid user message ok'); - }); - - it('rejects entries with sourceToolUseID present', () => { - const input = [ - line({ type: 'user', sourceToolUseID: 'tool-123', message: { role: 'user', content: 'hidden content here xx' } }), - userMsg('visible user message comes through ok'), - ].join('\n'); - - const { userSignals } = extractChannels(input); - expect(userSignals).toHaveLength(1); - expect(userSignals[0]).toBe('visible user message comes through ok'); - }); - - it('rejects entries with toolUseResult present', () => { - const input = [ - line({ type: 'user', toolUseResult: { output: 'foo' }, message: { role: 'user', content: 'tool result noise' } }), - userMsg('clean message after tool result ok'), - ].join('\n'); - - const { userSignals } = extractChannels(input); - expect(userSignals).toHaveLength(1); - expect(userSignals[0]).toBe('clean message after tool result ok'); - }); - - it('rejects string user content matching wrapper', () => { - const input = [ - userMsg('devflow:implement loaded context'), - userMsg('plain user message that is fine'), - ].join('\n'); - - const { userSignals } = extractChannels(input); - expect(userSignals).toHaveLength(1); - expect(userSignals[0]).toBe('plain user message that is fine'); - }); - - it('rejects string user content matching { - const input = [ - userMsg('bar baz content'), - userMsg('good user message here for signals'), - ].join('\n'); - - const { userSignals } = extractChannels(input); - expect(userSignals).toHaveLength(1); - expect(userSignals[0]).toBe('good user message here for signals'); - }); - - it('rejects string user content matching wrapper', () => { - const input = [ - userMsg('Do not use certain tools.'), - userMsg('actual user instruction that matters'), - ].join('\n'); - - const { userSignals } = extractChannels(input); - expect(userSignals).toHaveLength(1); - expect(userSignals[0]).toBe('actual user instruction that matters'); - }); - - it('rejects string user content matching wrapper', () => { - const input = [ - userMsg('here is an example content block'), - userMsg('real user request text goes here'), - ].join('\n'); - - const { userSignals } = extractChannels(input); - expect(userSignals).toHaveLength(1); - expect(userSignals[0]).toBe('real user request text goes here'); - }); - - it('rejects user array turn where any item is type tool_result', () => { - const input = [ - userArrayMsg([ - { type: 'tool_result', content: 'result output data here' }, - { type: 'text', text: 'this text should also be excluded' }, - ]), - userMsg('clean user message passes through ok'), - ].join('\n'); - - const { userSignals } = extractChannels(input); - expect(userSignals).toHaveLength(1); - expect(userSignals[0]).toBe('clean user message passes through ok'); - }); - - it('excludes noisy text items from array but keeps clean items', () => { - const input = [ - userArrayMsg([ - { type: 'text', text: 'injected context noise' }, - { type: 'text', text: 'actual user text that is clean and valid ok' }, - ]), - userMsg('another valid message here too'), - ].join('\n'); - - const { userSignals } = extractChannels(input); - // First message has clean text after filtering noisy item - expect(userSignals).toHaveLength(2); - expect(userSignals[0]).toBe('actual user text that is clean and valid ok'); - }); - - it('rejects empty user content (< 5 chars after trim)', () => { - const input = [ - userMsg(' ok '), // 2 chars after trim — rejected - userMsg('valid user text that is long enough'), - ].join('\n'); - - const { userSignals } = extractChannels(input); - expect(userSignals).toHaveLength(1); - expect(userSignals[0]).toBe('valid user text that is long enough'); - }); - - it('rejects invalid JSON lines gracefully', () => { - const input = [ - '{ invalid json line here }', - userMsg('valid message is kept after bad json'), - ].join('\n'); - - const { userSignals } = extractChannels(input); - expect(userSignals).toHaveLength(1); - }); -}); - -describe('extractChannels — channel population', () => { - it('populates USER_SIGNALS from plain user text', () => { - const input = [ - userMsg('implement the plan, then run /self-review, then commit'), - userMsg('squash merge the PR, pull main, delete the feature branch'), - ].join('\n'); - - const { userSignals, dialogPairs } = extractChannels(input); - expect(userSignals).toHaveLength(2); - expect(userSignals[0]).toContain('implement the plan'); - expect(dialogPairs).toHaveLength(0); // no assistant turns precede these - }); - - it('populates DIALOG_PAIRS when user turn directly follows assistant turn', () => { - const input = [ - assistantMsg("I'll add a try/catch around the Result parsing to be safe here"), - userMsg("no — we use Result types precisely to avoid try/catch. Do not wrap."), - ].join('\n'); - - const { userSignals, dialogPairs } = extractChannels(input); - expect(userSignals).toHaveLength(1); - expect(dialogPairs).toHaveLength(1); - expect(dialogPairs[0].prior).toContain("I'll add a try/catch"); - expect(dialogPairs[0].user).toContain("we use Result types"); - }); - - it('does NOT add to DIALOG_PAIRS when user follows another user (no assistant prior)', () => { - const input = [ - userMsg('first user message about workflow steps here'), - userMsg('second user message directly following first one'), - ].join('\n'); - - const { dialogPairs } = extractChannels(input); - expect(dialogPairs).toHaveLength(0); - }); - - it('does NOT include DIALOG_PAIR when assistant turn has only tool-use content (rejected)', () => { - // Assistant turn with only noisy content is filtered out — cannot be a "prior" - const input = [ - line({ - type: 'assistant', - message: { - role: 'assistant', - content: 'some-command', - }, - }), - userMsg('user message after rejected assistant turn here ok'), - ].join('\n'); - - const { userSignals, dialogPairs } = extractChannels(input); - // User message still appears in signals - expect(userSignals).toHaveLength(1); - // But no dialog pair because assistant turn was filtered - expect(dialogPairs).toHaveLength(0); - }); - - it('builds multiple dialog pairs correctly', () => { - const input = [ - assistantMsg("I'll update the file and amend the commit for you right now."), - userMsg("don't amend pushed commits. Create a new one."), - assistantMsg("Understood. I'll create a new commit with the changes needed."), - userMsg("correct — thank you for confirming that approach"), - ].join('\n'); - - const { dialogPairs } = extractChannels(input); - expect(dialogPairs).toHaveLength(2); - expect(dialogPairs[0].prior).toContain("amend the commit"); - expect(dialogPairs[0].user).toContain("don't amend pushed commits"); - expect(dialogPairs[1].prior).toContain("new commit"); - expect(dialogPairs[1].user).toContain("thank you"); - }); -}); - -describe('extractChannels — caps and limits', () => { - it('caps text to 1200 chars per turn', () => { - const longText = 'x'.repeat(2000); - const input = userMsg(longText); - - const { userSignals } = extractChannels(input); - expect(userSignals).toHaveLength(1); - expect(userSignals[0].length).toBe(1200); - }); - - it('caps to last 80 turns when more are present', () => { - // Create 90 user messages - const lines: string[] = []; - for (let i = 0; i < 90; i++) { - lines.push(userMsg(`user message number ${i} which is valid and long enough`)); - } - - const { userSignals } = extractChannels(lines.join('\n')); - // Should have at most 80 turns worth of signals - expect(userSignals.length).toBeLessThanOrEqual(80); - }); - - it('handles empty input gracefully', () => { - const { userSignals, dialogPairs } = extractChannels(''); - expect(userSignals).toHaveLength(0); - expect(dialogPairs).toHaveLength(0); - }); - - it('handles input with only blank lines', () => { - const { userSignals, dialogPairs } = extractChannels('\n\n\n'); - expect(userSignals).toHaveLength(0); - expect(dialogPairs).toHaveLength(0); - }); -}); diff --git a/tests/learning/merge-observation.test.ts b/tests/learning/merge-observation.test.ts deleted file mode 100644 index 78d56e4a..00000000 --- a/tests/learning/merge-observation.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -// tests/learning/merge-observation.test.ts -// Tests for the `merge-observation` op. -// Phase 3: ID-keyed lookup only — pattern-based dedup removed. -// The LLM now decides dedup by submitting matching obs IDs. -// D11: ID collision recovery (_b suffix). -// D12: evidence capped at 10 (FIFO). - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { runHelper } from './helpers.js'; - -function readLog(logPath: string): Record[] { - if (!fs.existsSync(logPath)) return []; - return fs.readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l)); -} - -const NOW = new Date().toISOString(); - -function baseLogEntry(id: string, type = 'workflow', extra: Record = {}): Record { - return { - id, type, - pattern: 'deploy workflow pattern name', - confidence: 0.33, - observations: 1, - first_seen: NOW, - last_seen: NOW, - status: 'observing', - evidence: ['first evidence item here'], - details: 'step 1, step 2, step 3', - quality_ok: false, - ...extra, - }; -} - -describe('merge-observation — id-keyed reinforce', () => { - let tmpDir: string; - let logFile: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-obs-test-')); - logFile = path.join(tmpDir, 'learning-log.jsonl'); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('E1: self-creates parent dir and log file when both are absent (fresh project)', () => { - // D54: merge-observation creates parent directory on first write, matching - // the behaviour process-observations had. Callers do not need to pre-create the log dir. - const deepLogDir = path.join(tmpDir, 'nested', 'subdir'); - const deepLogFile = path.join(deepLogDir, 'learning-log.jsonl'); - // Neither directory nor file exists yet. - expect(fs.existsSync(deepLogDir)).toBe(false); - - const newObs = JSON.stringify({ - id: 'obs_e1fresh', - type: 'workflow', - pattern: 'first write on fresh project', - evidence: ['initial evidence'], - details: 'step 1', - quality_ok: true, - confidence: 0.5, - status: 'observing', - }); - - runHelper(`merge-observation "${deepLogFile}" '${newObs}'`); - - expect(fs.existsSync(deepLogDir)).toBe(true); - expect(fs.existsSync(deepLogFile)).toBe(true); - const entries = readLog(deepLogFile); - expect(entries).toHaveLength(1); - expect(entries[0]['id']).toBe('obs_e1fresh'); - }); - - it('reinforces existing entry when id matches', () => { - fs.writeFileSync(logFile, JSON.stringify(baseLogEntry('obs_m001')) + '\n'); - - const newObs = JSON.stringify({ - id: 'obs_m001', // same id → reinforce - type: 'workflow', - pattern: 'deploy workflow pattern name', - evidence: ['second evidence item added'], - details: 'step 1, step 2, step 3', - quality_ok: false, - confidence: 0.50, - status: 'observing', - }); - - const result = JSON.parse(runHelper(`merge-observation "${logFile}" '${newObs}'`)); - expect(result.merged).toBe(true); - expect(result.id).toBe('obs_m001'); - - const entries = readLog(logFile); - expect(entries).toHaveLength(1); // no duplicate - expect(entries[0].observations).toBe(2); - expect(entries[0].evidence).toContain('first evidence item here'); - expect(entries[0].evidence).toContain('second evidence item added'); - // LLM-provided confidence stored verbatim - expect(entries[0].confidence).toBe(0.50); - }); - - it('creates new entry when id does not match any existing', () => { - fs.writeFileSync(logFile, JSON.stringify(baseLogEntry('obs_m001')) + '\n'); - - const newObs = JSON.stringify({ - id: 'obs_m002', // different id → new entry - type: 'workflow', - pattern: 'completely different workflow', - evidence: ['unrelated evidence'], - details: 'different steps', - quality_ok: true, - confidence: 0.75, - status: 'ready', - }); - - const result = JSON.parse(runHelper(`merge-observation "${logFile}" '${newObs}'`)); - expect(result.merged).toBe(false); - - const entries = readLog(logFile); - expect(entries).toHaveLength(2); - const newEntry = entries.find(e => e['id'] === 'obs_m002'); - expect(newEntry).toBeDefined(); - expect(newEntry!['confidence']).toBe(0.75); - expect(newEntry!['status']).toBe('ready'); - }); - - it('caps evidence at 10 items (FIFO cap, D12)', () => { - const existing = { - ...baseLogEntry('obs_evid001'), - evidence: Array.from({ length: 9 }, (_, i) => `existing item ${i + 1}`), - }; - fs.writeFileSync(logFile, JSON.stringify(existing) + '\n'); - - const newObs = JSON.stringify({ - id: 'obs_evid001', - type: 'workflow', - pattern: 'deploy workflow pattern name', - evidence: ['new item A', 'new item B', 'new item C'], - details: 'step 1, step 2, step 3', - quality_ok: false, - }); - - runHelper(`merge-observation "${logFile}" '${newObs}'`); - - const entries = readLog(logFile); - expect(entries[0].evidence as string[]).toHaveLength(10); - }); - - it('ID collision recovery: same ID already exists with different type → _b suffix (D11)', () => { - // Existing entry with obs_col001 type=workflow - fs.writeFileSync(logFile, JSON.stringify(baseLogEntry('obs_col001', 'workflow')) + '\n'); - - // New obs with same ID but different type — ID exists but type differs → collision - const newObs = JSON.stringify({ - id: 'obs_col001', // collision - type: 'procedural', // different type - pattern: 'debug hook failures procedure', - evidence: ['when debugging, check lock'], - details: 'procedure steps', - quality_ok: true, - }); - - const result = JSON.parse(runHelper(`merge-observation "${logFile}" '${newObs}'`)); - // The id obs_col001 EXISTS in the map, so it merges (same id-keyed lookup) - // unless types differ — let's check actual behavior - const entries = readLog(logFile); - // obs_col001 already exists — id lookup finds it regardless of type - // The new impl: const existing = logMap.get(newObs.id) — finds the workflow entry - // So merged=true (id match) even across types - expect(result.id).toBeDefined(); - }); -}); - -describe('merge-observation — quality_ok sticky', () => { - let tmpDir: string; - let logFile: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-sticky-test-')); - logFile = path.join(tmpDir, 'learning-log.jsonl'); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('quality_ok stays true even if new obs says false', () => { - const existing = baseLogEntry('obs_qok001', 'workflow', { quality_ok: true }); - fs.writeFileSync(logFile, JSON.stringify(existing) + '\n'); - - const newObs = JSON.stringify({ - id: 'obs_qok001', - type: 'workflow', - pattern: 'deploy workflow pattern name', - evidence: ['new evidence'], - details: 'step 1, step 2, step 3', - quality_ok: false, - }); - - runHelper(`merge-observation "${logFile}" '${newObs}'`); - - const entries = readLog(logFile); - expect(entries[0].quality_ok).toBe(true); // sticky - }); - - it('quality_ok set to true when incoming obs has quality_ok=true', () => { - const existing = baseLogEntry('obs_qok002', 'workflow', { quality_ok: false }); - fs.writeFileSync(logFile, JSON.stringify(existing) + '\n'); - - const newObs = JSON.stringify({ - id: 'obs_qok002', - type: 'workflow', - pattern: 'deploy workflow pattern name', - evidence: ['new evidence'], - details: 'step 1, step 2, step 3', - quality_ok: true, - }); - - runHelper(`merge-observation "${logFile}" '${newObs}'`); - - const entries = readLog(logFile); - expect(entries[0].quality_ok).toBe(true); - }); -}); - -describe('merge-observation — LLM-provided fields stored verbatim', () => { - let tmpDir: string; - let logFile: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-llm-test-')); - logFile = path.join(tmpDir, 'learning-log.jsonl'); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('new entry: stores LLM-provided confidence and status verbatim', () => { - const newObs = JSON.stringify({ - id: 'obs_llm001', - type: 'decision', - pattern: 'use X over Y', - evidence: ['user said so'], - details: 'context: X; decision: X over Y; rationale: performance', - quality_ok: true, - confidence: 0.90, - status: 'ready', - }); - - runHelper(`merge-observation "${logFile}" '${newObs}'`); - - const entries = readLog(logFile); - expect(entries[0].confidence).toBe(0.90); - expect(entries[0].status).toBe('ready'); - }); - - it('reinforce: updates confidence and status from new LLM-provided values', () => { - const existing = baseLogEntry('obs_llm002', 'decision', { - confidence: 0.33, - status: 'observing', - }); - fs.writeFileSync(logFile, JSON.stringify(existing) + '\n'); - - const newObs = JSON.stringify({ - id: 'obs_llm002', - type: 'decision', - pattern: 'deploy workflow pattern name', - evidence: ['evidence 2'], - details: 'step 1, step 2, step 3', - quality_ok: true, - confidence: 0.95, - status: 'ready', - }); - - runHelper(`merge-observation "${logFile}" '${newObs}'`); - - const entries = readLog(logFile); - expect(entries[0].confidence).toBe(0.95); - expect(entries[0].status).toBe('ready'); - }); -}); diff --git a/tests/learning/review-command.test.ts b/tests/learning/review-command.test.ts index d26c860f..05f0ea11 100644 --- a/tests/learning/review-command.test.ts +++ b/tests/learning/review-command.test.ts @@ -174,53 +174,6 @@ describe('observation attention flags detection', () => { }); }); -describe('count-active op (ledger-based)', () => { - // Phase 8: count-active now reads from decisions-ledger.jsonl exclusively. - // The legacy .md-file-path calling convention (count-active type) - // has been removed since all projects are now on the ledger model. - - let tmpDir: string; - let decisionsDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-review-')); - decisionsDir = path.join(tmpDir, '.devflow', 'decisions'); - fs.mkdirSync(decisionsDir, { recursive: true }); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - function writeLedger(rows: object[]): void { - const ledgerPath = path.join(decisionsDir, 'decisions-ledger.jsonl'); - fs.writeFileSync(ledgerPath, rows.map(r => JSON.stringify(r)).join('\n') + '\n', 'utf8'); - } - - it('count-active counts Accepted decision anchors from ledger', () => { - writeLedger([ - { anchor_id: 'ADR-001', type: 'decision', pattern: 'Active entry', decisions_status: 'Accepted' }, - { anchor_id: 'ADR-002', type: 'decision', pattern: 'Another active', decisions_status: 'Accepted' }, - ]); - const result = JSON.parse(runHelper(`count-active "${tmpDir}" decision`)); - expect(result.count).toBe(2); - }); - - it('count-active returns 0 when ledger is absent', () => { - // No ledger file — absent ledger means 0 active - const result = JSON.parse(runHelper(`count-active "${tmpDir}" decision`)); - expect(result.count).toBe(0); - }); - - it('count-active counts Active pitfall anchors from ledger', () => { - writeLedger([ - { anchor_id: 'PF-001', type: 'pitfall', pattern: 'Active pitfall', decisions_status: 'Active' }, - ]); - const result = JSON.parse(runHelper(`count-active "${tmpDir}" pitfall`)); - expect(result.count).toBe(1); - }); -}); - describe('--dismiss-capacity notification', () => { let tmpDir: string; let memoryDir: string; @@ -255,8 +208,7 @@ describe('--dismiss-capacity notification', () => { }); describe('staleness filter (mayBeStale only)', () => { - // After removal of needsReview and softCapExceeded, only mayBeStale remains as a - // stale-detection flag. The LLM Dream agent uses staleness.cjs output as a + // mayBeStale is the sole stale-detection flag on an observation. It is a // signal (not auto-deletion) to deprioritize reinforcing observations whose // referenced files are missing. diff --git a/tests/learning/staleness.test.ts b/tests/learning/staleness.test.ts deleted file mode 100644 index fc706e98..00000000 --- a/tests/learning/staleness.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -// tests/learning/staleness.test.ts -// Tests for staleness pass in the learning pipeline (D16). -// Imports the real checkStaleEntries from scripts/hooks/lib/staleness.cjs — the -// shared implementation used by devflow learn --run-background — so tests exercise -// the actual algorithm rather than a TypeScript reimplementation. - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { createRequire } from 'module'; - -const require = createRequire(import.meta.url); -const { checkStaleEntries } = require('../../scripts/hooks/lib/staleness.cjs') as { - checkStaleEntries: (entries: Record[], cwd: string) => Record[]; -}; - -describe('staleness detection (D16)', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'staleness-test-')); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('flags entry as stale when referenced file is deleted', () => { - // Create a file that will be referenced - const refFile = path.join(tmpDir, 'src', 'hooks.ts'); - fs.mkdirSync(path.dirname(refFile), { recursive: true }); - fs.writeFileSync(refFile, '// hook code\n'); - - const entries = [{ - id: 'obs_stale001', - type: 'procedural', - pattern: 'debug hooks', - details: 'Check src/hooks.ts for hook definitions', - evidence: ['look at src/hooks.ts first'], - status: 'observing', - }]; - - // Verify NOT stale when file exists - const before = checkStaleEntries(entries, tmpDir); - expect(before[0].mayBeStale).toBeUndefined(); - - // Delete the file - fs.unlinkSync(refFile); - - // Now should be stale - const after = checkStaleEntries(entries, tmpDir); - expect(after[0].mayBeStale).toBe(true); - expect(after[0].staleReason).toContain('code-ref-missing:'); - expect(after[0].staleReason).toContain('hooks.ts'); - }); - - it('does not flag entry when all referenced files exist', () => { - const refFile = path.join(tmpDir, 'scripts', 'deploy.sh'); - fs.mkdirSync(path.dirname(refFile), { recursive: true }); - fs.writeFileSync(refFile, '#!/bin/bash\n'); - - const entries = [{ - id: 'obs_no_stale', - type: 'workflow', - pattern: 'run deploy script', - details: 'Execute scripts/deploy.sh with proper flags', - evidence: ['run scripts/deploy.sh after tests'], - status: 'created', - }]; - - const result = checkStaleEntries(entries, tmpDir); - expect(result[0].mayBeStale).toBeUndefined(); - expect(result[0].staleReason).toBeUndefined(); - }); - - it('does not flag entry with no file references', () => { - const entries = [{ - id: 'obs_no_refs', - type: 'decision', - pattern: 'use async functions', - details: 'context: performance; decision: use async; rationale: non-blocking', - evidence: ['async is better because non-blocking'], - status: 'observing', - }]; - - const result = checkStaleEntries(entries, tmpDir); - expect(result[0].mayBeStale).toBeUndefined(); - }); - - it('picks up file references from evidence array as well as details', () => { - // Only referenced in evidence, not details - const refFile = path.join(tmpDir, 'config.md'); - fs.writeFileSync(refFile, '# Config\n'); - - const entries = [{ - id: 'obs_evid_ref', - type: 'procedural', - pattern: 'update config', - details: 'No file reference here', - evidence: ['always edit config.md before deploying'], - status: 'observing', - }]; - - // File exists — not stale - const before = checkStaleEntries(entries, tmpDir); - expect(before[0].mayBeStale).toBeUndefined(); - - fs.unlinkSync(refFile); - - // File deleted — stale - const after = checkStaleEntries(entries, tmpDir); - expect(after[0].mayBeStale).toBe(true); - expect(after[0].staleReason).toContain('config.md'); - }); - - it('handles entries with multiple file refs — flags on first missing', () => { - const existingFile = path.join(tmpDir, 'exists.ts'); - fs.writeFileSync(existingFile, '// exists\n'); - // missing.ts is intentionally not created - - const entries = [{ - id: 'obs_multi_ref', - type: 'procedural', - pattern: 'multi-file workflow', - details: 'Modify exists.ts then update missing.ts accordingly', - evidence: ['both exists.ts and missing.ts need changes'], - status: 'observing', - }]; - - const result = checkStaleEntries(entries, tmpDir); - expect(result[0].mayBeStale).toBe(true); - expect(result[0].staleReason).toContain('missing.ts'); - }); -}); - -// process-observations integration block removed in Part A — op was removed (zero -// production callers). The staleness signal is now consumed directly by the LLM -// Dream agent via staleness.cjs invocation during the learning phase. See shared/agents/dream.md. diff --git a/tests/memory.test.ts b/tests/memory.test.ts index a9ed699e..18808417 100644 --- a/tests/memory.test.ts +++ b/tests/memory.test.ts @@ -6,37 +6,32 @@ import { exec } from 'child_process'; import { addMemoryHooks, removeMemoryHooks, hasMemoryHooks, countMemoryHooks, cleanQueueFiles, hasMemoryDir, filterProjectsWithMemory } from '../src/cli/commands/memory.js'; describe('addMemoryHooks', () => { - it('adds all 5 dream hook types to empty settings', () => { + it('adds all 3 memory hook types to empty settings', () => { const result = addMemoryHooks('{}', '/home/user/.devflow'); const settings = JSON.parse(result); - expect(settings.hooks.UserPromptSubmit).toHaveLength(1); expect(settings.hooks.Stop).toHaveLength(1); - expect(settings.hooks.SessionEnd).toHaveLength(1); expect(settings.hooks.SessionStart).toHaveLength(1); expect(settings.hooks.PreCompact).toHaveLength(1); - expect(settings.hooks.UserPromptSubmit[0].hooks[0].command).toContain('dream-dispatch'); - expect(settings.hooks.Stop[0].hooks[0].command).toContain('dream-capture'); - expect(settings.hooks.SessionEnd[0].hooks[0].command).toContain('dream-evaluate'); + expect(settings.hooks.UserPromptSubmit).toBeUndefined(); + expect(settings.hooks.SessionEnd).toBeUndefined(); + expect(settings.hooks.Stop[0].hooks[0].command).toContain('memory-worker'); expect(settings.hooks.SessionStart[0].hooks[0].command).toContain('session-start-memory'); expect(settings.hooks.PreCompact[0].hooks[0].command).toContain('pre-compact-memory'); }); - it('preserves existing ambient preamble hook when adding dream hooks', () => { + it('does not touch UserPromptSubmit — owned by capture.ts, not memory.ts', () => { const input = JSON.stringify({ hooks: { - UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'preamble' }] }], + UserPromptSubmit: [{ hooks: [{ type: 'command', command: '/path/run-hook preamble' }] }], }, }); const result = addMemoryHooks(input, '/home/user/.devflow'); const settings = JSON.parse(result); - // Ambient preamble preserved alongside dream-dispatch - expect(settings.hooks.UserPromptSubmit).toHaveLength(2); - expect(settings.hooks.UserPromptSubmit[0].hooks[0].command).toBe('preamble'); - expect(settings.hooks.UserPromptSubmit[1].hooks[0].command).toContain('dream-dispatch'); + expect(settings.hooks.UserPromptSubmit).toHaveLength(1); + expect(settings.hooks.UserPromptSubmit[0].hooks[0].command).toContain('preamble'); expect(settings.hooks.Stop).toHaveLength(1); - expect(settings.hooks.SessionEnd).toHaveLength(1); expect(settings.hooks.SessionStart).toHaveLength(1); expect(settings.hooks.PreCompact).toHaveLength(1); }); @@ -51,9 +46,7 @@ describe('addMemoryHooks', () => { it('adds only missing hooks when partial state (1 hook missing)', () => { const input = JSON.stringify({ hooks: { - UserPromptSubmit: [{ hooks: [{ type: 'command', command: '/path/dream-dispatch', timeout: 10 }] }], - Stop: [{ hooks: [{ type: 'command', command: '/path/dream-capture', timeout: 10 }] }], - SessionEnd: [{ hooks: [{ type: 'command', command: '/path/dream-evaluate', timeout: 10 }] }], + Stop: [{ hooks: [{ type: 'command', command: '/path/memory-worker', timeout: 10 }] }], SessionStart: [{ hooks: [{ type: 'command', command: '/path/session-start-memory', timeout: 10 }] }], }, }); @@ -61,41 +54,13 @@ describe('addMemoryHooks', () => { const settings = JSON.parse(result); // Existing hooks preserved - expect(settings.hooks.UserPromptSubmit).toHaveLength(1); expect(settings.hooks.Stop).toHaveLength(1); - expect(settings.hooks.SessionEnd).toHaveLength(1); expect(settings.hooks.SessionStart).toHaveLength(1); // Missing hook added expect(settings.hooks.PreCompact).toHaveLength(1); expect(settings.hooks.PreCompact[0].hooks[0].command).toContain('pre-compact-memory'); }); - it('adds UserPromptSubmit dream-dispatch alongside existing preamble', () => { - // Simulate a partial install that already has ambient preamble - const input = JSON.stringify({ - hooks: { - UserPromptSubmit: [{ hooks: [{ type: 'command', command: '/path/preamble' }] }], - Stop: [{ hooks: [{ type: 'command', command: '/path/dream-capture', timeout: 10 }] }], - SessionEnd: [{ hooks: [{ type: 'command', command: '/path/dream-evaluate', timeout: 10 }] }], - SessionStart: [{ hooks: [{ type: 'command', command: '/path/session-start-memory', timeout: 10 }] }], - PreCompact: [{ hooks: [{ type: 'command', command: '/path/pre-compact-memory', timeout: 10 }] }], - }, - }); - const result = addMemoryHooks(input, '/home/user/.devflow'); - const settings = JSON.parse(result); - - // dream-dispatch added; preamble kept - expect(settings.hooks.UserPromptSubmit).toHaveLength(2); - const commands = settings.hooks.UserPromptSubmit.map((m: { hooks: { command: string }[] }) => m.hooks[0].command); - expect(commands.some((c: string) => c.includes('preamble'))).toBe(true); - expect(commands.some((c: string) => c.includes('dream-dispatch'))).toBe(true); - // Other hooks unchanged - expect(settings.hooks.Stop).toHaveLength(1); - expect(settings.hooks.SessionEnd).toHaveLength(1); - expect(settings.hooks.SessionStart).toHaveLength(1); - expect(settings.hooks.PreCompact).toHaveLength(1); - }); - it('creates hooks object if missing', () => { const input = JSON.stringify({ statusLine: { type: 'command' } }); const result = addMemoryHooks(input, '/home/user/.devflow'); @@ -109,12 +74,8 @@ describe('addMemoryHooks', () => { const result = addMemoryHooks('{}', '/custom/path/.devflow'); const settings = JSON.parse(result); - expect(settings.hooks.UserPromptSubmit[0].hooks[0].command).toContain('/custom/path/.devflow/scripts/hooks/run-hook'); - expect(settings.hooks.UserPromptSubmit[0].hooks[0].command).toContain('dream-dispatch'); expect(settings.hooks.Stop[0].hooks[0].command).toContain('/custom/path/.devflow/scripts/hooks/run-hook'); - expect(settings.hooks.Stop[0].hooks[0].command).toContain('dream-capture'); - expect(settings.hooks.SessionEnd[0].hooks[0].command).toContain('run-hook'); - expect(settings.hooks.SessionEnd[0].hooks[0].command).toContain('dream-evaluate'); + expect(settings.hooks.Stop[0].hooks[0].command).toContain('memory-worker'); expect(settings.hooks.SessionStart[0].hooks[0].command).toContain('run-hook'); expect(settings.hooks.SessionStart[0].hooks[0].command).toContain('session-start-memory'); expect(settings.hooks.PreCompact[0].hooks[0].command).toContain('run-hook'); @@ -138,16 +99,14 @@ describe('addMemoryHooks', () => { const result = addMemoryHooks('{}', '/home/user/.devflow'); const settings = JSON.parse(result); - expect(settings.hooks.UserPromptSubmit[0].hooks[0].timeout).toBe(10); expect(settings.hooks.Stop[0].hooks[0].timeout).toBe(10); - expect(settings.hooks.SessionEnd[0].hooks[0].timeout).toBe(10); expect(settings.hooks.SessionStart[0].hooks[0].timeout).toBe(10); expect(settings.hooks.PreCompact[0].hooks[0].timeout).toBe(10); }); }); describe('removeMemoryHooks', () => { - it('removes all 5 dream hook types', () => { + it('removes all 3 memory hook types', () => { const withHooks = addMemoryHooks('{}', '/home/user/.devflow'); const result = removeMemoryHooks(withHooks); const settings = JSON.parse(result); @@ -155,29 +114,22 @@ describe('removeMemoryHooks', () => { expect(settings.hooks).toBeUndefined(); }); - it('preserves ambient preamble when removing dream hooks', () => { + it('does not touch UserPromptSubmit — owned by capture.ts, not memory.ts', () => { const input = JSON.stringify({ hooks: { UserPromptSubmit: [ - { hooks: [{ type: 'command', command: 'preamble' }] }, - { hooks: [{ type: 'command', command: '/path/dream-dispatch' }] }, + { hooks: [{ type: 'command', command: '/path/run-hook preamble' }] }, + { hooks: [{ type: 'command', command: '/path/run-hook capture-prompt' }] }, ], - Stop: [{ hooks: [{ type: 'command', command: '/path/dream-capture' }] }], - SessionEnd: [{ hooks: [{ type: 'command', command: '/path/dream-evaluate' }] }], - SessionStart: [{ hooks: [{ type: 'command', command: '/path/session-start-memory' }] }], - PreCompact: [{ hooks: [{ type: 'command', command: '/path/pre-compact-memory' }] }], + Stop: [{ hooks: [{ type: 'command', command: '/path/memory-worker' }] }], }, }); const result = removeMemoryHooks(input); const settings = JSON.parse(result); - // Ambient preamble preserved; dream-dispatch removed - expect(settings.hooks.UserPromptSubmit).toHaveLength(1); - expect(settings.hooks.UserPromptSubmit[0].hooks[0].command).toBe('preamble'); + // UserPromptSubmit is untouched — both entries survive; only Stop is cleared + expect(settings.hooks.UserPromptSubmit).toHaveLength(2); expect(settings.hooks.Stop).toBeUndefined(); - expect(settings.hooks.SessionEnd).toBeUndefined(); - expect(settings.hooks.SessionStart).toBeUndefined(); - expect(settings.hooks.PreCompact).toBeUndefined(); }); it('is idempotent — safe to call when not present', () => { @@ -192,7 +144,7 @@ describe('removeMemoryHooks', () => { it('cleans empty hook type arrays', () => { const input = JSON.stringify({ hooks: { - Stop: [{ hooks: [{ type: 'command', command: '/path/dream-capture' }] }], + Stop: [{ hooks: [{ type: 'command', command: '/path/memory-worker' }] }], }, }); const result = removeMemoryHooks(input); @@ -212,8 +164,8 @@ describe('removeMemoryHooks', () => { it('removes only the hooks that exist (partial)', () => { const input = JSON.stringify({ hooks: { - Stop: [{ hooks: [{ type: 'command', command: '/path/dream-capture' }] }], - // UserPromptSubmit, SessionEnd, SessionStart, PreCompact already missing + Stop: [{ hooks: [{ type: 'command', command: '/path/memory-worker' }] }], + // SessionStart, PreCompact already missing }, }); const result = removeMemoryHooks(input); @@ -226,9 +178,7 @@ describe('removeMemoryHooks', () => { const input = JSON.stringify({ statusLine: { type: 'command' }, hooks: { - UserPromptSubmit: [{ hooks: [{ type: 'command', command: '/path/dream-dispatch' }] }], - Stop: [{ hooks: [{ type: 'command', command: '/path/dream-capture' }] }], - SessionEnd: [{ hooks: [{ type: 'command', command: '/path/dream-evaluate' }] }], + Stop: [{ hooks: [{ type: 'command', command: '/path/memory-worker' }] }], SessionStart: [{ hooks: [{ type: 'command', command: '/path/session-start-memory' }] }], PreCompact: [{ hooks: [{ type: 'command', command: '/path/pre-compact-memory' }] }], }, @@ -245,16 +195,14 @@ describe('removeMemoryHooks', () => { const reEnabled = addMemoryHooks(disabled, '/home/user/.devflow'); const settings = JSON.parse(reEnabled); - expect(settings.hooks.UserPromptSubmit).toHaveLength(1); expect(settings.hooks.Stop).toHaveLength(1); - expect(settings.hooks.SessionEnd).toHaveLength(1); expect(settings.hooks.SessionStart).toHaveLength(1); expect(settings.hooks.PreCompact).toHaveLength(1); }); }); describe('hasMemoryHooks', () => { - it('returns true when all 5 dream hooks present', () => { + it('returns true when all 3 memory hooks present', () => { const withHooks = addMemoryHooks('{}', '/home/user/.devflow'); expect(hasMemoryHooks(withHooks)).toBe(true); }); @@ -263,28 +211,26 @@ describe('hasMemoryHooks', () => { expect(hasMemoryHooks('{}')).toBe(false); }); - it('returns false when partial (1 of 5)', () => { + it('returns false when partial (1 of 3)', () => { const input = JSON.stringify({ hooks: { - Stop: [{ hooks: [{ type: 'command', command: '/path/dream-capture' }] }], + Stop: [{ hooks: [{ type: 'command', command: '/path/memory-worker' }] }], }, }); expect(hasMemoryHooks(input)).toBe(false); }); - it('returns false when partial (4 of 5 — missing SessionEnd)', () => { + it('returns false when partial (2 of 3 — missing PreCompact)', () => { const input = JSON.stringify({ hooks: { - UserPromptSubmit: [{ hooks: [{ type: 'command', command: '/path/dream-dispatch' }] }], - Stop: [{ hooks: [{ type: 'command', command: '/path/dream-capture' }] }], + Stop: [{ hooks: [{ type: 'command', command: '/path/memory-worker' }] }], SessionStart: [{ hooks: [{ type: 'command', command: '/path/session-start-memory' }] }], - PreCompact: [{ hooks: [{ type: 'command', command: '/path/pre-compact-memory' }] }], }, }); expect(hasMemoryHooks(input)).toBe(false); }); - it('returns false for ambient preamble only (not a dream hook)', () => { + it('returns false when only unrelated hooks (e.g. ambient preamble) are present', () => { const input = JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'preamble' }] }], @@ -295,37 +241,35 @@ describe('hasMemoryHooks', () => { }); describe('countMemoryHooks', () => { - it('returns 5 when all dream hooks present', () => { + it('returns 3 when all memory hooks present', () => { const withHooks = addMemoryHooks('{}', '/home/user/.devflow'); - expect(countMemoryHooks(withHooks)).toBe(5); + expect(countMemoryHooks(withHooks)).toBe(3); }); it('returns 0 when none present', () => { expect(countMemoryHooks('{}')).toBe(0); }); - it('returns correct partial count (2 of 5)', () => { + it('returns correct partial count (2 of 3)', () => { const input = JSON.stringify({ hooks: { - Stop: [{ hooks: [{ type: 'command', command: '/path/dream-capture' }] }], + Stop: [{ hooks: [{ type: 'command', command: '/path/memory-worker' }] }], SessionStart: [{ hooks: [{ type: 'command', command: '/path/session-start-memory' }] }], }, }); expect(countMemoryHooks(input)).toBe(2); }); - it('does not count ambient preamble as dream-dispatch', () => { + it('does not count unrelated UserPromptSubmit hooks toward the memory count', () => { const input = JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: '/path/preamble' }] }], - Stop: [{ hooks: [{ type: 'command', command: '/path/dream-capture' }] }], - SessionEnd: [{ hooks: [{ type: 'command', command: '/path/dream-evaluate' }] }], + Stop: [{ hooks: [{ type: 'command', command: '/path/memory-worker' }] }], SessionStart: [{ hooks: [{ type: 'command', command: '/path/session-start-memory' }] }], PreCompact: [{ hooks: [{ type: 'command', command: '/path/pre-compact-memory' }] }], }, }); - // preamble does not match 'dream-dispatch' marker - expect(countMemoryHooks(input)).toBe(4); + expect(countMemoryHooks(input)).toBe(3); }); }); @@ -333,14 +277,12 @@ describe('countMemoryHooks accepts parsed Settings', () => { it('accepts a parsed Settings object (not just JSON string)', () => { const settings = { hooks: { - UserPromptSubmit: [{ hooks: [{ type: 'command' as const, command: '/path/dream-dispatch', timeout: 10 }] }], - Stop: [{ hooks: [{ type: 'command' as const, command: '/path/dream-capture', timeout: 10 }] }], - SessionEnd: [{ hooks: [{ type: 'command' as const, command: '/path/dream-evaluate', timeout: 10 }] }], + Stop: [{ hooks: [{ type: 'command' as const, command: '/path/memory-worker', timeout: 10 }] }], SessionStart: [{ hooks: [{ type: 'command' as const, command: '/path/session-start-memory', timeout: 10 }] }], PreCompact: [{ hooks: [{ type: 'command' as const, command: '/path/pre-compact-memory', timeout: 10 }] }], }, }; - expect(countMemoryHooks(settings)).toBe(5); + expect(countMemoryHooks(settings)).toBe(3); expect(hasMemoryHooks(settings)).toBe(true); }); @@ -353,7 +295,7 @@ describe('countMemoryHooks accepts parsed Settings', () => { it('accepts parsed Settings with partial hooks', () => { const settings = { hooks: { - Stop: [{ hooks: [{ type: 'command' as const, command: '/path/dream-capture', timeout: 10 }] }], + Stop: [{ hooks: [{ type: 'command' as const, command: '/path/memory-worker', timeout: 10 }] }], SessionStart: [{ hooks: [{ type: 'command' as const, command: '/path/session-start-memory', timeout: 10 }] }], }, }; @@ -518,9 +460,7 @@ describe('removeMemoryHooks accepts parsed Settings', () => { it('accepts a parsed Settings object and returns JSON string', () => { const settings = { hooks: { - UserPromptSubmit: [{ hooks: [{ type: 'command' as const, command: '/path/dream-dispatch', timeout: 10 }] }], - Stop: [{ hooks: [{ type: 'command' as const, command: '/path/dream-capture', timeout: 10 }] }], - SessionEnd: [{ hooks: [{ type: 'command' as const, command: '/path/dream-evaluate', timeout: 10 }] }], + Stop: [{ hooks: [{ type: 'command' as const, command: '/path/memory-worker', timeout: 10 }] }], SessionStart: [{ hooks: [{ type: 'command' as const, command: '/path/session-start-memory', timeout: 10 }] }], PreCompact: [{ hooks: [{ type: 'command' as const, command: '/path/pre-compact-memory', timeout: 10 }] }], }, @@ -533,7 +473,7 @@ describe('removeMemoryHooks accepts parsed Settings', () => { it('does not mutate the original Settings object when passed by reference', () => { const settings = { hooks: { - Stop: [{ hooks: [{ type: 'command' as const, command: '/path/dream-capture', timeout: 10 }] }], + Stop: [{ hooks: [{ type: 'command' as const, command: '/path/memory-worker', timeout: 10 }] }], }, }; removeMemoryHooks(settings); @@ -544,7 +484,7 @@ describe('removeMemoryHooks accepts parsed Settings', () => { it('consistent API: string and Settings produce same result', () => { const settingsObj = { hooks: { - Stop: [{ hooks: [{ type: 'command' as const, command: '/path/dream-capture', timeout: 10 }] }], + Stop: [{ hooks: [{ type: 'command' as const, command: '/path/memory-worker', timeout: 10 }] }], }, }; const resultFromObj = removeMemoryHooks(settingsObj); @@ -667,8 +607,8 @@ describe('session-start-context hook integration', () => { }); }); -describe('removeMemoryHooks removes legacy pre-sidecar hooks', () => { - it('removes sidecar-dispatch from UserPromptSubmit (v3 sidecar→dream rename)', () => { +describe('removeMemoryHooks removes legacy hook registrations', () => { + it('removes sidecar-dispatch from UserPromptSubmit (v3 sidecar→dream rename) — legacy sweep only, UserPromptSubmit untouched otherwise', () => { const input = JSON.stringify({ hooks: { UserPromptSubmit: [ @@ -750,18 +690,46 @@ describe('removeMemoryHooks removes legacy pre-sidecar hooks', () => { { hooks: [{ type: 'command', command: '/path/run-hook session-end-learning', timeout: 10 }] }, { hooks: [{ type: 'command', command: '/path/run-hook session-end-decisions', timeout: 10 }] }, { hooks: [{ type: 'command', command: '/path/run-hook session-end-knowledge-refresh', timeout: 10 }] }, - { hooks: [{ type: 'command', command: '/path/run-hook dream-evaluate', timeout: 10 }] }, ], }, }); const result = removeMemoryHooks(input); const settings = JSON.parse(result); - // Legacy hooks removed; dream-evaluate (new hook) removed by MEMORY_HOOK_CONFIG + // Legacy pre-sidecar SessionEnd hooks removed via LEGACY_HOOK_MARKERS expect(settings.hooks).toBeUndefined(); }); - it('handles mix of old and new hooks — removes all', () => { + it('removes the dream-dispatch/dream-capture/dream-evaluate marker pipeline (dream system simplification)', () => { + // Upgrading users still carry the pre-cutover dream-* hook registrations — + // these are swept via LEGACY_HOOK_MARKERS, not MEMORY_HOOK_CONFIG (which no + // longer includes UserPromptSubmit or SessionEnd at all). + const input = JSON.stringify({ + hooks: { + UserPromptSubmit: [ + { hooks: [{ type: 'command', command: '/path/run-hook dream-dispatch', timeout: 10 }] }, + { hooks: [{ type: 'command', command: '/path/run-hook preamble', timeout: 10 }] }, + ], + Stop: [ + { hooks: [{ type: 'command', command: '/path/run-hook dream-capture', timeout: 10 }] }, + ], + SessionEnd: [ + { hooks: [{ type: 'command', command: '/path/run-hook dream-evaluate', timeout: 10 }] }, + ], + }, + }); + const result = removeMemoryHooks(input); + const settings = JSON.parse(result); + + // dream-dispatch removed; preamble preserved + expect(settings.hooks.UserPromptSubmit).toHaveLength(1); + expect(settings.hooks.UserPromptSubmit[0].hooks[0].command).toContain('preamble'); + // Stop and SessionEnd cleared entirely (only legacy entries were present) + expect(settings.hooks.Stop).toBeUndefined(); + expect(settings.hooks.SessionEnd).toBeUndefined(); + }); + + it('handles mix of old and new hooks — removes all legacy, preserves current registrations', () => { // Simulate an upgrading user with both old and new hooks installed const input = JSON.stringify({ hooks: { @@ -772,6 +740,7 @@ describe('removeMemoryHooks removes legacy pre-sidecar hooks', () => { Stop: [ { hooks: [{ type: 'command', command: '/path/run-hook stop-update-memory', timeout: 10 }] }, { hooks: [{ type: 'command', command: '/path/run-hook dream-capture', timeout: 10 }] }, + { hooks: [{ type: 'command', command: '/path/run-hook memory-worker', timeout: 10 }] }, ], SessionEnd: [ { hooks: [{ type: 'command', command: '/path/run-hook session-end-learning', timeout: 10 }] }, @@ -788,6 +757,9 @@ describe('removeMemoryHooks removes legacy pre-sidecar hooks', () => { const result = removeMemoryHooks(input); const settings = JSON.parse(result); + // Every entry present (across both legacy-swept and current MEMORY_HOOK_CONFIG + // arrays) was either a legacy marker or a current memory hook, so nothing + // survives — the whole `hooks` object is cleaned up entirely. expect(settings.hooks).toBeUndefined(); }); }); diff --git a/tests/migrations.test.ts b/tests/migrations.test.ts index 4ce44b34..1628705a 100644 --- a/tests/migrations.test.ts +++ b/tests/migrations.test.ts @@ -1900,3 +1900,315 @@ describe('purge-dead-working-memory-sentinel-v1 migration', () => { }); }); +// --------------------------------------------------------------------------- +// purge-dream-marker-pipeline-v1 (per-project) +// Sweeps decisions.*/curation.* markers and fixed-name stamps from the retired +// marker pipeline. The queue files and config.json are live inputs of the +// Dream agent and must survive. +// avoids PF-004 (idempotency means a buggy run is never re-swept — the rethrow +// contract must be correct on the first attempt) +// --------------------------------------------------------------------------- + +describe('purge-dream-marker-pipeline-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-purge-dream-markers-test-')); + projectRoot = path.join(tmpDir, 'project'); + devflowDir = path.join(projectRoot, '.devflow'); + await fs.mkdir(devflowDir, { recursive: true }); + originalHome = process.env.HOME; + process.env.HOME = path.join(tmpDir, 'home'); + fakeHome = path.join(tmpDir, 'home', '.devflow'); + await fs.mkdir(fakeHome, { recursive: true }); + }); + + afterEach(async () => { + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + function getMigration(): Migration<'per-project'> { + const m = MIGRATIONS.find(m => m.id === 'purge-dream-marker-pipeline-v1'); + if (!m) throw new Error('purge-dream-marker-pipeline-v1 migration not found'); + return m as Migration<'per-project'>; + } + + function makeCtx(): import('../src/cli/utils/migrations.js').PerProjectMigrationContext { + return { + scope: 'per-project', + devflowDir: fakeHome, + memoryDir: path.join(devflowDir, 'memory'), + projectRoot, + }; + } + + it('is registered in MIGRATIONS with per-project scope', () => { + const m = MIGRATIONS.find(m => m.id === 'purge-dream-marker-pipeline-v1'); + expect(m).toBeDefined(); + expect(m?.scope).toBe('per-project'); + }); + + it('removes legacy fixed-name stamps: .decisions-runs-today, .curation-last, .processor-spawned-at', async () => { + const dreamDir = path.join(devflowDir, 'dream'); + await fs.mkdir(dreamDir, { recursive: true }); + await fs.writeFile(path.join(dreamDir, '.decisions-runs-today'), '2026-01-01\t2', 'utf-8'); + await fs.writeFile(path.join(dreamDir, '.curation-last'), '1234567890', 'utf-8'); + await fs.writeFile(path.join(dreamDir, '.processor-spawned-at'), '1234567890', 'utf-8'); + + const result = await getMigration().run(makeCtx()); + + await expect(fs.access(path.join(dreamDir, '.decisions-runs-today'))).rejects.toThrow(); + await expect(fs.access(path.join(dreamDir, '.curation-last'))).rejects.toThrow(); + await expect(fs.access(path.join(dreamDir, '.processor-spawned-at'))).rejects.toThrow(); + expect(result?.infos?.length).toBeGreaterThan(0); + }); + + it('removes per-session decisions.*/curation.* markers across all 4 suffixes', async () => { + const dreamDir = path.join(devflowDir, 'dream'); + await fs.mkdir(dreamDir, { recursive: true }); + for (const type of ['decisions', 'curation']) { + for (const suffix of ['json', 'processing', 'retries', 'failed']) { + await fs.writeFile(path.join(dreamDir, `${type}.abc123.${suffix}`), '{}', 'utf-8'); + } + } + + await getMigration().run(makeCtx()); + + for (const type of ['decisions', 'curation']) { + for (const suffix of ['json', 'processing', 'retries', 'failed']) { + await expect(fs.access(path.join(dreamDir, `${type}.abc123.${suffix}`))).rejects.toThrow(); + } + } + }); + + it('does NOT touch config.json (shared multi-feature config)', async () => { + const dreamDir = path.join(devflowDir, 'dream'); + await fs.mkdir(dreamDir, { recursive: true }); + const configContent = JSON.stringify({ memory: true, decisions: true, knowledge: true }); + await fs.writeFile(path.join(dreamDir, 'config.json'), configContent, 'utf-8'); + await fs.writeFile(path.join(dreamDir, 'decisions.abc123.json'), '{}', 'utf-8'); + + await getMigration().run(makeCtx()); + + const survived = await fs.readFile(path.join(dreamDir, 'config.json'), 'utf-8'); + expect(survived).toBe(configContent); + }); + + it('does NOT touch the new .pending-turns.jsonl/.pending-turns.processing queue', async () => { + const dreamDir = path.join(devflowDir, 'dream'); + await fs.mkdir(dreamDir, { recursive: true }); + await fs.writeFile(path.join(dreamDir, '.pending-turns.jsonl'), '{"role":"user","content":"hi","ts":1}\n', 'utf-8'); + await fs.writeFile(path.join(dreamDir, '.pending-turns.processing'), '{"role":"user","content":"hi","ts":1}\n', 'utf-8'); + await fs.writeFile(path.join(dreamDir, 'decisions.abc123.json'), '{}', 'utf-8'); + + await getMigration().run(makeCtx()); + + await expect(fs.access(path.join(dreamDir, '.pending-turns.jsonl'))).resolves.toBeUndefined(); + await expect(fs.access(path.join(dreamDir, '.pending-turns.processing'))).resolves.toBeUndefined(); + }); + + it('does NOT touch .last-dream-ok or .worker.lock (owned by purge-dream-worker-state-v1)', async () => { + const dreamDir = path.join(devflowDir, 'dream'); + await fs.mkdir(dreamDir, { recursive: true }); + await fs.writeFile(path.join(dreamDir, '.last-dream-ok'), '', 'utf-8'); + await fs.mkdir(path.join(dreamDir, '.worker.lock'), { recursive: true }); + await fs.writeFile(path.join(dreamDir, 'decisions.abc123.json'), '{}', 'utf-8'); + + await getMigration().run(makeCtx()); + + await expect(fs.access(path.join(dreamDir, '.last-dream-ok'))).resolves.toBeUndefined(); + await expect(fs.access(path.join(dreamDir, '.worker.lock'))).resolves.toBeUndefined(); + }); + + it('is a no-op when dream dir does not exist', async () => { + await expect(getMigration().run(makeCtx())).resolves.not.toThrow(); + }); + + it('is idempotent (ENOENT-safe on second run)', async () => { + const dreamDir = path.join(devflowDir, 'dream'); + await fs.mkdir(dreamDir, { recursive: true }); + await fs.writeFile(path.join(dreamDir, '.decisions-runs-today'), '2026-01-01\t2', 'utf-8'); + await fs.writeFile(path.join(dreamDir, 'decisions.abc123.json'), '{}', 'utf-8'); + + await getMigration().run(makeCtx()); + await expect(getMigration().run(makeCtx())).resolves.not.toThrow(); + }); + + it('returns empty infos when nothing to remove', async () => { + const dreamDir = path.join(devflowDir, 'dream'); + await fs.mkdir(dreamDir, { recursive: true }); + await fs.writeFile(path.join(dreamDir, 'config.json'), '{}', 'utf-8'); + + const result = await getMigration().run(makeCtx()); + expect(result?.infos ?? []).toEqual([]); + }); + + it('appears after purge-dead-working-memory-sentinel-v1 in MIGRATIONS array', () => { + const deadIdx = MIGRATIONS.findIndex(m => m.id === 'purge-dead-working-memory-sentinel-v1'); + const dreamIdx = MIGRATIONS.findIndex(m => m.id === 'purge-dream-marker-pipeline-v1'); + expect(deadIdx).toBeGreaterThanOrEqual(0); + expect(dreamIdx).toBeGreaterThan(deadIdx); + }); + + it('rethrows non-ENOENT errors from fixed-file unlink (avoids PF-004 silent-swallow)', async () => { + const dreamDir = path.join(devflowDir, 'dream'); + await fs.mkdir(dreamDir, { recursive: true }); + await fs.writeFile(path.join(dreamDir, '.decisions-runs-today'), '2026-01-01\t2', 'utf-8'); + + const originalUnlink = fs.unlink.bind(fs); + const spy = vi.spyOn(fs, 'unlink').mockImplementation(async (p: fs.PathLike) => { + const filePath = p.toString(); + if (filePath.endsWith('.decisions-runs-today')) { + const err = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); + throw err; + } + return originalUnlink(p as Parameters[0]); + }); + + try { + await expect(getMigration().run(makeCtx())).rejects.toThrow(); + } finally { + spy.mockRestore(); + } + }); + + it('rethrows non-ENOENT errors from readdir (avoids PF-004 silent-swallow)', async () => { + const dreamDir = path.join(devflowDir, 'dream'); + await fs.mkdir(dreamDir, { recursive: true }); + + const spy = vi.spyOn(fs, 'readdir').mockRejectedValueOnce( + Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }) + ); + + try { + await expect(getMigration().run(makeCtx())).rejects.toThrow(); + } finally { + spy.mockRestore(); + } + }); +}); + + +// --------------------------------------------------------------------------- +// purge-dream-worker-state-v1 (per-project) +// Removes inert state files left by the retired detached dream worker: +// decisions/.disabled, dream/.last-dream-ok, dream/last-run-summary, and the +// dream/.worker.lock directory. The queue files and config.json are live +// inputs of the Dream agent and must survive. +// --------------------------------------------------------------------------- + +describe('purge-dream-worker-state-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-purge-dream-worker-test-')); + projectRoot = path.join(tmpDir, 'project'); + devflowDir = path.join(projectRoot, '.devflow'); + await fs.mkdir(devflowDir, { recursive: true }); + originalHome = process.env.HOME; + process.env.HOME = path.join(tmpDir, 'home'); + fakeHome = path.join(tmpDir, 'home', '.devflow'); + await fs.mkdir(fakeHome, { recursive: true }); + }); + + afterEach(async () => { + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + function getMigration(): Migration<'per-project'> { + const m = MIGRATIONS.find(m => m.id === 'purge-dream-worker-state-v1'); + if (!m) throw new Error('purge-dream-worker-state-v1 migration not found'); + return m as Migration<'per-project'>; + } + + function makeCtx(): import('../src/cli/utils/migrations.js').PerProjectMigrationContext { + return { + scope: 'per-project', + devflowDir: fakeHome, + memoryDir: path.join(devflowDir, 'memory'), + projectRoot, + }; + } + + it('is registered in MIGRATIONS with per-project scope, after purge-dream-marker-pipeline-v1', () => { + const markerIdx = MIGRATIONS.findIndex(m => m.id === 'purge-dream-marker-pipeline-v1'); + const workerIdx = MIGRATIONS.findIndex(m => m.id === 'purge-dream-worker-state-v1'); + expect(workerIdx).toBeGreaterThan(markerIdx); + expect(MIGRATIONS[workerIdx].scope).toBe('per-project'); + }); + + it('removes .disabled sentinel, .last-dream-ok, last-run-summary, and the .worker.lock dir', async () => { + const dreamDir = path.join(devflowDir, 'dream'); + const decisionsDir = path.join(devflowDir, 'decisions'); + await fs.mkdir(dreamDir, { recursive: true }); + await fs.mkdir(decisionsDir, { recursive: true }); + await fs.writeFile(path.join(decisionsDir, '.disabled'), '', 'utf-8'); + await fs.writeFile(path.join(dreamDir, '.last-dream-ok'), '', 'utf-8'); + await fs.writeFile(path.join(dreamDir, 'last-run-summary'), 'Materialized ADR-009.', 'utf-8'); + await fs.mkdir(path.join(dreamDir, '.worker.lock'), { recursive: true }); + + const result = await getMigration().run(makeCtx()); + + await expect(fs.access(path.join(decisionsDir, '.disabled'))).rejects.toThrow(); + await expect(fs.access(path.join(dreamDir, '.last-dream-ok'))).rejects.toThrow(); + await expect(fs.access(path.join(dreamDir, 'last-run-summary'))).rejects.toThrow(); + await expect(fs.access(path.join(dreamDir, '.worker.lock'))).rejects.toThrow(); + expect(result?.infos?.length).toBeGreaterThan(0); + }); + + it('does NOT touch config.json or the queue files', async () => { + const dreamDir = path.join(devflowDir, 'dream'); + await fs.mkdir(dreamDir, { recursive: true }); + const configContent = JSON.stringify({ memory: true, decisions: true, knowledge: true }); + await fs.writeFile(path.join(dreamDir, 'config.json'), configContent, 'utf-8'); + await fs.writeFile(path.join(dreamDir, '.pending-turns.jsonl'), '{"role":"user","content":"hi","ts":1}\n', 'utf-8'); + await fs.writeFile(path.join(dreamDir, '.pending-turns.processing'), '{"role":"user","content":"hi","ts":1}\n', 'utf-8'); + await fs.writeFile(path.join(dreamDir, '.last-dream-ok'), '', 'utf-8'); + + await getMigration().run(makeCtx()); + + expect(await fs.readFile(path.join(dreamDir, 'config.json'), 'utf-8')).toBe(configContent); + await expect(fs.access(path.join(dreamDir, '.pending-turns.jsonl'))).resolves.toBeUndefined(); + await expect(fs.access(path.join(dreamDir, '.pending-turns.processing'))).resolves.toBeUndefined(); + }); + + it('is a no-op when nothing exists (fresh project) and idempotent on second run', async () => { + const first = await getMigration().run(makeCtx()); + expect(first?.infos ?? []).toEqual([]); + await expect(getMigration().run(makeCtx())).resolves.not.toThrow(); + }); + + it('rethrows non-ENOENT errors from unlink (avoids PF-004 silent-swallow)', async () => { + const decisionsDir = path.join(devflowDir, 'decisions'); + await fs.mkdir(decisionsDir, { recursive: true }); + await fs.writeFile(path.join(decisionsDir, '.disabled'), '', 'utf-8'); + + const spy = vi.spyOn(fs, 'unlink').mockRejectedValueOnce( + Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }) + ); + + try { + await expect(getMigration().run(makeCtx())).rejects.toThrow(); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/tests/project-paths.test.ts b/tests/project-paths.test.ts index 36172868..aa80575c 100644 --- a/tests/project-paths.test.ts +++ b/tests/project-paths.test.ts @@ -21,9 +21,10 @@ import { getFeaturesDir, getDocsDir, getDreamConfigPath, + getDreamPendingTurnsPath, + getDreamPendingTurnsProcessingPath, getDecisionsFilePath, getPitfallsFilePath, - getDecisionsDisabledSentinel, getDecisionsConfigPath, getDecisionsLedgerPath, getDecisionsLogPath, @@ -34,7 +35,6 @@ import { getDecisionsUsageLockDir, getObservationsLockDir, getDecisionsNotificationsPath, - getDecisionsRunsTodayPath, getDecisionsBatchIdsPath, getWorkingMemoryPath, getBackupPath, @@ -84,6 +84,14 @@ describe('project-paths TypeScript module', () => { it('getDreamConfigPath returns .devflow/dream/config.json', () => { expect(getDreamConfigPath(ROOT)).toBe('/some/project/.devflow/dream/config.json'); }); + + it('getDreamPendingTurnsPath returns .devflow/dream/.pending-turns.jsonl', () => { + expect(getDreamPendingTurnsPath(ROOT)).toBe('/some/project/.devflow/dream/.pending-turns.jsonl'); + }); + + it('getDreamPendingTurnsProcessingPath returns .devflow/dream/.pending-turns.processing', () => { + expect(getDreamPendingTurnsProcessingPath(ROOT)).toBe('/some/project/.devflow/dream/.pending-turns.processing'); + }); }); describe('decisions files', () => { @@ -95,10 +103,6 @@ describe('project-paths TypeScript module', () => { expect(getPitfallsFilePath(ROOT)).toBe('/some/project/.devflow/decisions/pitfalls.md'); }); - it('getDecisionsDisabledSentinel returns .devflow/decisions/.disabled', () => { - expect(getDecisionsDisabledSentinel(ROOT)).toBe('/some/project/.devflow/decisions/.disabled'); - }); - it('getDecisionsConfigPath returns .devflow/decisions/decisions.json', () => { expect(getDecisionsConfigPath(ROOT)).toBe('/some/project/.devflow/decisions/decisions.json'); }); @@ -127,10 +131,6 @@ describe('project-paths TypeScript module', () => { expect(getDecisionsNotificationsPath(ROOT)).toBe('/some/project/.devflow/decisions/.decisions-notifications.json'); }); - it('getDecisionsRunsTodayPath returns .devflow/decisions/.decisions-runs-today', () => { - expect(getDecisionsRunsTodayPath(ROOT)).toBe('/some/project/.devflow/decisions/.decisions-runs-today'); - }); - it('getDecisionsBatchIdsPath returns .devflow/decisions/.decisions-batch-ids', () => { expect(getDecisionsBatchIdsPath(ROOT)).toBe('/some/project/.devflow/decisions/.decisions-batch-ids'); }); @@ -231,9 +231,10 @@ describe('CJS project-paths parity', () => { { name: 'getFeaturesDir', ts: getFeaturesDir, cjs: cjsPaths.getFeaturesDir }, { name: 'getDocsDir', ts: getDocsDir, cjs: cjsPaths.getDocsDir }, { name: 'getDreamConfigPath', ts: getDreamConfigPath, cjs: cjsPaths.getDreamConfigPath }, + { name: 'getDreamPendingTurnsPath', ts: getDreamPendingTurnsPath, cjs: cjsPaths.getDreamPendingTurnsPath }, + { name: 'getDreamPendingTurnsProcessingPath', ts: getDreamPendingTurnsProcessingPath, cjs: cjsPaths.getDreamPendingTurnsProcessingPath }, { name: 'getDecisionsFilePath', ts: getDecisionsFilePath, cjs: cjsPaths.getDecisionsFilePath }, { name: 'getPitfallsFilePath', ts: getPitfallsFilePath, cjs: cjsPaths.getPitfallsFilePath }, - { name: 'getDecisionsDisabledSentinel', ts: getDecisionsDisabledSentinel, cjs: cjsPaths.getDecisionsDisabledSentinel }, { name: 'getDecisionsConfigPath', ts: getDecisionsConfigPath, cjs: cjsPaths.getDecisionsConfigPath }, { name: 'getDecisionsLedgerPath', ts: getDecisionsLedgerPath, cjs: cjsPaths.getDecisionsLedgerPath }, { name: 'getDecisionsLogPath', ts: getDecisionsLogPath, cjs: cjsPaths.getDecisionsLogPath }, @@ -244,7 +245,6 @@ describe('CJS project-paths parity', () => { { name: 'getDecisionsUsageLockDir', ts: getDecisionsUsageLockDir, cjs: cjsPaths.getDecisionsUsageLockDir }, { name: 'getObservationsLockDir', ts: getObservationsLockDir, cjs: cjsPaths.getObservationsLockDir }, { name: 'getDecisionsNotificationsPath', ts: getDecisionsNotificationsPath, cjs: cjsPaths.getDecisionsNotificationsPath }, - { name: 'getDecisionsRunsTodayPath', ts: getDecisionsRunsTodayPath, cjs: cjsPaths.getDecisionsRunsTodayPath }, { name: 'getDecisionsBatchIdsPath', ts: getDecisionsBatchIdsPath, cjs: cjsPaths.getDecisionsBatchIdsPath }, { name: 'getWorkingMemoryPath', ts: getWorkingMemoryPath, cjs: cjsPaths.getWorkingMemoryPath }, { name: 'getBackupPath', ts: getBackupPath, cjs: cjsPaths.getBackupPath }, diff --git a/tests/queue-append.test.ts b/tests/queue-append.test.ts new file mode 100644 index 00000000..65724347 --- /dev/null +++ b/tests/queue-append.test.ts @@ -0,0 +1,384 @@ +/** + * tests/queue-append.test.ts + * + * Tests for scripts/hooks/queue-append — the shared dual-queue-append helper + * used by capture-prompt, capture-turn, and capture-question. + * + * Harness note: queue_append_row/queue_append_both/queue_read_gates are bash + * functions (sourced, not standalone executables), so each test sources + * json-parse -> get-mtime -> dream-lock -> queue-append and then calls the + * function(s) under test via a small inline bash script executed with `bash -c`. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const HOOKS_DIR = path.resolve(__dirname, '..', 'scripts', 'hooks'); +const QUEUE_APPEND = path.join(HOOKS_DIR, 'queue-append'); + +/** Source the full dependency chain queue-append needs, then run `script`. */ +function runWithQueueAppend(script: string): { stdout: string; stderr: string; exitCode: number } { + const full = ` +set -e +log() { :; } +dbg() { :; } +source "${path.join(HOOKS_DIR, 'json-parse')}" +source "${path.join(HOOKS_DIR, 'get-mtime')}" +source "${path.join(HOOKS_DIR, 'dream-lock')}" +source "${QUEUE_APPEND}" +${script} +`; + try { + const result = execSync(`bash -c '${full.replace(/'/g, "'\\''")}'`, { stdio: ['pipe', 'pipe', 'pipe'] }); + return { stdout: result.toString(), stderr: '', exitCode: 0 }; + } catch (e: unknown) { + const err = e as { stdout?: Buffer; stderr?: Buffer; status?: number }; + return { stdout: err.stdout?.toString() ?? '', stderr: err.stderr?.toString() ?? '', exitCode: err.status ?? 1 }; + } +} + +function readJsonl(file: string): Record[] { + if (!fs.existsSync(file)) return []; + return fs + .readFileSync(file, 'utf-8') + .trim() + .split('\n') + .filter(Boolean) + .map((l) => JSON.parse(l)); +} + +describe('shell hook syntax: queue-append passes bash -n', () => { + it('bash -n succeeds', () => { + expect(() => { + execSync(`bash -n "${QUEUE_APPEND}"`, { stdio: 'pipe' }); + }).not.toThrow(); + }); +}); + +describe('queue_append_row', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'queue-append-row-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('creates the queue file with mode 0600 on first write', () => { + const q = path.join(tmpDir, 'q.jsonl'); + runWithQueueAppend(`queue_append_row "${q}" "user" "hello" "1000000000"`); + expect(fs.existsSync(q)).toBe(true); + const mode = fs.statSync(q).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it('appends a valid {role, content, ts} JSON row', () => { + const q = path.join(tmpDir, 'q.jsonl'); + runWithQueueAppend(`queue_append_row "${q}" "assistant" "hi there" "1234"`); + const rows = readJsonl(q); + expect(rows).toEqual([{ role: 'assistant', content: 'hi there', ts: 1234 }]); + }); + + it('appends multiple rows without truncating existing content', () => { + const q = path.join(tmpDir, 'q.jsonl'); + runWithQueueAppend(` + queue_append_row "${q}" "user" "one" "1" + queue_append_row "${q}" "assistant" "two" "2" + `); + const rows = readJsonl(q); + expect(rows).toHaveLength(2); + expect(rows[0].content).toBe('one'); + expect(rows[1].content).toBe('two'); + }); + + describe('escaping fuzz (quotes, newlines, command substitution, unicode)', () => { + const cases: Array<{ name: string; content: string }> = [ + { name: 'double quotes', content: 'she said "hello" to me' }, + { name: 'single quotes', content: "it's a test" }, + { name: 'newlines', content: 'line one\nline two\nline three' }, + { name: 'command substitution syntax', content: 'run $(rm -rf /) or `echo pwned`' }, + { name: 'unicode', content: 'café 日本語 \u{1f600}' }, + { name: 'backslashes', content: 'C:\\Users\\test\\path' }, + { name: 'mixed', content: '"$(nested \'quotes\')" \n with \\backslash and émoji \u{1f600}' }, + ]; + + for (const { name, content } of cases) { + it(`round-trips ${name} exactly`, () => { + const q = path.join(tmpDir, 'q.jsonl'); + const tmpContentFile = path.join(tmpDir, 'content.txt'); + fs.writeFileSync(tmpContentFile, content); + // Pass content via a file + command substitution to avoid the TEST + // harness's own shell-escaping concerns; queue_append_row itself + // receives it as a plain positional argument, exactly like a real + // hook would pass $PROMPT/$ASSISTANT_MSG. + const { exitCode } = runWithQueueAppend(` + CONTENT="$(cat "${tmpContentFile}")" + queue_append_row "${q}" "user" "$CONTENT" "1" + `); + expect(exitCode).toBe(0); + const rows = readJsonl(q); + expect(rows).toHaveLength(1); + expect(rows[0].content).toBe(content); + }); + } + }); + + describe('overflow: 200 -> 100 truncation under lock', () => { + it('truncates to newest 100 lines once the file exceeds 200', () => { + const q = path.join(tmpDir, 'q.jsonl'); + const lines: string[] = []; + for (let i = 0; i < 205; i++) { + lines.push(JSON.stringify({ role: 'user', content: `line-${i}`, ts: i })); + } + fs.writeFileSync(q, lines.join('\n') + '\n'); + + runWithQueueAppend(`queue_append_row "${q}" "user" "final-row" "9999"`); + + // 205 pre-seeded + 1 appended = 206 transiently, then truncated to the + // newest 100 (which includes the just-appended row, since it's newest). + const rows = readJsonl(q); + expect(rows).toHaveLength(100); + expect(rows[rows.length - 1].content).toBe('final-row'); + // Oldest surviving row should be from near the tail of the original 205 + expect((rows[0].content as string).startsWith('line-')).toBe(true); + }); + + it('does not truncate when the file is at or below 200 lines', () => { + const q = path.join(tmpDir, 'q.jsonl'); + const lines: string[] = []; + for (let i = 0; i < 150; i++) { + lines.push(JSON.stringify({ role: 'user', content: `line-${i}`, ts: i })); + } + fs.writeFileSync(q, lines.join('\n') + '\n'); + + runWithQueueAppend(`queue_append_row "${q}" "user" "extra" "9999"`); + + const rows = readJsonl(q); + expect(rows).toHaveLength(151); + }); + + it('no lock directory left behind after truncation', () => { + const q = path.join(tmpDir, 'q.jsonl'); + const lines: string[] = []; + for (let i = 0; i < 205; i++) lines.push(JSON.stringify({ role: 'user', content: `l${i}`, ts: i })); + fs.writeFileSync(q, lines.join('\n') + '\n'); + + runWithQueueAppend(`queue_append_row "${q}" "user" "x" "1"`); + + expect(fs.existsSync(`${q}.lock`)).toBe(false); + }); + }); + + describe('degraded no-jq path (node fallback)', () => { + it('still produces valid JSONL when _HAS_JQ is forced false', () => { + const q = path.join(tmpDir, 'q.jsonl'); + const { exitCode } = runWithQueueAppend(` + _HAS_JQ=false + queue_append_row "${q}" "user" "no jq here: \\"quoted\\"" "42" + `); + expect(exitCode).toBe(0); + const rows = readJsonl(q); + expect(rows).toEqual([{ role: 'user', content: 'no jq here: "quoted"', ts: 42 }]); + }); + }); + + describe('20-parallel-append interleave (no corruption)', () => { + it('20 concurrent appends to the SAME queue file all survive as valid JSON lines', async () => { + const q = path.join(tmpDir, 'q.jsonl'); + const N = 20; + const scriptPath = path.join(tmpDir, 'append-one.sh'); + fs.writeFileSync( + scriptPath, + `#!/bin/bash +set -e +log() { :; } +dbg() { :; } +source "${path.join(HOOKS_DIR, 'json-parse')}" +source "${path.join(HOOKS_DIR, 'get-mtime')}" +source "${path.join(HOOKS_DIR, 'dream-lock')}" +source "${QUEUE_APPEND}" +queue_append_row "$1" "user" "row-$2" "$2" +`, + ); + fs.chmodSync(scriptPath, 0o755); + + const { spawn } = await import('child_process'); + const runs = Array.from({ length: N }, (_, i) => { + return new Promise((resolve) => { + const proc = spawn('bash', [scriptPath, q, String(i)], { stdio: 'ignore' }); + proc.on('close', (code) => resolve(code)); + }); + }); + const codes = await Promise.all(runs); + for (const c of codes) expect(c).toBe(0); + + const rows = readJsonl(q); + // Every line must be valid, parseable JSON (readJsonl would throw otherwise) + // and all N distinct row identifiers must be present exactly once. + expect(rows).toHaveLength(N); + const contents = new Set(rows.map((r) => r.content)); + expect(contents.size).toBe(N); + for (let i = 0; i < N; i++) { + expect(contents.has(`row-${i}`)).toBe(true); + } + }, 15000); + }); + + describe('truncate-vs-append race', () => { + // ACCEPTED RISK (documented in the design): the truncate path is + // read-then-replace (`tail -100 file > tmp && mv tmp file`), not an + // in-place lock-held write. A concurrent lock-free append landing on the + // original file AFTER the `tail` snapshot but BEFORE the `mv` replaces it + // can be silently dropped by the replace — the same class of race the + // pre-existing dream-capture/dream-dispatch queue-overflow logic already + // has (this helper extracts, not changes, that behavior). The guarantee + // this test actually pins is NO CORRUPTION (every surviving line remains + // valid, parseable JSON) — not zero data loss under a genuine race. + it('races a truncation without ever corrupting the file (parseable JSONL, sane length)', async () => { + const q = path.join(tmpDir, 'q.jsonl'); + const lines: string[] = []; + for (let i = 0; i < 205; i++) lines.push(JSON.stringify({ role: 'user', content: `seed-${i}`, ts: i })); + fs.writeFileSync(q, lines.join('\n') + '\n'); + + const scriptPath = path.join(tmpDir, 'append-race.sh'); + fs.writeFileSync( + scriptPath, + `#!/bin/bash +set -e +log() { :; } +dbg() { :; } +source "${path.join(HOOKS_DIR, 'json-parse')}" +source "${path.join(HOOKS_DIR, 'get-mtime')}" +source "${path.join(HOOKS_DIR, 'dream-lock')}" +source "${QUEUE_APPEND}" +queue_append_row "$1" "user" "race-$2" "$2" +`, + ); + fs.chmodSync(scriptPath, 0o755); + + const { spawn } = await import('child_process'); + const runs = [0, 1, 2].map((i) => { + return new Promise((resolve) => { + const proc = spawn('bash', [scriptPath, q, String(i)], { stdio: 'ignore' }); + proc.on('close', (code) => resolve(code)); + }); + }); + const codes = await Promise.all(runs); + for (const c of codes) expect(c).toBe(0); + + // No corruption: readJsonl throws on any malformed line, so reaching + // this point at all already proves every surviving line is valid JSON. + const rows = readJsonl(q); + // Sane length: truncation keeps the newest 100 plus whatever raced in + // after the last truncate pass — never near-zero, never wildly over. + expect(rows.length).toBeGreaterThanOrEqual(100); + expect(rows.length).toBeLessThanOrEqual(103); + + // Best-effort (not guaranteed under the accepted race): most or all of + // the 3 racing rows typically survive — assert at least one did, so a + // total-loss regression (e.g. a bug that drops ALL concurrent appends) + // would still be caught. + const contents = new Set(rows.map((r) => r.content)); + const survived = ['race-0', 'race-1', 'race-2'].filter((r) => contents.has(r)); + expect(survived.length).toBeGreaterThanOrEqual(1); + }, 15000); + }); +}); + +describe('queue_append_both', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'queue-append-both-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('writes to both queues when both flags are true', () => { + const mem = path.join(tmpDir, 'mem.jsonl'); + const dream = path.join(tmpDir, 'dream.jsonl'); + runWithQueueAppend(`queue_append_both "${mem}" "${dream}" "true" "true" "user" "hi" "1"`); + expect(readJsonl(mem)).toHaveLength(1); + expect(readJsonl(dream)).toHaveLength(1); + }); + + it('writes only to the memory queue when dream_enabled is false', () => { + const mem = path.join(tmpDir, 'mem.jsonl'); + const dream = path.join(tmpDir, 'dream.jsonl'); + runWithQueueAppend(`queue_append_both "${mem}" "${dream}" "true" "false" "user" "hi" "1"`); + expect(readJsonl(mem)).toHaveLength(1); + expect(fs.existsSync(dream)).toBe(false); + }); + + it('writes only to the dream queue when memory_enabled is false', () => { + const mem = path.join(tmpDir, 'mem.jsonl'); + const dream = path.join(tmpDir, 'dream.jsonl'); + runWithQueueAppend(`queue_append_both "${mem}" "${dream}" "false" "true" "user" "hi" "1"`); + expect(fs.existsSync(mem)).toBe(false); + expect(readJsonl(dream)).toHaveLength(1); + }); + + it('writes to neither queue when both flags are false', () => { + const mem = path.join(tmpDir, 'mem.jsonl'); + const dream = path.join(tmpDir, 'dream.jsonl'); + runWithQueueAppend(`queue_append_both "${mem}" "${dream}" "false" "false" "user" "hi" "1"`); + expect(fs.existsSync(mem)).toBe(false); + expect(fs.existsSync(dream)).toBe(false); + }); +}); + +describe('queue_read_gates', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'queue-read-gates-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function readGates(config: Record | null): { memory: string; decisions: string; exitCode: number } { + const configPath = path.join(tmpDir, 'config.json'); + if (config !== null) fs.writeFileSync(configPath, JSON.stringify(config)); + + const { stdout, exitCode } = runWithQueueAppend(` + queue_read_gates "${configPath}" + echo "MEMORY=$_QG_MEMORY" + echo "DECISIONS=$_QG_DECISIONS" + `); + const memMatch = stdout.match(/MEMORY=(\S*)/); + const decMatch = stdout.match(/DECISIONS=(\S*)/); + return { memory: memMatch?.[1] ?? '', decisions: decMatch?.[1] ?? '', exitCode }; + } + + it('both default to true when config is missing', () => { + const r = readGates(null); + expect(r).toMatchObject({ memory: 'true', decisions: 'true', exitCode: 0 }); + }); + + it('reads both explicit fields in one pass', () => { + const r = readGates({ memory: true, decisions: false }); + expect(r).toMatchObject({ memory: 'true', decisions: 'false' }); + }); + + it('memory:false, decisions field absent -> memory false, decisions defaults true', () => { + const r = readGates({ memory: false }); + expect(r).toMatchObject({ memory: 'false', decisions: 'true' }); + }); + + it('never exits non-zero (set -e safety)', () => { + // Regression guard: queue_read_gates's exit status must never leak the + // truthiness of its last internal test into a `set -e` caller that + // invokes it as a plain statement. + const r = readGates({}); + expect(r.exitCode).toBe(0); + }); +}); diff --git a/tests/shell-hooks.test.ts b/tests/shell-hooks.test.ts index 7bbf64b2..ad2a7fb1 100644 --- a/tests/shell-hooks.test.ts +++ b/tests/shell-hooks.test.ts @@ -26,11 +26,11 @@ const HOOK_SCRIPTS = [ 'ensure-devflow-init', 'ensure-root-gitignore', 'resolve-project-root', - 'dream-capture', - 'dream-evaluate', - 'dream-dispatch', - 'eval-helpers', - 'eval-decisions', + 'queue-append', + 'capture-prompt', + 'capture-turn', + 'capture-question', + 'memory-worker', ]; describe('shell hook syntax checks', () => { @@ -177,190 +177,6 @@ describe('debug-trace helper behaviors', () => { }); }); -const EVAL_HELPERS = path.join(HOOKS_DIR, 'eval-helpers'); -const DREAM_LOCK = path.join(HOOKS_DIR, 'dream-lock'); - -describe('eval-helpers: read_daily_cap', () => { - it('returns 0 when counter file absent', () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); - try { - const counterFile = path.join(tmpDir, '.runs-today'); - const result = execSync(`bash -c ' - TODAY=$(date +%Y-%m-%d) - source "${EVAL_HELPERS}" - read_daily_cap "${counterFile}" 0 - '`, { stdio: 'pipe' }).toString().trim(); - expect(result).toBe('0'); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it('returns count when date matches today', () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); - const counterFile = path.join(tmpDir, '.runs-today'); - const today = execSync('date +%Y-%m-%d', { stdio: 'pipe' }).toString().trim(); - try { - fs.writeFileSync(counterFile, `${today}\t5\n`); - const result = execSync(`bash -c ' - TODAY=$(date +%Y-%m-%d) - source "${EVAL_HELPERS}" - read_daily_cap "${counterFile}" 0 - '`, { stdio: 'pipe' }).toString().trim(); - expect(result).toBe('5'); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it('returns 0 when counter file has a stale date', () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); - const counterFile = path.join(tmpDir, '.runs-today'); - try { - fs.writeFileSync(counterFile, `2020-01-01\t99\n`); - const result = execSync(`bash -c ' - TODAY=$(date +%Y-%m-%d) - source "${EVAL_HELPERS}" - read_daily_cap "${counterFile}" 0 - '`, { stdio: 'pipe' }).toString().trim(); - expect(result).toBe('0'); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); -}); - -describe('eval-helpers: atomic_increment_daily', () => { - it('creates counter file with count 1 on first call', () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); - const counterFile = path.join(tmpDir, '.runs-today'); - const today = execSync('date +%Y-%m-%d', { stdio: 'pipe' }).toString().trim(); - try { - execSync(`bash -c ' - TODAY=$(date +%Y-%m-%d) - source "${DREAM_LOCK}" - source "${EVAL_HELPERS}" - atomic_increment_daily "${counterFile}" "$TODAY" - '`, { stdio: 'pipe' }); - const content = fs.readFileSync(counterFile, 'utf-8').trim(); - expect(content).toBe(`${today}\t1`); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it('increments existing count on subsequent call', () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); - const counterFile = path.join(tmpDir, '.runs-today'); - const today = execSync('date +%Y-%m-%d', { stdio: 'pipe' }).toString().trim(); - try { - fs.writeFileSync(counterFile, `${today}\t3\n`); - execSync(`bash -c ' - TODAY=$(date +%Y-%m-%d) - source "${DREAM_LOCK}" - source "${EVAL_HELPERS}" - atomic_increment_daily "${counterFile}" "$TODAY" - '`, { stdio: 'pipe' }); - const content = fs.readFileSync(counterFile, 'utf-8').trim(); - expect(content).toBe(`${today}\t4`); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); -}); - -describe('eval-helpers: load_existing_ids', () => { - it('returns empty array when log file is absent', () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); - try { - const missingFile = path.join(tmpDir, 'nonexistent.jsonl'); - const result = execSync(`bash -c ' - source "${EVAL_HELPERS}" - load_existing_ids "${missingFile}" - '`, { stdio: 'pipe' }).toString().trim(); - expect(JSON.parse(result)).toEqual([]); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it('returns empty array when log file is empty', () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); - const logFile = path.join(tmpDir, 'empty.jsonl'); - try { - fs.writeFileSync(logFile, ''); - const result = execSync(`bash -c ' - source "${EVAL_HELPERS}" - load_existing_ids "${logFile}" - '`, { stdio: 'pipe' }).toString().trim(); - expect(JSON.parse(result)).toEqual([]); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it('returns ids from valid JSONL as JSON array', () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); - const logFile = path.join(tmpDir, 'learning.jsonl'); - try { - fs.writeFileSync(logFile, [ - JSON.stringify({ id: 'obs_aaa', type: 'workflow', confidence: 0.9 }), - JSON.stringify({ id: 'obs_bbb', type: 'procedural', confidence: 0.7 }), - JSON.stringify({ id: 'obs_ccc', type: 'decision', confidence: 0.8 }), - ].join('\n') + '\n'); - const result = execSync(`bash -c ' - source "${EVAL_HELPERS}" - load_existing_ids "${logFile}" - '`, { stdio: 'pipe' }).toString().trim(); - const ids = JSON.parse(result); - expect(ids).toContain('obs_aaa'); - expect(ids).toContain('obs_bbb'); - expect(ids).toContain('obs_ccc'); - expect(ids).toHaveLength(3); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); -}); - -describe('eval-helpers: _eval_release_lock', () => { - it('releases a held lock dir by removing it', () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); - const lockDir = path.join(tmpDir, 'test.lock'); - try { - // Create the lock directory (simulating an acquired lock) - fs.mkdirSync(lockDir); - expect(fs.existsSync(lockDir)).toBe(true); - - execSync(`bash -c ' - source "${DREAM_LOCK}" - source "${EVAL_HELPERS}" - _eval_release_lock "${lockDir}" - '`, { stdio: 'pipe' }); - - expect(fs.existsSync(lockDir)).toBe(false); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it('is a no-op when lock dir does not exist', () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-test-')); - const lockDir = path.join(tmpDir, 'nonexistent.lock'); - try { - expect(() => { - execSync(`bash -c ' - source "${DREAM_LOCK}" - source "${EVAL_HELPERS}" - _eval_release_lock "${lockDir}" - '`, { stdio: 'pipe' }); - }).not.toThrow(); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); -}); - describe('json-helper.js operations', () => { it('get-field extracts a field with default', () => { const result = execSync( @@ -529,445 +345,6 @@ describe('json-parse wrapper', () => { }); }); -describe('working memory queue behavior', () => { - const STOP_HOOK = path.join(HOOKS_DIR, 'dream-capture'); - const PREAMBLE_HOOK = path.join(HOOKS_DIR, 'preamble'); - const PROMPT_CAPTURE_HOOK = path.join(HOOKS_DIR, 'dream-dispatch'); - - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-queue-test-')); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - // Helper: write a fresh WORKING-MEMORY.md with an old mtime so the throttle passes. - // dream-capture throttles if WORKING-MEMORY.md was updated <120s ago. - // We write the file then backdate its mtime to 10 minutes ago. - function writeStaleWorkingMemory(tmpDir: string): void { - const memFile = path.join(tmpDir, '.devflow', 'memory', 'WORKING-MEMORY.md'); - fs.writeFileSync(memFile, '## Now\n- stale memory'); - // Backdate 10 minutes (600 seconds) - const tenMinutesAgo = new Date(Date.now() - 600 * 1000); - fs.utimesSync(memFile, tenMinutesAgo, tenMinutesAgo); - } - - it('empty last_assistant_message — no queue append', () => { - // Hook exits early when last_assistant_message is absent/empty - fs.mkdirSync(path.join(tmpDir, '.devflow', 'memory'), { recursive: true }); - - const input = JSON.stringify({ - cwd: tmpDir, - session_id: 'test-session-001', - last_assistant_message: '', - }); - - execSync(`bash "${STOP_HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - - const queueFile = path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'); - expect(fs.existsSync(queueFile)).toBe(false); - }); - - it('last_assistant_message present — appends assistant turn to queue', () => { - // Create .devflow/memory/ directory - fs.mkdirSync(path.join(tmpDir, '.devflow', 'memory'), { recursive: true }); - // Write stale WORKING-MEMORY.md so throttle check passes (no memory.processing marker needed) - writeStaleWorkingMemory(tmpDir); - - const input = JSON.stringify({ - cwd: tmpDir, - session_id: 'test-session-002', - last_assistant_message: 'test response', - }); - - execSync(`bash "${STOP_HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - - const queueFile = path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'); - expect(fs.existsSync(queueFile)).toBe(true); - - const lines = fs.readFileSync(queueFile, 'utf-8').trim().split('\n').filter(Boolean); - expect(lines).toHaveLength(1); - - const entry = JSON.parse(lines[0]) as { role: string; content: string; ts: number }; - expect(entry.role).toBe('assistant'); - expect(entry.content).toBe('test response'); - expect(typeof entry.ts).toBe('number'); - }); - - it('dream-dispatch captures user prompt to queue', () => { - // Create .devflow/memory/ directory so capture is triggered - fs.mkdirSync(path.join(tmpDir, '.devflow', 'memory'), { recursive: true }); - - const input = JSON.stringify({ - cwd: tmpDir, - session_id: 'test-session-003', - prompt: 'implement the cache', - }); - - execSync(`bash "${PROMPT_CAPTURE_HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - - const queueFile = path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'); - expect(fs.existsSync(queueFile)).toBe(true); - - const lines = fs.readFileSync(queueFile, 'utf-8').trim().split('\n').filter(Boolean); - expect(lines).toHaveLength(1); - - const entry = JSON.parse(lines[0]) as { role: string; content: string; ts: number }; - expect(entry.role).toBe('user'); - expect(entry.content).toBe('implement the cache'); - expect(typeof entry.ts).toBe('number'); - }); - - it('dream-dispatch with missing .devflow/ — creates it via ensure-devflow-init, exit 0', () => { - // tmpDir exists but has no .devflow/ subdirectory — ensure-devflow-init creates it - const input = JSON.stringify({ - cwd: tmpDir, - session_id: 'test-session-004a', - prompt: 'implement the cache', - }); - - expect(() => { - execSync(`bash "${PROMPT_CAPTURE_HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - }).not.toThrow(); - - // Hook creates .devflow/memory/ and writes to queue - const queueFile = path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'); - expect(fs.existsSync(queueFile)).toBe(true); - }); - - it('preamble does NOT write to queue — zero file I/O', () => { - // Create .devflow/memory/ to confirm preamble doesn't touch the queue even when it exists - fs.mkdirSync(path.join(tmpDir, '.devflow', 'memory'), { recursive: true }); - - const input = JSON.stringify({ - cwd: tmpDir, - session_id: 'test-session-004', - prompt: 'implement the cache', - }); - - // Should not throw (exit 0) - expect(() => { - execSync(`bash "${PREAMBLE_HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - }).not.toThrow(); - - const queueFile = path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'); - expect(fs.existsSync(queueFile)).toBe(false); - }); - - it('preamble with slash command — exits 0, no queue write', () => { - fs.mkdirSync(path.join(tmpDir, '.devflow', 'memory'), { recursive: true }); - - const input = JSON.stringify({ - cwd: tmpDir, - session_id: 'test-session-004b', - prompt: '/code-review', - }); - - expect(() => { - execSync(`bash "${PREAMBLE_HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - }).not.toThrow(); - - const queueFile = path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'); - expect(fs.existsSync(queueFile)).toBe(false); - }); - - it('queue JSONL format — each line is valid JSON with role, content, ts', () => { - fs.mkdirSync(path.join(tmpDir, '.devflow', 'memory'), { recursive: true }); - const queueFile = path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'); - - const now = Math.floor(Date.now() / 1000); - const entries = [ - { role: 'user', content: 'hello world', ts: now }, - { role: 'assistant', content: 'I will help you', ts: now + 1 }, - { role: 'user', content: 'thanks', ts: now + 2 }, - ]; - - fs.writeFileSync(queueFile, entries.map(e => JSON.stringify(e)).join('\n') + '\n'); - - const lines = fs.readFileSync(queueFile, 'utf-8').trim().split('\n').filter(Boolean); - expect(lines).toHaveLength(3); - - for (const line of lines) { - const parsed = JSON.parse(line); - expect(['user', 'assistant']).toContain(parsed.role); - expect(typeof parsed.content).toBe('string'); - expect(typeof parsed.ts).toBe('number'); - } - }); - - it('preserves existing user entries when appending assistant turn', () => { - fs.mkdirSync(path.join(tmpDir, '.devflow', 'memory'), { recursive: true }); - writeStaleWorkingMemory(tmpDir); - - const queueFile = path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'); - const now = Math.floor(Date.now() / 1000); - // Pre-populate with user-only entries (from dream-dispatch) - const userLines = Array.from({ length: 5 }, (_, i) => - JSON.stringify({ role: 'user', content: `user prompt ${i}`, ts: now + i }), - ); - fs.writeFileSync(queueFile, userLines.join('\n') + '\n'); - - const input = JSON.stringify({ - cwd: tmpDir, - session_id: 'test-session-autoclean', - last_assistant_message: 'first real assistant response', - }); - - execSync(`bash "${STOP_HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - - const lines = fs.readFileSync(queueFile, 'utf-8').trim().split('\n').filter(Boolean); - // All 5 user entries preserved + 1 new assistant entry - expect(lines).toHaveLength(6); - const lastEntry = JSON.parse(lines[5]) as { role: string; content: string; ts: number }; - expect(lastEntry.role).toBe('assistant'); - expect(lastEntry.content).toBe('first real assistant response'); - }); - - it('empty queue file — assistant entry appended normally', () => { - fs.mkdirSync(path.join(tmpDir, '.devflow', 'memory'), { recursive: true }); - writeStaleWorkingMemory(tmpDir); - - const queueFile = path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'); - fs.writeFileSync(queueFile, ''); - - const input = JSON.stringify({ - cwd: tmpDir, - session_id: 'test-session-autoclean-empty', - last_assistant_message: 'response after empty queue', - }); - - execSync(`bash "${STOP_HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - - const lines = fs.readFileSync(queueFile, 'utf-8').trim().split('\n').filter(Boolean); - expect(lines).toHaveLength(1); - const entry = JSON.parse(lines[0]) as { role: string; content: string; ts: number }; - expect(entry.role).toBe('assistant'); - expect(entry.content).toBe('response after empty queue'); - }); - - it('single user entry preserved when assistant turn appends', () => { - fs.mkdirSync(path.join(tmpDir, '.devflow', 'memory'), { recursive: true }); - writeStaleWorkingMemory(tmpDir); - - const queueFile = path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'); - const now = Math.floor(Date.now() / 1000); - const userEntry = JSON.stringify({ role: 'user', content: 'user prompt', ts: now }); - fs.writeFileSync(queueFile, userEntry + '\n'); - - const input = JSON.stringify({ - cwd: tmpDir, - session_id: 'test-session-autoclean-single', - last_assistant_message: 'response after user prompt', - }); - - execSync(`bash "${STOP_HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - - const lines = fs.readFileSync(queueFile, 'utf-8').trim().split('\n').filter(Boolean); - // User entry preserved + new assistant entry = 2 lines - expect(lines).toHaveLength(2); - const firstEntry = JSON.parse(lines[0]) as { role: string; content: string; ts: number }; - expect(firstEntry.role).toBe('user'); - expect(firstEntry.content).toBe('user prompt'); - const lastEntry = JSON.parse(lines[1]) as { role: string; content: string; ts: number }; - expect(lastEntry.role).toBe('assistant'); - expect(lastEntry.content).toBe('response after user prompt'); - }); - - it('queue with mixed entries preserved when assistant turn appends', () => { - fs.mkdirSync(path.join(tmpDir, '.devflow', 'memory'), { recursive: true }); - writeStaleWorkingMemory(tmpDir); - - const queueFile = path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'); - const now = Math.floor(Date.now() / 1000); - // Pre-populate with mixed entries (healthy queue) - const mixedLines = [ - JSON.stringify({ role: 'user', content: 'hello', ts: now }), - JSON.stringify({ role: 'assistant', content: 'hi there', ts: now + 1 }), - JSON.stringify({ role: 'user', content: 'next prompt', ts: now + 2 }), - ]; - fs.writeFileSync(queueFile, mixedLines.join('\n') + '\n'); - - const input = JSON.stringify({ - cwd: tmpDir, - session_id: 'test-session-no-autoclean', - last_assistant_message: 'another response', - }); - - execSync(`bash "${STOP_HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - - const lines = fs.readFileSync(queueFile, 'utf-8').trim().split('\n').filter(Boolean); - // 3 existing + 1 new = 4 - expect(lines).toHaveLength(4); - }); - - it('queue with only assistant entries preserved when new entry appends', () => { - fs.mkdirSync(path.join(tmpDir, '.devflow', 'memory'), { recursive: true }); - writeStaleWorkingMemory(tmpDir); - - const queueFile = path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'); - const now = Math.floor(Date.now() / 1000); - // Pre-populate with assistant-only entries - const assistantLines = Array.from({ length: 3 }, (_, i) => - JSON.stringify({ role: 'assistant', content: `response ${i}`, ts: now + i }), - ); - fs.writeFileSync(queueFile, assistantLines.join('\n') + '\n'); - - const input = JSON.stringify({ - cwd: tmpDir, - session_id: 'test-session-autoclean-assistant-only', - last_assistant_message: 'new assistant response', - }); - - execSync(`bash "${STOP_HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - - const resultLines = fs.readFileSync(queueFile, 'utf-8').trim().split('\n').filter(Boolean); - // 3 existing assistant entries preserved + 1 new = 4 - expect(resultLines).toHaveLength(4); - const lastEntry = JSON.parse(resultLines[resultLines.length - 1]) as { role: string; content: string; ts: number }; - expect(lastEntry.role).toBe('assistant'); - expect(lastEntry.content).toBe('new assistant response'); - }); - - it('queue overflow — >200 lines truncated to last 100', () => { - fs.mkdirSync(path.join(tmpDir, '.devflow', 'memory'), { recursive: true }); - // Write stale WORKING-MEMORY.md so throttle passes - writeStaleWorkingMemory(tmpDir); - - const queueFile = path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'); - const now = Math.floor(Date.now() / 1000); - - // Pre-populate queue with 201 entries (mixed roles to avoid auto-clean) - const existingLines = Array.from({ length: 201 }, (_, i) => - JSON.stringify({ role: i % 2 === 0 ? 'user' : 'assistant', content: `entry ${i}`, ts: now + i }), - ); - fs.writeFileSync(queueFile, existingLines.join('\n') + '\n'); - - // Trigger stop hook — appends 1 more entry, then overflow check fires - const input = JSON.stringify({ - cwd: tmpDir, - session_id: 'test-session-006', - last_assistant_message: 'overflow trigger response', - }); - - execSync(`bash "${STOP_HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - - // After overflow: 201 pre-existing + 1 new = 202 lines → truncated to last 100 - const resultLines = fs.readFileSync(queueFile, 'utf-8').trim().split('\n').filter(Boolean); - expect(resultLines).toHaveLength(100); - - // The first preserved entry must be from the tail of the original data - // 201 pre-existing + 1 new = 202 lines → tail -100 starts at index 102 - const firstEntry = JSON.parse(resultLines[0]) as { role: string; content: string; ts: number }; - expect(firstEntry.content).toBe('entry 102'); - - // The new entry (the assistant turn) must be present as the last line - const lastEntry = JSON.parse(resultLines[resultLines.length - 1]) as { role: string; content: string; ts: number }; - expect(lastEntry.role).toBe('assistant'); - expect(lastEntry.content).toBe('overflow trigger response'); - }); - - it('dispatch then capture preserves user turn in fresh queue', () => { - fs.mkdirSync(path.join(tmpDir, '.devflow', 'memory'), { recursive: true }); - writeStaleWorkingMemory(tmpDir); - - const queueFile = path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'); - - // Step 1: dream-dispatch writes user turn - execSync(`bash "${PROMPT_CAPTURE_HOOK}"`, { - input: JSON.stringify({ cwd: tmpDir, session_id: 'test-fresh', prompt: 'implement feature X' }), - stdio: ['pipe', 'pipe', 'pipe'], - }); - - // Step 2: dream-capture writes assistant turn - execSync(`bash "${STOP_HOOK}"`, { - input: JSON.stringify({ cwd: tmpDir, session_id: 'test-fresh', last_assistant_message: 'done' }), - stdio: ['pipe', 'pipe', 'pipe'], - }); - - const lines = fs.readFileSync(queueFile, 'utf-8').trim().split('\n').filter(Boolean); - expect(lines).toHaveLength(2); - const first = JSON.parse(lines[0]) as { role: string; content: string }; - const second = JSON.parse(lines[1]) as { role: string; content: string }; - expect(first.role).toBe('user'); - expect(first.content).toBe('implement feature X'); - expect(second.role).toBe('assistant'); - expect(second.content).toBe('done'); - }); - - it('dream-dispatch truncates prompts longer than 2000 chars', () => { - fs.mkdirSync(path.join(tmpDir, '.devflow', 'memory'), { recursive: true }); - - const longPrompt = 'a'.repeat(3000); - const input = JSON.stringify({ - cwd: tmpDir, - session_id: 'test-trunc-001', - prompt: longPrompt, - }); - - execSync(`bash "${PROMPT_CAPTURE_HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - - const queueFile = path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'); - const lines = fs.readFileSync(queueFile, 'utf-8').trim().split('\n').filter(Boolean); - expect(lines).toHaveLength(1); - - const entry = JSON.parse(lines[0]) as { role: string; content: string; ts: number }; - // Truncated at 2000 chars + '... [truncated]' suffix (15 chars) = 2015 - expect(entry.content.length).toBe(2015); - expect(entry.content).toContain('[truncated]'); - }); - - it('dream-capture truncates assistant content longer than 2000 chars', () => { - fs.mkdirSync(path.join(tmpDir, '.devflow', 'memory'), { recursive: true }); - // Write stale WORKING-MEMORY.md so throttle passes - writeStaleWorkingMemory(tmpDir); - - const longMessage = 'b'.repeat(5000); - const input = JSON.stringify({ - cwd: tmpDir, - session_id: 'test-trunc-002', - last_assistant_message: longMessage, - }); - - execSync(`bash "${STOP_HOOK}"`, { input, stdio: ['pipe', 'pipe', 'pipe'] }); - - const queueFile = path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'); - const lines = fs.readFileSync(queueFile, 'utf-8').trim().split('\n').filter(Boolean); - expect(lines).toHaveLength(1); - - const entry = JSON.parse(lines[0]) as { role: string; content: string; ts: number }; - // Truncated at 2000 chars + '... [truncated]' suffix (15 chars) = 2015 - expect(entry.content.length).toBe(2015); - expect(entry.content).toContain('[truncated]'); - }); - - it('dream-capture exits cleanly when DEVFLOW_BG_UPDATER=1', () => { - fs.mkdirSync(path.join(tmpDir, '.devflow', 'memory'), { recursive: true }); - - // Hook exits at line 11 before reading stdin, so don't pipe input — would race - // and EPIPE on Node 20 when bash closes the pipe before execSync flushes. - expect(() => { - execSync(`DEVFLOW_BG_UPDATER=1 bash "${STOP_HOOK}"`, { stdio: 'ignore' }); - }).not.toThrow(); - - const queueFile = path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'); - expect(fs.existsSync(queueFile)).toBe(false); - }); - - it('dream-dispatch exits cleanly when DEVFLOW_BG_UPDATER=1', () => { - fs.mkdirSync(path.join(tmpDir, '.devflow', 'memory'), { recursive: true }); - - expect(() => { - execSync(`DEVFLOW_BG_UPDATER=1 bash "${PROMPT_CAPTURE_HOOK}"`, { stdio: 'ignore' }); - }).not.toThrow(); - - const queueFile = path.join(tmpDir, '.devflow', 'memory', '.pending-turns.jsonl'); - expect(fs.existsSync(queueFile)).toBe(false); - }); -}); - // ============================================================================= // resolve-project-root — anchor .devflow/ to the project root (Fix 6) // ============================================================================= @@ -1034,20 +411,14 @@ describe('resolve-project-root: df_resolve_root', () => { }); describe('hooks anchor .devflow/ to the project root (no stray nested .devflow/)', () => { - const STOP_HOOK = path.join(HOOKS_DIR, 'dream-capture'); + const STOP_HOOK = path.join(HOOKS_DIR, 'capture-turn'); - it('dream-capture run with a CWD inside .devflow/ writes the queue at the repo root, not a nested .devflow/', () => { + it('capture-turn run with a CWD inside .devflow/ writes the queue at the repo root, not a nested .devflow/', () => { const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-anchor-')); try { execSync(`git init -q "${repo}"`, { stdio: 'pipe' }); const real = fs.realpathSync(repo); - // Pre-scaffold the real .devflow/memory and a fresh trigger so the hook stays - // throttled and never spawns a background worker during the test. - const memDir = path.join(real, '.devflow', 'memory'); - fs.mkdirSync(memDir, { recursive: true }); - fs.writeFileSync(path.join(memDir, '.working-memory-last-trigger'), ''); - // The hook runs with a CWD deep inside .devflow/ — the stray-nesting scenario. const nestedCwd = path.join(real, '.devflow', 'docs', 'waves', 'w', 'tickets'); fs.mkdirSync(nestedCwd, { recursive: true }); @@ -1588,126 +959,48 @@ describe('get-mtime behavioral', () => { }); }); -// ============================================================================= -// transcript-filter.cjs CLI handler -// ============================================================================= +describe('run-hook behavioral', () => { + const RUN_HOOK = path.join(HOOKS_DIR, 'run-hook'); + + it('exits 0 with a stderr warning when the named script is absent', () => { + // Covers the init upgrade-swap window and hand-edited settings.json entries + // that still point at a hook name that was renamed or removed (e.g. a + // pre-cutover dream-* registration lingering until the next `devflow init`). + // Exit is 0 (no throw), so the warning is captured via 2>&1 redirection + // rather than relying on execSync's catch-path stderr capture. + const output = execSync(`bash "${RUN_HOOK}" definitely-not-a-real-hook-name 2>&1`, { + stdio: ['ignore', 'pipe', 'pipe'], + }).toString(); + expect(output).toContain('definitely-not-a-real-hook-name'); + expect(output).toContain('not found'); + }); -describe('transcript-filter.cjs CLI', () => { - const FILTER = path.join(HOOKS_DIR, 'lib', 'transcript-filter.cjs'); + it('still execs a real hook script normally (get-mtime sourced via a no-op wrapper check)', () => { + // run-hook execs `bash