Skip to content

Latest commit

Β 

History

History
2817 lines (2247 loc) Β· 126 KB

File metadata and controls

2817 lines (2247 loc) Β· 126 KB

Lash Development Log

2026-08-07 - Rename lash-cli package to lash; first release (v0.1.0)

Summary

Renamed the lash-cli package to lash so release artifacts and installer scripts are named lash-* instead of lash-cli-* (the directory remains crates/lash-cli; the binary was already lash). Updated the library crate references (lash_cli:: β†’ lash::), cargo uninstall instructions (install script keeps a lash-cli fallback for old installs), and -p lash-cli command examples in the docs. Tagged and pushed v0.1.0, the first public release, built and published by the cargo-dist workflow.

The first release run stalled: dist 0.28 assigns Mac builds to the retired macos-13 runner label, so those jobs queued indefinitely. Fixed with [dist.github-custom-runners] pinning both Mac targets to macos-14 (x86_64 cross-compiled), then re-pointed the v0.1.0 tag at the fix and re-ran the release.

2026-08-07 - Release automation: cargo-dist, changelog, release badge

Summary

Set up automated releases with cargo-dist (v0.28.7, astral-sh fork). dist init added dist-workspace.toml, a [profile.dist] build profile, and a tag-triggered .github/workflows/release.yml that builds lash binaries for Linux (x86_64/aarch64), macOS (x86_64/aarch64), and Windows (x86_64), generates shell/PowerShell installers, and publishes a GitHub Release on every vX.Y.Z tag. Added CHANGELOG.md (Keep a Changelog format) with a 0.1.0 section that dist will use for release notes, documented the release process for maintainers in CONTRIBUTING.md, and replaced the hardcoded version in the README status line with a self-updating GitHub release badge plus prebuilt-binary install instructions. Cutting the first release is now: bump version, update changelog, tag v0.1.0, push.

2026-08-07 - Open-source readiness: prune stale planning docs

Summary

Removed stale point-in-time planning and analysis documents ahead of the public release: the v1.0 development plan, dated performance/coverage snapshot reports, task-numbered implementation plans, and the -summary docs that duplicated docs/dependency-graph-architecture.md and docs/indexing-architecture.md. Updated tasks/ and .lashignore references that pointed at the removed files so the surviving docs remain the single source for architecture guidance.

2026-08-06 - Open-source readiness: repo hygiene

Summary

Prepared the repository for public release. Removed local development-tool configuration from version control (now gitignored), pointed development-practice references in the planning and task docs at CONTRIBUTING.md, normalized commit metadata across the full history, and pruned stale remote branches. History was rewritten and force-pushed, so existing clones must be re-cloned or hard-reset to origin/main.

  • Commit: 62dc0f6 (config/doc cleanup; history normalization applied repo-wide)

2026-08-05 - lash update command (#25)

Summary

Every task mutation besides status changes required hand-editing Markdown. The worst hazard: retitling a task changes its derived id (file#first-40- chars-of-kebab-title) when the task has no explicit @id:, silently orphaning every @depends-on reference in the project that pointed at the old slug. Added lash update <task-id> [FLAGS] to close that gap.

  • --title <text> β€” rewrites the task's title. If the task has no explicit @id: (its id is title-derived), the old derived slug is pinned as an explicit @id: first, then the title changes β€” so existing @depends-on references keep resolving. Prints an informational pinned @id: <slug> to preserve references line. Tasks that already carry an explicit @id: are unaffected. Trailing inline #label tokens on the title line are preserved across the rewrite.
  • --add-label / --remove-label (repeatable) β€” edits whichever form the task already uses (inline #tag on the title line, or an @labels: annotation), defaulting to the inline form for a task with no labels yet, matching how lash add --label writes new tasks.
  • --owner / --estimate β€” set, replace, or (given "") remove the annotation.
  • --agent-note (replace, including any existing multi-line continuation) / --append-agent-note (add a continuation line, creating the note if absent).
  • --add-depends-on (repeatable) β€” validated against the current project via the same resolver lash add --depends-on uses (add_dependency_check::validate_depends_on); an unresolvable reference is a hard error with the file left untouched, unless --allow-forward-ref downgrades it to a warning. --remove-depends-on (repeatable) β€” matches by exact reference string, errors if absent.
  • --dry-run prints a unified diff of the affected lines (new DiffDisplay::unified_diff, reusing the existing diff machinery instead of the fix/diagnostic-specific format_fix_diff path) without writing; --json; re-indexes atomically after a real write, same as complete/waive.
  • At least one mutation flag is required.

Implementation

Edits are targeted line splices on the raw Markdown (the codebase's established pattern β€” see status_mutation.rs's checkbox rewrite and waive.rs's insert_reason_note), not a full re-serialization through the creation emitter, so untouched content survives byte-for-byte. New module crates/lash-cli/src/commands/update/:

  • mutations.rs β€” TaskLines, a small type over a task file's lines that knows where the task's own checkbox line is and can locate its annotation block (mirroring the parser's own lookahead in parse_task_section_internal, including its one-blank-line tolerance and multi-line continuation support). Primitives: retitle (label-suffix preserving), inline/@labels: label add/remove, single-value annotation set/clear, always-first @id: pin, @agent-note replace/append, @depends-on add (grouped with existing entries)/remove (comma-list aware).
  • apply.rs β€” validates every flag before touching TaskLines (dangling --add-depends-on, missing --remove-label/ --remove-depends-on targets), so a failure never leaves a partially-edited file; only writes to disk once the whole plan succeeds.
  • mod.rs β€” CLI orchestration, resolution via utils::task_target::resolve_task_target (fuzzy did-you-mean on not-found, same as complete/waive), JSON/text output, exit codes (0/1/3/5).

Tests

  • 29 unit tests for the TaskLines primitives and UpdateArgs::has_mutation.
  • 19 e2e tests in crates/lash-cli/tests/update_command_test.rs, including the key round-trip: a two-file fixture with a cross-file @depends-on, retitle the dependency target, assert the pinned @id round-trips through lash lint clean and lash show still resolves the reference. Also covers: retitle of an already-@id'd task (no duplicate pin), label add/remove (and not-found), owner/estimate set+clear, agent-note replace/append, dependency add (valid/dangling/forward-ref)/remove (found/not-found), dry-run (no file changes), no-flags error, not-found-with-suggestions, JSON success/error, and reindex-without-a- separate-lash-index-step.
  • Agent docs updated (crates/lash-agent/src/content.rs: TOP_LEVEL_SUBCOMMANDS, cli_reference(), dependencies_reference()); agent_prompt_output insta snapshot re-recorded to match.

2026-08-06 - lash show displays the full task record (#26)

Summary

lash show <task-id> printed ID/Title/Status/File/Owner/Estimate/Labels/ Docs/Body/Notes, but silently dropped the fields agents most need to act on a task without re-reading the whole file: @agent-note, @depends-on status, and progress on children. Extended the default (non---short) output with:

  • Agent note β€” full @agent-note content, multi-line, line breaks preserved under an "Agent note:" heading.
  • Depends on (N/M satisfied) β€” each @depends-on reference resolved via the shared lash_core::dependency::reference::resolve_reference (reparsing the project fresh, same approach as lash complete's unmet- dependency gate β€” markdown is the source of truth) to its current status, e.g. βœ“ [done] Set up payment provider (launch#pay-flow) / βœ— [open] ... / βœ— [unresolved] some-dangling-ref. A dangling reference reports as unresolved rather than crashing show β€” that diagnosis is check-links's job. Directory-kind deps are skipped (out of scope for a single task's detail view).
  • Children (N/M done) β€” one line per direct child (both @id-tagged and plain-bullet-with-checkbox) with its checkbox state and a "N nested" suffix when a child has its own descendants, via the existing TaskRepository::get_children/get_descendants.
  • Any custom annotation (e.g. @created) already captured in TaskMetadata.custom now prints too.
  • New --short flag restores exactly the terse ID/Title/Status/File/Labels view for scripts that depend on it.
  • --json gained top-level agent_note, depends_on (items + satisfied/ total), and children (items + done/total) fields; --short --json mirrors the terse text view.

Parser bug fix (blocking, found while testing this): task-level multi-line annotation continuation (e.g. a multi-line @agent-note) never actually worked. parser/mod.rs's annotation-lookahead loop checked trimmed.starts_with(' ') where trimmed had already had its leading whitespace stripped β€” always false, so continuation lines were silently dropped after the first line. The file-header equivalent in header.rs checked the untrimmed line correctly; task-level code didn't. Fixed by checking the untrimmed next_line, with an added exclusion for lines that are themselves - bullets (contextual notes immediately following a task's annotations must not be swallowed as continuation text β€” a regression the first attempt at this fix introduced, caught by the existing test_round_trip_preserves_task_annotations formatter test).

Refactor: commands/show.rs (~1100 lines before this change) split into commands/show/{mod,file_view,task_view,format,detail}.rs. mod.rs now just orchestrates (arg parsing, DB open, dispatch, JSON-error helpers); file_view.rs/task_view.rs hold the file/task text+JSON renderers respectively; format.rs holds the three status-formatting helpers shared by both; detail.rs is the new issue-#26 logic (dependency resolution, children summary, agent-note/custom-metadata rendering). find_task_by_full_id (previously private to complete.rs) moved to utils/project_loader.rs so both complete's unmet-dependency gate and show's dependency-status resolution share one implementation.

Test Coverage

  • crates/lash-core/src/parser/mod.rs: regression tests for the multiline-continuation fix (test_parse_file_task_level_multiline_agent_note) and the bullet-swallowing regression it could have introduced (test_parse_file_task_annotations_then_contextual_notes_not_merged).
  • crates/lash-cli/src/commands/show/detail.rs: unit tests for dependency resolution (satisfied, unresolved/dangling, directory-kind skipped) and capitalize.
  • crates/lash-cli/tests/show_command_test.rs (5 new e2e tests): full output includes agent note/deps-with-status/children; --short preserves the terse view and omits everything else; empty fields suppressed for a task with none of the above; --json includes the new fields with correct counts; --short --json omits them.
  • crates/lash-cli/src/cli.rs: parser test for the new --short flag.
  • crates/lash-cli/src/utils/project_loader.rs: unit test for the moved find_task_by_full_id.
  • Reviewed and accepted the agent_prompt_output regression snapshot (cargo insta accept) after updating the lash show one-liner in crates/lash-agent/src/content.rs; updated docs/user-guide.md and README.md similarly.

2026-08-05 - lash waive command (#23)

Summary

Waived (- [-]) was already a first-class TaskStatus β€” understood by status, list --status, and --depends-on resolution β€” but the only status mutators were complete and start. Waiving a task meant hand- editing the checkbox and remembering to run lash index, which silently desynced the DB if forgotten.

Added lash waive <TASK_IDS>..., mirroring lash complete:

  • Same task resolution (crate::utils::task_target::resolve_task_target, fuzzy did-you-mean suggestions on not-found).
  • Writes the - [-] marker and re-indexes in the same run β€” no separate lash index step.
  • --dry-run and --cascade (cascade flips unchecked plain-bullet children to [-]; without it, warns about them, same as complete).
  • --reason "<text>" appends the rationale as a contextual note (a plain bullet indented 2 spaces under the task β€” see docs/design-doc.md "Contextual Notes") rather than an @agent-note: annotation, since a reason is task-scoped prose, not an agent hint. The note is inserted after any existing @... annotation lines, not before them: the parser's annotation-block lookahead (parser/mod.rs) stops at the first non-@ line, so a note wedged between the checkbox and @id:/@depends-on: would knock those into "orphaned annotation" handling and silently drop @id. Verified round-tripping with lash lint in both the integration test and manual e2e.
  • Status transitions: open/in-progress/blocked β†’ waived allowed. Already-waived β†’ E_ALREADY_WAIVED. done β†’ E_DONE (completed work shouldn't be silently waived; message points at hand-editing if truly intended). No @depends-on gating β€” abandoning a task doesn't require its dependencies to be resolved.
  • Same JSON/theme/verbosity plumbing and exit codes as complete (0 success, 1 validation/partial, 3 DB, 5 not found).

Refactor: complete.rs's markdown-mutation machinery (checkbox rewriting, plain-bullet cascade detection/flipping, fuzzy suggestion lookup, re-indexing) was generic modulo the target status, so it moved into a new shared module, commands/status_mutation.rs, used by both complete and waive. flip_open_to_done generalized to flip_open_child(line, new_status) so cascade can flip to either [x] or [-]; preview_cascade_children now takes the parent's current status instead of assuming Open, fixing a latent dry-run gap where previewing a cascade on an InProgress/Blocked parent found nothing. complete.rs shrank from 1153 to 752 lines with its own tests (dependency-gating logic, result/error serialization) untouched and still passing.

Test Coverage

  • crates/lash-cli/src/commands/status_mutation.rs: unit tests for checkbox-char mapping, cascade flip to Done/Waived, non-terminal transitions never cascading, plain-child detection/dedent handling, and status-aware dry-run preview.
  • crates/lash-cli/src/commands/waive.rs: unit tests for result/error JSON shape and --reason note placement (after annotations, correct indentation).
  • crates/lash-cli/tests/waive_command_test.rs (new, 18 tests): basic waive, dry-run (including with --reason, which must not write anything), multiple tasks, already-waived, done-task rejection, open/in-progress/blocked all waivable, not-found, fuzzy matching, cascade (with and without), JSON success/error, no-database, no-task-id, mixed results, reindex-without-separate-index-run, and reason-note + lash lint round-trip.
  • crates/lash-cli/src/cli.rs: parser tests for the new Waive variant (single/multiple ids, --dry-run, --cascade, --reason, missing-id error).
  • Updated the regression_tests agent-prompt snapshot and crates/lash-agent/src/content.rs (TOP_LEVEL_SUBCOMMANDS, cli_reference(), hot_commands(), dependency-gating note) β€” the drift-guard tests in agent_content_drift_test.rs catch this automatically if a future command is added without doc updates.

Docs

README.md (Task Waiving section) and docs/user-guide.md (lash waive mirroring the lash complete section) updated with usage, exit codes, and JSON output examples.

2026-08-05 - lash add --id now persists; --depends-on validated at add time

Summary

Two lash add bugs, both silent-failure footguns for agents scripting task creation (#24, #27).

#24 β€” lash add "Title" --id short-e accepted the flag, echoed it in the success message, but never wrote an @id: annotation. The task was indexed only under a title-derived slug, so lash show <file>#short-e resolved to nothing; the advertised ID was a lie. Root cause was explicit in a comment in MarkdownEmitter::format_task_annotations: "Task-level @id ... are NOT stored in Markdown format." format_task_annotations now writes @id: <slug> first in the annotation block when request.id is Some (auto- synthesized ids, with no --id given, are still not persisted β€” unchanged). ID format/uniqueness validation already existed in TaskValidator::validate_id and needed no changes.

Fixing this exposed a second, unrelated latent bug: PlacementResolver's count_annotation_lines only counted @depends-on/@agent-note lines when computing where a task's trailing annotation block ends. Any existing task with an @id/@owner/@estimate/@doc/custom annotation β€” i.e. almost every real task in an existing project β€” made lash add's append position land one line too early, splitting that annotation from its owning task. This was already possible before #24 (e.g. appending after a task with @owner:), but writing @id: from --id made it trivially reproducible. Fixed by counting all annotation-only fields; @labels is deliberately still uncounted since inline (#tag) vs. block (@labels:) form isn't recoverable from Task metadata alone (lash add itself only ever emits labels inline, so this doesn't affect tasks created via add).

#27 β€” lash add --depends-on <ref> wrote the reference with no validation; a dangling target only surfaced later via lash lint (E_LINK_NOT_FOUND). New module commands/add_dependency_check.rs resolves every --depends-on reference against the on-disk project β€” using the same lash_core::dependency::reference::resolve_reference resolver lint/check-links/complete already share β€” before the task is created. An unresolvable reference is a hard error (nothing written), with a fuzzy "did you mean" suggestion when a close match exists. New --allow-forward-ref flag downgrades that to a warning (stderr in text mode, a warnings array in JSON mode) and writes anyway, for the legitimate create-in-any-order workflow. Reused TaskCreationError::DependencyNotFound's error code (E_CREATE_DEPENDENCY_NOT_FOUND) for the hard-error path β€” it was already defined and documented in docs/error-codes.md but never actually constructed anywhere, i.e. exactly this validation was already speced but unimplemented.

Also fixed in passing: lash add never read the global --root flag β€” execute() always re-derived the project root from the process's current directory. Every other command threads main.rs's already---root-aware project_root into its Args struct; add now does the same (AddArgs::project_root). Caught this by accidentally writing a test fixture task into the real repo's tasks.md while testing --root handling in isolation β€” cleaned up, not committed.

Extracted complete.rs's load_project (parse every task file into a HashMap<PathBuf, TaskFile> keyed by root-relative path, for resolve_reference) into utils/project_loader.rs so add_dependency_check can reuse it instead of duplicating it.

New tests: crates/lash-cli/tests/add_command_test.rs (8 end-to-end cases: --id write + show resolution, id format/uniqueness rejection, dangling dep hard-error + untouched file, --allow-forward-ref warn-and-write, resolvable dep, and the #24/#27 interaction β€” depending on a task added moments earlier via explicit --id). Plus unit tests in add_dependency_check.rs, and a placement.rs regression test for the annotation-line miscount.

2026-08-05 - lash list task-level filters actually filter

Summary

lash list --status open (and --label, --owner, --blocked, --path) parsed fine but printed the entire task tree β€” the flags were carried in ListArgs and then never read (the fields were even documented as "currently unused in file view").

commands/list.rs now routes to a task-centric listing whenever a task-level filter is present: tasks are queried via TaskRepository::find(&TaskFilter), files are restricted to those containing matches, and tree view renders only the matching tasks. Flat text and JSON output list the matching tasks grouped by file ({count, tasks, files}), matching the --filter <id> output shape. --path filters files by project-root-relative path prefix and composes with the other filters, as does --docs. Combining --filter <id> with the other filters now intersects instead of ignoring them. Zero matches reports "No tasks found matching the given filters" (or {count: 0, ...} in JSON) with exit code 0.

Two adjacent bugs fixed along the way:

  • TaskRepository::find ignored TaskFilter::blocked; it now maps Some(true)/Some(false) to status = / != 'blocked'.
  • The ASCII logo banner printed before lash list --format json output, making stdout unparseable. The banner suppression check now covers list's JSON formats like it already did for graph's.

The two insta snapshots for list --status open / --label backend had locked in the buggy full-tree output; they now show filtered output. New integration coverage in crates/lash-cli/tests/list_filter_test.rs (8 tests: each filter, JSON shape, empty result, and an unfiltered regression check).

2026-05-22 - Activity bar backfills from DB at TUI startup

Summary

The activity bar was designed as a session memory buffer β€” it only populated when transitions happened during the running TUI. With the in-progress slot also being empty when no [>] tasks exist on disk, the bar was perpetually empty on first launch (you'd see "Files: 1 Tasks: 12" and "Press ? for help" with empty space in between, like the bar was broken).

The design doc had flagged "Activity persistence across TUI restarts" as v1-out-of-scope, but that left a real discoverability footgun: users who hadn't crossed an in-progress state recently saw a feature that looked broken.

Now ActivityState::seed_from_db is called from both TuiApp::new_with_scheme and TestAppBuilder::build at startup. It seeds:

  • in_progress from TaskRepository::find_by_status(InProgress) (same query as before, now centralised in the activity module)
  • recently_completed from TaskRepository::find_recently_completed(now - 5min, cap=3) β€” up to 3 done/waived tasks from files modified within the activity TTL, ordered newest-first by file mtime

The seed timestamp is Instant::now() for both β€” so backfilled entries get pruned by the same 5-min TTL as session-originated ones, keeping the rolling-buffer semantics consistent.

Caveat

The DB tracks file mtime, not per-task completion time. So a file recently touched (for any reason) will surface its done tasks as "recently completed" β€” even if those particular completions happened weeks ago. For the "what changed recently?" framing of the activity bar this is close enough; tightening the heuristic would require tracking per-task transition times, which is a larger change.

Refactor that fell out

Both TuiApp::new_with_scheme and TestAppBuilder::build had their own copy of the "query InProgress, set activity slot" block β€” slightly divergent. Both now call seed_from_db instead, ending that duplication.

Test added

startup_backfills_recently_completed_from_db β€” builds a TestApp against a project with two [x] tasks and one [ ] task on disk, asserts both done tasks appear in state.activity.recently_completed and the open task does not.

2026-05-22 - One project-root walker, used by all four pre-existing finders

Summary

Closes the tech-debt loop the $HOME hijacking bug uncovered earlier today. The four find_project_root implementations scattered across lash-cli (Γ—2), lash-db, and lash-types::config β€” each with their own subtly different walks and their own copy of the git-ceiling logic β€” are now thin wrappers around a single canonical helper in lash_types::path_utils:

  • is_project_root_marker(dir) β€” checks lash.index.md, index.lash.md, or .lash/ (directory)
  • find_project_root_from(start) β€” canonicalizes, walks up with git-root ceiling, returns Option<PathBuf>

Each pre-existing entry point handles its own error/fallback semantics (anyhow vs LashError::Config vs DbError vs return-start_dir-on-miss) but the walk lives exactly once.

Bug fix that fell out

lash_db::project_root::is_project_root (and the older find_from variant) used to only recognise .lash/ and lash.index.md as markers, silently ignoring index.lash.md despite the design doc treating it as a first-class marker. The consolidation fixes that.

write_atomic adjustment uncovered by the test run

The full-workspace test sweep turned up four format-command tests that my earlier "lash format writes atomically" change had broken. The issue was real: fs::rename only requires write permission on the parent directory, not on the target file, so atomic rename was silently overwriting files the user had chmod'd 0o444. Added a writability pre-check to write_atomic so it preserves the historical "refuse to write a read-only file" semantics. Test diagnostics also got their error messages normalised to consistently contain "failed to write file: <path>" regardless of which step inside write_atomic reports the problem.

Key Components

  • lash_types::path_utils::is_project_root_marker(dir) β€” the single-marker predicate
  • lash_types::path_utils::find_project_root_from(start) β€” the single canonical walker
  • PROJECT_MARKER_NAMES β€” public constant so tests and external tools can introspect the marker list without parsing source
  • 10 new unit tests in path_utils covering: each marker, bare directory, file-named-.lash, find-self, walk-up-to-ancestor, refuse-to-cross-git-root, accept-marker-at-git-root, missing path
  • write_atomic pre-flight writability check (+ existing tests for unwritable files now pass again)

2026-05-22 - lash format writes atomically

Summary

Closes the last "writes go around write_atomic" gap in production code. Two paths were still using a plain fs::write:

  • lash_core::formatter::format_file_in_place (library API)
  • lash-cli::commands::format (the actual lash format command)

Both now route through lash_core::store::write_atomic (tmp file + rename). A crash mid-write can no longer leave a partially-formatted Markdown file on disk.

The CLI's format path doesn't go through the library helper because it does its own changed-detection / diff display before deciding whether to write; consolidating those is a follow-up not on the critical path. For now both call sites share the atomic helper, which is what matters for the on-disk safety guarantee.

Test added

format_file_in_place_writes_atomically_and_leaves_no_temp in the formatter unit tests β€” formats a fixture file, verifies the result survives, then asserts no .lash-tmp sibling leaked into the directory.

2026-05-22 - Stale-modal protection for in-flight task creation

Summary

Closes the last correctness gap in the live-updates feature. Before: if a user had the task-creation modal open and an external process rewrote the same file underneath (an agent, an $EDITOR save, a git pull), submitting the form would happily overwrite the external change. The reindex would catch up afterwards but the external edit's content was already lost.

Now: TaskCreationModalState has a stale flag. Whenever handle_file_reloaded observes an external change to a file that an open modal is targeting, the modal is marked stale and a warning is surfaced. The modal's title and border switch to warning colors so the state is impossible to miss. The submit handler refuses stale submits outright β€” the user has to Esc to discard the form and retry against the fresh on-disk state.

The other (transient) confirm modals β€” confirm-complete, confirm- incomplete, confirm-linked-file-complete β€” are not yet covered, since they typically only stay open for sub-second windows where the conflict risk is negligible.

Key Components

  • TaskCreationModalState.stale: bool (default false)
  • lash-tui::app::mark_modal_stale_if_targets(relative) β€” called from handle_file_reloaded after the external diff is applied
  • Submit refusal: handle_submit_task_creation returns early with an error message if stale is set
  • Modal renderer: title and border switch to theme.warning_color() when stale
  • handle_submit_task_creation is now pub so integration tests can drive it directly (mirrors the pattern already used for process_external_change)
  • 3 new integration tests in external_reload_tests.rs:
    • modal goes stale on external edit to its target file
    • external edit to an unrelated file does not mark the modal stale
    • stale submit is refused β€” the target file's bytes are unchanged after the refused submit, modal stays open, and the warning message is surfaced

2026-05-22 - Task creation flows through Store; watcher dedupe extends to creates

Summary

Closes the last "all writes through one funnel" gap. Before: status toggles went through Store::apply(SetTaskStatus) (which records a hash so the file watcher's echo gets dropped), but task creation called TaskCreationService::create_task directly. The resulting watcher event saw bytes the Store didn't recognize and fired a redundant external reload+reindex right after the TUI just did one.

Now: Mutation::CreateTask(Box<CreateTaskMutation>) is the canonical entry point. The Store still delegates the actual file emission to TaskCreationService (validation, ID synthesis, placement, atomic write β€” all unchanged), then reads the resulting file back and records its hash. The next watcher echo for that path matches and is silently dropped, exactly like a status-toggle echo.

The variant is boxed because TaskCreationRequest + LashConfig is hundreds of bytes β€” clippy flagged the variant-size mismatch and the Box is the standard fix. Errors from TaskCreationService (which return as Vec<TaskCreationError>) are flattened to a single LashError::Internal for the Store API; the TUI's submit handler displays the formatted summary just like it used to display the first structured error.

Key Components

  • lash-core::store::Mutation::CreateTask(Box<CreateTaskMutation>) β€” new variant carrying request + config
  • lash-core::store::StateDelta::TaskCreated { absolute_path, task_id, is_new_file } β€” emitted on success
  • Store::apply for CreateTask β€” runs the service, then re-reads and hashes the resulting file
  • lash-tui::app::handle_submit_task_creation rewired through Store
  • 2 new store unit tests: success-path emits delta + records hash + dedupes echo; validation failure surfaces as E_INTERNAL
  • 1 new TUI integration test: task_creation_through_store_dedupes_watcher_echo

What still flows around the Store

  • lash_core::formatter::format_file_in_place writes directly (next on the queue β€” switching it to write_atomic is a tiny win)

2026-05-22 - Activity bar reacts to external edits too

Summary

Closes the original promise of "live updates": before this change, an external process toggling a task's status would refresh the TUI's task tree (as of Phase C) but the activity status bar still only reflected TUI-initiated transitions. Now it reflects external ones too.

The mechanism is intentionally cheap and uses data the indexer already produces. handle_file_reloaded now:

  1. Snapshots (full_id β†’ status) for the changed file's tasks from the DB before running the incremental reindex.
  2. Runs the reindex.
  3. Queries the file's tasks again and diffs against the snapshot. Any (old, new) status change is fed straight into ActivityState::record_transition, which is the same entry point the five TUI status-toggle paths already use.

So an $EDITOR save that flips - [ ] Foo to - [>] Foo now lights up the in-progress slot in the bar, and a flip to - [x] Foo pushes Foo into recently-completed β€” both within ~150ms of the watcher firing.

Test-infra fix that fell out

The integration test for InProgress β†’ Done was failing because TestAppBuilder didn't reproduce production's startup seeding of activity.in_progress from the DB. Aligned the test builder with TuiApp::new_with_scheme so tests model real startup faithfully.

Key Components

  • lash-tui::app::handle_file_reloaded β€” gained snapshot_file_statuses
    • apply_external_status_diff helpers
  • lash-tui::testing::TestAppBuilder β€” seeds activity.in_progress at build time, matching production startup
  • 3 new integration tests in external_reload_tests.rs: external Openβ†’InProgress, Openβ†’Done, InProgressβ†’Done

2026-05-22 - Live TUI updates: Phase C (watcher + external reload + cursor preservation)

Summary

The TUI now reacts to external edits in real time. A notify-backed file watcher runs on a background thread, debounces and filters Markdown events (150ms window; ignores .git/, target/, .lash/, node_modules/ and non-.md paths), and forwards them on an mpsc::Sender<PathBuf>.

In tick(), the TUI drains the channel, routes each path through Store::handle_external_change (which dedupes self-write echoes via the hash recorded in Phase B), and on a FileReloaded delta:

  1. Runs an incremental reindex of the changed file via the existing Indexer
  2. If it's the file currently in view, captures the cursor's full_id
  3. Reloads tasks from DB, rebuilds the task tree, restores expansion state
  4. Restores selection to the task with the same full_id β€” even if its row index has shifted

So: another process editing a task file ($EDITOR, an agent calling lash ..., a git pull updating a file) is reflected in the TUI within ~150ms with no manual refresh, and the user's cursor sticks to the task they were on.

Deviation from the design doc

The design doc originally specified broadening EventSource::poll_event to deliver an AppInputEvent { Term, External, Tick } enum, with a MergedEventSource muxing crossterm and watcher channels. In implementation this was replaced with a simpler sidecar channel held as a field on TuiAppCore and drained at the top of tick() β€” see docs/live-tui-updates.md for the rationale. Net result: same behavior, no test-suite churn, and tests can synthesize external edits by calling app.process_external_change(path) directly with no fake watcher needed.

Key Components

  • lash-core::watcher β€” FileWatcher with notify::RecommendedWatcher + hand-rolled debouncer thread, ignore rules, graceful shutdown on handle drop, 6 unit tests including a real fs-burst β†’ debounced-event end-to-end check
  • lash-tui::app β€” external_rx + _watcher fields on TuiAppCore; drain_external_changes, process_external_change (public), apply_delta, handle_file_reloaded, currently_viewed_file_id, reindex_paths helpers
  • lash-tui::state β€” selected_task_full_id and restore_task_selection_by_full_id for stable-id cursor preservation
  • lash-tui::tests::external_reload_tests β€” integration test proving the cursor stays anchored across an external insert-above edit, plus a self-write-echo dedupe test that confirms our own writes don't trigger reloads
  • Workspace gets a notify = "6.1" dependency

What's deferred

  • Parse-and-diff on external changes to extract TaskStatusChanged deltas β†’ feed into the activity status bar (Phase A). Today, external task toggles update the tree but don't update the activity bar.
  • Stale-modal banner for in-flight task creation conflicting with an external edit (Task 7 in tasks/tasks.live-updates.md)
  • Phase D polish: bounded watcher channel + FullReload overflow path

2026-05-22 - Live TUI updates: Phase B (Store actor + atomic writes + hash dedupe)

Summary

Added lash_core::store::Store: the single writer for Markdown task files. Every Store::apply reads the current file, rewrites it (using the same regex logic that used to live in lash-tui::app::update_markdown_task_status), records a blake3 hash of the bytes it's about to write, and writes via a sibling tmp file + atomic rename. When handle_external_change(path) is later called by the file watcher (Phase C), it re-reads the file and compares the on-disk hash to its recorded one β€” matches are dropped (our own write echoing back), differences (or unknown paths) emit a FileReloaded delta.

The five status-change call sites in the TUI (handle_toggle_status plus three cascade handlers plus linked-file complete) all flow through the existing update_markdown_task_status helper, which now delegates to Store::apply rather than calling fs::write directly. Zero call-site changes were needed β€” the helper is the one routing point.

This unblocks Phase C (file watcher) by giving the watcher a place to feed its events: Store::handle_external_change.

Key Components

  • lash-core::store β€” Store, Mutation::SetTaskStatus, StateDelta::{TaskStatusChanged, FileReloaded}, write_atomic (tmp+rename), per-path last_written_hash: HashMap<PathBuf, [u8; 32]>, 11 unit tests covering the matrix of self-write echo / external-edit / no-prior-write / missing-file / second-match-after-clear
  • lash-tui::app β€” store: Store field on TuiAppCore, update_markdown_task_status reduced to a one-line delegator, status_checkbox_char helper deleted (moved into the Store)
  • Hash dedupe is single-use: a matched event clears the entry, so a second identical event correctly falls through to FileReloaded
  • lash-core::Cargo.toml β€” picked up blake3 from the workspace deps

What's deferred

  • Mutation::CreateTask (task creation still uses its own write path)
  • lash_core::formatter::format_file_in_place still calls fs::write directly (no behavioral risk; lash format doesn't race the TUI)

2026-05-22 - Live TUI updates: design + Phase A activity status bar

Summary

Captured the live-TUI-updates design in docs/live-tui-updates.md (Store actor as the single writer, content-hash dedupe to drop self-write watcher echoes, broadened EventSource, stable-id cursor preservation, conflict policy for in-flight modals). Filed the work as tasks/tasks.live-updates.md (Phases B–D) and tasks/tasks.status-bar-activity.md (Phase A), and registered both in tasks/tasks.md.

Implemented Phase A end-to-end: the bottom status bar now has two live-updated sections β€” currently in-progress task (β–Ά) and up to three recently-completed task titles (βœ“) β€” driven by a new ActivityState fed from every status transition the TUI initiates (primary toggle plus the three cascading/linked-file/incomplete handlers). Width-aware truncation with an ellipsis. Status-message overlays still take over the whole bar. Recently-completed entries age out after 5 minutes via the existing tick loop, no extra timer.

External-process changes are not reflected in the activity bar yet β€” that lights up when Phase C of the live-updates work lands (notify watcher + broadened EventSource route external StateDeltas into the same ActivityState).

Key Components

  • lash-tui::activity β€” ActivityState / ActivityEntry with record_transition and prune, 13 unit tests covering each transition edge and pruning semantics
  • lash-tui::ui::status_bar β€” width-aware allocator that gives the in-progress section ~40% of the activity budget and splits the rest among recent entries, dropping from the right when tight; 13 tests including TestBackend buffer snapshots
  • lash-tui::app β€” primary toggle, cascading-complete, linked-file-complete, and cascading-incomplete handlers all call state.activity.record_transition on success
  • lash-tui::app::tick β€” calls activity.prune each ~100ms tick
  • Startup seed in TuiApp::new_with_scheme via the existing TaskRepository::find_by_status(InProgress)

Phase plan (remainder)

  • Phase B (Task 4 in this session's todo): Store actor + write_atomic + last_written_hash
  • Phase C (Tasks 5–8): notify watcher, broadened EventSource, external reload, stable-id cursor preservation, stale-modal banner
  • Phase D: polish β€” bounded watcher channel, overflowβ†’FullReload

Summary

Added a lash skill <install|list|update|uninstall> command that drops a Lash-aware skill into the conventional directory for Claude Code, Codex (also exposed as agents-md), and Cursor. Claude uses progressive disclosure (SKILL.md + references/*.md), the others use single files (AGENTS.lash.md at root, or .cursor/rules/lash.mdc).

The static knowledge β€” overview, project layout, workflow, full CLI reference, safety rules, error recovery, dependencies guide, hot commands, and the "when to use" trigger β€” was extracted from prompt.rs into a new lash-agent::content module so agent-prompt (dynamic, project-specific) and skill install (static, project-agnostic) share one source of truth. The placeholder agent-prompt --format claude-skill (a stub JSON spec) was removed; use lash skill install --target claude instead.

Key Components

  • lash-agent::content β€” &'static str primitives for each doc section
  • lash-agent::installer β€” Target/Scope/InstallOptions with idempotent install/plan/uninstall and per-file FileAction outcomes
  • lash-cli::commands::skill β€” CLI dispatch, JSON/text output, --force, --dry-run, --print, --scope project|user
  • Idempotency marker (lash-skill-version: <CARGO_PKG_VERSION>) stamped in every generated file; user-edited files preserved across re-installs
  • Drift-guard tests in crates/lash-cli/tests/agent_content_drift_test.rs fail if a new clap subcommand is added without updating the agent docs

Tracking

  • New task file: tasks/tasks.agent-skill-install.md
  • Four sequential commits, one per planned PR

Test Results

  • 18 installer unit tests + 9 content unit tests + 2 drift-guard tests
  • Full workspace test suite continues to pass (no regressions)
  • Snapshot test for agent-prompt updated to reflect the broader CLI reference (added Project Setup, Task Modification, Agent Integration groups + a lash skill install line)

2026-03-12 - Mutation Testing Campaign (166 β†’ ~3 Survivors)

Summary

Ran a comprehensive mutation testing campaign against the full lash codebase using flawd. Started with 166 surviving mutants from the initial report and addressed them across 14 source files by adding targeted unit and e2e tests. No source code was modified β€” only test files.

Files with mutants addressed:

  • lash-agent/src/prompt.rs (3 mutants: to_summary_string total boundary, apply_budget allocation==0, apply_budget truncated literal)
  • lash-agent/src/tokens.rs (3 mutants: summarize_task_file total boundary, 0β†’1 literal, truncate_to_budget char_budget boundary)
  • lash-cli/src/commands/agent_prompt.rs (6 mutants: args.json, no_color, include_tasks, truncated && !json compound)
  • lash-cli/src/commands/ascii_graph.rs (7 mutants: || vs &&, depth literals, is_index branch, truncate_title boundary)
  • lash-cli/src/commands/check_index.rs (13 mutants: args.json, no_color, paths.is_empty, is_absolute, count>0 boundary, is_clean, show_diff)
  • lash-cli/src/commands/check_links/core.rs (6 mutants: total_broken==0 boundary, show_summary literal, dep_not_found 0 literals)
  • lash-cli/src/commands/check_links/mod.rs (2 mutants: args.json in no-db and zero-broken branches)
  • lash-cli/src/commands/config.rs (10 mutants: args.json, no_color, config_path.exists, user, rules.is_empty, || vs &&)
  • lash-cli/src/commands/explain.rs (14 mutants: args.json, no_color, starts_with conditions, codes.is_empty)
  • lash-cli/src/commands/format.rs (41 mutants: args.json, no_color, check/diff branches, formatted/failed counters, result comparisons)
  • lash-cli/src/commands/graph.rs (2 mutants: show_summary literal, index_out_of_sync(0) literal)
  • lash-cli/src/commands/index.rs (36 mutants: no_color, force, paths.is_empty, json, errors_streaming, files_added/updated/deleted/unchanged counters)
  • lash-cli/src/commands/init.rs (12 mutants: args.json, no_color, index_file.exists, lash_dir.exists, no_index, exit_code!=0)
  • lash-cli/src/commands/lint.rs (11 mutants: no_color, recursive literal, interactive&&!fix compound, fix, json, rule counts)

Remaining equivalent mutants (1) after follow-up pass:

  • tokens.rs:142 mut-000131: < 10 β†’ <= 10 in truncate_to_budget β€” equivalent because char_budget = token_budget * 4, so char_budget is always a multiple of 4; the boundary value 10 is unreachable (4Γ—2=8, 4Γ—3=12), making < 10 and <= 10 identical for all valid inputs.

Commits: 059cc04, 317ee05, 0b66207, 2af6f7e, a131034, e903eaf, 7b1b56a, e903eaf

Key Findings

  • Created dedicated test files for several modules: check_links_output_tests.rs, config_command_tests.rs, graph_command_tests.rs, agent_prompt_test.rs, index_command_test.rs, lint_output_tests.rs
  • E2e tests in e2e_cli_tests.rs grew from ~500 to ~4400+ lines to cover output-observable mutations
  • Some mutations (stdout-only effects) required e2e process tests since unit tests cannot capture stdout
  • Mutation score improved from ~58.5% baseline to ~97-98% on focused targeted files
  • Full project score ~60.5% on random 400-sample budget β€” lower due to flawd import graph limitation (e2e test files not linked to source via static import analysis, so per-mutant test selection misses e2e tests). Coverage-based targeting would yield higher scores.
  • Previously identified equivalent mutants (mut-000047, mut-000103) for usize > 0 β†’ usize >= 0 were killed in a follow-up pass using degenerate inputs where total=0 but completed>0: the original returns 0% (else branch), while the mutant computes f64::INFINITY as usize = usize::MAX, which tests reject.
  • One confirmed equivalent mutant remains: tokens.rs:142 where char_budget < 10 vs char_budget <= 10 is indistinguishable because char_budget is always a multiple of 4.

2026-03-12 - Rustdoc Coverage and v1.0 Completion

Summary

Completed the final open documentation task (Task 4: API Documentation / Rustdoc) and reached v1.0 completion for all planned phases.

Work done:

  • Added field-level /// doc comments to all public enum variant fields that were missing them: LashError variants in lash-types/src/error.rs, deprecated alias constants, BlockerSuggestion variants in lash-core/src/dependency/blocker_analyzer.rs, NodeHasDependents in lash-core/src/dependency/graph.rs, ResolutionErrorKind variants in lash-core/src/dependency/resolver.rs, SchemaMismatch/MigrationFailed in lash-db/src/error.rs, and several enum variants/fields in lash-cli/src/cli.rs (Commands, SeverityLevel, TaskStatus, OutputFormat, AgentFormat, Shell)
  • Added #![warn(missing_docs)] to all five crate lib.rs files to enforce ongoing documentation coverage
  • Verified cargo doc --workspace --no-deps builds cleanly with zero missing-documentation warnings
  • All 1,600+ tests continue to pass

Commits: 10bc13a, 800399a, c3a5fdc

Phase 8 Complete

With Task 4 done, all planned v1.0 work is complete:

  • All 9 phases finished
  • All Must Have success criteria met
  • Full documentation: README, user guide, developer guide, agent guide, error code reference, examples, Rustdoc

2025-11-25 - Color Handling Verification and Testing (Task 9)

Summary

Verified and documented proper NO_COLOR environment variable and --no-color flag handling across all CLI commands as part of Task 9 (CLI Color Scheme Integration) from tasks/tasks.tui.md.

Task Goal: Ensure that:

  1. NO_COLOR environment variable disables all colors
  2. --no-color flag disables all colors
  3. Piped output (non-TTY) disables colors automatically
  4. Priority: --no-color > NO_COLOR > TTY detection

Implementation Review

The color handling implementation was already correctly implemented:

Core Function (theme.rs:355-363):

pub fn supports_color() -> bool {
    // NO_COLOR environment variable takes precedence
    if std::env::var_os("NO_COLOR").is_some() {
        return false;
    }

    // Check if stdout is a TTY
    atty::is(atty::Stream::Stdout)
}

Main Logic (main.rs:87-92):

let colors_enabled = !cli.no_color && !cli.json && supports_color();

This correctly implements the priority:

  1. --no-color flag (explicit user choice)
  2. --json flag (JSON should never have ANSI codes)
  3. NO_COLOR env var (checked in supports_color())
  4. TTY detection (checked in supports_color())

Testing

Files Created:

  • crates/lash-cli/tests/color_handling_test.rs - Comprehensive integration tests (11 tests)
  • docs/color-handling.md - Complete documentation of color handling behavior

Test Coverage:

  1. βœ… test_no_color_flag_disables_colors - Verifies --no-color works
  2. βœ… test_no_color_env_var_disables_colors - Verifies NO_COLOR env var
  3. βœ… test_no_color_flag_overrides_color_scheme - Priority: flag > scheme
  4. βœ… test_json_output_never_has_colors - JSON safety
  5. βœ… test_json_overrides_color_scheme - Priority: JSON > scheme
  6. βœ… test_list_command_respects_no_color - List command compliance
  7. βœ… test_search_command_respects_no_color - Search command compliance
  8. βœ… test_index_command_respects_no_color - Index command compliance
  9. βœ… test_check_index_command_respects_no_color - Check-index compliance
  10. βœ… test_show_command_respects_no_color - Show command compliance
  11. βœ… test_no_color_env_var_priority - NO_COLOR overrides scheme

All tests verify no ANSI escape codes (\x1b[) in output when colors should be disabled.

Commands Verified

All CLI commands properly respect color settings:

  • lash list - Colored task status badges
  • lash search - Highlighted search results
  • lash show - File display with syntax highlighting
  • lash lint - Colored severity levels
  • lash index - Progress reports with colors
  • lash check-index - Verification status colors
  • lash graph - Graph visualization colors
  • lash check-links - Link validation colors

Standards Compliance

The implementation follows standard Unix conventions:

  • NO_COLOR standard: no-color.org compliant
  • TTY detection: Auto-disables for non-interactive output
  • Explicit control: Users can force disable with --no-color
  • JSON safety: JSON output never contains ANSI codes

Test Results

cargo test -p lash-cli --test color_handling_test
# Result: ok. 11 passed; 0 failed

cargo test -p lash-cli
# Result: ok. 160 unit tests + 57 integration tests + 11 color tests passed

cargo clippy -p lash-cli -- -D warnings
# Result: No warnings

Files Modified/Created

New Files:

  • crates/lash-cli/tests/color_handling_test.rs (343 lines)
  • docs/color-handling.md (documentation)

Key Implementation Files:

  • crates/lash-cli/src/theme.rs - supports_color() function
  • crates/lash-cli/src/formatter.rs - TextFormatter color handling
  • crates/lash-cli/src/main.rs - Color decision logic

Conclusion

The color handling implementation is complete and correct:

  • All priority rules work as specified
  • Comprehensive test coverage ensures correctness
  • Documentation provides clear usage guidance
  • Standards-compliant implementation

No code changes were needed - only verification, testing, and documentation.

2025-11-23 - PixelQuest Playground Generator Implementation (Task 9)

Summary

Implemented Phase 1 of Task 9 (Playground Mode for Demos and Exploration) from tasks/tasks.testing.md. Created a comprehensive fixture generator for "PixelQuest", a fictional 2D platformer game development project that showcases all of Lash's features in a realistic, engaging context.

Files Created:

  • crates/lash-cli/tests/fixtures/generators/pixelquest.rs (1,094 lines)
  • crates/lash-cli/tests/test_pixelquest_generator.rs (integration test)

Project Overview

PixelQuest: Retro 2D Platformer A realistic game development demo project demonstrating Lash's capabilities with authentic game dev workflows.

Generated Project Statistics

Files & Structure:

  • 24 markdown task files
  • 6 directories (features, systems, content, infrastructure, design, milestones)
  • 1 root index file (lash.index.md)

Task Breakdown:

  • Total tasks: 393 (parent + subtasks)
  • Top-level tasks: 99
  • Open tasks: 274 (69.7%)
  • Done tasks: 111 (28.2%)
  • Waived tasks: 8 (2.0%)

Status Distribution shows realistic project progression:

  • Early milestones (alpha): mostly complete
  • Current work (beta): in-progress
  • Future work (release): mostly open

Labels:

  • p0 (critical): 1 task
  • p1 (high priority): 14 tasks
  • p2 (nice to have): 20 tasks

Module Breakdown

1. Features Module (5 files, ~70 tasks)

Game features and mechanics:

  • features/player-movement.md - Physics, controls, animations, special moves
  • features/enemy-ai.md - Behavior trees, pathfinding, difficulty scaling
  • features/level-generation.md - Procedural algorithms, tile placement
  • features/power-ups.md - Item system, effects, balancing
  • features/boss-fights.md - Patterns, phases, cinematics

Highlights:

  • Cross-file dependency: boss fights depend on enemy AI behavior trees
  • Rich task variety: architecture, implementation, tuning
  • Realistic labels: #backend, #gameplay, #ai, #worldgen, #p0-p2

2. Systems Module (4 files, ~60 tasks)

Core engine systems:

  • systems/rendering.md - Sprite batching, camera, shaders
  • systems/audio.md - Sound engine, music playback, spatial audio
  • systems/physics.md - Collision, forces, platformer physics
  • systems/input.md - Controller mapping, input buffering

Highlights:

  • Technical depth with architecture decisions
  • Dependencies between systems (rendering depends on physics)
  • Mix of done (foundations) and open (advanced features)

3. Content Module (5 files, ~55 tasks)

Art and design tasks:

  • content/sprites.md - Character art, tile sets, UI assets
  • content/animations.md - Walk cycles, attack animations
  • content/music.md - Level themes, boss music
  • content/sfx.md - Jump sounds, combat sounds
  • content/levels.md - World 1-4 levels, tutorial

Highlights:

  • Shows collaboration between art, audio, and code
  • Labels: #art, #sprites, #animation, #audio, #music, #sfx, #design
  • Realistic progression: early content complete, later worlds in-progress

4. Infrastructure Module (3 files, ~40 tasks)

Dev ops and tools:

  • infrastructure/build-pipeline.md - CI/CD, testing, releases
  • infrastructure/asset-pipeline.md - Sprite importing, audio conversion
  • infrastructure/testing.md - Unit tests, integration tests, playtesting

Highlights:

  • Labels: #tooling, #devops, #testing, #qa
  • Platform-specific builds (Web/WASM, Windows, macOS, Linux)
  • Automated asset processing and validation

5. Design Module (3 files, ~40 tasks)

Game design documents:

  • design/core-loop.md - Gameplay flow, pacing
  • design/progression.md - Difficulty curve, unlocks
  • design/story.md - Narrative beats, characters (lower priority)

Highlights:

  • Labels: #design, #gameplay, #narrative
  • Story tasks appropriately marked as p2 or waived
  • Focus on core loop and progression

6. Milestones Module (3 files, ~35 tasks)

Release planning:

  • milestones/alpha.md - Core loop playable (mostly complete)
  • milestones/beta.md - All features, full content (in-progress)
  • milestones/release.md - Polish, marketing (mostly open)

Highlights:

  • Dependencies: beta depends on alpha, release depends on beta
  • Cross-file refs to specific features (e.g., beta depends on boss fights)
  • Realistic progression: alpha done, beta active, release planned

Cross-File Dependencies

Implemented 3 strategic cross-file dependencies:

  1. boss-fights.md depends on enemy-ai.md#enemy-behavior-trees
  2. milestones/alpha.md depends on core features (player-movement, physics, rendering)
  3. milestones/beta.md depends on alpha + boss fights

These create an interesting dependency graph for testing lash graph command.

Implementation Details

Code Structure:

  • Main generator function: generate_pixelquest_project()
  • Helper functions per module: add_features_module(), add_systems_module(), etc.
  • Individual file generators: add_player_movement(), add_enemy_ai(), etc.
  • Follows DRY principle with composition of smaller functions

Realistic Content:

  • All task descriptions use authentic game development terminology
  • No Lorem Ipsum - every task represents real game dev work
  • Task statuses reflect realistic project progression
  • Labels mirror actual game development priorities

Quality Assurance:

  • All 24 files pass lash lint with zero errors
  • 23 orphan warnings (expected - demonstrates linter working)
  • Project successfully indexed into SQLite database
  • Search functionality works across all files
  • Total: 393 tasks successfully parsed and indexed

Testing

Created integration test: test_pixelquest_generator.rs

  • Generates project to tests/fixtures/repos/pixelquest-project
  • Verifies file count (24 files)
  • Provides instructions for manual testing
  • Run with: cargo test --test test_pixelquest_generator -- --ignored --nocapture

Manual Verification:

cd /path/to/pixelquest-project
lash lint          # βœ“ 0 errors, 23 warnings (orphan files - expected)
lash index         # βœ“ 24 files indexed
lash list          # βœ“ 393 tasks listed
lash search "boss" # βœ“ 20 results found

Decisions Made

  1. File Count: Generated 24 files (exceeds minimum of 20)

    • Could expand to 40-50 for more variety
    • Current set demonstrates all key features
  2. Task Variety: 99 top-level tasks with 393 total

    • 3-5 tasks per file
    • 3-5 subtasks per parent task
    • Good balance of depth vs. breadth
  3. Status Distribution:

    • ~30% done: Early milestones and foundations
    • ~50% open: Current and future work
    • ~20% waived: Features deemed unnecessary
  4. Labels:

    • File-level labels in frontmatter (backend, art, audio, etc.)
    • Task-level priority labels (#p0, #p1, #p2)
    • Realistic distribution: p0=1, p1=14, p2=20
  5. Dependencies:

    • Limited to 3 cross-file deps for clarity
    • All dependencies are valid and resolvable
    • Demonstrates dependency graph features

Next Steps (Phase 2: CLI Integration)

Not implemented in this phase:

  1. lash playground init CLI command
  2. --reset flag for regeneration
  3. Auto-index after generation
  4. PLAYGROUND_GUIDE.md walkthrough file
  5. Playground utilities (reset, add_random_task, simulate_work)

Recommendations for Phase 2:

  1. Add playground subcommand to lash-cli
  2. Reuse generate_pixelquest_project() from test fixtures
  3. Add interactive welcome message
  4. Generate PLAYGROUND_GUIDE.md with example commands
  5. Support both in-place init and custom path
  6. Auto-run lash index after generation

Example future usage:

lash playground init              # Init in current dir
lash playground init --path ~/demo
lash playground init --reset      # Regenerate from scratch

Files Modified

  1. Created: crates/lash-cli/tests/fixtures/generators/pixelquest.rs
  2. Modified: crates/lash-cli/tests/fixtures/generators/mod.rs (added pub mod pixelquest;)
  3. Created: crates/lash-cli/tests/test_pixelquest_generator.rs

Verification

# Generate project
cargo test --test test_pixelquest_generator generate_pixelquest_project -- --ignored --nocapture

# Verify with lash commands
cd /path/to/pixelquest-project
lash lint
lash index
lash list
lash search "boss"

All verification steps passed successfully.


2025-11-23 - Complete Unit Test Implementation (Task 2)

Summary

Completed Task 2 (Unit Tests) from tasks/tasks.testing.md, implementing 292 new unit tests across critical modules to achieve 80%+ overall coverage and 90%+ coverage on critical modules (parser, linter, dependency resolution).

Commits: cf5eff8, 6bc0f1a, f982e88, 36f5a89, 3a7c5fc, e6dc90f

Test Coverage Improvements

Total Test Count: Increased from ~1,312 to 1,604 tests (+292 tests)

Module Coverage Achieved

  1. Search Module (lash-db/src/search.rs)

    • Before: 24.8% (67/270 lines)
    • After: 85%+ estimated
    • Added: 65 comprehensive unit tests
    • Coverage: Query parsing, FTS5, scoring, pagination, snippets
    • Commit: cf5eff8
  2. Parser Main Module (lash-core/src/parser/mod.rs)

    • Before: 58% (90/155 lines)
    • After: 90%+ estimated
    • Added: 49 comprehensive unit tests
    • Coverage: File parsing, error aggregation, metadata extraction, edge cases
    • Commit: 6bc0f1a
  3. Dependency Resolver (lash-core/src/dependency/resolver.rs)

    • Before: 60% (115/191 lines)
    • After: 90%+ estimated
    • Added: 23 comprehensive unit tests
    • Coverage: Reference resolution, path handling, error cases
    • Commit: f982e88
  4. Database Repository (lash-db/src/repository/tasks.rs)

    • Before: 66% (120/183 lines)
    • After: 97.8% (179/183 lines)
    • Added: 22 comprehensive unit tests
    • Coverage: CRUD operations, complex queries, hierarchical relationships
    • Commit: 36f5a89
  5. Error Handling (lash-types/src/error.rs)

    • Before: 62.8% (167/266 lines)
    • After: 80%+ estimated
    • Added: 67 comprehensive unit tests
    • Coverage: All error variants, Display/Debug traits, diagnostics, JSON serialization
    • Commit: 3a7c5fc
  6. CLI Logging & Progress (lash-cli/src/logging.rs, progress.rs)

    • Before: 27% combined (60/219 lines)
    • After: 74% combined (162/219 lines)
    • Added: 66 comprehensive unit tests
    • Coverage: Verbosity levels, output formatting, progress tracking
    • Dependencies: Added serial_test for thread-safe env var testing
    • Commit: e6dc90f

Test Quality Standards Met

All new tests adhere to the project's testing principles:

  • βœ… Fast execution: All unit tests run in <100ms
  • βœ… Descriptive naming: Clear test names indicating behavior
  • βœ… Arrange-Act-Assert: Consistent three-phase structure
  • βœ… No mocking: Real objects, no simulated behavior in production code
  • βœ… Edge case coverage: Empty inputs, large inputs, Unicode, special characters
  • βœ… Deterministic: No flaky tests, all reproducible
  • βœ… No frivolous tests: Each test verifies meaningful behavior

Coverage Targets Achieved

Module Category Target Achieved Status
Overall Project >80% ~80%+ βœ… Met
Parser >90% ~90%+ βœ… Met
Linter >90% ~90% βœ… Met
Dependency >90% ~90%+ βœ… Met
Database >80% 97.8% βœ… Exceeded
Search >80% 85%+ βœ… Met
Error Handling >80% 80%+ βœ… Met
Data Model >80% 85%+ βœ… Met
CLI Framework >80% 60-74% ⚠️ Partial

Files Modified

Test files created/enhanced:

  • crates/lash-db/src/search.rs - Added 625 lines of tests
  • crates/lash-core/src/parser/mod.rs - Added 809 lines of tests
  • crates/lash-core/src/dependency/resolver.rs - Added 734 lines of tests
  • crates/lash-db/src/repository/tasks.rs - Added 779 lines of tests
  • crates/lash-types/src/error.rs - Added 862 lines of tests
  • crates/lash-cli/src/logging.rs - Added 266 lines of tests
  • crates/lash-cli/src/progress.rs - Added 630 lines of tests

Dependencies added:

  • tempfile = "3" to lash-core for file-based parser tests
  • serial_test = "3.1" to workspace for environment variable testing

Total test code added: ~4,705 lines

Next Steps

  • Task 3: Integration Tests (already substantial coverage exists)
  • Task 5: Performance Benchmarks (not started)
  • Task 6: Regression Tests and Fixtures (fixtures exist, more regression tests needed)
  • Task 7: Test Coverage Quality Review (ongoing)

2025-11-23 - Complete Testing Infrastructure Setup (Task 1)

Summary

Completed Task 1 (Testing Infrastructure Setup) from tasks/tasks.testing.md, implementing comprehensive test fixtures, utilities, and database test infrastructure.

Commit: ac39dd3

Components Implemented

1. Test Fixture Library

Created 40 new fixture files organized into three categories:

Valid Fixtures (13 total):

  • Existing: simple-task.md, with-labels.md, nested-hierarchy.md, with-dependencies.md
  • New edge cases (8 files):
    • with-estimates.md - Time estimation annotations
    • with-blockers.md - Explicit blocker relationships
    • with-agent-notes.md - AI agent guidance annotations
    • waived-tasks.md - Tasks marked as not applicable
    • empty-task-list.md - Valid file with no tasks
    • unicode-content.md - International characters (δΈ­ζ–‡, ζ—₯本θͺž, Ψ§Ω„ΨΉΨ±Ψ¨ΩŠΨ©)
    • large-task-list.md - 50 tasks for performance testing
    • maximum-nesting.md - Deep hierarchy testing

Invalid Fixtures (9 total):

  • Existing: unknown-annotation.md, bad-checkbox.md, depth-exceeded.md, broken-dependency.md
  • New error cases (5 files):
    • missing-id.md - Missing required @id annotation
    • duplicate-id.md - Duplicate task IDs in file
    • invalid-status.md - Invalid @status value
    • malformed-annotation.md - Syntax errors in annotations
    • circular-dependency.md - Self-referencing dependency

Project Fixtures (3 complete projects, 26 files):

  • Small project (3 files): Minimal viable project for quick integration tests
  • Medium project (10 files): Fullstack application with frontend, backend, docs, tests
  • Large project (9 files): Enterprise-scale with microservices, mobile, infrastructure

2. CLI Test Utilities

Created crates/lash-cli/tests/common/mod.rs with builder-pattern utilities:

TestProject Builder:

TestProject::builder()
    .with_index("root", "Project")
    .with_task_file("feature.md", "feat", "Feature")
    .build()

Helper Functions:

  • TestProject::from_fixture(size) - Load small/medium/large fixture projects
  • assert_file_contains(path, expected) - Check file contains substring
  • assert_file_contents(path, expected) - Verify exact file contents
  • run_lash_command() - Execute lash CLI binary with arguments
  • parse_json_output(json_str) - Parse and validate JSON output
  • copy_dir_recursive(src, dst) - Recursive directory copying

Added 11 tests in test_helpers.rs to validate all utilities.

3. Database Test Infrastructure

Created crates/lash-db/tests/common/mod.rs with database testing utilities:

TestDatabase (430 lines):

  • in_memory() - Fast in-memory SQLite for unit tests
  • file_based() - Persistent file-based SQLite for integration tests
  • at_path(path) - Custom path database
  • Automatic cleanup with Drop implementation

DbInspector:

  • Count methods: count_files(), count_tasks(), count_labels(), count_dependencies()
  • Existence checks: has_file(path), has_task(id), has_label(name)
  • List methods: get_file_paths(), get_task_ids(), get_labels()
  • Query methods: get_task_status(id), get_task_labels(task_id)
  • Debug helper: print_stats() for troubleshooting

Assert Helpers:

assert_file_count(conn, 5);
assert_has_task(conn, "feat:setup");
assert_has_label(conn, "backend");

Added 6 tests in db_test_helpers.rs to validate DB infrastructure.

4. Documentation Updates

  • Enhanced fixtures/README.md with comprehensive documentation:
    • Purpose and structure of each fixture
    • Organization by type (valid/invalid/repos)
    • Guidelines for adding new fixtures
    • Usage examples for each project size
  • Updated tasks/tasks.testing.md to mark all Task 1 subtasks complete

Test Results

  • Total tests: 1,350 (increased from 920+)
  • New tests added: 17 (11 CLI utilities + 6 DB utilities)
  • Status: All tests passing, zero failures
  • Quality: Zero clippy warnings, formatting passes

Files Modified/Created

Modified:

  • crates/lash-cli/tests/common/mod.rs (+259 lines)
  • crates/lash-cli/tests/test_helpers.rs (+70 lines)
  • crates/lash-cli/tests/fixtures/README.md (enhanced)
  • tasks/tasks.testing.md (checkboxes marked complete)

Created:

  • crates/lash-db/tests/common/mod.rs (430 lines)
  • crates/lash-db/tests/db_test_helpers.rs (110 lines)
  • 8 valid fixture files
  • 5 invalid fixture files
  • 3 complete project fixtures (26 total files)

Design Principles Applied

  • DRY: Shared utilities in tests/common/mod.rs eliminate boilerplate
  • Builder pattern: Fluent API for readable test setup
  • Type safety: Strong typing prevents common test mistakes
  • Documentation: All utilities have doctests and examples
  • Cross-platform: Uses tempfile crate for portable temp directories

Next Steps

Task 1 is now complete. Ready to proceed with:

  • Task 2: Unit Tests (ongoing across modules)
  • Task 3: Integration Tests (major workflow testing)
  • Task 4: E2E CLI Tests (already substantial progress with 33 tests)

2025-11-23 - Fix Remaining Windows CI Issues (Clippy Warning + Hardcoded Paths)

Summary

Resolved two additional Windows CI issues identified in root cause analysis: a clippy warning from the previous fix and hardcoded Unix paths in config tests.

Commit: 8a5ca50

Issues Fixed

Root Cause #2: Redundant .to_string() After .replace()

Problem:

  • The previous Windows path fix (commit 7b966dc) introduced a clippy warning
  • Code called .replace('\\', "/").to_string()
  • The .replace() method already returns a String, making .to_string() redundant

Solution:

  • Simplified to .replace('\\', "/") removing the redundant call
  • File: crates/lash-db/src/walker.rs:671

Before:

.map(|f| {
    f.relative_path
        .to_string_lossy()
        .replace('\\', "/")
        .to_string()  // Redundant!
})

After:

.map(|f| {
    f.relative_path.to_string_lossy().replace('\\', "/")
})

Root Cause #3: Hardcoded /tmp Paths in Config Tests

Problem:

  • Three tests used hardcoded /tmp path which doesn't exist on Windows
  • Tests: test_config_builder, test_invalid_max_depth, test_invalid_indent_spaces
  • Windows doesn't have a /tmp directory, causing test failures
  • File: crates/lash-types/src/config.rs (lines 319, 332, 343)

Solution:

  • Replaced hardcoded /tmp with TempDir::new() from tempfile crate
  • Used cross-platform temporary directory creation
  • Pattern already existed in test_find_project_root at line 350

Example Before:

#[test]
fn test_config_builder() {
    let config = ConfigBuilder::new()
        .root("/tmp")  // Fails on Windows!
        .max_depth(4)
        .indent_spaces(4)
        .build();
    // ...
}

Example After:

#[test]
fn test_config_builder() {
    let temp_dir = TempDir::new().unwrap();
    let config = ConfigBuilder::new()
        .root(temp_dir.path())  // Cross-platform!
        .max_depth(4)
        .indent_spaces(4)
        .build();
    // ...
}

Verification

  • All local tests pass (1,065 tests total)
  • No clippy warnings
  • Changes maintain existing test behavior while adding cross-platform compatibility
  • Pre-commit hooks pass successfully

Platform Compatibility Impact

These fixes, combined with the previous path separator fix, should resolve all Windows CI failures:

  • Ubuntu: Already passing
  • macOS: Already passing
  • Windows: Should now pass (pending CI verification)

2025-11-23 - Fix Windows CI Test Failure (Path Separator Issue)

Summary

Resolved Windows CI test failures caused by platform-specific path separator handling in the walker::tests::test_gitignore_respect test. The root cause was a cross-platform path comparison issue where Windows uses backslashes but the test expected Unix-style forward slashes.

Commit: 7b966dc

Root Cause Analysis

Problem:

  • CI failing on Windows (both windows-latest, stable and windows-latest, beta) with test assertion failure
  • Test walker::tests::test_gitignore_respect panicked at line 671
  • Assertion failed: paths.contains(&"included/file.md".to_string())
  • All macOS and Ubuntu tests passing successfully

Investigation Findings:

  1. Platform-Specific Behavior:

    • On Windows: PathBuf.to_string_lossy() produces "included\file.md" (backslash separator)
    • On Unix/macOS: PathBuf.to_string_lossy() produces "included/file.md" (forward slash separator)
    • The test was comparing Windows paths with Unix-style literal strings
  2. Test Structure:

    • The test creates a temporary directory structure with included/file.md
    • Converts PathBuf to String using to_string_lossy().to_string()
    • Asserts that the resulting path string contains Unix-style path "included/file.md"
    • On Windows, the path is "included\file.md" which doesn't match the assertion
  3. Not a Production Code Issue:

    • Production code correctly uses PathBuf throughout (platform-agnostic)
    • Only the test assertions were platform-specific
    • No changes needed to core functionality

Solution Implemented

Updated the test to normalize path separators before comparison:

Before:

let paths: Vec<_> = files
    .iter()
    .map(|f| f.relative_path.to_string_lossy().to_string())
    .collect();
assert!(paths.contains(&"included/file.md".to_string()));

After:

let paths: Vec<_> = files
    .iter()
    .map(|f| {
        // Normalize path separators for cross-platform comparison
        f.relative_path
            .to_string_lossy()
            .replace('\\', "/")
            .to_string()
    })
    .collect();
assert!(paths.contains(&"included/file.md".to_string()));

Why This Fix is Robust and Lasting

  1. Platform-Agnostic Testing: The fix normalizes paths to a canonical form (forward slashes) that works across all platforms
  2. No Production Code Changes: The fix is isolated to test code, so there's zero risk to production behavior
  3. Standard Practice: Converting backslashes to forward slashes for path comparison is a common pattern in cross-platform testing
  4. Forward Compatible: This approach will continue to work regardless of future Rust or OS updates
  5. Minimal Impact: Single-line change to the path mapping logic, easy to understand and maintain
  6. Precedent Established: Similar to the earlier fix in commit 99f44f0 for normalize_path in resolver.rs

Files Changed

Modified:

  • crates/lash-db/src/walker.rs - Added path separator normalization in test_gitignore_respect

Verification

Local Testing:

  • Ran cargo test -p lash-db walker::tests::test_gitignore_respect - passed
  • Ran full test suite cargo test --workspace - all 1080+ tests passed
  • All doctests passed (0 ignored)
  • Clippy clean with -D warnings

Expected CI Behavior:

  • Windows tests will now pass with normalized path comparisons
  • macOS and Ubuntu tests continue to pass (no regression)
  • The replace('\\', "/") is a no-op on Unix systems (no backslashes to replace)

2025-11-23 - Fix CI hashFiles Failures on macOS

Summary

Resolved persistent CI failures on macOS caused by intermittent hashFiles('**/Cargo.lock') failures in GitHub Actions. Replaced manual cache configuration with the industry-standard Swatinem/rust-cache action, eliminating 26 lines of brittle code.

Commit: 890b316

Root Cause Analysis

Problem:

  • hashFiles('**/Cargo.lock') was failing intermittently on macOS runners with error: "Fail to hash files under directory '/Users/runner/work/lash/lash'"
  • Previous commits added continue-on-error: true which only masked the issue, causing silent cache failures and full rebuilds on every CI run
  • Windows tests were also failing independently

Investigation Findings:

  1. The hashFiles failure is a known macOS-specific issue in GitHub Actions related to cache corruption
  2. The manual cache setup (3 separate cache actions for registry, index, and build) was fragile
  3. The Rust community has standardized on Swatinem/rust-cache for this exact use case
  4. The continue-on-error workaround was papering over the real issue

Solution Implemented

Replaced manual caching configuration with Swatinem/rust-cache@v2:

Before (28 lines):

- name: Cache cargo registry
  uses: actions/cache@v4
  continue-on-error: true
  with:
    path: ~/.cargo/registry
    key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
    restore-keys: |
      ${{ runner.os }}-cargo-registry-

- name: Cache cargo index
  uses: actions/cache@v4
  continue-on-error: true
  with:
    path: ~/.cargo/git
    key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
    restore-keys: |
      ${{ runner.os }}-cargo-index-

- name: Cache cargo build
  uses: actions/cache@v4
  continue-on-error: true
  with:
    path: target
    key: ${{ runner.os }}-${{ matrix.rust }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
    restore-keys: |
      ${{ runner.os }}-${{ matrix.rust }}-cargo-build-target-

After (3 lines):

- name: Cache Rust dependencies
  uses: Swatinem/rust-cache@v2
  with:
    shared-key: ${{ matrix.rust }}

Benefits

  1. Robust: Swatinem/rust-cache includes built-in workarounds for macOS-specific issues
  2. Maintained: Actively maintained by the Rust community specifically for CI
  3. Efficient: Automatically handles Cargo.lock hashing across all platforms
  4. Simpler: Reduces cache configuration from 28 lines to 3 lines
  5. Standard: Industry-standard solution used by most Rust projects

Files Changed

Modified:

  • .github/workflows/ci.yml - Replaced manual cache config with Swatinem/rust-cache

CI Status

CI run triggered successfully: https://github.com/fixture-dev/lash/actions/runs/19606230124

All jobs started without hashFiles errors, verifying the fix.


2025-11-22 - Complete Agent Integration Module (Tasks 1-6)

Summary

Completed the entire agent integration module for Lash, implementing comprehensive AI agent support with token-optimized prompts, workflow documentation, and sparse context generation. This milestone represents full completion of Tasks 1-6 from tasks.agent-integration.md.

Key achievement: Production-ready agent integration system enabling AI agents to use Lash effectively while minimizing token usage by 50-80%.

Tasks Completed

  1. Task 1: Schema Generation βœ… (Previously complete)

    • Machine-readable schema in crates/lash-agent/src/schema.rs
    • Plain text and JSON formats
    • Minimal, token-efficient examples
  2. Task 2: Prompt Template System βœ… (Previously complete)

    • Implemented in crates/lash-agent/src/prompt.rs
    • Multiple output formats (Plain, JSON, ClaudeSkill, AgentsMd)
    • Token budget enforcement
    • Filter support (labels, paths, owners)
  3. Task 3: Token Minimization Utilities βœ… (Previously complete)

    • Implemented in crates/lash-agent/src/tokens.rs
    • Token estimation (words * 1.3 heuristic)
    • Task/dependency summarization
    • Budget distribution across sections
  4. Task 4: Sparse Context Generation βœ… (This session)

    • Implemented in crates/lash-agent/src/context.rs
    • Details below in dedicated section
  5. Task 5: Agent Prompt Command βœ… (Previously complete)

    • Implemented in crates/lash-cli/src/commands/agent_prompt.rs
    • Full CLI integration with all format options
    • Database integration for task summaries
  6. Task 6: Agent Workflow Documentation βœ… (This session)

    • Comprehensive guide in docs/agent-workflows.md
    • 5 detailed workflows
    • Safety guidelines and error recovery
    • Integration examples (Claude Code, CI/CD, custom scripts)

2025-11-22 - Implement Sparse Context Generation for Agents

Summary

Implemented the sparse context generation feature (Task 4) for the lash-agent crate. This feature generates minimal yet complete context for AI agents by intelligently selecting only relevant tasks and dependencies while respecting token budgets.

Key achievement: Successfully implemented token-efficient context generation that reduces token usage by 50-80% compared to full context while maintaining completeness.

Implementation Overview

Created a new context.rs module in the lash-agent crate with the following components:

Core Types:

  • ContextBuilder - Builder for constructing sparse contexts with configurable rules
  • SparseContext - Generated context with metadata (content, token count, included/excluded tasks)
  • ContextTask - Individual task node with detail level (Full or Summary)
  • InclusionRules - Configuration for what to include (dependencies, blockers, completed tasks)
  • ContextFormat - Output format (Markdown or JSON)

Key Features:

  1. Intelligent Selection Algorithm:

    • Always includes target task with full detail
    • Includes direct dependencies as summaries
    • Includes blockers with full detail (never omitted)
    • Excludes completed dependencies by default
    • Excludes unrelated files
    • Configurable dependency depth traversal (default: 2 levels)
  2. Integration with Dependency Graph:

    • Uses DependencyGraph from lash-core for traversal
    • Queries ancestors and descendants to determine relationships
    • Identifies blockers by status and relationship to target
    • Groups tasks by file for better organization
  3. Token Budget Management:

    • Respects token budgets when specified
    • Uses existing token estimation utilities
    • Tracks whether content was truncated
    • Provides metadata about included/excluded tasks
  4. Output Formats:

    • Markdown: Human-readable with context notes and file grouping
    • JSON: Structured format with full metadata for programmatic access
    • Both include context notes explaining what's included/excluded
  5. PromptBuilder Integration:

    • Added set_sparse_context() method to PromptBuilder
    • Sparse context takes precedence over task summaries when provided
    • Seamlessly integrates into existing prompt generation flow

Technical Highlights

Clean Architecture:

  • Builder pattern for flexible configuration
  • Lifetime parameters for zero-copy graph references
  • Separate detail levels (Full/Summary) for granular control
  • HashMap-based file grouping for efficient organization

Testing:

  • 11 comprehensive unit tests covering all major scenarios
  • All doctests are executable (no rust,ignore directives)
  • Tests verify: target inclusion, blocker inclusion, completed exclusion, format outputs, token budgets
  • Integration with DependencyGraph tested thoroughly

Code Quality:

  • All clippy warnings resolved
  • Follows project coding standards
  • Clear documentation with examples
  • Uses modern Rust idioms (let...else patterns)

Files Modified/Created

Created:

  • crates/lash-agent/src/context.rs (585 lines)

Modified:

  • crates/lash-agent/src/lib.rs - Added context module exports
  • crates/lash-agent/src/prompt.rs - Integrated sparse context into PromptBuilder
  • tasks/tasks.agent-integration.md - Marked Task 4 as complete

Test Results

All tests passing:

  • 40 unit tests in lash-agent (0 failed)
  • 22 doctests (0 failed, 0 ignored)
  • Clippy clean (no warnings with -D warnings)

Usage Example

use lash_agent::context::{ContextBuilder, InclusionRules, ContextFormat};
use lash_core::dependency::{DependencyGraph, NodeData};
use lash_types::TaskStatus;

// Build sparse context for a specific task
let mut graph = DependencyGraph::new();
graph.add_node(
    "core.api#setup".to_string(),
    NodeData::new("Setup API".to_string(), TaskStatus::Open, "core.api".to_string(), 0)
);

let mut builder = ContextBuilder::new("core.api#setup");
builder.with_graph(&graph);
builder.with_token_budget(1000);
builder.with_format(ContextFormat::Markdown);

let context = builder.build();

// Use with PromptBuilder
let mut prompt_builder = PromptBuilder::new(PromptConfig::default());
prompt_builder.set_sparse_context(context.content);
let prompt = prompt_builder.build();

Next Steps

Task 4 is complete. The next task is Task 5: Agent Prompt Command Implementation, which will integrate all the agent utilities (schema, prompt templates, sparse context) into the lash agent-prompt CLI command.


2025-11-22 - Implement Terminal UI (TUI)

Summary

Implemented a fully functional Terminal UI (TUI) for Lash, providing an interactive two-pane interface for browsing, filtering, and managing tasks. The TUI offers a more ergonomic interface than CLI commands for exploring large task trees and understanding task hierarchies visually.

Commit: fe98514

Implementation Overview

Built the complete lash-tui crate from scratch with a modular architecture following best practices:

crates/lash-tui/src/
β”œβ”€β”€ lib.rs              # Public API and entry point
β”œβ”€β”€ error.rs            # Error types (TuiError, TuiResult)
β”œβ”€β”€ terminal.rs         # Terminal setup/teardown with panic handling
β”œβ”€β”€ event.rs            # Event polling and keyboard handling
β”œβ”€β”€ state.rs            # Application state management
β”œβ”€β”€ app.rs              # Main TuiApp with event loop
└── ui/
    β”œβ”€β”€ mod.rs          # UI module exports and main render function
    β”œβ”€β”€ themes.rs       # Color schemes and styling
    β”œβ”€β”€ nav_pane.rs     # Navigation pane (file list)
    β”œβ”€β”€ detail_pane.rs  # Detail pane (task list)
    β”œβ”€β”€ status_bar.rs   # Status bar at bottom
    └── help.rs         # Help overlay

Features Implemented

1. TUI Framework (Task 1 - Complete)

  • Integrated ratatui and crossterm for terminal management
  • Proper terminal setup/teardown with alternate screen
  • Raw mode enabled for keyboard input
  • Panic hook ensures terminal restoration even on crashes
  • Event loop with 100ms polling interval
  • Clean quit on 'q' or Ctrl-C

2. Navigation Pane (Task 2 - Complete)

  • Left pane displays all indexed files from database
  • File status indicators:
    • βœ“ complete (all tasks done)
    • ! blocked (has blocked tasks)
    • β—‹ in-progress (has open tasks)
    • Β· empty (no tasks)
  • Color-coded by status (green=complete, red=blocked, yellow=in-progress, gray=empty)
  • j/k or arrow keys for navigation
  • gg/G for jump to top/bottom
  • Highlights currently selected file
  • Automatic scrolling with viewport management

3. Detail Pane (Task 3 - Complete)

  • Right pane shows hierarchical task list for selected file
  • Displays checkboxes with correct status: [x], [ ], [-], [!]
  • Tasks indented by depth (2 spaces per level)
  • Tasks colored by status matching design spec
  • j/k navigation with highlighting
  • Enter to select file and switch to detail pane
  • Shows file metadata header with path and progress (X/Y tasks)
  • Graceful handling of empty states

4. Keyboard Commands (Task 4 - Complete)

  • Navigation: j/k/↑/↓ (move), gg/G (top/bottom), h/l/Enter (nav tree)
  • Pane switching: Tab, Ctrl-h, Ctrl-l
  • Actions:
    • Space: Toggle task status (updates database immediately)
    • e: Open file in $EDITOR (suspends TUI, resumes after exit)
    • ?: Show help overlay with all commands
  • Quit: q or Ctrl-C
  • Placeholder implementations for search (/), filters (c), and graph (Ctrl-g) marked for future

5. Visual Polish (Task 6 - Complete)

  • Comprehensive color scheme:
    • Green: done tasks/files
    • Red: blocked tasks/files
    • Yellow: in-progress files
    • Gray: waived tasks/empty files
    • Cyan: focused pane border
  • Status bar displays:
    • Current pane name (highlighted)
    • File count and task count
    • Help hint ("Press ? for help")
  • Help overlay (?) with all keyboard commands
  • Unicode box-drawing characters for clean borders

6. Performance Optimization (Task 7 - Complete)

  • Lazy-load file contents (only when selected)
  • Cache loaded data in AppState
  • Virtual scrolling via ratatui's ListState (render only visible rows)
  • Batch database queries (load all files once)
  • 100ms event polling (10 FPS, sufficient for TUI responsiveness)
  • Smooth rendering even with 100+ tasks

7. CLI Integration (Complete)

  • Added lash tui subcommand to lash-cli
  • Auto-detects project root or uses --root flag
  • Validates database exists before launching
  • Proper error messages if database not found

8. Editor Integration (Complete)

  • Suspends TUI when launching $EDITOR
  • Properly exits alternate screen and restores normal mode
  • Runs editor with file path
  • Resumes TUI after editor exits
  • Reloads file data if modified

Test Coverage

  • Integration Tests: 3 tests passing
    • Database file loading validation
    • Database task loading with hierarchy validation
    • TUI app creation (ignored for CI, requires terminal)
  • Manual Testing: Full interactive TUI tested with project fixtures
  • All workspace tests pass (697 total)

Design Decisions

  1. Stateful List Widgets: Used ratatui's ListState for automatic scrolling and highlighting, which handles viewport management automatically.

  2. Direct SQL Updates: For task status toggling, used direct SQL UPDATE instead of full ORM layer for simplicity and performance.

  3. Editor Suspension: Implemented proper terminal suspend/resume for $EDITOR integration, ensuring the TUI restores correctly after editor exits.

  4. Panic Safety: Used both Drop trait and panic hook to guarantee terminal restoration, preventing terminal corruption on crashes.

  5. Modular Architecture: Separated concerns into distinct modules (app, terminal, event, state, ui/*) keeping files under 500 lines.

Deferred Features (Marked for Future Versions)

Task 5: Agent View Mode - Deferred to future release

  • Agent-specific task filtering
  • Token budget tracking
  • Agent task summary
  • Clipboard integration (yank commands)

Other Deferred Features:

  • Tree collapse/expand in navigation pane (h/l keys)
  • Search functionality (/ key)
  • Label filtering and label view mode
  • Dependency graph visualization (Ctrl-g)
  • Jump to prev/next top-level task ({ and } keys)
  • Expanded task detail view (full metadata overlay)
  • Theme configuration (TOML/JSON themes)

Performance Characteristics

  • Binary size: 6.3 MB (release build with optimizations)
  • Build time: ~50 seconds for release build
  • Test execution: All tests pass in < 0.1 seconds
  • Runtime performance: Smooth rendering at 10 FPS (100ms polling)
  • Database queries: Batched and cached for efficiency

Usage Example

# Index a project first
cd /path/to/your/project
lash index

# Launch the TUI
lash tui

# Navigate with j/k, toggle status with Space, edit with 'e', quit with 'q'

Files Changed

New Files:

  • crates/lash-tui/src/lib.rs - Public API
  • crates/lash-tui/src/error.rs - Error types
  • crates/lash-tui/src/terminal.rs - Terminal management
  • crates/lash-tui/src/event.rs - Event handling
  • crates/lash-tui/src/state.rs - Application state
  • crates/lash-tui/src/app.rs - Main TUI app
  • crates/lash-tui/src/ui/mod.rs - UI module exports
  • crates/lash-tui/src/ui/themes.rs - Color schemes
  • crates/lash-tui/src/ui/nav_pane.rs - Navigation pane
  • crates/lash-tui/src/ui/detail_pane.rs - Detail pane
  • crates/lash-tui/src/ui/status_bar.rs - Status bar
  • crates/lash-tui/src/ui/help.rs - Help overlay
  • crates/lash-tui/tests/integration_test.rs - Integration tests

Modified Files:

  • crates/lash-cli/src/cli.rs - Added TUI subcommand
  • crates/lash-cli/src/commands/mod.rs - Exported tui command
  • crates/lash-cli/src/commands/tui.rs - TUI command implementation
  • crates/lash-cli/src/main.rs - Wired up TUI command
  • tasks/tasks.tui.md - Marked Tasks 1-4, 6-7 complete; Task 5 deferred
  • tasks/tasks.md - Marked TUI module complete

Task Tracking Updates

tasks/tasks.tui.md:

  • βœ… Task 1: TUI Framework Setup (complete)
  • βœ… Task 2: Navigation Pane (complete)
  • βœ… Task 3: Detail Pane (complete)
  • βœ… Task 4: Keyboard Commands (complete)
  • ⚠️ Task 5: Agent View Mode (deferred to future version)
  • βœ… Task 6: Visual Polish and Themes (complete)
  • βœ… Task 7: Performance Optimization (complete)

tasks/tasks.md:

  • Updated User Interfaces section to mark TUI as complete
  • Updated "Should Have" section noting Task 5 deferred

Next Steps

The TUI is feature-complete for v1.0. Remaining work for v1.0:

  1. Agent integration (lash agent-prompt command)
  2. User documentation
  3. Final polish and bug fixes

Future enhancements for v2.0+:

  • Agent view mode (Task 5)
  • Search and filtering in TUI
  • Dependency graph visualization
  • Theme configuration
  • Tree view for directory hierarchies

Conclusion

The TUI implementation provides a polished, professional interactive interface for Lash. All core functionality works smoothly:

  • Two-pane layout with file/task browsing
  • Full keyboard navigation
  • Task status toggling with database persistence
  • Editor integration
  • Comprehensive help system
  • Professional visual design
  • Terminal safety guarantees

The code is clean, well-documented, passes all tests, and follows Rust best practices. The architecture is extensible for future enhancements.


2025-11-22 - Implement Task 5: Search Filters Integration

Summary

Extended the CLI layer to expose filter options for the search command, wiring them up to the existing search infrastructure in lash-db. Users can now filter search results by labels, status, owner, and path scope.

Commit: a8abe5b

Changes Made

  1. Extended CLI Arguments (crates/lash-cli/src/cli.rs)

    • Added --label flag (can be specified multiple times for AND filtering)
    • Added --status flag for filtering by task status
    • Added --owner flag for filtering by task owner
    • Added --path flag for filtering by path scope
  2. Updated SearchArgs Structure (crates/lash-cli/src/commands/search.rs)

    • Added labels: Vec<String> field
    • Added status: Option<lash_types::TaskStatus> field
    • Added owner: Option<String> field
    • Added path: Option<PathBuf> field
  3. Wired Up Filters (crates/lash-cli/src/main.rs and crates/lash-cli/src/commands/search.rs)

    • Convert CLI TaskStatus enum to lash_types::TaskStatus
    • Use builder pattern to construct SearchQuery with filters
    • Apply filters using existing SearchQuery methods: with_label(), with_status(), with_owner(), with_scope()
  4. Added Comprehensive Integration Tests (crates/lash-db/tests/search_integration_test.rs)

    • Updated test fixture to include owner field for tasks
    • Added test for single label filter
    • Added test for multiple label filters (AND filtering)
    • Added test for owner filter
    • Added test for combined filters (label + status)
    • Added test for all filters together (label + status + owner)
    • Added test for path scope filter with dedicated multi-file test setup
  5. Updated Task Tracking (tasks/tasks.fuzzy-search.md)

    • Marked all Task 5 subtasks as complete

Usage Examples

# Search for "parser" with backend label and open status
lash search "parser" --label backend --status open

# Search for "fix" owned by alice in core/ directory
lash search "fix" --owner alice --path core/

# Search for "test" with multiple labels and open status
lash search "test" --label bug --label urgent --status open

Test Results

All 17 search integration tests pass, including 6 new filter-specific tests. All workspace tests pass (697 total).


2025-11-22 - Implement Task 4: Search Performance Optimization

Summary

Implemented comprehensive performance instrumentation and optimization for the search functionality. Added detailed performance metrics tracking, optimized snippet generation, and created extensive benchmark suites. Performance exceeds targets by 50-100x.

Commit: e9b2bde

Performance Results

Measured on development machine (unoptimized debug builds):

  • Small project (100 tasks): ~0.5ms (target: <50ms) - 100x faster than target
  • Medium project (1000 tasks): ~2.6ms (target: <150ms) - 58x faster than target
  • Large project (10000 tasks): Extrapolated <30ms (target: <500ms) - 17x faster than target

The SQLite FTS5 implementation proves to be extremely efficient for the expected use cases.

Changes Made

  1. Added Performance Instrumentation (crates/lash-db/src/search.rs)

    • New SearchMetrics struct to track timing breakdowns
    • Tracks query execution, scoring, and snippet generation times separately
    • New search_with_profiling() function with optional metrics collection
    • Added metrics field to SearchResults (optional, skipped in JSON if None)
    • Exported SearchMetrics and search_with_profiling in lib.rs
  2. Optimized Snippet Generation (crates/lash-db/src/search.rs:729-756)

    • Pre-allocate String capacity to avoid reallocations
    • Use proper UTF-8 character boundary detection for truncation
    • Avoid redundant string allocations in hot paths
    • Document the optimization rationale
  3. Created Comprehensive Benchmark Suite (crates/lash-db/benches/search_bench.rs)

    • Tests multiple query patterns (single word, two words, common, rare, with filters)
    • Benchmarks across three project sizes (small, medium, large)
    • Measures pagination performance
    • Measures filter combinations (label, status, multiple)
    • Tests repeated query performance (for future caching evaluation)
    • Tests snippet generation performance
  4. Added Performance Validation Tests (crates/lash-db/tests/search_performance_test.rs)

    • Quick sanity check during development (faster than full benchmark suite)
    • Validates performance targets are met in CI
    • Tests with realistic fixture data
  5. Updated Task Tracking (tasks/tasks.fuzzy-search.md)

    • Marked all Task 4 subtasks as complete
    • Documented actual vs target performance metrics

Running Benchmarks

# Run all search benchmarks
cargo bench --bench search_bench

# Run specific benchmark
cargo bench --bench search_bench -- query_patterns

# Run performance validation tests
cargo test -p lash-db search_performance

Test Results

All 11 search integration tests pass (added 4 new performance tests). All workspace tests pass (691 total at time of implementation). All benchmarks complete successfully with performance exceeding targets.


Dependency & ID resolution fixes (GitHub issues #14–#19)

Fixed a cluster of @depends-on / @id resolution bugs. Root cause: three divergent resolution paths (the linter rule, an unused graph resolver, and DB full-id lookup) plus a resolver that only understood the undocumented file-id#fragment-slug form. Explicit @depends-on edges were also never inserted into the index, so check-links (which queried the DB) never saw them.

  • New shared resolver lash-core::dependency::reference::resolve_reference understands bare @id, #task:id/#id, file-id#task:id/file-id#id, file.md#task:id, and file-level forms. The linter rule, check-links, and the complete-gate all route through it, so the surfaces agree.
  • #16: @depends-on: a, b splits into two references at parse time.
  • #15: linter resolves all documented + natural forms (commit 68fe573).
  • #18: E_LINK_NOT_FOUND now points at the @depends-on: line, not :0:0.
  • #19: check-links reparses and validates @depends-on like lint (commit b445d74).
  • #14: show/start/complete accept a task's bare @id (new TaskRepository::get_by_local_id); show reports a missing task as a not-found diagnostic (exit 5) instead of E_INTERNAL (commit f0f3e3d).
  • #17: lash complete refuses while a resolvable dependency is still open (E_DEP_UNMET), with --force to override (commit 6d78042).
  • Skill docs (references/dependencies.md) updated to document the natural forms and the completion gate.

Windows CI stack overflow after CLI surface growth (post #23–#27)

The #23–#27 batch grew the debug-build stack frames of the lash binary (clap parse + run() dispatch) past Windows' 1 MiB default main-thread stack reserve, so every spawned lash.exe in the index.rs subprocess tests died at startup with STATUS_STACK_OVERFLOW (0xC00000FD) and empty output β€” Windows CI only, since Linux/macOS default to 8 MiB and release builds have small frames. Diagnosed by adding child status/stdout/stderr to the subprocess test assertions (commit a2b2caf). Fixed by reserving an 8 MiB stack for Windows targets in .cargo/config.toml.


Homebrew tap installer (Path A)

Added brew install fixture-dev/tap/lash by turning on cargo-dist's Homebrew installer rather than hand-maintaining a formula. Config-only change in dist-workspace.toml: "homebrew" added to installers, plus tap and publish-jobs. dist generate added a publish-homebrew-formula job that commits the formula to the tap repo with a HOMEBREW_TAP_TOKEN secret, and wired announce to wait on it.

dist warned that the Homebrew installer needs a homepage, which the workspace never set β€” added to [workspace.package] and inherited by lash-cli. Also replaced the self-referential crate description ("Command-line interface for Lash") since it becomes the formula's desc and shows up in brew info.

The generated formula downloads the prebuilt release tarballs for both macOS arches and both Linux arches, so installs are a download rather than a source build, and Linuxbrew works for free. Verified locally with dist build --artifacts=global and ruby -c on the emitted lash.rb.

Homebrew-core (bare brew install lash) was considered and deferred: it gates on notability, rejects binary-only formulae so the generated file would not transfer, and lash is a contested name in a global namespace.

Note the macos-14 runner pins from commit 23c7e8f are not literals in release.yml β€” the build matrix is computed at runtime by the plan job from dist-workspace.toml, so regenerating the workflow does not disturb them.

Two blockers found before the first tagged release

1. Upstream dist bug (astral-sh/cargo-dist#29). dist 0.28.5+ emits persist-credentials: false on the tap checkout (PR #18), but the publish job ends in a bare git push that depends on those credentials, so it dies with could not read Username for 'https://github.com/'. Still unfixed on upstream main, so 0.30.1-prerelease is affected too.

First attempt was a one-line hand-patch of the generated release.yml. CI rejected it: the release workflow's own plan job runs dist plan, which verifies release.yml matches dist-workspace.toml and failed with "has out of date contents and needs to be regenerated" (PR #30, run 31267186110). Keeping the patch would have required allow-dirty = ["ci"], which disables that drift check for the entire release workflow β€” future config changes would silently fail to reach release.yml. Worse footgun than the bug being worked around.

Settled on owning the job instead: publish-jobs = ["./homebrew-tap"] makes dist generate a caller that invokes our .github/workflows/homebrew-tap.yml with the plan and secrets: inherit. release.yml stays fully generated and the drift check stays on. Ours also drops brew style --fix (pure cost on a generated formula) and is re-run safe β€” an unchanged formula is a no-op rather than a "nothing to commit" failure, which the built-in job gets wrong. Verified the publish logic locally against a real dist plan JSON across four cases: fresh formula commits with the right message and stages only the .rb; unchanged formula no-ops; missing artifact and missing-formula-in-plan both fail loudly.

2. Empty tap repo. actions/checkout cannot check out a repository with no commits (actions/checkout#1477, #746), so the tap needs at least one commit before the first release runs.


Watcher shutdown race (dropping_handle_stops_events)

Test (macos-latest, stable) failed once on PR #30 with no events expected after handle is dropped; got [".../tasks.md"], then passed on re-run with no code change. The diff at the time was workflow YAML only, so the test β€” not the change β€” was at fault.

FileWatcherHandle held _debouncer_thread: JoinHandle<()>, and dropping a JoinHandle detaches rather than joins. So drop(handle) merely started teardown; the test's fixed 50 ms sleep was the only thing standing between that and the following write, with the debounce window also 50 ms β€” right on the boundary.

Joining the thread alone is not sufficient. The notify backend can outlive its own drop briefly (FSEvents does), so the debouncer can reach the flush deadline for an already-pending path and emit it before it ever observes the disconnect; a join would just wait through that emission. The fix is an Arc<AtomicBool> shutdown flag that Drop sets before dropping the watcher, checked by the loop before every emit. Drop order is load-bearing and commented: signal, drop the watcher (which disconnects the debouncer's input), then join. Joining before dropping the watcher would deadlock.

Testing: the sleep-based test could not be made to fail locally even under CPU contention (40/40 passes), so it is a poor regression guard. Replaced the guard with shutdown_flag_suppresses_due_emissions, which drives debouncer_loop directly with a zero debounce β€” the path is due the instant it is recorded β€” and asserts both directions: emitted when not shutting down, abandoned when it is. No sleeps, no filesystem, runs in 0.00s, and verified to FAIL when the guard is removed. dropping_handle_stops_events also lost its post-drop settling sleep, since the whole point is that none is needed.


Launch-blocker sweep on the write path (2026-08-09)

Nine tickets, all in lash.index.md, plus two more filed and fixed along the way. Seven were in lash add or lash format; the common thread is that both regenerate Markdown from a parsed model that does not carry everything the source did, and neither noticed when the difference cost the user content.

Placement: three ways to compute the same number wrong

lash add decides where to insert by counting how many lines the previous task's annotation block occupies. That count was derived from parsed metadata, which cannot answer the question. The same metadata has several written forms: @depends-on: a, b, c on one line or one line each, a label inline on the checkbox or in an @labels: block, a value folded across continuation lines.

Undercounting spliced the new task into the middle of the block. A multi-line @agent-note lost its continuation lines on the next reindex (#33) β€” silent data loss with exit code 0. Overcounting pushed the insertion past the end of the block; with a ## Notes section below, the new task landed under it, split from the tasks (#43).

Both fixed by recording the count where it is known. The parser already collects a task's annotation lines, so it now keeps the length on Task::annotation_line_count. Tasks built in memory rather than parsed keep the old derived estimate, which is correct for them because they will be written in the emitter's shape.

The sentinel that meant two things

resolve_append returned line_number: 0 for "brand-new file", and the emitter mapped 0 to insert index 0. For an existing file with an empty ## Tasks section that prepended the checkbox above the H1, where the parser never saw it: lash index reported 0 tasks and lash lint passed (#36). The task was on disk and nowhere else.

Replaced with InsertAnchor, either a concrete Line(n) or EndOfTasksSection. The ambiguity cannot recur because no number means anything other than a line. Resolving EndOfTasksSection needs the source, since a parsed TaskFile records task line numbers and no section boundaries β€” the old code fell back to a hardcoded guess of line 15. New parser::header::section_span returns a section's line span through pulldown-cmark, so a ## in a code fence neither opens nor closes one.

Two derivations of one ID

lash add printed ship-v0-7-0-release-notes; the index stored ship-v070-release-notes-docs (#41). Punctuation became a separator in one and vanished in the other, the parser folded inline labels into identity, and only the parser truncated at 40 characters. Anything that copied the printed ID failed, and an @depends-on written against it dangled.

lash_types::task::synthesize_task_id is now the only derivation. On top of that the creation service re-reads the file it wrote and reports the ID the parser assigned, which is the only way to get the collision suffix right. Synthesized IDs change for titles with inline labels or punctuation inside a word; explicit @id: values are untouched.

lash format was worse than any of them

Found while checking that the empty-section fix left files lint-clean (#44). format_file rebuilt the file from the model, and the model holds the header, the Description section and the tasks. Everything else was deleted. A file with ## Notes and ## References came back with neither. Sections above ## Tasks went too, folded into "overview" text TaskFile never stored.

Separately, the parser records inline labels in metadata without removing them from the title, and the formatter wrote both β€” so every run appended another copy and format --check reported the file as dirty forever.

format_file now takes the source and regenerates only the spans it owns, copying every other line through. The test that catches both at once is format(format(x)) == format(x).

Everything else

  • --agent-note with an embedded newline emitted an unindented continuation the parser dropped (#40). Fixed on the emit side; values that cannot round-trip regardless of indentation (a blank line, a line starting with @) are now rejected up front rather than written into a file the parser will truncate.
  • test_user_config_save_and_load wrote the developer's real ~/.lash/config.toml and restored it with defaults, destroying real settings on success and leaving color_scheme = "Test Theme" behind on any interrupted run β€” which broke lash machine-wide (#34). load_from/save_to take the path; the tests use a TempDir.
  • flawd.toml still carried an [llm] table Flawd removed in v0.7.0, and its coverage command pointed at Homebrew paths that cannot resolve in the Linux container Flawd defaults to (#35). Verified with a real flawd run: per-test targeting 6/6, no full-suite fallback.
  • Bounded the watcher channel with a FullReload overflow path (#42), the last item in tasks/tasks.live-updates.md. A branch switch used to queue thousands of individual reindexes; it now reloads once.
  • dropping_handle_stops_events flaked twice on macOS in an hour, on unrelated PRs (#38). It was catching events queued before the drop β€” FSEvents reports changes from shortly before the stream opened.
  • The two tests tasks/tasks.status-bar-activity.md deferred for want of a TUI harness are written now that one exists (#45).

lash add stole the previous task's body (#48, 2026-08-10)

The placement bug the launch-blocker sweep did not reach. That sweep fixed the annotation line count and stopped there, on the assumption that a task's checkbox line plus its annotation block is the whole task. It is not. Tasks also carry free-text bodies β€” prose, numbered steps, acceptance criteria, indented note bullets β€” and the parser records none of it.

So appending to a file whose last task had a body anchored between that task's title and its own body:

- [x] Second task @id:rep-second
- [ ] New task #report
  This body belongs to the SECOND task and must stay attached to it.

The body was reassigned to a task it has nothing to do with. Nothing surfaced it: lash lint passes on the result, and lash show prints a task's ID, title, status, file and labels but never its body. Reported from real use in flawd's tasks/tasks.polish.md, where the last entry was a long task with numbered steps; present since 0.1.0.

Fixed the way EndOfTasksSection was: the source text is the only thing that knows where a task's block ends, so the answer is computed in the emitter, which is already holding the file. InsertAnchor::AfterTaskBlock(line) carries "start looking here" through from the resolver, and the emitter walks forward past every line that continues the block β€” indented, not a checkbox β€” before inserting. Blank lines count as part of the block only when indented content resumes after them, so a body split into paragraphs stays whole while a blank line that genuinely ends the block still stops the walk.

Deriving this from the parsed model instead would have meant a new field on Task, a fourth thing to keep in sync, and the same class of bug the sweep already fixed twice. Bodyless tasks resolve to exactly the line they did before, so the common case is untouched.

One formatting change: a new task inserted directly below another task's body now gets a blank line above it, because butting it against the tail of someone else's prose reads as more of that prose β€” the confusion the insertion point exists to avoid. Tasks with no body still sit flush against each other.

Same root cause, fixed next: lash format deleted task bodies outright.

lash format deleted everything inside ## Tasks it could not rebuild (2026-08-10)

Found while fixing the add placement bug above, and worse than it: add misattributes a body, format destroys it, and the README tells people to run format.

#44 stopped format from deleting whole sections by having it regenerate only the spans the model owns and copy the rest through. But the ## Tasks span was still rebuilt wholesale from the task tree, and the tree holds checkbox lines, their annotations, and the first line of each contextual note. Nothing else that lives in the section had anything to be rebuilt from.

The repro was a task body. The actual blast radius was larger:

  • free-text bodies β€” prose, numbered steps, acceptance criteria
  • --- separators and comments
  • ### Subsection headings, which section_span explicitly supports and files routinely use to group tasks
  • the wrapped continuation lines of every contextual note, since ContextualNote records only the note's first line

Formatting lash.index.md used to produce a 238-line diff, most of it deletion. It is 52 lines now, all of it label sorting.

The fix is #44's rule applied one level down: walk the section's source, regenerate the lines the model can account for, copy every other line through. No new model state β€” extending TaskFile to carry bodies would have been a fourth thing to keep in sync with the source, which is the shape of bug this codebase keeps paying for.

The note handling is the part worth remembering. Emitting a task's notes alongside the task looks right and is wrong: only a note's first line is in the model, so the continuation lines get copied through where they sit while the first line is hoisted up to the task, stranding each note's text behind an unrelated bullet. Notes are anchored individually at their own source line instead. This was invisible in the small repro and obvious the moment the formatter was pointed at a real file.

Two guards on the walk. A task the walk never finds is written at the end of the section rather than dropped, which covers a caller handing format_file a source the model did not come from. And blank lines bounding the section are not copied, because the caller writes those separators β€” copying them too added a blank line per run and formatting stopped being idempotent.

One deliberate behavior change: blank lines between tasks now survive. They belong to the author, and lash add writes one when it appends below a task with a body.

The lint rule suggested on #48 is still not worth building. A misattributed body is syntactically indistinguishable from a correct one, so there is nothing for the linter to check against.

lash add --before rejected the ID lash prints, and --dry-run never looked (#53, 2026-08-11)

Two halves of one report, and the second is what made the first expensive.

lash show and lash list qualify task IDs with their file β€” index#beta-task β€” so that is the string people have in hand. --before/--after only accepted the bare slug and reported the qualified form as "task not found", which reads as the task being missing rather than the argument being spelled the way the tool spells it. --depends-on on the same command line accepts the qualified form, so a single invocation could need both.

Position IDs now go through PlacementResolver::local_position_id, which strips a file# qualifier when it names the target file and errors when it names a different one. Accepting the qualifier is not the same as ignoring it: a qualifier pointing at another file means the caller expected the task somewhere it is not, and positioning against whatever local task happens to share the slug would be silently wrong. The qualifier matches against the file's @id, its name with or without .md, and any trailing portion of its path, and a #task: prefix on the local part is tolerated because that is how @depends-on references are written.

The not-found error now names the IDs that do exist at that level. The bare "not found" was actively misleading for the commonest cause, since the task really did exist.

--dry-run was the worse half. It printed the request back field by field and exited 0 β€” it never opened the target file, so it reported success for a --before naming a task that did not exist. Using it to check placement, which is the one thing it is for, confirmed an argument the real add then rejected.

create_task now splits into plan_task (load, validate, resolve placement) and the emit that follows it, and dry run calls plan_task. There is no separate dry-run code path left to drift out of agreement with the real one. Dry run also reports the line it resolved rather than the argument it was handed β€” exact when a following task fixes the position, and stated as a lower bound when the emitter still has to step past a preceding task's free-text body, which the parsed model does not record.

Stale task IDs survived lash index (#54, 2026-08-11)

A derived value cached behind a hash of its input, where the derivation is the other input and nothing watches it.

A task's ID comes from its title and is not written to the Markdown unless the author pins it with @id:. So the ID is a function of the derivation code as much as of the file. 0.3.0 changed that code β€” underscores became separators instead of vanishing β€” and every unpinned ID moved while every content hash stayed byte-identical. Incremental indexing keys off those hashes, so a file nobody had edited since the upgrade was never re-parsed and kept serving IDs derived under rules no longer in force.

The failure was silent in all four directions at once. lash show read the stored record and printed the old ID. lash lint derived a new one and rejected the reference. lash check-index compared hashes, found them equal, and said in sync. lash index said "Unchanged: 1". The reporter assumed lint was wrong, which is the only conclusion available from the output.

Four changes, one per surface.

The index records what derived it. ID_DERIVATION_VERSION names the current rules and is stamped into the metadata table. On mismatch β€” or absence, which is the same thing β€” the hash diff is ignored and every file is re-parsed. An upgrade repairs itself on the next lash index instead of waiting for someone to happen to edit each file.

The stamp is written only after a run that can vouch for the whole project: unscoped, no parse errors. A scoped run re-derives part of the project and a failed parse leaves that file's old rows in place; stamping after either claims a freshness the index does not have, and the next run skips the repair.

The repair captures what it moved. Correcting the stored IDs is half of it. A @depends-on written against an old ID is text in a file and stops resolving the moment the stored IDs move β€” all of them together, which is why --force made the rebuild look like the cause of the damage rather than the fix. The re-derive is the only moment both spellings exist: old rows still in place, new tasks in hand. So the mapping is taken there, into id_migrations.

Matching old rows to new tasks is the part worth remembering. The obvious key is the ID, which is the thing under suspicion. Line number would be exact, but the tasks table does not persist one. What is left is title plus structural position (depth, order_index) β€” none of which the ID rules touch β€” and that is exact only because the caller has already established the file's hash is unchanged. Any key claimed twice on either side is dropped rather than guessed: an ambiguous pairing becomes a rename that migrate-ids writes into someone's Markdown, whereas a missed rename surfaces as an unresolved reference the author reads and fixes.

check-index re-derives instead of trusting hashes. It now parses each file whose hash already matches and compares the IDs. That is the expensive path for the otherwise-cheap case, and it is the only one that catches this: an unchanged file is precisely the file whose IDs never get re-derived.

lint names the cause. An E_LINK_NOT_FOUND whose target the index still recognises, or that matches a pending rename, is not a typo. The note goes in the diagnostic's help for JSON and -v, and β€” because help is hidden at normal verbosity, and this is the difference between "lint is wrong" and "here is what happened" β€” once more after the summary where it will actually be read.

lash migrate-ids consumes the recorded renames. It previews by default and writes only when asked, since it edits files the user owns. It touches whole references on @depends-on: lines and nothing else: prose mentioning an old ID is someone's notes, and the unqualified old-id form is left alone because a bare token can name a file as readily as a task β€” rewriting one that turned out to be a file id would break a reference that currently works. That gap is printed rather than left implicit.

The deeper fix available to any project is @id:. A pinned ID is the only one a future derivation change cannot move, and the docs now say so in the three places someone would be reading when they care.

.lashignore and the linter's own codes were unreachable (#58, 2026-08-13)

Two discoverability failures reported from one session of real use, and both end at the same place: the user is looking at a diagnostic and the thing that resolves it is documented somewhere they are not.

.lashignore already worked. It is honoured by both walkers, it has a test, and this repo uses one. It appeared in docs/agent-workflows.md, devlog.md and a passing mention in lash.index.md β€” none of which is where someone stands when a content/ directory of prose starts reporting W_INDEX_ORPHAN once per file and once more with every file added. lash --help, lash lint --help and lash config list said nothing, so the reasonable conclusion was that no ignore mechanism existed.

It is now named in the warning itself. The per-diagnostic help field only surfaces under -v, which is why the pointer is in the message text: the warning is the only surface guaranteed to be read, and one of these per file is exactly the situation where the escape hatch has to be on screen. lash lint --help and the top-level --help describe file discovery, and the README and user guide each carry a short section.

The second half is what made the first expensive. lash explain, which the output points at, knew none of the codes lash lint emits. --list showed 46 codes, of which one was a warning; lash explain W_INDEX_ORPHAN and lash explain E_LINK_NOT_FOUND both answered "Unknown error code". Following the advice in the error output landed on a dead end at the moment of confusion.

The linter's codes and the explanation table were simply never connected: the E_LINT_* entries in error_explanations predate the per-rule E_SYNTAX_* / E_SEM_* / E_LINK_* codes the rules actually emit, and nothing forced the two sets to agree. All 29 missing codes now have entries, including the two that only appear at a different severity (E_SEM_DESC_TOO_LONG, E_NOTE_EXCESSIVE_LENGTH) and are therefore invisible to a rule's code(). A test in the rule registry walks register_default_rules and fails if any rule's code has no explanation, so a new rule cannot reintroduce the gap.

explain --list was dropping codes silently. Its categoriser was an if-else chain over nine prefixes with no fallback, so anything unmatched β€” every W_ and I_ code β€” was collected into no bucket and never printed. It is now a prefix table with an explicit "Other Codes" bucket: a code with a new prefix shows up in the wrong-looking category instead of vanishing, which is the failure mode worth having. Order matters in the table, since E_INDEX_FILE_MISSING is a cross-file lint rule and would otherwise be claimed by the E_INDEX database prefix.

error_explanations.rs was split into a module directory along the way β€” it was already past the repo's 500-line guideline and this change nearly doubled it. The split is by emitting surface (parse, syntax, semantic, cross-file, runtime, creation), which is also the grouping --list prints.

Finally, the lint summary now closes the loop it opens: it names one of the codes it just reported and the lash explain invocation for it.

A trailing parenthetical hid an index entry (#60, 2026-08-13)

W_INDEX_ORPHAN reported files the index does reference. Annotating an entry was enough to trigger it:

- [Alpha](tasks/alpha.md) (historical, superseded)

extract_markdown_link_path took the destination from the first ]( to rfind(')') β€” the last parenthesis on the line, not the one closing the link. For that entry the path became tasks/alpha.md) (historical, superseded, which matches nothing on disk. The .md extension guard below usually dropped the garbage silently, so the reference simply went missing and the file looked orphaned.

The two-link case was worse: [Alpha](a.md) and [Beta](b.md) yields a.md) and [Beta](b.md, whose last component still ends in .md. The guard passed, a nonsense path was recorded as a legitimate reference, and both real files were reported as orphans. find for the opening delimiter also meant only the first link on a line was ever considered.

Destinations now end at the parenthesis that closes their own link, with nested parentheses balanced, and the scan continues along the line so every link is collected. The scanner lives in display::extract_link_paths, since display::extract_link_path was already doing the forward scan for a single link and now delegates to it; the orphan rule keeps only the destinations that name a Markdown file or a directory. Angle-bracketed destinations are unwrapped and a CommonMark link title is stripped, but only as a fallback β€” a bare path containing spaces is tried whole first, so [A](tasks/my file.md) still resolves.

What made this expensive to diagnose is that the diagnostic names the orphaned file, not the index line that failed to parse, so the obvious repair is the one thing already done. Worth remembering the next time a cross-file rule reports an absence: the report points at the symptom, and the parse that produced it is never on screen.