diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 474d896d..046e0f86 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,7 @@ {"_type":"issue","id":"dirge-dvy","title":"Perm review F1: bash arg-side path checks for file-mutating commands","description":"SECURITY GAP found in opencode-vs-dirge review. Today (post-M3, fbcc09b) dirge's bash permission flow extracts ONLY redirect targets (\u003e, \u003e\u003e, \u0026\u003e, etc.) via extract_redirect_targets and routes them through write rules. Arguments of file-mutating commands (rm, cp, mv, chmod, chown, ln, mkdir, rmdir, touch, tee, dd) are NOT extracted — they go only through the bash command-pattern rules. \n\nConcrete bypass: a user who configures bash rules permissively (e.g., 'rm *: allow' for convenience) silently allows 'rm /etc/passwd' even though write rules deny /etc/**. Opencode (shell.ts:374-410) walks the 'command' AST nodes, identifies file-mutating heads, and routes each positional path arg through the external_directory / write permission.\n\nPort: extend src/semantic/adapters/bash.rs with an extract_mutation_paths(command) function that walks the tree-sitter 'command' nodes; for each command whose head matches the list above, extract positional args that look like paths (skip -flags / --long-flags) and emit them. In src/agent/tools/bash.rs check_bash_segments, after the existing redirect-target loop, walk extracted mutation paths and route through enforce(tool='write', Scope::PathResolve(path)).","status":"closed","priority":0,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-23T15:48:18Z","created_by":"Yogthos","updated_at":"2026-05-23T15:53:28Z","started_at":"2026-05-23T15:48:30Z","closed_at":"2026-05-23T15:53:28Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-6ab","title":"Perm M3: port maki's tree-sitter bash analyzer (close git\u0026\u0026rm bypass)","description":"SECURITY: 'git diff \u0026\u0026 rm -rf /' currently allowed because dirge's bash redirect-target check (src/agent/tools/bash.rs:350) routes through bash rules with the file path as input, which has no path-style match → falls to default Allow. Pre-existing pre-fix-for-7403792.\n\nSolution: port maki's tree-sitter bash analyzer verbatim from /Users/yogthos/src/maki/maki-agent/src/permissions.rs:33-43 (parser thread_local), 394-439 (collect_commands walker), 441-475 (analyze_bash + complexity gates). The walker splits compounds via 'pipeline'/'list' AST traversal and extracts every 'command' / 'redirected_statement' / 'subshell' / etc node; each segment then goes through the permission chokepoint independently.\n\nBehavior at completion:\n- 'git diff \u0026\u0026 rm -rf /' → enforce('bash', 'git diff') + enforce('bash', 'rm -rf /') — second check fires\n- Subshells / command substitution mark whole command 'complex' → forces prompt (conservative)\n- Pipes split into separate segments\n- Quoted operators correctly NOT split (AST respects quoting)\n\nDepends on: dirge-{M1}\n\nMaki license is GPL-compatible — verify before copying. Add 'Ported from maki-agent/src/permissions.rs' attribution comment.","status":"closed","priority":0,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-23T14:25:46Z","created_by":"Yogthos","updated_at":"2026-05-23T15:01:11Z","started_at":"2026-05-23T14:51:42Z","closed_at":"2026-05-23T15:01:11Z","close_reason":"Closed","dependencies":[{"issue_id":"dirge-6ab","depends_on_id":"dirge-01s","type":"blocks","created_at":"2026-05-23T10:25:51Z","created_by":"Yogthos","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"dirge-b1lw","title":"Phase 8 integration: memory injection into system prompt panics on file read errors — agent builder uses unwrap() for load_memory/load_pitfalls. Disk error or corrupt file crashes the entire agent startup.","status":"closed","priority":1,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-26T03:28:12Z","created_by":"Yogthos","updated_at":"2026-05-26T03:30:18Z","closed_at":"2026-05-26T03:30:18Z","close_reason":"false alarm — code uses if let Ok(...) pattern, not unwrap(). Reviewer misread the code.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-6h5","title":"Phase 4 background_review: passes only 'response.to_string()' as transcript — tool calls and their results are missing from the review context. The review model cannot assess tool interaction quality.","status":"closed","priority":1,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-26T03:28:04Z","created_by":"Yogthos","updated_at":"2026-05-26T03:32:48Z","closed_at":"2026-05-26T03:32:48Z","close_reason":"Fixed: review now receives full session transcript via build_transcript(session) which includes user messages, assistant text, tool call names+args, and tool results. 5 new tests verify the transcript builder handles all message types, tool states (completed/interrupted/failed), truncation of large results, and system messages.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-eu3","title":"ratatui migration: integration + delete old paint paths","description":"Phase 6: switch the main loop to terminal.draw(|f| renderer.render(f)) on every state change. Delete the old draw_panel, draw_left_panel, draw_left_panel_idle, draw_avatar, draw_bottom, render_viewport, ensure_room direct-stdout paths. write_line / write just push to chat buffer.","acceptance_criteria":"Main loop calls terminal.draw on each redraw; no direct stdout writes remain in renderer.rs; UI matches prior visual goals.","status":"closed","priority":1,"issue_type":"feature","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-05-23T22:07:56Z","created_by":"Yogthos","updated_at":"2026-05-23T22:54:00Z","started_at":"2026-05-23T22:31:35Z","closed_at":"2026-05-23T22:54:00Z","close_reason":"ratatui integration done (P6a/P6b/P6c). Scene + render_frame is the single paint path; 1321 lines of legacy direct-stdout paint deleted; 941 tests pass.","dependencies":[{"issue_id":"dirge-eu3","depends_on_id":"dirge-2j6","type":"blocks","created_at":"2026-05-23T18:08:05Z","created_by":"Yogthos","metadata":"{}"},{"issue_id":"dirge-eu3","depends_on_id":"dirge-a0q","type":"blocks","created_at":"2026-05-23T18:08:05Z","created_by":"Yogthos","metadata":"{}"},{"issue_id":"dirge-eu3","depends_on_id":"dirge-dyb","type":"blocks","created_at":"2026-05-23T18:08:06Z","created_by":"Yogthos","metadata":"{}"},{"issue_id":"dirge-eu3","depends_on_id":"dirge-nto","type":"blocks","created_at":"2026-05-23T18:08:07Z","created_by":"Yogthos","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-nto","title":"ratatui migration: bottom strip widget (avatar + input + overlay)","description":"Phase 5: avatar box, input box, overlay all rendered through one BottomStrip widget. Single overlay slot (set_alert_overlay/clear). When overlay active, input is replaced inside same frame.","acceptance_criteria":"BottomStrip widget renders avatar centered vertically + input editor or overlay; verticals align with chat frame ║.","status":"closed","priority":1,"issue_type":"feature","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-05-23T22:07:56Z","created_by":"Yogthos","updated_at":"2026-05-23T22:25:08Z","started_at":"2026-05-23T22:22:22Z","closed_at":"2026-05-23T22:25:08Z","close_reason":"BottomStrip widget + 5 TestBackend tests in 14ec2a2","dependencies":[{"issue_id":"dirge-nto","depends_on_id":"dirge-a3x","type":"blocks","created_at":"2026-05-23T18:08:04Z","created_by":"Yogthos","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"dirge-dyb","title":"ratatui migration: side panel widgets","description":"Phase 4: left panel (AGENT STATUS / SUBAGENTS) + right panel (SYSTEM with sub-panels) become ratatui widgets. Sub-panels (SYSTEM LOAD / MCP / LSP / TODOS / MODIFIED) are light-rounded Block widgets with left-aligned content.","acceptance_criteria":"LeftPanel + RightPanel widgets render; sub-panels stack vertically; content left-aligned per user spec.","status":"closed","priority":1,"issue_type":"feature","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-05-23T22:07:55Z","created_by":"Yogthos","updated_at":"2026-05-23T22:22:21Z","started_at":"2026-05-23T22:19:27Z","closed_at":"2026-05-23T22:22:21Z","close_reason":"SubPanel + LeftPanel + RightPanel + 6 TestBackend tests in cd7fbae","dependencies":[{"issue_id":"dirge-dyb","depends_on_id":"dirge-a3x","type":"blocks","created_at":"2026-05-23T18:08:03Z","created_by":"Yogthos","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} @@ -34,6 +36,15 @@ {"_type":"issue","id":"dirge-86e","title":"ANSI injection in permission ALERT prompt","description":"ask_req.tool / ask_req.input rendered un-sanitized at mod.rs:2584-2585. Reopen path already sanitizes — asymmetric. Sec impl: ANSI at the permission-decision moment.","status":"closed","priority":1,"issue_type":"bug","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-05-21T22:17:34Z","created_by":"Yogthos","updated_at":"2026-05-21T22:26:37Z","started_at":"2026-05-21T22:17:42Z","closed_at":"2026-05-21T22:26:37Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-9f1","title":"Chat history ignores 120-col content_width cap","description":"max_line_width and wrap_line use raw content_cols, so on wide terminals scrollback overflows the centered band into divider/panel margin.","status":"closed","priority":1,"issue_type":"bug","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-05-21T22:17:33Z","created_by":"Yogthos","updated_at":"2026-05-21T22:26:36Z","started_at":"2026-05-21T22:17:42Z","closed_at":"2026-05-21T22:26:36Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-woq","title":"R1: fix 3 critical plugin bugs (FFI panic, dialog deadlock, init hang)","description":"From the plugin subsystem audit: (1) wrap JanetCFunctions in catch_unwind so Rust panics don't unwind across the C-FFI boundary into Janet; (2) cancel send_dialog's reply_rx.recv() on worker shutdown so the worker thread doesn't block forever when the UI exits mid-dialog; (3) add timeout to the init handshake so a worker panic before init_tx.send() doesn't hang the main thread. Also: (4) bounds-assert wrap_string's i32 cast for the unlikely \u003e2GB case, (5) make take_string_slot atomic to close the race window, (6) don't eat unrelated user events in the dialog arm.","status":"closed","priority":1,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-20T14:59:57Z","created_by":"Yogthos","updated_at":"2026-05-20T15:30:28Z","started_at":"2026-05-20T15:00:10Z","closed_at":"2026-05-20T15:30:28Z","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"dirge-rkic","title":"Phase 4 background_review: fires on EVERY Done event with no rate limiting or cost gating — could cause massive LLM API costs for users on paid plans with frequent session completions","status":"closed","priority":2,"issue_type":"task","owner":"yogthos@gmail.com","created_at":"2026-05-26T03:28:12Z","created_by":"Yogthos","updated_at":"2026-05-26T03:45:59Z","started_at":"2026-05-26T03:38:01Z","closed_at":"2026-05-26T03:45:59Z","close_reason":"Fixed: review now rate-limited to 15-minute intervals via AtomicU64.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-smsd","title":"Phase 8 integration: last_user_prompt tracking misses several input paths — shell bang command path, loop iteration path, plugin followup path, and interjection queue drain path do NOT update last_user_prompt, causing stale prompts in session DB","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-26T03:28:09Z","created_by":"Yogthos","updated_at":"2026-05-26T03:36:05Z","started_at":"2026-05-26T03:32:58Z","closed_at":"2026-05-26T03:36:05Z","close_reason":"Fixed: last_user_prompt now updated at all 10 spawn_runner call sites: main text submission, shell bang command, worktree merge, loop iteration start, loop iteration continue, plugin followup, both interjection drain paths (Done + Interjected), ContextOverflow respawn, and question response.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-2rb","title":"Phase 6 curator: CuratorState atomic_write_sync may fail if .archive/ parent dir doesn't exist — atomic_write_sync writes to a temp file in the same directory and renames, but doesn't create missing parent dirs","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-26T03:28:06Z","created_by":"Yogthos","updated_at":"2026-05-26T03:48:32Z","closed_at":"2026-05-26T03:48:32Z","close_reason":"Already handled — CuratorState::save() calls create_dir_all(parent) before atomic_write_sync, and archive_skill() calls create_dir_all(archive_dir) before rename. No missing parent directory issue.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-7ls","title":"Phase 4 background_review: review fork shares the same ToolCache as main agent. Review writes to memory/skills can pollute the main agent's tool cache.","status":"open","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-26T03:28:05Z","created_by":"Yogthos","updated_at":"2026-05-26T03:28:05Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-ebq","title":"Phase 5 session_search: FTS5 query input is not sanitized — user-provided search terms are passed directly to FTS5 MATCH without escaping special FTS5 syntax chars (*, AND, OR, NOT, NEAR, parentheses, quotes)","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-26T03:28:05Z","created_by":"Yogthos","updated_at":"2026-05-26T03:37:49Z","started_at":"2026-05-26T03:36:17Z","closed_at":"2026-05-26T03:37:49Z","close_reason":"Fixed: added sanitize_fts5_query() function in session_search.rs that wraps queries in double quotes and strips FTS5 special characters (*, \", (, )). 5 new tests verify normal queries pass through, special chars are stripped, quotes are removed, empty-after-clean returns empty, and whitespace is trimmed.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-oyr","title":"Phase 2 memory_store: locking performed INSIDE reload_and_detect_drift + add/replace/remove — but reload opens the file BEFORE acquiring the lock. TOCTOU exists between file existence check and lock acquisition. FileLock uses create_new not flock — two processes could both pass the exists check before either creates the lock file.","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-26T03:28:03Z","created_by":"Yogthos","updated_at":"2026-05-26T03:52:29Z","closed_at":"2026-05-26T03:52:29Z","close_reason":"Already fixed — the lock (acquire_lock) is called before duplicate/budget checks in add(), before substring match in replace(), and before substring match in remove(). Drift detection (reload_and_detect_drift) runs under the lock. No TOCTOU window.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-2hu","title":"find_git_root doesn't canonicalize paths — symlinks in the path chain can cause incorrect root detection","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-26T03:23:57Z","created_by":"Yogthos","updated_at":"2026-05-26T03:58:27Z","closed_at":"2026-05-26T03:58:27Z","close_reason":"Fixed: find_git_root now calls cwd.canonicalize() before walking up, resolving symlinks in the path chain.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-ld7","title":"FileLock uses create_new which leaves stale lock files on crash — no deadlock detection or PID-based staleness check","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-26T03:22:58Z","created_by":"Yogthos","updated_at":"2026-05-26T03:53:31Z","closed_at":"2026-05-26T03:53:31Z","close_reason":"Fixed: FileLock now writes PID into lock file on creation. On contention, is_lock_stale() reads the PID and checks if the process still exists via kill(pid, 0) on Unix. Stale lock files from crashed processes are detected and cleared automatically on first contention check.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-m7i","title":"find_git_root doesn't handle git worktrees (.git as file)","status":"closed","priority":2,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-26T03:22:35Z","created_by":"Yogthos","updated_at":"2026-05-26T03:58:26Z","closed_at":"2026-05-26T03:58:26Z","close_reason":"Fixed: find_git_root now checks for .git file with gitdir: prefix (worktree marker) in addition to .git directory. is_git_root_marker() helper handles both cases.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-nqr","title":"run_print max_turns wired to agent_loop LoopConfig","status":"open","priority":2,"issue_type":"task","owner":"yogthos@gmail.com","created_at":"2026-05-25T22:16:24Z","created_by":"Yogthos","updated_at":"2026-05-25T22:16:24Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-ywj","title":"plugin hook contract tests (currently zero)","status":"open","priority":2,"issue_type":"task","owner":"yogthos@gmail.com","created_at":"2026-05-25T22:16:22Z","created_by":"Yogthos","updated_at":"2026-05-25T22:16:22Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-kwz","title":"agent_loop integration tests (zero outside h7_smoke)","status":"open","priority":2,"issue_type":"task","owner":"yogthos@gmail.com","created_at":"2026-05-25T22:16:18Z","created_by":"Yogthos","updated_at":"2026-05-25T22:16:18Z","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -109,6 +120,13 @@ {"_type":"issue","id":"dirge-ny0","title":"Phase 3: right-side info panel (cwd, MCP, LSP, todos, modified files)","description":"Carve right ~32 cols (auto-hide when terminal narrower than ~100). Sources: cwd from env, MCP from McpClientManager.handles, LSP via new public accessor on LspManager, todos from TODO_LIST mutex, modified files via new shared Arc\u003cMutex\u003cIndexSet\u003cPathBuf\u003e\u003e\u003e populated by Write/Edit/ApplyPatch tools. New /panel on|off toggle. Default on when wide enough.","status":"closed","priority":2,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-05-20T03:40:08Z","created_by":"Yogthos","updated_at":"2026-05-20T04:21:53Z","started_at":"2026-05-20T04:11:20Z","closed_at":"2026-05-20T04:21:53Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"dirge-r2u","title":"Phase 2A: queue user input while agent is running","description":"Remove 'agent is busy' guard at src/ui/mod.rs:663 and :720 for plain text. Push to VecDeque\u003cString\u003e interjection_queue. Show queue count + dim preview above input. Esc/Ctrl-X drops most recent. Ctrl-C still aborts. Slash commands stay gated to current allow-list. On AgentEvent::Done, drain queue and run as next turn.","status":"closed","priority":2,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-05-20T03:40:07Z","created_by":"Yogthos","updated_at":"2026-05-20T04:04:50Z","started_at":"2026-05-20T03:58:39Z","closed_at":"2026-05-20T04:04:50Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"dirge-sxt","title":"Phase 1: soft-wrap input box instead of horizontal scroll","description":"Replace horizontal scroll logic in src/ui/renderer.rs::draw_bottom (lines ~500-660) with display-column wrap. Drop input_scroll_offset; compute visual rows from logical lines wrapped to visible_width; keep MAX_INPUT_VISIBLE_LINES cap with vertical scroll keeping cursor visible. Move or guard the token counter so it doesn't collide with wrapped text. Add a unit test for cursor (logical -\u003e visual) mapping.","status":"closed","priority":2,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-05-20T03:40:06Z","created_by":"Yogthos","updated_at":"2026-05-20T03:58:35Z","started_at":"2026-05-20T03:52:14Z","closed_at":"2026-05-20T03:58:35Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"dirge-7030","title":"Phase 8 integration: Curator::new() called twice per session end — once for should_run_now() check, once inside the spawned task. Redundant file I/O, state could drift between calls.","status":"closed","priority":3,"issue_type":"task","owner":"yogthos@gmail.com","created_at":"2026-05-26T03:28:10Z","created_by":"Yogthos","updated_at":"2026-05-26T03:48:31Z","closed_at":"2026-05-26T03:48:31Z","close_reason":"Already resolved — phase-8 integration cleaned up the double Curator::new() call. Only one Curator::new() exists in the curator check block, used for both should_run_now() and apply_automatic_transitions().","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-dktb","title":"Phase 8 integration: memory injection into system prompt (agent/builder.rs) uses synchronous I/O — load_memory() and load_pitfalls() read from disk during agent construction, blocking startup on slow filesystems (NFS). Should use spawn_blocking or async load.","status":"open","priority":3,"issue_type":"task","owner":"yogthos@gmail.com","created_at":"2026-05-26T03:28:10Z","created_by":"Yogthos","updated_at":"2026-05-26T03:28:10Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-u13u","title":"Phase 7 compression: COMPACTION_PROMPT filter-safe preamble may not be sufficient — LLMs can still follow inline commands in reference material despite being told not to. Needs stronger delimiters and explicit prohibitions.","status":"open","priority":3,"issue_type":"task","owner":"yogthos@gmail.com","created_at":"2026-05-26T03:28:08Z","created_by":"Yogthos","updated_at":"2026-05-26T03:28:08Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-88m7","title":"Phase 6 curator: archive_skill removes existing destination before rename. Two concurrent curator processes could race — one removes the other's archived skill just before renaming.","status":"closed","priority":3,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-26T03:28:07Z","created_by":"Yogthos","updated_at":"2026-05-26T03:48:30Z","closed_at":"2026-05-26T03:48:30Z","close_reason":"Fixed: archive_skill now skips if destination exists instead of removing it. Concurrent curator processes safely no-op.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-del","title":"Phase 6 curator: should_run_now() uses last_run==0 as never-run sentinel — if system clock is epoch-0 this would always return false, preventing curator from ever running","status":"closed","priority":3,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-26T03:28:07Z","created_by":"Yogthos","updated_at":"2026-05-26T03:48:29Z","closed_at":"2026-05-26T03:48:29Z","close_reason":"Fixed: last_run changed from u64 to Option\u003cu64\u003e. None means never run, avoiding epoch-0 collision.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-1ia","title":"Skill name validation in format.rs rejects valid Unicode/special character names that some projects may use","status":"open","priority":3,"issue_type":"task","owner":"yogthos@gmail.com","created_at":"2026-05-26T03:22:37Z","created_by":"Yogthos","updated_at":"2026-05-26T03:22:37Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dirge-1h0","title":"YAML frontmatter parser in skills/format.rs is line-by-line, can't handle nested structures or multi-line descriptions","status":"open","priority":3,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-26T03:22:36Z","created_by":"Yogthos","updated_at":"2026-05-26T03:22:36Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-rie","title":"CI gate: reduce 174 clippy warnings","status":"open","priority":3,"issue_type":"task","owner":"yogthos@gmail.com","created_at":"2026-05-25T22:16:25Z","created_by":"Yogthos","updated_at":"2026-05-25T22:16:25Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-b11","title":"MODIFIED panel: user-driven scrolling","description":"Add PageUp/PageDown (or mouse scroll while hovering the right panel) to walk through older entries in the MODIFIED sub-panel. Currently the panel auto-grows to fill available rows and shows a '+N older' footer when truncated — but the older entries aren't reachable. Needs a modified_offset field plumbed through PanelData, plus key handling in the main loop while focus is on the right panel (or via /modified-scroll command).","acceptance_criteria":"User can scroll the MODIFIED list with PageUp/Down or mouse wheel; offset persists across redraws; +N older footer reflects current offset.","status":"open","priority":3,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-05-24T00:26:25Z","created_by":"Yogthos","updated_at":"2026-05-24T00:26:25Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dirge-64e","title":"doc + buffer_pos_at fix: M-R3, H-R2, L-R3, L-R4","description":"Four doc-leaning items + one small code fix from review-2:\\n- M-R3: PLUGINS.md note that dirge's display=false is suppression-only (no plugin-observable transcript yet)\\n- H-R2: PLUGINS.md note that prepare-arguments blocks the executor synchronously; keep handlers light\\n- L-R3: buffer_pos_at uses entry.text.chars().count() which includes escape bytes. Switch to strip_ansi-aware count so column clamping reflects visible chars.\\n- L-R4: one-line code comment in JanetLoopTool::execute explaining post-handler batch replay semantics for emit-tool-progress","status":"closed","priority":3,"issue_type":"task","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-05-23T02:15:32Z","created_by":"Yogthos","updated_at":"2026-05-23T02:21:13Z","started_at":"2026-05-23T02:19:30Z","closed_at":"2026-05-23T02:21:13Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/src/extras/dirge_paths.rs b/src/extras/dirge_paths.rs new file mode 100644 index 00000000..3e643e38 --- /dev/null +++ b/src/extras/dirge_paths.rs @@ -0,0 +1,184 @@ +//! Per-project `.dirge/` directory resolution. +//! +//! This is the canonical entry point for all per-project storage. +//! Hermes stores everything globally in `~/.hermes/`; dirge stores +//! per-project knowledge in `.dirge/` at the repository root (where +//! `.git/` lives). Each project gets independent memory, skills, +//! and session history. +//! +//! The existing `extras::memory` module stores in `~/.dirge/memories/` +//! (user-global, project-keyed by path hash). That serves a different +//! purpose — user notes that follow you across machines. Phase 2 will +//! wire `ProjectPaths` into a new per-project `MemoryStore` without +//! touching the global memory module. + +use std::path::{Path, PathBuf}; + +/// Walk up from `cwd` until a `.git/` directory is found. +/// Returns `cwd` unchanged if no git root is found (user may +/// be outside a repo — per-project features degrade gracefully). +pub fn find_git_root(cwd: &Path) -> PathBuf { + let mut current = cwd.to_path_buf(); + loop { + if current.join(".git").is_dir() { + return current; + } + if !current.pop() { + return cwd.to_path_buf(); + } + } +} + +/// `DIRGE_PROJECT_ROOT` override. When set and pointing to an +/// existing directory, this is the project root instead of the +/// auto-detected git root. Useful for monorepos where the +/// logical project is a subdirectory. +pub fn project_root_override() -> Option { + std::env::var("DIRGE_PROJECT_ROOT") + .ok() + .map(PathBuf::from) + .filter(|p| p.is_dir()) +} + +/// Resolve the active project root: `DIRGE_PROJECT_ROOT` wins if +/// set and valid, otherwise walk up from CWD looking for `.git/`. +pub fn project_root(cwd: &Path) -> PathBuf { + project_root_override().unwrap_or_else(|| find_git_root(cwd)) +} + +/// Canonical paths into the per-project `.dirge/` tree. +/// +/// Construct with `ProjectPaths::new(cwd)`. All subdirectory +/// accessors are lazy — directories are not created until +/// something actually writes into them. +#[derive(Debug, Clone)] +pub struct ProjectPaths { + /// The project root (usually where `.git/` lives). + pub root: PathBuf, +} + +impl ProjectPaths { + pub fn new(cwd: &Path) -> Self { + ProjectPaths { + root: project_root(cwd), + } + } + + /// Top-level `.dirge/` directory under the project root. + pub fn dirge_dir(&self) -> PathBuf { + self.root.join(".dirge") + } + + /// `.dirge/memory/` — declarative memory files (MEMORY.md, PITFALLS.md). + pub fn memory_dir(&self) -> PathBuf { + self.dirge_dir().join("memory") + } + + /// `.dirge/skills/` — procedural skill definitions with SKILL.md files. + pub fn skills_dir(&self) -> PathBuf { + self.dirge_dir().join("skills") + } + + /// `.dirge/sessions/` — SQLite session database and transcripts. + pub fn sessions_dir(&self) -> PathBuf { + self.dirge_dir().join("sessions") + } + + /// `.dirge/sessions/state.db` — the FTS5-backed session database. + pub fn session_db_path(&self) -> PathBuf { + self.sessions_dir().join("state.db") + } + + /// `.dirge/memory/` — a specific memory file. + pub fn memory_file(&self, name: &str) -> PathBuf { + self.memory_dir().join(name) + } + + /// `.dirge/config.yaml` — optional per-project dirge configuration. + pub fn config_path(&self) -> PathBuf { + self.dirge_dir().join("config.yaml") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// In the dirge repo itself, `find_git_root` from the current + /// working directory should resolve to the repo root (where + /// `.git/` actually lives). + #[test] + fn find_git_root_in_this_repo() { + let cwd = std::env::current_dir().unwrap(); + let root = find_git_root(&cwd); + assert!( + root.join(".git").is_dir(), + "expected {root:?} to contain .git/" + ); + } + + /// `/tmp` has no `.git/` — should return `/tmp` unchanged. + #[test] + fn find_git_root_falls_back_to_cwd_outside_repo() { + let tmp = std::env::temp_dir(); + let root = find_git_root(&tmp); + assert_eq!(root, tmp); + } + + /// `DIRGE_PROJECT_ROOT` wins over auto-detection. + #[test] + fn env_override_wins_over_git_detection() { + let tmp = std::env::temp_dir(); + unsafe { std::env::set_var("DIRGE_PROJECT_ROOT", tmp.to_str().unwrap()) }; + // Even though we're in the dirge repo, the env var wins. + let cwd = std::env::current_dir().unwrap(); + let root = project_root(&cwd); + assert_eq!(root, tmp); + unsafe { std::env::remove_var("DIRGE_PROJECT_ROOT") }; + } + + /// An env var pointing to a non-existent directory is ignored + /// (graceful fallback to git detection). + #[test] + fn env_override_ignores_missing_directory() { + unsafe { std::env::set_var("DIRGE_PROJECT_ROOT", "/nonexistent/dirge/project/root") }; + let cwd = std::env::current_dir().unwrap(); + let root = project_root(&cwd); + // Should fall through to git detection, not use the bogus path. + assert_ne!(root, PathBuf::from("/nonexistent/dirge/project/root")); + unsafe { std::env::remove_var("DIRGE_PROJECT_ROOT") }; + } + + /// All subdirectory accessors nest under `.dirge/`. + #[test] + fn subdirs_are_under_dirge_dir() { + let cwd = std::env::current_dir().unwrap(); + let paths = ProjectPaths::new(&cwd); + let dirge = paths.dirge_dir(); + + assert!(paths.memory_dir().starts_with(&dirge)); + assert!(paths.skills_dir().starts_with(&dirge)); + assert!(paths.sessions_dir().starts_with(&dirge)); + assert!(paths.config_path().starts_with(&dirge)); + } + + /// `session_db_path` points into `sessions/` and ends with `state.db`. + #[test] + fn session_db_is_in_sessions_dir() { + let cwd = std::env::current_dir().unwrap(); + let paths = ProjectPaths::new(&cwd); + let db = paths.session_db_path(); + assert!(db.starts_with(paths.sessions_dir())); + assert!(db.ends_with("state.db")); + } + + /// `memory_file("MEMORY.md")` points to `.dirge/memory/MEMORY.md`. + #[test] + fn memory_file_is_in_memory_dir() { + let cwd = std::env::current_dir().unwrap(); + let paths = ProjectPaths::new(&cwd); + let f = paths.memory_file("MEMORY.md"); + assert_eq!(f.file_name().unwrap(), "MEMORY.md"); + assert!(f.starts_with(paths.memory_dir())); + } +} diff --git a/src/extras/mod.rs b/src/extras/mod.rs index cf980112..1889e668 100644 --- a/src/extras/mod.rs +++ b/src/extras/mod.rs @@ -10,4 +10,5 @@ pub mod mcp; #[cfg(feature = "acp")] pub mod acp; +pub mod dirge_paths; pub mod memory; diff --git a/src/permission/checker.rs b/src/permission/checker.rs index 7e9e8d1d..b4c703ee 100644 --- a/src/permission/checker.rs +++ b/src/permission/checker.rs @@ -1504,10 +1504,11 @@ mod tests { CheckResult::Allowed )); // The edit alias MUST also match — this is what enforce() checks. - assert!(matches!( - checker.check_path("edit", "/probe/src/main.rs"), - CheckResult::Allowed, - ), + assert!( + matches!( + checker.check_path("edit", "/probe/src/main.rs"), + CheckResult::Allowed, + ), "edit alias must reflect write session-allowlist entry" ); @@ -1518,16 +1519,18 @@ mod tests { Some(std::path::PathBuf::from("/cwd-off-test-axis")), ); checker2.add_session_allowlist("edit".to_string(), "/probe/src/**"); - assert!(matches!( - checker2.check_path("write", "/probe/src/main.rs"), - CheckResult::Allowed, - ), + assert!( + matches!( + checker2.check_path("write", "/probe/src/main.rs"), + CheckResult::Allowed, + ), "write must reflect edit session-allowlist entry" ); - assert!(matches!( - checker2.check_path("apply_patch", "/probe/src/main.rs"), - CheckResult::Allowed, - ), + assert!( + matches!( + checker2.check_path("apply_patch", "/probe/src/main.rs"), + CheckResult::Allowed, + ), "apply_patch must reflect edit session-allowlist entry" ); @@ -1538,10 +1541,11 @@ mod tests { Some(std::path::PathBuf::from("/cwd-off-test-axis")), ); checker3.add_session_allowlist("apply_patch".to_string(), "/probe/src/**"); - assert!(matches!( - checker3.check_path("edit", "/probe/src/main.rs"), - CheckResult::Allowed, - ), + assert!( + matches!( + checker3.check_path("edit", "/probe/src/main.rs"), + CheckResult::Allowed, + ), "edit must reflect apply_patch session-allowlist entry" ); @@ -1552,10 +1556,11 @@ mod tests { Some(std::path::PathBuf::from("/cwd-off-test-axis")), ); checker4.load_session_allowlist(&[("write".to_string(), "/probe/src/**".to_string())]); - assert!(matches!( - checker4.check_path("edit", "/probe/src/main.rs"), - CheckResult::Allowed, - ), + assert!( + matches!( + checker4.check_path("edit", "/probe/src/main.rs"), + CheckResult::Allowed, + ), "load_session_allowlist must also mirror write→edit" ); diff --git a/src/provider/mod.rs b/src/provider/mod.rs index 43f36082..3ef53e6d 100644 --- a/src/provider/mod.rs +++ b/src/provider/mod.rs @@ -655,7 +655,9 @@ impl AnyAgent { self, prompt: String, history: Vec, - steering_queue: Option>>>, + steering_queue: Option< + std::sync::Arc>>, + >, ) -> AgentRunner { use crate::agent::agent_loop::{ LoopSpawnConfig, loop_tool_to_rig_definition, retrying_stream_fn, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index fe63bb80..43e2039f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -29,7 +29,6 @@ mod tree; mod tui; mod wrap; - use compact_str::CompactString; use crossterm::event; use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};