diff --git a/.gitignore b/.gitignore index e27881f..c9c9252 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,8 @@ tmp/ # ai-flow: local plan working copies (canonical copy is the GitHub issue) .cursor/plans/ -# Generated by dev (machine-local, rendered from the org knowledge cache) +# Linked by dev (a symlink to the machine-local render of the org +# knowledge repo's invariants) .cursor/rules/org-invariants.mdc # Generated by dev install-deps/up: skill links from lockfile-matched gems diff --git a/README.md b/README.md index 8f1cf69..f19dbbb 100644 --- a/README.md +++ b/README.md @@ -354,7 +354,7 @@ Custom integrations implement `Dev::Deps::Integration` (with `install_all(pins, ### Built-in commands - **`dev update-deps`** — resolve constraints from `dependencies.rb`, write lockfiles (recording the manifest digest for the staleness check). Always available (no need to define in `dev.yml`). -- **`dev install-deps`** — install locked deps handled on the host (gh releases, steam apps) into their version-keyed install dirs, filtered to the detected env and host OS. Finishes by refreshing agent skill links (see [Agent skills & org knowledge](#agent-skills--org-knowledge)). +- **`dev install-deps`** — install locked deps handled on the host (gh releases, steam apps) into their version-keyed install dirs, filtered to the detected env and host OS. Finishes by refreshing agent skill links (see [Agent skills & org learnings](#agent-skills--org-learnings)). - **`dev up`** — auto-installs all deps from lockfiles (build group first), then runs the project's `up:` command from `dev.yml` if defined. On success, stamps the installed lockfile digest (see `dev check`). Finishes by refreshing agent skill links, like `install-deps`. - **`dev check`** — report dependency-state staleness explicitly: `dependencies.rb` vs lockfiles (digest recorded by `update-deps`), and lockfiles vs the per-machine installed stamp (`~/.dev/state//installed-digest`, written after a fully-successful `up`/`install-deps`). The same two O(1) checks run at every command start — warning on workstations, erroring in CI. - **`dev deps path `** — print the absolute path of a locked artifact (e.g. `dev deps path ficsit SML LinuxServer`, or `dev deps path xcode` for the pinned DEVELOPER_DIR) so scripts don't reconstruct cache keys or layout conventions. @@ -362,16 +362,16 @@ Custom integrations implement `Dev::Deps::Integration` (with `install_all(pins, - **`dev cd `** — jump to a checkout under `$DEV_CD_ROOT` (default `~/src`) by fuzzy name, with Tab completion (see [dev cd](#dev-cd--jump-between-checkouts)). Global: works without a `dev.yml`. - **`dev cache gc [--keep N]`** — reclaim host caches dev owns (see below). - **`dev reset-container`** — remove the persistent build container (clears its incremental cache); registered only when `build.container.persist` is set. -- **`dev plan …`** — global (works without a `dev.yml`; the workspace is the nearest dev.yml or git root). Sync Cursor plans with GitHub issues (ai-flow): the issue is the canonical plan, the local `.cursor/plans/gh--.plan.md` is a transient working copy carrying an `` header. Subcommands: `new "" [--org]` (create issue + linked plan; `--org` scaffolds a `Target repos:` line), `link <n> [<file>]` / `link <file>` (attach a draft to an existing issue / create one from it), `pull <n> [--merge]` (fetch, 3-way merging when both sides changed — the merge base lives at `~/.local/state/ai-flow/`), `push [<file>|<n>]` (guarded body PATCH — refuses to clobber newer remote edits; a number resolves the linked plan like `pull`), and `status` (clean / ahead / behind / diverged, per linked plan). `--org` targets the org plans repo (`plans_repo:` in `~/.config/dev/config.yml`, or `DEV_PLANS_REPO`) instead of the current repo's origin. Every invocation also refreshes the user-global links for dev's shipped skills (`share/cursor-skills/*` → `~/.cursor/skills/`, so the Cursor agent knows these verbs) and the org knowledge artifacts (see [Agent skills & org knowledge](#agent-skills--org-knowledge)). For auto-push, a participating repo adds a Cursor `afterFileEdit` hook to `.cursor/hooks.json` running `dev plan hook-after-edit` — it reads the hook payload from stdin and no-ops unless the edited file is a linked plan. What happens to a plan after it's canonical — `/ask`, `/edit`, `/split` (two-phase dry/apply), `/build` — is ai-flow's remote half: see [plan-lifecycle.md](https://github.com/d3mlabs/ai-flow/blob/HEAD/docs/plan-lifecycle.md) and [commands.md](https://github.com/d3mlabs/ai-flow/blob/HEAD/docs/commands.md). -- **`dev knowledge sync|status`** — global (works without a `dev.yml`). `sync` refreshes the machine's org knowledge cache now (blocking, TTL bypassed) and re-renders its artifacts; `status` reports the configured repo, cache location, age, and freshness. See [Agent skills & org knowledge](#agent-skills--org-knowledge). +- **`dev plan …`** — global (works without a `dev.yml`; the workspace is the nearest dev.yml or git root). Sync Cursor plans with GitHub issues (ai-flow): the issue is the canonical plan, the local `.cursor/plans/gh-<n>-<slug>.plan.md` is a transient working copy carrying an `<!-- ai-flow … -->` header. Subcommands: `new "<title>" [--org]` (create issue + linked plan; `--org` scaffolds a `Target repos:` line), `link <n> [<file>]` / `link <file>` (attach a draft to an existing issue / create one from it), `pull <n> [--merge]` (fetch, 3-way merging when both sides changed — the merge base lives at `~/.local/state/ai-flow/`), `push [<file>|<n>]` (guarded body PATCH — refuses to clobber newer remote edits; a number resolves the linked plan like `pull`), and `status` (clean / ahead / behind / diverged, per linked plan). `--org` targets the org plans repo (`plans_repo:` in `~/.config/dev/config.yml`, or `DEV_PLANS_REPO`) instead of the current repo's origin. Every invocation also refreshes the user-global links for dev's shipped skills (`share/cursor-skills/*` → `~/.cursor/skills/`, so the Cursor agent knows these verbs) and the org learnings artifacts (see [Agent skills & org learnings](#agent-skills--org-learnings)). For auto-push, a participating repo adds a Cursor `afterFileEdit` hook to `.cursor/hooks.json` running `dev plan hook-after-edit` — it reads the hook payload from stdin and no-ops unless the edited file is a linked plan. What happens to a plan after it's canonical — `/ask`, `/edit`, `/split` (two-phase dry/apply), `/build` — is ai-flow's remote half: see [plan-lifecycle.md](https://github.com/d3mlabs/ai-flow/blob/HEAD/docs/plan-lifecycle.md) and [commands.md](https://github.com/d3mlabs/ai-flow/blob/HEAD/docs/commands.md). +- **`dev learnings sync|status|invariants`** — global (works without a `dev.yml`). `sync` refreshes the whole learnings read path now, blocking, errors bubbling: pull the machine cache of the knowledge repo, relink skills (shipped, org, and the project's gem skills), render the invariants rule and link it into the enclosing project. Outside a project the machine-global parts run and the project-scoped ones are skipped. `status` reports the configured knowledge repo, cache location and age, and what's rendered/linked per tier. `invariants` prints the Tier-0 prompt block (the invariants section extracted from the org index) — the seam prompt-building consumers like ai-flow shell out to instead of parsing the cache themselves. See [Agent skills & org learnings](#agent-skills--org-learnings). -## Agent skills & org knowledge +## Agent skills & org learnings dev distributes agent-facing skills (Cursor-style `SKILL.md` directories) over three channels, all refreshed at the same cheap, idempotent hook points — `dev up`, `dev install-deps`, and `dev plan` — so there is no separate setup step: - **dev's own skills** (`share/cursor-skills/*`) link user-globally into `~/.cursor/skills/`; `brew upgrade` refreshes them automatically because the symlinks resolve through the installed tree. - **Gem-shipped skills.** A gem's skill is part of what installing that dependency means, so `dev up` / `dev install-deps` finish by scanning the resolved (lockfile-matched) gem set for `skills/*/SKILL.md` and linking each project-scoped as `.agents/skills/gem-<gem>--<skill>` (gitignored; an agent-neutral dir, so the mechanism isn't Cursor-locked). Links for gems that leave the lock are pruned on the next install — a skill-set change rides the same staleness story as any dependency change. -- **Org knowledge** (opt-in). With `knowledge_repo: <owner>/<repo>` in `~/.config/dev/config.yml` (or `DEV_KNOWLEDGE_REPO`), dev keeps a machine-local cache of the org knowledge repo under `~/.local/share/dev/knowledge`, refreshed on a TTL (`knowledge_ttl:` seconds, default 900). The fetch is async and rides the user's `gh` auth; no dev command ever blocks on the network for knowledge — offline serves the cache. From the cache, dev links the repo's `skills/*` user-globally into `~/.cursor/skills/` and renders the index's `## Invariants (always-on)` section into the current project as `.cursor/rules/org-invariants.mdc` — generated, content-compared, and never committed (a participating repo's only footprint is one `.gitignore` line), so drift from the canonical repo is structurally impossible. Machines without the setting simply have no org sync: dev is public and ships only the mechanism, never the content. `dev knowledge sync` forces a refresh; `dev knowledge status` reports cache age. +- **Org learnings** (opt-in). With `knowledge_repo: <owner>/<repo>` in `~/.config/dev/config.yml` (or `DEV_KNOWLEDGE_REPO`), dev keeps a machine-local cache of the org knowledge repo under `~/.local/share/dev/knowledge`. Hooks refresh it inline with a short timeout (~2s, with a hardcoded ~30s courtesy floor between pulls — the repo is tiny, so there is no TTL knob): the pull happens *before* distribution, so a hook never renders content it just found stale, and on timeout or offline the current cache is served (the pull finishes detached). The fetch rides the user's `gh` auth. From the cache, dev links the repo's `skills/*` user-globally into `~/.cursor/skills/` and renders the index's `## Invariants (always-on)` section **once, cache-side**, then links each project's `.cursor/rules/org-invariants.mdc` at that render as a symlink — one refresh updates every project on the machine simultaneously, nothing is committed (a participating repo's only footprint is one `.gitignore` line), and drift from the canonical repo is structurally impossible. Machines without the setting simply have no org sync: dev is public and ships only the mechanism, never the content. `dev learnings sync` forces a blocking refresh of the whole read path; `dev learnings status` reports what's cached, rendered, and linked; `dev learnings invariants` prints the Tier-0 prompt block. **Runner bootstrap contract:** an agent-runner workflow (e.g. ai-flow's) runs an explicit blocking `dev learnings sync` step before starting agent sessions, so they never start on stale invariants — the dependency is stated in the workflow instead of hiding as a side effect of `install-deps`. ### Repo learnings diff --git a/bin/dev b/bin/dev index 259fda7..862e4a0 100755 --- a/bin/dev +++ b/bin/dev @@ -29,8 +29,8 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require "dev" require "dev/global_dispatch" -# Global builtins (cd/plan/cred) dispatch before any dev.yml lookup — they -# are host- or workspace-global, not project commands. +# Global builtins (cd/plan/cred/learnings) dispatch before any dev.yml +# lookup — they are host- or workspace-global, not project commands. global_dispatch = Dev::GlobalDispatch.new if global_dispatch.global_command?(ARGV) global_dispatch.run(ARGV) diff --git a/lib/dev/knowledge.rb b/lib/dev/knowledge.rb deleted file mode 100644 index 8409734..0000000 --- a/lib/dev/knowledge.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -require "dev/knowledge/cache" -require "dev/knowledge/invariants_renderer" -require "dev/knowledge/synchronizer" -require "dev/knowledge/accessor" - -module Dev - # Org knowledge distribution: a TTL-refreshed machine cache of the org - # knowledge repo, org skills linked into ~/.cursor/skills, and the - # invariants index rendered into each project dev touches. See - # `dev knowledge` (Dev::Knowledge::Accessor) for the command surface; - # the passive path rides dev's hook points via Synchronizer#sync. - module Knowledge - end -end diff --git a/lib/dev/knowledge/accessor.rb b/lib/dev/knowledge/accessor.rb deleted file mode 100644 index 0bf484b..0000000 --- a/lib/dev/knowledge/accessor.rb +++ /dev/null @@ -1,101 +0,0 @@ -# frozen_string_literal: true - -require "pathname" -require_relative "../settings" -require_relative "cache" -require_relative "synchronizer" - -module Dev - module Knowledge - # Dispatch for `dev knowledge …` — the explicit surface over the machine - # knowledge cache. Passive distribution rides dev's hook points (`dev up` - # / `install-deps` / `dev plan`); these verbs are the manual override and - # the inspection: - # - # - `sync` — refresh the cache now (blocking, TTL bypassed), then link - # skills and re-render the invariants rule - # - `status` — configured repo, cache location, age, and freshness - # - # RuntimeError subclasses throughout so the CLI boundary prints clean - # `dev:` messages instead of backtraces. - class Accessor - class UsageError < RuntimeError; end - - USAGE = <<~USAGE.strip - usage: dev knowledge <subcommand> - dev knowledge sync refresh the machine knowledge cache now (bypasses the TTL) - dev knowledge status cache location, age, and freshness - USAGE - - # @param project_root [Pathname] the enclosing repo (invariants render - # target); the caller resolves it from the cwd - # @param settings [Dev::Settings] - # @param cache [Dev::Knowledge::Cache, nil] override for tests; defaults - # to a cache over the configured knowledge repo (nil when unconfigured) - # @param synchronizer [Dev::Knowledge::Synchronizer, nil] - def initialize(project_root:, settings: Dev::Settings.new, cache: nil, synchronizer: nil) - @project_root = Pathname(project_root) - @settings = settings - repo = settings.knowledge_repo - @cache = cache || (repo && Cache.new(repo: repo)) - @synchronizer = synchronizer || Synchronizer.new(settings: settings, cache: @cache) - end - - # Dispatch a `dev knowledge …` invocation. - # - # @param args [Array<String>] argv after the "knowledge" command - # @param out [IO] output stream - # @return [void] - # @raise [UsageError] on an unrecognized invocation - def run(args, out: $stdout) - case args - when ["sync"] then sync(out:) - when ["status"] then status(out:) - else raise UsageError, USAGE - end - end - - private - - # @param out [IO] - # @return [void] - def sync(out:) - @synchronizer.sync!(project_root: @project_root) - out.puts "dev: knowledge cache synced from #{@settings.knowledge_repo} (#{@cache.dir})." - end - - # @param out [IO] - # @return [void] - def status(out:) - repo = @settings.knowledge_repo - if repo.nil? - out.puts "dev: no knowledge repo configured — add `knowledge_repo: <owner>/<repo>` " \ - "to #{@settings.config_path} (or set DEV_KNOWLEDGE_REPO)." - return - end - - out.puts "dev: knowledge repo: #{repo}" - unless @cache.present? - out.puts "dev: cache: #{@cache.dir} (not cloned yet — run `dev knowledge sync`)." - return - end - - ttl = @settings.knowledge_ttl - freshness = @cache.stale?(ttl) ? "stale — the next dev hook refreshes it, or run `dev knowledge sync`" : "fresh" - out.puts "dev: cache: #{@cache.dir}" - out.puts "dev: last synced #{format_age(Time.now - @cache.synced_at)} ago (TTL #{format_age(ttl)}; #{freshness})." - end - - # @param seconds [Numeric] - # @return [String] a compact human age, e.g. "42s", "7m", "3h", "2d" - def format_age(seconds) - case seconds - when 0...60 then "#{seconds.to_i}s" - when 60...3600 then "#{(seconds / 60).to_i}m" - when 3600...86_400 then "#{(seconds / 3600).to_i}h" - else "#{(seconds / 86_400).to_i}d" - end - end - end - end -end diff --git a/lib/dev/knowledge/invariants_renderer.rb b/lib/dev/knowledge/invariants_renderer.rb deleted file mode 100644 index e9281cf..0000000 --- a/lib/dev/knowledge/invariants_renderer.rb +++ /dev/null @@ -1,123 +0,0 @@ -# frozen_string_literal: true - -require "fileutils" -require "pathname" - -module Dev - module Knowledge - # Renders the org invariants index into a project's rules dir as a - # generated, always-on Cursor rule (.cursor/rules/org-invariants.mdc). - # - # Carriage without commits: the canonical lines live only in the knowledge - # repo's index.md ("## Invariants (always-on)" section); this render is - # regenerated from the machine cache at every hook point, so drift is - # structurally impossible — no copies, no stamps, no fan-out PRs. The - # write is content-compared and only ever touches a file carrying the - # generated marker, so a user file in the way survives untouched. - # - # Stateless: one reusable instance renders any index into any target. - class InvariantsRenderer - # The heading the invariant lines live under in the knowledge index, - # and where the next section cuts them off. - INVARIANTS_HEADING = /^## Invariants\b/ - SECTION_HEADING = /^## / - - GENERATED_MARKER = "<!-- generated by dev knowledge sync — do not edit or commit;" - - # Render (or refresh, or retract) the generated rule. - # - # @param index_file [Pathname] the cached knowledge index (index.md) - # @param rules_file [Pathname] the target .mdc inside a project - # @param repo [String] knowledge repo label for the generated header - # @return [void] - def render(index_file:, rules_file:, repo:) - lines = invariant_lines(index_file) - return retract(rules_file) if lines.nil? - - write_if_changed(rules_file, rule_content(lines, repo)) - end - - private - - # The invariants section body, or nil when the index (or the section) - # doesn't exist — nil means the render target must be retracted, which - # is a normal state of the world, not an error. - # - # @param index_file [Pathname] - # @return [String, nil] - def invariant_lines(index_file) - return nil unless index_file.file? - - section = [] - in_section = false - index_file.read.each_line do |line| - if line.match?(INVARIANTS_HEADING) - in_section = true - elsif in_section && line.match?(SECTION_HEADING) - break - elsif in_section - section << line - end - end - return nil unless in_section - - section.join.strip - end - - # @param lines [String] the invariant index lines - # @param repo [String] - # @return [String] the full .mdc content - def rule_content(lines, repo) - <<~CONTENT - --- - description: "Org engineering invariants — always-on index, generated by dev from #{repo}" - alwaysApply: true - --- - #{GENERATED_MARKER} changes land in #{repo} --> - - # Org invariants - - #{lines} - - Each → pointer is an on-demand skill installed under ~/.cursor/skills/ — - read it before working in that entry's territory. - CONTENT - end - - # Write the rule unless it is already current. A pre-existing file - # without the generated marker is user-owned: warn and leave it. - # - # @param rules_file [Pathname] - # @param content [String] - # @return [void] - def write_if_changed(rules_file, content) - if rules_file.file? - existing = rules_file.read - return if existing == content - return warn_user_owned(rules_file) unless existing.include?(GENERATED_MARKER) - end - - FileUtils.mkdir_p(rules_file.dirname) - rules_file.write(content) - end - - # Remove a previously generated rule whose source is gone. Only a file - # carrying the marker is ever deleted. - # - # @param rules_file [Pathname] - # @return [void] - def retract(rules_file) - return unless rules_file.file? - return warn_user_owned(rules_file) unless rules_file.read.include?(GENERATED_MARKER) - - rules_file.delete - end - - # @param rules_file [Pathname] - # @return [void] - def warn_user_owned(rules_file) - $stderr.puts "dev: warning: #{rules_file} exists and is not dev-generated — leaving it in place." - end - end - end -end diff --git a/lib/dev/learnings.rb b/lib/dev/learnings.rb new file mode 100644 index 0000000..93dcbba --- /dev/null +++ b/lib/dev/learnings.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "dev/learnings/cache" +require "dev/learnings/invariants_renderer" +require "dev/learnings/synchronizer" +require "dev/learnings/accessor" + +module Dev + # The learnings read path: a machine cache of the org knowledge repo, org + # skills linked into ~/.cursor/skills, one machine-side render of the + # invariants index, and a per-project symlink at that render. See + # `dev learnings` (Dev::Learnings::Accessor) for the command surface; + # the passive path rides dev's hook points via Synchronizer#sync. + module Learnings + end +end diff --git a/lib/dev/learnings/accessor.rb b/lib/dev/learnings/accessor.rb new file mode 100644 index 0000000..72c3723 --- /dev/null +++ b/lib/dev/learnings/accessor.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +require "pathname" +require_relative "../deps/gem_skill_linker" +require_relative "../settings" +require_relative "../skill_installer" +require_relative "cache" +require_relative "invariants_renderer" +require_relative "synchronizer" + +module Dev + module Learnings + # Dispatch for `dev learnings …` — the explicit surface over the learnings + # read path. Passive distribution rides dev's hook points (`dev up` / + # `install-deps` / `dev plan`); these verbs are the manual override and + # the inspection: + # + # - `sync` — refresh the whole read path now (blocking): pull the + # knowledge repo cache, relink skills (shipped, org, and + # the project's gem skills), render + link the invariants + # rule. Outside a project the machine-global parts run + # and the project-scoped ones are skipped. + # - `status` — configured knowledge repo, cache location/age, and + # what's rendered/linked per tier + # - `invariants` — print the Tier-0 prompt block (the invariants section + # extracted from the org index); the seam prompt-building + # consumers like ai-flow shell out to + # + # RuntimeError subclasses throughout so the CLI boundary prints clean + # `dev:` messages instead of backtraces. + class Accessor + class UsageError < RuntimeError; end + + # `dev learnings invariants` cannot produce the block: no knowledge repo + # configured, no cache cloned yet, or no invariants section upstream. + class InvariantsUnavailableError < RuntimeError; end + + USAGE = <<~USAGE.strip + usage: dev learnings <subcommand> + dev learnings sync refresh the whole read path now (blocking): knowledge repo cache, skill links, invariants render + dev learnings status configured knowledge repo, cache location/age, what's rendered and linked + dev learnings invariants print the always-on org invariants block (the Tier-0 prompt seam) + USAGE + + # @param project_root [Pathname, String, nil] the enclosing project for + # the project-scoped artifacts (invariants link, gem skill links); + # nil when invoked outside any project — those parts are skipped + # @param settings [Dev::Settings] + # @param cache [Dev::Learnings::Cache, nil] override for tests; defaults + # to a cache over the configured knowledge repo (nil when unconfigured) + # @param synchronizer [Dev::Learnings::Synchronizer, nil] + # @param skill_installer [Dev::SkillInstaller] target of the shipped and + # org skill links; defaults to the user-global ~/.cursor/skills + # @param gem_skill_linker [Dev::Deps::GemSkillLinker, nil] override for + # tests; defaults to the project's linker (nil outside a project) + # @param renderer [Dev::Learnings::InvariantsRenderer] + def initialize(project_root:, settings: Dev::Settings.new, cache: nil, synchronizer: nil, + skill_installer: Dev::SkillInstaller.new, gem_skill_linker: nil, + renderer: InvariantsRenderer.new) + @project_root = project_root && Pathname(project_root) + @settings = settings + repo = settings.knowledge_repo + @cache = cache || (repo && Cache.new(repo: repo)) + @synchronizer = synchronizer || Synchronizer.new(settings: settings, cache: @cache) + @skill_installer = skill_installer + @gem_skill_linker = gem_skill_linker || + (@project_root && Dev::Deps::GemSkillLinker.new(project_root: @project_root)) + @renderer = renderer + end + + # Dispatch a `dev learnings …` invocation. + # + # @param args [Array<String>] argv after the "learnings" command + # @param out [IO] output stream + # @return [void] + # @raise [UsageError] on an unrecognized invocation + def run(args, out: $stdout) + case args + when ["sync"] then sync(out:) + when ["status"] then status(out:) + when ["invariants"] then invariants(out:) + else raise UsageError, USAGE + end + end + + private + + # The whole read path, blocking, errors bubbling: shipped skill links, + # the org tier (cache pull, org skill links, invariants render + project + # link), and the project's gem skill relinks. + # + # @param out [IO] + # @return [void] + def sync(out:) + @skill_installer.install_all(Dev::SkillInstaller::SHIPPED_SKILLS_DIR) + @synchronizer.sync!(project_root: @project_root) + @gem_skill_linker&.link_all + out.puts "dev: learnings synced from #{@settings.knowledge_repo} (#{@cache.dir})." + out.puts "dev: no enclosing project — skipped the invariants link and gem skill links." if @project_root.nil? + end + + # @param out [IO] + # @return [void] + def status(out:) + repo = @settings.knowledge_repo + if repo.nil? + out.puts "dev: no knowledge repo configured — add `knowledge_repo: <owner>/<repo>` " \ + "to #{@settings.config_path} (or set DEV_KNOWLEDGE_REPO)." + return + end + + out.puts "dev: knowledge repo: #{repo}" + unless @cache.present? + out.puts "dev: cache: #{@cache.dir} (not cloned yet — run `dev learnings sync`)." + return + end + + out.puts "dev: cache: #{@cache.dir} (refreshed #{format_age(Time.now - @cache.synced_at)} ago)." + status_org_tier(out) + status_project_tier(out) + end + + # @param out [IO] + # @return [void] + # @raise [InvariantsUnavailableError] when the block cannot be produced + def invariants(out:) + if @cache.nil? + raise InvariantsUnavailableError, + "no knowledge repo configured — add `knowledge_repo: <owner>/<repo>` " \ + "to #{@settings.config_path} (or set DEV_KNOWLEDGE_REPO)." + end + unless @cache.present? + raise InvariantsUnavailableError, + "the knowledge repo cache has not been cloned yet — run `dev learnings sync`." + end + + block = @renderer.prompt_block(@cache.index_file) + raise InvariantsUnavailableError, "#{@cache.index_file} has no `## Invariants` section." if block.nil? + + out.puts block + end + + # The org tier's rendered/linked state: the machine-side invariants + # render and the org skill links. + # + # @param out [IO] + # @return [void] + def status_org_tier(out) + rendered = @synchronizer.rendered_invariants_file + out.puts(if rendered.file? + "dev: invariants: rendered at #{rendered}." + else + "dev: invariants: not rendered (no invariants section in the index, or never synced)." + end) + out.puts "dev: org skills: #{org_skill_link_count} linked into #{@skill_installer.skills_dir}." + end + + # The project tier's linked state: the invariants link and the gem skill + # links, or a pointer when there is no enclosing project. + # + # @param out [IO] + # @return [void] + def status_project_tier(out) + if @project_root.nil? + out.puts "dev: project: none — run inside a repo to see its invariants link and gem skills." + return + end + + rules_file = @synchronizer.project_rules_file(@project_root) + out.puts "dev: project invariants link: #{rules_file} (#{invariants_link_state(rules_file)})." + out.puts "dev: gem skills: #{gem_skill_link_count} linked under #{gem_skills_dir}." + end + + # @param rules_file [Pathname] + # @return [String] + def invariants_link_state(rules_file) + if rules_file.symlink? && rules_file.readlink == @synchronizer.rendered_invariants_file + "linked" + elsif rules_file.symlink? || rules_file.file? + "present but not dev's link — run `dev learnings sync`" + else + "missing — run `dev learnings sync`" + end + end + + # Org skill links are the entries in the skills dir pointing into the + # cache's skills corpus. + # + # @return [Integer] + def org_skill_link_count + dir = @skill_installer.skills_dir + return 0 unless dir.directory? + + corpus_prefix = "#{@cache.skills_dir}#{File::SEPARATOR}" + dir.children.count { |link| link.symlink? && link.readlink.to_s.start_with?(corpus_prefix) } + end + + # @return [Pathname] + def gem_skills_dir + @project_root.join(*Dev::Deps::GemSkillLinker::AGENT_SKILLS_SUBDIRS) + end + + # @return [Integer] + def gem_skill_link_count + dir = gem_skills_dir + return 0 unless dir.directory? + + dir.children.count do |link| + link.symlink? && link.basename.to_s.start_with?(Dev::Deps::GemSkillLinker::LINK_PREFIX) + end + end + + # @param seconds [Numeric] + # @return [String] a compact human age, e.g. "42s", "7m", "3h", "2d" + def format_age(seconds) + case seconds + when 0...60 then "#{seconds.to_i}s" + when 60...3600 then "#{(seconds / 60).to_i}m" + when 3600...86_400 then "#{(seconds / 3600).to_i}h" + else "#{(seconds / 86_400).to_i}d" + end + end + end + end +end diff --git a/lib/dev/knowledge/cache.rb b/lib/dev/learnings/cache.rb similarity index 58% rename from lib/dev/knowledge/cache.rb rename to lib/dev/learnings/cache.rb index e6c6d97..9a78dd0 100644 --- a/lib/dev/knowledge/cache.rb +++ b/lib/dev/learnings/cache.rb @@ -5,15 +5,18 @@ require "pathname" module Dev - module Knowledge + module Learnings # Machine-local git cache of the org knowledge repo, under # $XDG_DATA_HOME/dev/knowledge (~/.local/share/dev/knowledge). # - # Two frequencies, no network on the hot path: hooks call refresh_async - # when the TTL has lapsed (a detached child process; the calling command - # never blocks on the network), and `dev knowledge sync` calls refresh - # (blocking, TTL bypassed). Offline simply serves the cache; the - # staleness ceiling is the TTL. + # Two refresh shapes, both cheap (the knowledge repo is tiny): + # `dev learnings sync` calls refresh (blocking, errors bubble), and hook + # points call refresh_bounded — an inline pull capped by a short timeout, + # so a hook distributes what it just fetched instead of what it found. On + # timeout the pull keeps running detached and the current cache is served; + # offline simply serves the cache. A hardcoded ~30s courtesy floor keeps + # rapid-fire hooks (e.g. per-edit plan hooks) from pulling on every call — + # deliberately a constant, not a setting. # # A "owner/repo" source clones through `gh` so the fetch rides the user's # gh auth (dev is public and carries no credentials of its own); any other @@ -32,6 +35,18 @@ class KnowledgeFetchError < RuntimeError; end OWNER_REPO_PATTERN = %r{\A[\w.-]+/[\w.-]+\z} + # How long a bounded refresh waits for the pull before detaching it and + # serving the current cache. + REFRESH_TIMEOUT_SECONDS = 2 + + # Courtesy floor between bounded refreshes: within it, refresh_bounded + # is a no-op. A constant, not a setting — the only cost it caps is a + # subsecond no-op pull. + REFRESH_FLOOR_SECONDS = 30 + + # Poll cadence while waiting on a bounded refresh's child process. + REFRESH_POLL_SECONDS = 0.05 + # @return [Pathname] the clone's location attr_reader :dir @@ -39,9 +54,15 @@ class KnowledgeFetchError < RuntimeError; end # any git-clonable URL or local path # @param dir [Pathname, String, nil] override for tests; defaults to the # XDG data location - def initialize(repo:, dir: nil) + # @param refresh_timeout [Numeric] override for tests; how long a + # bounded refresh blocks before detaching + # @param refresh_floor [Numeric] override for tests; minimum age before + # a bounded refresh pulls again + def initialize(repo:, dir: nil, refresh_timeout: REFRESH_TIMEOUT_SECONDS, refresh_floor: REFRESH_FLOOR_SECONDS) @repo = repo @dir = Pathname(dir || default_dir) + @refresh_timeout = refresh_timeout + @refresh_floor = refresh_floor end # @return [Boolean] whether the cache has been cloned @@ -75,20 +96,23 @@ def refresh end end - # Fire-and-forget refresh in a detached child process, so hook points - # never block on the network. A first-run clone is async too: this - # command renders nothing, the next one serves the fresh cache. Never - # raises — a failed background refresh only extends staleness, and the - # explicit `dev knowledge sync` path reports errors properly. + # Bounded refresh for hook points: pull (or first-clone) inline, waiting + # up to the timeout so the calling hook distributes fresh content, then + # detach and fall back to the current cache when the network is slower + # than that. Within the courtesy floor of the last successful refresh it + # is a no-op. Never raises — offline or a failed pull only means the + # cache is served as-is, and the explicit `dev learnings sync` path + # reports errors properly. # # @return [void] - def refresh_async + def refresh_bounded + return if refreshed_within_floor? + FileUtils.mkdir_p(@dir.dirname) pid = Process.spawn(*(present? ? pull_command : clone_command), out: File::NULL, err: File::NULL) - Process.detach(pid) - nil + wait_or_detach(pid) rescue SystemCallError => e - $stderr.puts "dev: warning: could not start the knowledge cache refresh (#{e.message})." + $stderr.puts "dev: warning: could not start the knowledge repo cache refresh (#{e.message})." end # When the cache last talked to the remote: the fetch marker's mtime, @@ -100,14 +124,31 @@ def synced_at marker&.mtime end - # @param ttl_seconds [Integer] staleness ceiling - # @return [Boolean] true when never synced or older than the TTL - def stale?(ttl_seconds) + private + + # @return [Boolean] whether the last successful refresh is inside the + # courtesy floor + def refreshed_within_floor? at = synced_at - at.nil? || (Time.now - at) > ttl_seconds + !at.nil? && (Time.now - at) <= @refresh_floor end - private + # Wait for the refresh child up to the timeout; past it, detach so the + # pull finishes in the background and the next hook serves its result. + # + # @param pid [Integer] + # @return [void] + def wait_or_detach(pid) + deadline = Time.now + @refresh_timeout + until Process.waitpid(pid, Process::WNOHANG) + if Time.now >= deadline + Process.detach(pid) + return + end + + sleep(REFRESH_POLL_SECONDS) + end + end # @param command [Array<String>] # @param error_class [Class<RuntimeError>] diff --git a/lib/dev/learnings/invariants_renderer.rb b/lib/dev/learnings/invariants_renderer.rb new file mode 100644 index 0000000..365aea6 --- /dev/null +++ b/lib/dev/learnings/invariants_renderer.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require "fileutils" +require "pathname" + +module Dev + module Learnings + # Renders the org invariants index once, cache-side, as a generated + # always-on Cursor rule, and links each project's + # .cursor/rules/org-invariants.mdc at that render as a symlink. + # + # Carriage without commits: the canonical lines live only in the knowledge + # repo's index.md ("## Invariants (always-on)" section). The render lands + # in one machine-local file beside the cache, so one refresh updates every + # project on the machine simultaneously; a project's footprint is a single + # symlink (same shape as the user-global skill links). The render is + # content-compared, and the project link only ever replaces a symlink or a + # file carrying the generated marker, so a user file in the way survives + # untouched. + # + # The section extraction is also the Tier-0 prompt seam: prompt_block is + # what `dev learnings invariants` prints for consumers (e.g. ai-flow) that + # embed the invariants in a prompt instead of a rules file. + # + # Stateless: one reusable instance renders any index into any target. + class InvariantsRenderer + # The heading the invariant lines live under in the knowledge index, + # and where the next section cuts them off. + INVARIANTS_HEADING = /^## Invariants\b/ + SECTION_HEADING = /^## / + + GENERATED_MARKER = "<!-- generated by dev learnings sync — do not edit or commit;" + + # Pre-rename renders carried this marker; the link step recognizes it so + # upgrading replaces the old per-project file with a symlink. + LEGACY_GENERATED_MARKER = "<!-- generated by dev knowledge sync — do not edit or commit;" + + # Trails the invariant lines in every rendered/printed block: the lines + # only buy awareness, the pointed skills carry the rules. + SKILL_POINTER_NOTE = + "Each → pointer is an on-demand skill installed under ~/.cursor/skills/ —\n" \ + "read it before working in that entry's territory." + + # The Tier-0 prompt block: the invariants section extracted from the org + # index, plus the skill-pointer note — embeddable in a prompt as-is. + # + # @param index_file [Pathname] the cached knowledge index (index.md) + # @return [String, nil] nil when the index (or the section) doesn't exist + def prompt_block(index_file) + lines = invariant_lines(index_file) + return nil if lines.nil? + + "#{lines}\n\n#{SKILL_POINTER_NOTE}" + end + + # Render (or refresh, or retract) the machine-side generated rule. + # + # @param index_file [Pathname] the cached knowledge index (index.md) + # @param rendered_file [Pathname] the machine-local render target + # (beside the cache; never inside a project) + # @param repo [String] knowledge repo label for the generated header + # @return [void] + def render(index_file:, rendered_file:, repo:) + block = prompt_block(index_file) + if block.nil? + FileUtils.rm_f(rendered_file) + return + end + + content = rule_content(block, repo) + return if rendered_file.file? && rendered_file.read == content + + FileUtils.mkdir_p(rendered_file.dirname) + rendered_file.write(content) + end + + # Link a project's rules file at the machine-side render (or retract the + # link when the render is gone). Symlinks and marker-carrying generated + # files are ours to manage; anything else is user-owned — warn and leave. + # + # @param rendered_file [Pathname] the machine-local render + # @param rules_file [Pathname] the target .mdc inside a project + # @return [void] + def link(rendered_file:, rules_file:) + return unlink(rules_file) unless rendered_file.file? + return if rules_file.symlink? && rules_file.readlink == rendered_file + return warn_user_owned(rules_file) if user_owned?(rules_file) + + FileUtils.mkdir_p(rules_file.dirname) + FileUtils.rm_f(rules_file) + File.symlink(rendered_file, rules_file) + end + + private + + # The invariants section body, or nil when the index (or the section) + # doesn't exist — nil means the render target must be retracted, which + # is a normal state of the world, not an error. + # + # @param index_file [Pathname] + # @return [String, nil] + def invariant_lines(index_file) + return nil unless index_file.file? + + section = [] + in_section = false + index_file.read.each_line do |line| + if line.match?(INVARIANTS_HEADING) + in_section = true + elsif in_section && line.match?(SECTION_HEADING) + break + elsif in_section + section << line + end + end + return nil unless in_section + + section.join.strip + end + + # @param block [String] the Tier-0 prompt block + # @param repo [String] + # @return [String] the full .mdc content + def rule_content(block, repo) + <<~CONTENT + --- + description: "Org engineering invariants — always-on index, generated by dev from #{repo}" + alwaysApply: true + --- + #{GENERATED_MARKER} changes land in #{repo} --> + + # Org invariants + + #{block} + CONTENT + end + + # A regular file at the link target that doesn't carry a generated + # marker was written by the user, not by dev. + # + # @param rules_file [Pathname] + # @return [Boolean] + def user_owned?(rules_file) + return false if rules_file.symlink? || !rules_file.file? + + existing = rules_file.read + !existing.include?(GENERATED_MARKER) && !existing.include?(LEGACY_GENERATED_MARKER) + end + + # Remove a link (or a previously generated file) whose render is gone. + # + # @param rules_file [Pathname] + # @return [void] + def unlink(rules_file) + return unless rules_file.symlink? || rules_file.file? + return warn_user_owned(rules_file) if user_owned?(rules_file) + + FileUtils.rm_f(rules_file) + end + + # @param rules_file [Pathname] + # @return [void] + def warn_user_owned(rules_file) + $stderr.puts "dev: warning: #{rules_file} exists and is not dev-generated — leaving it in place." + end + end + end +end diff --git a/lib/dev/knowledge/synchronizer.rb b/lib/dev/learnings/synchronizer.rb similarity index 52% rename from lib/dev/knowledge/synchronizer.rb rename to lib/dev/learnings/synchronizer.rb index dc4c77f..9b63cc8 100644 --- a/lib/dev/knowledge/synchronizer.rb +++ b/lib/dev/learnings/synchronizer.rb @@ -7,29 +7,35 @@ require_relative "invariants_renderer" module Dev - module Knowledge - # Orchestrates org knowledge distribution: refresh the machine cache - # (TTL-gated and async on hooks, forced and blocking for `dev knowledge - # sync`), link the cache's skills user-globally into ~/.cursor/skills, - # and render the invariants index into the project at hand. + module Learnings + # Orchestrates the org tier of the learnings read path: refresh the + # machine cache of the knowledge repo (bounded-inline on hooks, blocking + # for `dev learnings sync`), link the cache's skills user-globally into + # ~/.cursor/skills, render the invariants index once beside the cache, and + # link the render into the project at hand. The refresh always precedes + # distribution, so a hook never renders content it just found stale. # # Unconfigured machines (no `knowledge_repo` setting) have no org sync — # dev is public and ships only the mechanism; content stays in the # private knowledge repo. class Synchronizer - # `dev knowledge sync` was asked to sync with no knowledge repo configured. + # `dev learnings sync` was asked to sync with no knowledge repo configured. class KnowledgeRepoNotConfiguredError < RuntimeError; end - # Where the generated always-on rule lands inside a project. Committed + # Where the per-project link to the machine-side render lands. Committed # footprint per repo is one .gitignore line for this path. ORG_INVARIANTS_RULE_SUBDIRS = [".cursor", "rules", "org-invariants.mdc"].freeze + # The machine-side render, beside the cache clone (one refresh updates + # every project on the machine through its symlink). + RENDERED_INVARIANTS_FILENAME = "org-invariants.mdc" + # @param settings [Dev::Settings] - # @param cache [Dev::Knowledge::Cache, nil] override for tests; defaults + # @param cache [Dev::Learnings::Cache, nil] override for tests; defaults # to a cache over the configured knowledge repo (nil when unconfigured) # @param skill_installer [Dev::SkillInstaller] target for the org skill # links; defaults to the user-global ~/.cursor/skills - # @param renderer [Dev::Knowledge::InvariantsRenderer] + # @param renderer [Dev::Learnings::InvariantsRenderer] def initialize(settings: Dev::Settings.new, cache: nil, skill_installer: Dev::SkillInstaller.new, renderer: InvariantsRenderer.new) @settings = settings @@ -44,27 +50,38 @@ def configured? !@cache.nil? end - # The passive hook entry (`dev up` / `install-deps` / `dev plan`): kick - # an async refresh when the TTL lapsed, then distribute whatever the - # cache currently holds — idempotent, content-compared, and never - # blocking on the network. Never raises: knowledge sync is hygiene - # riding another command, and hygiene must not block correctness. + # @return [Pathname] the machine-side invariants render (beside the cache) + def rendered_invariants_file + @cache.dir.dirname / RENDERED_INVARIANTS_FILENAME + end + + # @param project_root [Pathname, String] the enclosing project + # @return [Pathname] the project's link to the invariants render + def project_rules_file(project_root) + Pathname(project_root).join(*ORG_INVARIANTS_RULE_SUBDIRS) + end + + # The passive hook entry (`dev up` / `install-deps` / `dev plan`): pull + # inline within the cache's short timeout (falling back to the current + # cache when the network is slower, or offline), then distribute. + # Never raises: learnings sync is hygiene riding another command, and + # hygiene must not block correctness. # - # @param project_root [Pathname, String, nil] project to render the - # invariants rule into; nil skips the render (no project context) + # @param project_root [Pathname, String, nil] project to link the + # invariants render into; nil skips the link (no project context) # @return [void] def sync(project_root: nil) return unless configured? - @cache.refresh_async if @cache.stale?(@settings.knowledge_ttl) + @cache.refresh_bounded distribute(project_root) rescue StandardError => e - $stderr.puts "dev: warning: org knowledge sync failed (#{e.message})." + $stderr.puts "dev: warning: org learnings sync failed (#{e.message})." end - # The explicit entry (`dev knowledge sync`): refresh now, blocking and - # TTL-bypassed, then distribute. Errors bubble — the user asked for - # this sync and must hear when it fails. + # The explicit entry (`dev learnings sync`): refresh now, blocking, then + # distribute. Errors bubble — the user asked for this sync and must hear + # when it fails. # # @param project_root [Pathname, String, nil] as for sync # @return [void] @@ -84,8 +101,9 @@ def sync!(project_root: nil) private - # Link the cached skills corpus user-globally and render the invariants - # rule into the project. A no-op before the first clone lands. + # Link the cached skills corpus user-globally, refresh the machine-side + # invariants render, and point the project's rules file at it. A no-op + # before the first clone lands. # # @param project_root [Pathname, String, nil] # @return [void] @@ -93,13 +111,14 @@ def distribute(project_root) return unless @cache.present? @skill_installer.install_all(@cache.skills_dir) - return unless project_root - @renderer.render( index_file: @cache.index_file, - rules_file: Pathname(project_root).join(*ORG_INVARIANTS_RULE_SUBDIRS), + rendered_file: rendered_invariants_file, repo: @settings.knowledge_repo, ) + return unless project_root + + @renderer.link(rendered_file: rendered_invariants_file, rules_file: project_rules_file(project_root)) end end end diff --git a/lib/dev/plan.rb b/lib/dev/plan.rb index 80837a3..bdf1836 100644 --- a/lib/dev/plan.rb +++ b/lib/dev/plan.rb @@ -2,7 +2,7 @@ require "dev/settings" require "dev/skill_installer" -require "dev/knowledge" +require "dev/learnings" require "dev/plan/executor" require "dev/plan/header" require "dev/plan/frontmatter" diff --git a/lib/dev/plan/accessor.rb b/lib/dev/plan/accessor.rb index 0bbedae..08c08d8 100644 --- a/lib/dev/plan/accessor.rb +++ b/lib/dev/plan/accessor.rb @@ -33,10 +33,10 @@ class UsageError < RuntimeError; end # @param merge_base [Dev::Plan::MergeBase, nil] # @param skill_installer [Dev::SkillInstaller, nil] target for dev's # shipped skill links (defaults to the user-global ~/.cursor/skills) - # @param knowledge [Dev::Knowledge::Synchronizer, nil] + # @param learnings [Dev::Learnings::Synchronizer, nil] # @param executor [Dev::Plan::Executor] CLI boundary (injectable for tests) def initialize(project_root:, executor: Executor.new, workspace: nil, issues: nil, - settings: nil, merge_base: nil, skill_installer: nil, knowledge: nil) + settings: nil, merge_base: nil, skill_installer: nil, learnings: nil) @project_root = project_root @executor = executor @workspace = workspace || Workspace.new(project_root: project_root, executor: executor) @@ -44,7 +44,7 @@ def initialize(project_root:, executor: Executor.new, workspace: nil, issues: ni @settings = settings || Dev::Settings.new @merge_base = merge_base || MergeBase.new @skill_installer = skill_installer || Dev::SkillInstaller.new - @knowledge = knowledge || Knowledge::Synchronizer.new + @learnings = learnings || Learnings::Synchronizer.new end # Dispatch a `dev plan …` invocation. @@ -54,11 +54,11 @@ def initialize(project_root:, executor: Executor.new, workspace: nil, issues: ni # @param input [IO] input stream (the Cursor hook payload for hook-after-edit) # @raise [UsageError] on an unrecognized invocation def run(args, out: $stdout, input: $stdin) - # Hook point: refresh dev's shipped skill links and the org knowledge - # artifacts. Cheap and idempotent (content-compared, network only in a - # detached background refresh), so every invocation can afford it. + # Hook point: refresh dev's shipped skill links and the org learnings + # artifacts. Cheap and idempotent (content-compared, the network pull + # bounded by a short timeout), so every invocation can afford it. @skill_installer.install_all(Dev::SkillInstaller::SHIPPED_SKILLS_DIR) - @knowledge.sync(project_root: @project_root) + @learnings.sync(project_root: @project_root) subcommand, *rest = args case subcommand when "new" then new_plan(rest, out:) diff --git a/lib/dev/settings.rb b/lib/dev/settings.rb index c437b03..343d7e8 100644 --- a/lib/dev/settings.rb +++ b/lib/dev/settings.rb @@ -9,23 +9,16 @@ module Dev # # plans_repo: d3mlabs/plans # knowledge_repo: d3mlabs/knowledge - # knowledge_ttl: 900 # # `plans_repo` is the org-wide plans repo that `dev plan new --org` / # `dev plan link --org` target. `knowledge_repo` is the org knowledge repo # dev keeps a machine-local cache of; leaving it unset simply means no org - # knowledge sync (dev is public and hardcodes no org content). - # `knowledge_ttl` is the cache staleness ceiling in seconds. ENV overrides: - # DEV_PLANS_REPO and DEV_KNOWLEDGE_REPO (matching the credentials ENV-first - # convention). + # learnings sync (dev is public and hardcodes no org content). ENV + # overrides: DEV_PLANS_REPO and DEV_KNOWLEDGE_REPO (matching the + # credentials ENV-first convention). class Settings class MissingSettingError < RuntimeError; end - # Aggressive default for the corpus's growth phase: a fresh learning - # should reach machines within the merge-to-next-session window. Relax - # toward daily once the base matures — a default change, not a code change. - DEFAULT_KNOWLEDGE_TTL_SECONDS = 15 * 60 - # @return [String] path of the config file settings are read from attr_reader :config_path @@ -51,7 +44,7 @@ def plans_repo # The org knowledge repo the machine cache syncs from. Unset is a # supported state, not an error: machines without the setting have no - # org knowledge sync. + # org learnings sync. # # @return [String, nil] "owner/repo" (or any git-clonable URL), or nil def knowledge_repo @@ -62,12 +55,6 @@ def knowledge_repo (value && !value.empty?) ? value : nil end - # @return [Integer] knowledge cache staleness ceiling in seconds - def knowledge_ttl - value = load_config["knowledge_ttl"] - value ? Integer(value) : DEFAULT_KNOWLEDGE_TTL_SECONDS - end - private # @return [String] diff --git a/lib/dev/skill_installer.rb b/lib/dev/skill_installer.rb index fec69dc..a3dfe93 100644 --- a/lib/dev/skill_installer.rb +++ b/lib/dev/skill_installer.rb @@ -23,6 +23,9 @@ class SkillInstaller # (lib/dev/ → repo or libexec root) — the installed location under brew. SHIPPED_SKILLS_DIR = Pathname(File.expand_path(File.join(__dir__, "..", "..", "share", "cursor-skills"))) + # @return [Pathname] target dir the symlinks live in + attr_reader :skills_dir + # @param skills_dir [Pathname, String] target dir the symlinks live in; # defaults to the user-global ~/.cursor/skills def initialize(skills_dir: Pathname(Dir.home) / ".cursor" / "skills") diff --git a/src/dev/global_dispatch.rb b/src/dev/global_dispatch.rb index 16575a4..42095f6 100644 --- a/src/dev/global_dispatch.rb +++ b/src/dev/global_dispatch.rb @@ -4,7 +4,7 @@ require "pathname" require "dev/cd" require "dev/plan" -require "dev/knowledge" +require "dev/learnings" require "dev/credentials" require "dev/credential_accessor" @@ -16,8 +16,8 @@ module Dev # - `dev cred` — host-global (credentials live under XDG / ~/.config/dev) # - `dev plan` — workspace-global (plans live in the enclosing # workspace, no project config is read) - # - `dev knowledge` — host-global (the machine knowledge cache lives under - # XDG / ~/.local/share/dev) + # - `dev learnings` — host-global (the machine cache of the knowledge repo + # lives under XDG / ~/.local/share/dev) # # Runs before Dev::Runner is constructed, so these commands work from any # directory. Project commands (`up`, yaml-declared names) keep the existing @@ -25,7 +25,7 @@ module Dev class GlobalDispatch extend T::Sig - GLOBAL_COMMANDS = T.let(%w[cd plan cred knowledge].freeze, T::Array[String]) + GLOBAL_COMMANDS = T.let(%w[cd plan cred learnings].freeze, T::Array[String]) # Candidates shown in an ambiguous `dev cd` error before truncating. AMBIGUOUS_CANDIDATE_CAP = 10 @@ -58,10 +58,10 @@ def run(argv) cmd_name = T.must(args.shift) case cmd_name when "cd" then @cd_accessor.run(args) - # Plan and Knowledge accessors are built per run: their workspace root + # Plan and Learnings accessors are built per run: their workspace root # depends on the cwd. when "plan" then Dev::Plan::Accessor.new(project_root: workspace_root).run(args) - when "knowledge" then Dev::Knowledge::Accessor.new(project_root: workspace_root).run(args) + when "learnings" then Dev::Learnings::Accessor.new(project_root: enclosing_project_root).run(args) when "cred" then @cred_accessor.run(args) else raise ArgumentError, "not a global command: #{cmd_name}" end @@ -101,6 +101,16 @@ def print_ambiguous(error) # @return [Pathname] sig { returns(Pathname) } def workspace_root + enclosing_project_root || Pathname.new(Dir.pwd) + end + + # The enclosing project (nearest dev.yml, else nearest git root), or nil + # when the cwd sits in no project at all — `dev learnings` outside any + # checkout does only the machine-global work. + # + # @return [Pathname, nil] + sig { returns(T.nilable(Pathname)) } + def enclosing_project_root cwd = Pathname.new(Dir.pwd) cwd.ascend do |path| return path if (path / Dev::DEV_YAML_FILENAME).exist? @@ -108,7 +118,7 @@ def workspace_root cwd.ascend do |path| return path if (path / ".git").exist? end - cwd + nil end end end diff --git a/src/dev/runner.rb b/src/dev/runner.rb index ee4533e..c7c45b4 100644 --- a/src/dev/runner.rb +++ b/src/dev/runner.rb @@ -19,7 +19,7 @@ require 'dev/deps/repository' require 'dev/deps/resolver' require 'dev/deps/staleness' -require 'dev/knowledge' +require 'dev/learnings' require 'dev/plan' require 'dev/runner_setup' require 'dev/cli/ui' @@ -271,12 +271,12 @@ def register_builtins(registry) Dev::Cd::Accessor.new.run(args) end) - # `dev knowledge` is dispatched globally in bin/dev, like cd; this + # `dev learnings` is dispatched globally in bin/dev, like cd; this # registration only surfaces it in `dev --help`. - registry.register("knowledge", BuiltinCommand.new( - desc: "Org knowledge cache (sync: refresh now, status: age/freshness)", + registry.register("learnings", BuiltinCommand.new( + desc: "Learnings read path (sync: refresh now, status: what's linked, invariants: Tier-0 block)", ) do |args, context| - Dev::Knowledge::Accessor.new(project_root: context.project_root).run(args) + Dev::Learnings::Accessor.new(project_root: context.project_root).run(args) end) registry.register("check", BuiltinCommand.new( @@ -433,9 +433,12 @@ def install_locked_deps(context) installer.install(env: Dev::Deps.detect_env, host: Dev::Deps.detect_host) # Installing a dependency includes its shipped skills: finish by linking # the locked gem set's skills project-scoped, and refresh the machine's - # org knowledge artifacts (both hooks are best-effort and never raise). + # org learnings artifacts (both hooks are best-effort and never raise). + # This is hygiene, not a bootstrap contract: workflows that must start + # on fresh invariants (e.g. ai-flow's runner) run an explicit blocking + # `dev learnings sync` step instead of relying on this side effect. Dev::Deps::GemSkillLinker.new(project_root: context.project_root).link_all - Dev::Knowledge::Synchronizer.new.sync(project_root: context.project_root) + Dev::Learnings::Synchronizer.new.sync(project_root: context.project_root) end # taps is empty (custom-tap installs go through the container path) and the diff --git a/test/dev/global_dispatch_test.rb b/test/dev/global_dispatch_test.rb index 53688fa..f760ec9 100644 --- a/test/dev/global_dispatch_test.rb +++ b/test/dev/global_dispatch_test.rb @@ -33,15 +33,15 @@ class Dev::GlobalDispatchTest < Minitest::Test "cd" | true "plan" | true "cred" | true - "knowledge" | true + "learnings" | true "up" | false "test" | false "update-deps" | false end - test "dev knowledge status dispatches globally without a dev.yml lookup" do + test "dev learnings status dispatches globally without a dev.yml lookup" do Given "a knowledge repo via ENV and tmpdir-scoped XDG homes" - dir = Dir.mktmpdir("dispatch-knowledge-") + dir = Dir.mktmpdir("dispatch-learnings-") saved = { "DEV_KNOWLEDGE_REPO" => ENV["DEV_KNOWLEDGE_REPO"], "XDG_DATA_HOME" => ENV["XDG_DATA_HOME"], @@ -55,8 +55,8 @@ class Dev::GlobalDispatchTest < Minitest::Test old_stdout = $stdout $stdout = out - When "we dispatch dev knowledge status from a cwd with no dev.yml" - Dir.chdir(dir) { dispatch.run(["knowledge", "status"]) } + When "we dispatch dev learnings status from a cwd with no dev.yml" + Dir.chdir(dir) { dispatch.run(["learnings", "status"]) } Then "the status reports the configured repo and the empty cache" out.string.include?(File.join(dir, "knowledge")) diff --git a/test/dev/knowledge/accessor_test.rb b/test/dev/knowledge/accessor_test.rb deleted file mode 100644 index bad6f81..0000000 --- a/test/dev/knowledge/accessor_test.rb +++ /dev/null @@ -1,173 +0,0 @@ -# typed: false -# frozen_string_literal: true - -require "test_helper" -require "dev/knowledge" -require "dev/settings" -require "dev/skill_installer" -require "pathname" -require "tmpdir" -require "fileutils" -require "stringio" - -transform!(RSpock::AST::Transformation) -class Dev::Knowledge::AccessorTest < Minitest::Test - # A real git source repo plus a fully tmpdir-scoped accessor (nothing - # touches the real user config, cache, or skills). Returns [accessor, dir - # pieces we assert on]. - def build_env(dir) - source = build_source_repo(dir) - config = File.join(dir, "config.yml") - File.write(config, "knowledge_repo: #{source}\nknowledge_ttl: 900\n") - settings = Dev::Settings.new(config_path: config) - cache = Dev::Knowledge::Cache.new(repo: source, dir: File.join(dir, "cache")) - installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills")) - synchronizer = Dev::Knowledge::Synchronizer.new(settings: settings, cache: cache, skill_installer: installer) - project = Pathname(dir) / "repo" - FileUtils.mkdir_p(project) - accessor = Dev::Knowledge::Accessor.new( - project_root: project, settings: settings, cache: cache, synchronizer: synchronizer, - ) - [accessor, cache, project] - end - - def build_source_repo(dir) - source = File.join(dir, "knowledge") - FileUtils.mkdir_p(File.join(source, "skills", "srp")) - File.write(File.join(source, "skills", "srp", "SKILL.md"), "# srp\n") - File.write(File.join(source, "index.md"), "## Invariants (always-on)\n\n- [design/srp] One reason.\n") - system("git", "-C", source, "init", "-q", exception: true) - system("git", "-C", source, "add", ".", exception: true) - system("git", "-C", source, "-c", "user.email=dev@test", "-c", "user.name=dev", - "commit", "-qm", "seed", exception: true) - source - end - - test "knowledge sync clones, distributes, and reports" do - Given "a configured accessor with no cache yet" - dir = Dir.mktmpdir("dev-knowledge-acc-test-") - accessor, cache, project = build_env(dir) - out = StringIO.new - - When "running dev knowledge sync" - accessor.run(["sync"], out: out) - - Then "the cache and both artifacts exist, and the sync is reported" - cache.present? - File.symlink?(File.join(dir, "user-skills", "srp")) - (project / ".cursor" / "rules" / "org-invariants.mdc").file? - out.string.include?("knowledge cache synced") - - Cleanup - FileUtils.rm_rf(dir) - end - - test "status reports a missing configuration with instructions" do - Given "an accessor over empty settings" - dir = Dir.mktmpdir("dev-knowledge-acc-test-") - saved_env = ENV.delete("DEV_KNOWLEDGE_REPO") - accessor = Dev::Knowledge::Accessor.new( - project_root: dir, settings: Dev::Settings.new(config_path: File.join(dir, "config.yml")), - ) - out = StringIO.new - - When "running dev knowledge status" - accessor.run(["status"], out: out) - - Then "the message names the setting and the ENV override" - out.string.include?("knowledge_repo:") - out.string.include?("DEV_KNOWLEDGE_REPO") - - Cleanup - ENV["DEV_KNOWLEDGE_REPO"] = saved_env if saved_env - FileUtils.rm_rf(dir) - end - - test "status before the first clone points at dev knowledge sync" do - Given "a configured accessor with no cache yet" - dir = Dir.mktmpdir("dev-knowledge-acc-test-") - accessor, = build_env(dir) - out = StringIO.new - - When "running dev knowledge status" - accessor.run(["status"], out: out) - - Then - out.string.include?("not cloned yet") - - Cleanup - FileUtils.rm_rf(dir) - end - - test "status reports freshness against the TTL" do - Given "a synced cache" - dir = Dir.mktmpdir("dev-knowledge-acc-test-") - accessor, cache, = build_env(dir) - cache.refresh - fresh_out = StringIO.new - accessor.run(["status"], out: fresh_out) - - When "the cache ages past the TTL" - old = Time.now - 3600 - File.utime(old, old, File.join(dir, "cache", ".git", "HEAD")) - stale_out = StringIO.new - accessor.run(["status"], out: stale_out) - - Then "fresh before, stale after" - fresh_out.string.include?("fresh") - stale_out.string.include?("stale") - stale_out.string.include?("dev knowledge sync") - - Cleanup - FileUtils.rm_rf(dir) - end - - test "status reports multi-day ages in days" do - Given "a cache last synced three days ago" - dir = Dir.mktmpdir("dev-knowledge-acc-test-") - accessor, cache, = build_env(dir) - cache.refresh - old = Time.now - (3 * 86_400) - File.utime(old, old, File.join(dir, "cache", ".git", "HEAD")) - out = StringIO.new - - When "running dev knowledge status" - accessor.run(["status"], out: out) - - Then "the age renders in days" - out.string.include?("3d ago") - - Cleanup - FileUtils.rm_rf(dir) - end - - test "an unrecognized invocation is rejected with usage" do - Given "a configured accessor" - dir = Dir.mktmpdir("dev-knowledge-acc-test-") - accessor, = build_env(dir) - - When "running an unknown subcommand" - accessor.run(["bogus"], out: StringIO.new) - - Then - raises Dev::Knowledge::Accessor::UsageError - - Cleanup - FileUtils.rm_rf(dir) - end - - test "extra arguments to a subcommand are rejected with usage" do - Given "a configured accessor" - dir = Dir.mktmpdir("dev-knowledge-acc-test-") - accessor, = build_env(dir) - - When "running sync with a stray argument" - accessor.run(["sync", "extra"], out: StringIO.new) - - Then - raises Dev::Knowledge::Accessor::UsageError - - Cleanup - FileUtils.rm_rf(dir) - end -end diff --git a/test/dev/knowledge/cache_test.rb b/test/dev/knowledge/cache_test.rb deleted file mode 100644 index d4f673d..0000000 --- a/test/dev/knowledge/cache_test.rb +++ /dev/null @@ -1,197 +0,0 @@ -# typed: false -# frozen_string_literal: true - -require "test_helper" -require "dev/knowledge/cache" -require "tmpdir" -require "fileutils" -require "stringio" - -transform!(RSpock::AST::Transformation) -class Dev::Knowledge::CacheTest < Minitest::Test - # A real git repo playing the knowledge repo: index.md + one skill. - def build_source_repo(dir) - source = File.join(dir, "knowledge") - FileUtils.mkdir_p(File.join(source, "skills", "typed-errors")) - File.write(File.join(source, "skills", "typed-errors", "SKILL.md"), "# typed errors\n") - File.write(File.join(source, "index.md"), "## Invariants (always-on)\n\n- [x] a line\n") - git(source, "init", "-q") - git(source, "add", ".") - commit(source, "seed") - source - end - - def git(dir, *args) - system("git", "-C", dir, *args, exception: true) - end - - def commit(dir, message) - git(dir, "-c", "user.email=dev@test", "-c", "user.name=dev", "commit", "-qm", message) - end - - test "refresh clones the repo into the cache dir on first run" do - Given "a source repo and an empty cache location" - dir = Dir.mktmpdir("dev-knowledge-cache-test-") - source = build_source_repo(dir) - cache = Dev::Knowledge::Cache.new(repo: source, dir: File.join(dir, "cache")) - - When "refreshing" - cache.refresh - - Then "the cache is present, with the knowledge layout readable" - cache.present? - cache.index_file.file? - (cache.skills_dir / "typed-errors" / "SKILL.md").file? - !cache.synced_at.nil? - - Cleanup - FileUtils.rm_rf(dir) - end - - test "refresh fast-forwards an existing cache to new upstream content" do - Given "a cloned cache and a new upstream commit" - dir = Dir.mktmpdir("dev-knowledge-cache-test-") - source = build_source_repo(dir) - cache = Dev::Knowledge::Cache.new(repo: source, dir: File.join(dir, "cache")) - cache.refresh - File.write(File.join(source, "index.md"), "## Invariants (always-on)\n\n- [x] a newer line\n") - git(source, "add", ".") - commit(source, "update") - - When "refreshing again" - cache.refresh - - Then "the cache carries the new content" - cache.index_file.read.include?("a newer line") - - Cleanup - FileUtils.rm_rf(dir) - end - - test "stale? follows the TTL against the last sync time" do - Given "a freshly cloned cache" - dir = Dir.mktmpdir("dev-knowledge-cache-test-") - source = build_source_repo(dir) - cache = Dev::Knowledge::Cache.new(repo: source, dir: File.join(dir, "cache")) - cache.refresh - - When "the sync marker ages an hour" - old = Time.now - 3600 - marker = File.join(dir, "cache", ".git", "HEAD") - File.utime(old, old, marker) - - Then "staleness follows the TTL" - cache.stale?(900) - !cache.stale?(7200) - - Cleanup - FileUtils.rm_rf(dir) - end - - test "a never-cloned cache is absent, unsynced, and stale" do - Given "a cache over a dir that was never cloned" - dir = Dir.mktmpdir("dev-knowledge-cache-test-") - cache = Dev::Knowledge::Cache.new(repo: File.join(dir, "nowhere"), dir: File.join(dir, "cache")) - - Expect - !cache.present? - cache.synced_at.nil? - cache.stale?(900) - - Cleanup - FileUtils.rm_rf(dir) - end - - test "a failing first clone raises KnowledgeCloneError" do - Given "a nonexistent source" - dir = Dir.mktmpdir("dev-knowledge-cache-test-") - cache = Dev::Knowledge::Cache.new(repo: File.join(dir, "nowhere"), dir: File.join(dir, "cache")) - - When "refreshing" - cache.refresh - - Then - raises Dev::Knowledge::Cache::KnowledgeCloneError - - Cleanup - FileUtils.rm_rf(dir) - end - - test "a failing pull raises KnowledgeFetchError" do - Given "a cloned cache whose upstream disappeared" - dir = Dir.mktmpdir("dev-knowledge-cache-test-") - source = build_source_repo(dir) - cache = Dev::Knowledge::Cache.new(repo: source, dir: File.join(dir, "cache")) - cache.refresh - FileUtils.rm_rf(source) - - When "refreshing" - cache.refresh - - Then - raises Dev::Knowledge::Cache::KnowledgeFetchError - - Cleanup - FileUtils.rm_rf(dir) - end - - test "an owner/repo source clones through gh (riding the user's gh auth)" do - Given "a fake gh on PATH that records its invocation" - dir = Dir.mktmpdir("dev-knowledge-cache-test-") - bin = File.join(dir, "bin") - FileUtils.mkdir_p(bin) - record = File.join(dir, "gh-args.txt") - File.write(File.join(bin, "gh"), "#!/bin/sh\necho \"$@\" > \"#{record}\"\n") - FileUtils.chmod(0o755, File.join(bin, "gh")) - original_path = ENV.fetch("PATH") - ENV["PATH"] = "#{bin}:#{original_path}" - cache = Dev::Knowledge::Cache.new(repo: "d3mlabs/knowledge", dir: File.join(dir, "cache")) - - When "refreshing on first run" - cache.refresh - - Then "the clone went through gh repo clone" - File.read(record).start_with?("repo clone d3mlabs/knowledge") - - Cleanup - ENV["PATH"] = original_path - FileUtils.rm_rf(dir) - end - - test "refresh_async warns instead of raising when the cache location cannot be created" do - Given "a cache location under a read-only parent" - dir = Dir.mktmpdir("dev-knowledge-cache-test-") - read_only_parent = File.join(dir, "read-only") - FileUtils.mkdir_p(read_only_parent) - FileUtils.chmod(0o555, read_only_parent) - cache = Dev::Knowledge::Cache.new(repo: "d3mlabs/knowledge", dir: File.join(read_only_parent, "sub", "cache")) - old_stderr = $stderr - $stderr = StringIO.new - - When "kicking a background refresh" - cache.refresh_async - - Then "the failure is a warning, not an exception" - $stderr.string.include?("could not start the knowledge cache refresh") - - Cleanup - $stderr = old_stderr - FileUtils.chmod(0o755, read_only_parent) - FileUtils.rm_rf(dir) - end - - test "refresh_async never raises, even when the refresh will fail" do - Given "a cache whose upstream is gone" - dir = Dir.mktmpdir("dev-knowledge-cache-test-") - cache = Dev::Knowledge::Cache.new(repo: File.join(dir, "nowhere"), dir: File.join(dir, "cache")) - - When "kicking a background refresh" - cache.refresh_async - - Then "the caller is not disturbed (the failure only extends staleness)" - true - - Cleanup - FileUtils.rm_rf(dir) - end -end diff --git a/test/dev/knowledge/invariants_renderer_test.rb b/test/dev/knowledge/invariants_renderer_test.rb deleted file mode 100644 index e0bc8b6..0000000 --- a/test/dev/knowledge/invariants_renderer_test.rb +++ /dev/null @@ -1,132 +0,0 @@ -# typed: false -# frozen_string_literal: true - -require "test_helper" -require "dev/knowledge/invariants_renderer" -require "pathname" -require "tmpdir" -require "fileutils" -require "stringio" - -transform!(RSpock::AST::Transformation) -class Dev::Knowledge::InvariantsRendererTest < Minitest::Test - INDEX = <<~INDEX - # Org learnings index - - Preamble prose. - - ## Invariants (always-on) - - - [design/single-responsibility] One reason to change per unit. - → skills/single-responsibility/ - - ## Knowledge (on-demand) - - - [ruby/typed-errors] Named error classes. → skills/typed-errors/ - INDEX - - def build_env(dir) - index = Pathname(dir) / "index.md" - index.write(INDEX) - rules = Pathname(dir) / "repo" / ".cursor" / "rules" / "org-invariants.mdc" - [index, rules] - end - - test "renders the invariants section as an always-on generated rule" do - Given "a cached index and a project without the rule" - dir = Dir.mktmpdir("dev-invariants-test-") - index, rules = build_env(dir) - renderer = Dev::Knowledge::InvariantsRenderer.new - - When "rendering" - renderer.render(index_file: index, rules_file: rules, repo: "d3mlabs/knowledge") - - Then "the rule is always-on, marked generated, and carries only the invariant lines" - content = rules.read - content.include?("alwaysApply: true") - content.include?(Dev::Knowledge::InvariantsRenderer::GENERATED_MARKER) - content.include?("d3mlabs/knowledge") - content.include?("[design/single-responsibility]") - !content.include?("[ruby/typed-errors]") - - Cleanup - FileUtils.rm_rf(dir) - end - - test "an unchanged render never rewrites the file" do - Given "an already-rendered rule, backdated to detect writes" - dir = Dir.mktmpdir("dev-invariants-test-") - index, rules = build_env(dir) - renderer = Dev::Knowledge::InvariantsRenderer.new - renderer.render(index_file: index, rules_file: rules, repo: "d3mlabs/knowledge") - old = Time.now - 3600 - File.utime(old, old, rules) - - When "rendering the same content again" - renderer.render(index_file: index, rules_file: rules, repo: "d3mlabs/knowledge") - - Then "the file was not touched" - (File.mtime(rules) - old).abs < 5 - - Cleanup - FileUtils.rm_rf(dir) - end - - test "a user-owned file in the rule's place survives, with a warning" do - Given "a hand-written file at the render target" - dir = Dir.mktmpdir("dev-invariants-test-") - index, rules = build_env(dir) - FileUtils.mkdir_p(rules.dirname) - rules.write("my own rule\n") - renderer = Dev::Knowledge::InvariantsRenderer.new - old_stderr = $stderr - $stderr = StringIO.new - - When "rendering" - renderer.render(index_file: index, rules_file: rules, repo: "d3mlabs/knowledge") - - Then "the file is untouched and the collision reported" - rules.read == "my own rule\n" - $stderr.string.include?("not dev-generated") - - Cleanup - $stderr = old_stderr - FileUtils.rm_rf(dir) - end - - test "a vanished index retracts a previously generated rule" do - Given "a rendered rule whose index disappears" - dir = Dir.mktmpdir("dev-invariants-test-") - index, rules = build_env(dir) - renderer = Dev::Knowledge::InvariantsRenderer.new - renderer.render(index_file: index, rules_file: rules, repo: "d3mlabs/knowledge") - index.delete - - When "rendering" - renderer.render(index_file: index, rules_file: rules, repo: "d3mlabs/knowledge") - - Then "the generated rule is gone" - !rules.exist? - - Cleanup - FileUtils.rm_rf(dir) - end - - test "an index without an invariants section retracts the rule" do - Given "a rendered rule, then an index that dropped the section" - dir = Dir.mktmpdir("dev-invariants-test-") - index, rules = build_env(dir) - renderer = Dev::Knowledge::InvariantsRenderer.new - renderer.render(index_file: index, rules_file: rules, repo: "d3mlabs/knowledge") - index.write("# Org learnings index\n\n## Knowledge (on-demand)\n\n- a line\n") - - When "rendering" - renderer.render(index_file: index, rules_file: rules, repo: "d3mlabs/knowledge") - - Then "the generated rule is gone" - !rules.exist? - - Cleanup - FileUtils.rm_rf(dir) - end -end diff --git a/test/dev/learnings/accessor_test.rb b/test/dev/learnings/accessor_test.rb new file mode 100644 index 0000000..0be2aca --- /dev/null +++ b/test/dev/learnings/accessor_test.rb @@ -0,0 +1,368 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/deps/gem_skill_linker" +require "dev/learnings" +require "dev/settings" +require "dev/skill_installer" +require "pathname" +require "tmpdir" +require "fileutils" +require "stringio" + +# A gem skill linker stand-in: the learnings surface is under test here, and +# the real linker would shell out to bundler. +class RecordingGemSkillLinker + attr_reader :link_all_calls + + def initialize + @link_all_calls = 0 + end + + def link_all + @link_all_calls += 1 + end +end unless defined?(RecordingGemSkillLinker) + +transform!(RSpock::AST::Transformation) +class Dev::Learnings::AccessorTest < Minitest::Test + # A real git source repo plus a fully tmpdir-scoped accessor (nothing + # touches the real user config, cache, or skills). Returns the accessor and + # the pieces we assert on. + def build_env(dir, project_root: :default) + source = build_source_repo(dir) + config = File.join(dir, "config.yml") + File.write(config, "knowledge_repo: #{source}\n") + settings = Dev::Settings.new(config_path: config) + cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache")) + installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills")) + synchronizer = Dev::Learnings::Synchronizer.new(settings: settings, cache: cache, skill_installer: installer) + project = project_root == :default ? Pathname(dir) / "repo" : project_root + FileUtils.mkdir_p(project) if project + gem_skill_linker = project && RecordingGemSkillLinker.new + accessor = Dev::Learnings::Accessor.new( + project_root: project, settings: settings, cache: cache, synchronizer: synchronizer, + skill_installer: installer, gem_skill_linker: gem_skill_linker, + ) + [accessor, cache, project, synchronizer, gem_skill_linker] + end + + def build_source_repo(dir) + source = File.join(dir, "knowledge") + FileUtils.mkdir_p(File.join(source, "skills", "srp")) + File.write(File.join(source, "skills", "srp", "SKILL.md"), "# srp\n") + File.write(File.join(source, "index.md"), "## Invariants (always-on)\n\n- [design/srp] One reason.\n") + system("git", "-C", source, "init", "-q", exception: true) + system("git", "-C", source, "add", ".", exception: true) + system("git", "-C", source, "-c", "user.email=dev@test", "-c", "user.name=dev", + "commit", "-qm", "seed", exception: true) + source + end + + # Rewind the cache's sync marker (the same files Cache#synced_at reads) so + # status reports an older age. + def backdate_cache(cache, seconds) + markers = [cache.dir / ".git" / "FETCH_HEAD", cache.dir / ".git" / "HEAD"].select(&:exist?) + FileUtils.touch(markers, mtime: Time.now - seconds) + end + + test "learnings sync refreshes the whole read path and reports" do + Given "a configured accessor with no cache yet" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + accessor, cache, project, synchronizer, gem_skill_linker = build_env(dir) + out = StringIO.new + + When "running dev learnings sync" + accessor.run(["sync"], out: out) + + Then "cache cloned, org skill linked, invariants rendered + linked, gem skills relinked, and reported" + cache.present? + File.symlink?(File.join(dir, "user-skills", "srp")) + synchronizer.rendered_invariants_file.file? + (project / ".cursor" / "rules" / "org-invariants.mdc").symlink? + gem_skill_linker.link_all_calls == 1 + out.string.include?("learnings synced") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "learnings sync outside a project does the machine-global parts and says what it skipped" do + Given "a configured accessor with no enclosing project" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + accessor, cache, _project, synchronizer, = build_env(dir, project_root: nil) + out = StringIO.new + + When "running dev learnings sync" + accessor.run(["sync"], out: out) + + Then "cache and machine-side render exist; the project-scoped parts were skipped" + cache.present? + synchronizer.rendered_invariants_file.file? + out.string.include?("no enclosing project") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "status reports a missing configuration with instructions" do + Given "an accessor over empty settings" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + saved_env = ENV.delete("DEV_KNOWLEDGE_REPO") + accessor = Dev::Learnings::Accessor.new( + project_root: dir, settings: Dev::Settings.new(config_path: File.join(dir, "config.yml")), + ) + out = StringIO.new + + When "running dev learnings status" + accessor.run(["status"], out: out) + + Then "the message names the setting and the ENV override" + out.string.include?("knowledge_repo:") + out.string.include?("DEV_KNOWLEDGE_REPO") + + Cleanup + ENV["DEV_KNOWLEDGE_REPO"] = saved_env if saved_env + FileUtils.rm_rf(dir) + end + + test "status before the first clone points at dev learnings sync" do + Given "a configured accessor with no cache yet" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + accessor, = build_env(dir) + out = StringIO.new + + When "running dev learnings status" + accessor.run(["status"], out: out) + + Then + out.string.include?("not cloned yet") + out.string.include?("dev learnings sync") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "status after a sync reports each tier's rendered/linked artifacts" do + Given "a fully synced read path" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + accessor, = build_env(dir) + accessor.run(["sync"], out: StringIO.new) + out = StringIO.new + + When "running dev learnings status" + accessor.run(["status"], out: out) + + Then "cache age, the machine-side render, the org skill links, and the project link are all reported" + out.string.include?("refreshed") + out.string.include?("ago") + out.string.include?("invariants: rendered at") + out.string.include?("org skills: 1 linked") + out.string.include?("project invariants link:") + out.string.include?("(linked)") + out.string.include?("gem skills: 0 linked") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "status with a cloned but never-synced cache points both tiers at dev learnings sync" do + Given "a cloned cache that has never been distributed" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + accessor, cache, = build_env(dir) + cache.refresh + out = StringIO.new + + When "running dev learnings status" + accessor.run(["status"], out: out) + + Then "the org render and the project link are both reported missing" + out.string.include?("invariants: not rendered") + out.string.include?("missing — run `dev learnings sync`") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "status flags an invariants rules file that is not dev's link" do + Given "a synced project whose rules file was replaced by a plain file" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + accessor, _cache, project, synchronizer, = build_env(dir) + accessor.run(["sync"], out: StringIO.new) + rules_file = synchronizer.project_rules_file(project) + rules_file.delete + rules_file.write("# hand-rolled, not dev's symlink\n") + out = StringIO.new + + When "running dev learnings status" + accessor.run(["status"], out: out) + + Then + out.string.include?("present but not dev's link") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "status counts the project's gem skill links and ignores other entries" do + Given "a synced project with one gem skill link and one unrelated file" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + accessor, _cache, project, = build_env(dir) + accessor.run(["sync"], out: StringIO.new) + gem_skills_dir = project.join(*Dev::Deps::GemSkillLinker::AGENT_SKILLS_SUBDIRS) + FileUtils.mkdir_p(gem_skills_dir) + File.symlink(File.join(dir, "knowledge", "skills", "srp"), gem_skills_dir / "gem-rspock--rspock") + File.write(gem_skills_dir / "README.md", "not a link\n") + out = StringIO.new + + When "running dev learnings status" + accessor.run(["status"], out: out) + + Then + out.string.include?("gem skills: 1 linked") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "status formats the cache age in minutes, hours, and days" do + Given "a synced accessor" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + accessor, cache, = build_env(dir) + accessor.run(["sync"], out: StringIO.new) + + When "running status with the cache's sync marker backdated to each granularity" + reports = { 5 * 60 => StringIO.new, 3 * 3600 => StringIO.new, 2 * 86_400 => StringIO.new } + reports.each do |age_seconds, out| + backdate_cache(cache, age_seconds) + accessor.run(["status"], out: out) + end + + Then "each report carries the compact age" + reports[5 * 60].string.include?("(refreshed 5m ago)") + reports[3 * 3600].string.include?("(refreshed 3h ago)") + reports[2 * 86_400].string.include?("(refreshed 2d ago)") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "status without a project reports the machine tiers and says the project is absent" do + Given "a synced accessor with no enclosing project" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + accessor, = build_env(dir, project_root: nil) + accessor.run(["sync"], out: StringIO.new) + out = StringIO.new + + When "running dev learnings status" + accessor.run(["status"], out: out) + + Then + out.string.include?("invariants: rendered at") + out.string.include?("project: none") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "invariants prints the Tier-0 prompt block" do + Given "a synced cache" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + accessor, cache, = build_env(dir) + cache.refresh + out = StringIO.new + + When "running dev learnings invariants" + accessor.run(["invariants"], out: out) + + Then "the block carries the invariant lines and the skill-pointer note, no mdc framing" + out.string.include?("[design/srp]") + out.string.include?("on-demand skill") + !out.string.include?("alwaysApply") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "invariants raises when no knowledge repo is configured" do + Given "an accessor over empty settings" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + saved_env = ENV.delete("DEV_KNOWLEDGE_REPO") + accessor = Dev::Learnings::Accessor.new( + project_root: dir, settings: Dev::Settings.new(config_path: File.join(dir, "config.yml")), + ) + + When "running dev learnings invariants" + accessor.run(["invariants"], out: StringIO.new) + + Then + raises Dev::Learnings::Accessor::InvariantsUnavailableError + + Cleanup + ENV["DEV_KNOWLEDGE_REPO"] = saved_env if saved_env + FileUtils.rm_rf(dir) + end + + test "invariants raises before the first clone" do + Given "a configured accessor with no cache yet" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + accessor, = build_env(dir) + + When "running dev learnings invariants" + accessor.run(["invariants"], out: StringIO.new) + + Then + raises Dev::Learnings::Accessor::InvariantsUnavailableError + + Cleanup + FileUtils.rm_rf(dir) + end + + test "invariants raises when the index has no invariants section" do + Given "a synced cache whose index carries no invariants section" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + accessor, cache, = build_env(dir) + cache.refresh + cache.index_file.write("# Org learnings index\n\n## Knowledge (on-demand)\n\n- a line\n") + + When "running dev learnings invariants" + accessor.run(["invariants"], out: StringIO.new) + + Then + raises Dev::Learnings::Accessor::InvariantsUnavailableError + + Cleanup + FileUtils.rm_rf(dir) + end + + test "an unrecognized invocation is rejected with usage" do + Given "a configured accessor" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + accessor, = build_env(dir) + + When "running an unknown subcommand" + accessor.run(["bogus"], out: StringIO.new) + + Then + raises Dev::Learnings::Accessor::UsageError + + Cleanup + FileUtils.rm_rf(dir) + end + + test "extra arguments to a subcommand are rejected with usage" do + Given "a configured accessor" + dir = Dir.mktmpdir("dev-learnings-acc-test-") + accessor, = build_env(dir) + + When "running sync with a stray argument" + accessor.run(["sync", "extra"], out: StringIO.new) + + Then + raises Dev::Learnings::Accessor::UsageError + + Cleanup + FileUtils.rm_rf(dir) + end +end diff --git a/test/dev/learnings/cache_test.rb b/test/dev/learnings/cache_test.rb new file mode 100644 index 0000000..645c4a0 --- /dev/null +++ b/test/dev/learnings/cache_test.rb @@ -0,0 +1,249 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/learnings/cache" +require "tmpdir" +require "fileutils" +require "stringio" + +transform!(RSpock::AST::Transformation) +class Dev::Learnings::CacheTest < Minitest::Test + # A real git repo playing the knowledge repo: index.md + one skill. + def build_source_repo(dir) + source = File.join(dir, "knowledge") + FileUtils.mkdir_p(File.join(source, "skills", "typed-errors")) + File.write(File.join(source, "skills", "typed-errors", "SKILL.md"), "# typed errors\n") + File.write(File.join(source, "index.md"), "## Invariants (always-on)\n\n- [x] a line\n") + git(source, "init", "-q") + git(source, "add", ".") + commit(source, "seed") + source + end + + def git(dir, *args) + system("git", "-C", dir, *args, exception: true) + end + + def commit(dir, message) + git(dir, "-c", "user.email=dev@test", "-c", "user.name=dev", "commit", "-qm", message) + end + + def commit_index_update(source, line) + File.write(File.join(source, "index.md"), "## Invariants (always-on)\n\n- [x] #{line}\n") + git(source, "add", ".") + commit(source, "update") + end + + test "refresh clones the repo into the cache dir on first run" do + Given "a source repo and an empty cache location" + dir = Dir.mktmpdir("dev-learnings-cache-test-") + source = build_source_repo(dir) + cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache")) + + When "refreshing" + cache.refresh + + Then "the cache is present, with the knowledge repo layout readable" + cache.present? + cache.index_file.file? + (cache.skills_dir / "typed-errors" / "SKILL.md").file? + !cache.synced_at.nil? + + Cleanup + FileUtils.rm_rf(dir) + end + + test "refresh fast-forwards an existing cache to new upstream content" do + Given "a cloned cache and a new upstream commit" + dir = Dir.mktmpdir("dev-learnings-cache-test-") + source = build_source_repo(dir) + cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache")) + cache.refresh + commit_index_update(source, "a newer line") + + When "refreshing again" + cache.refresh + + Then "the cache carries the new content" + cache.index_file.read.include?("a newer line") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "refresh_bounded pulls new upstream content inline" do + Given "a cloned cache past the courtesy floor, and a new upstream commit" + dir = Dir.mktmpdir("dev-learnings-cache-test-") + source = build_source_repo(dir) + cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"), refresh_floor: 0) + cache.refresh + commit_index_update(source, "a newer line") + + When "a bounded refresh runs" + cache.refresh_bounded + + Then "the cache already carries the new content — the caller distributes it" + cache.index_file.read.include?("a newer line") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "refresh_bounded within the courtesy floor is a no-op" do + Given "a just-refreshed cache and a new upstream commit" + dir = Dir.mktmpdir("dev-learnings-cache-test-") + source = build_source_repo(dir) + cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"), refresh_floor: 3600) + cache.refresh + commit_index_update(source, "a newer line") + + When "a bounded refresh runs inside the floor" + cache.refresh_bounded + + Then "no pull happened — the cache still serves the previous content" + !cache.index_file.read.include?("a newer line") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "refresh_bounded detaches a pull slower than the timeout and serves the cache" do + Given "a cloned cache whose git hangs (fake git on PATH), and a tight timeout" + dir = Dir.mktmpdir("dev-learnings-cache-test-") + source = build_source_repo(dir) + cache = Dev::Learnings::Cache.new( + repo: source, dir: File.join(dir, "cache"), refresh_floor: 0, refresh_timeout: 0.2, + ) + cache.refresh + bin = File.join(dir, "bin") + FileUtils.mkdir_p(bin) + File.write(File.join(bin, "git"), "#!/bin/sh\nsleep 30\n") + FileUtils.chmod(0o755, File.join(bin, "git")) + original_path = ENV.fetch("PATH") + ENV["PATH"] = "#{bin}:#{original_path}" + + When "a bounded refresh runs against the hanging git" + started = Time.now + cache.refresh_bounded + elapsed = Time.now - started + + Then "it returned around the timeout, not the pull's 30s, and the cache is intact" + elapsed < 5 + cache.index_file.file? + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(dir) + end + + test "refresh_bounded falls back silently when the pull fails (offline)" do + Given "a cloned cache whose upstream disappeared" + dir = Dir.mktmpdir("dev-learnings-cache-test-") + source = build_source_repo(dir) + cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"), refresh_floor: 0) + cache.refresh + FileUtils.rm_rf(source) + + When "a bounded refresh runs" + cache.refresh_bounded + + Then "no exception — the current cache is served as-is" + cache.present? + cache.index_file.file? + + Cleanup + FileUtils.rm_rf(dir) + end + + test "a never-cloned cache is absent and unsynced" do + Given "a cache over a dir that was never cloned" + dir = Dir.mktmpdir("dev-learnings-cache-test-") + cache = Dev::Learnings::Cache.new(repo: File.join(dir, "nowhere"), dir: File.join(dir, "cache")) + + Expect + !cache.present? + cache.synced_at.nil? + + Cleanup + FileUtils.rm_rf(dir) + end + + test "a failing first clone raises KnowledgeCloneError" do + Given "a nonexistent source" + dir = Dir.mktmpdir("dev-learnings-cache-test-") + cache = Dev::Learnings::Cache.new(repo: File.join(dir, "nowhere"), dir: File.join(dir, "cache")) + + When "refreshing" + cache.refresh + + Then + raises Dev::Learnings::Cache::KnowledgeCloneError + + Cleanup + FileUtils.rm_rf(dir) + end + + test "a failing pull raises KnowledgeFetchError" do + Given "a cloned cache whose upstream disappeared" + dir = Dir.mktmpdir("dev-learnings-cache-test-") + source = build_source_repo(dir) + cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache")) + cache.refresh + FileUtils.rm_rf(source) + + When "refreshing" + cache.refresh + + Then + raises Dev::Learnings::Cache::KnowledgeFetchError + + Cleanup + FileUtils.rm_rf(dir) + end + + test "an owner/repo source clones through gh (riding the user's gh auth)" do + Given "a fake gh on PATH that records its invocation" + dir = Dir.mktmpdir("dev-learnings-cache-test-") + bin = File.join(dir, "bin") + FileUtils.mkdir_p(bin) + record = File.join(dir, "gh-args.txt") + File.write(File.join(bin, "gh"), "#!/bin/sh\necho \"$@\" > \"#{record}\"\n") + FileUtils.chmod(0o755, File.join(bin, "gh")) + original_path = ENV.fetch("PATH") + ENV["PATH"] = "#{bin}:#{original_path}" + cache = Dev::Learnings::Cache.new(repo: "d3mlabs/knowledge", dir: File.join(dir, "cache")) + + When "refreshing on first run" + cache.refresh + + Then "the clone went through gh repo clone" + File.read(record).start_with?("repo clone d3mlabs/knowledge") + + Cleanup + ENV["PATH"] = original_path + FileUtils.rm_rf(dir) + end + + test "refresh_bounded warns instead of raising when the cache location cannot be created" do + Given "a cache location under a read-only parent" + dir = Dir.mktmpdir("dev-learnings-cache-test-") + read_only_parent = File.join(dir, "read-only") + FileUtils.mkdir_p(read_only_parent) + FileUtils.chmod(0o555, read_only_parent) + cache = Dev::Learnings::Cache.new(repo: "d3mlabs/knowledge", dir: File.join(read_only_parent, "sub", "cache")) + old_stderr = $stderr + $stderr = StringIO.new + + When "a bounded refresh runs" + cache.refresh_bounded + + Then "the failure is a warning, not an exception" + $stderr.string.include?("could not start the knowledge repo cache refresh") + + Cleanup + $stderr = old_stderr + FileUtils.chmod(0o755, read_only_parent) + FileUtils.rm_rf(dir) + end +end diff --git a/test/dev/learnings/invariants_renderer_test.rb b/test/dev/learnings/invariants_renderer_test.rb new file mode 100644 index 0000000..c2b3b04 --- /dev/null +++ b/test/dev/learnings/invariants_renderer_test.rb @@ -0,0 +1,213 @@ +# typed: false +# frozen_string_literal: true + +require "test_helper" +require "dev/learnings/invariants_renderer" +require "pathname" +require "tmpdir" +require "fileutils" +require "stringio" + +transform!(RSpock::AST::Transformation) +class Dev::Learnings::InvariantsRendererTest < Minitest::Test + INDEX = <<~INDEX + # Org learnings index + + Preamble prose. + + ## Invariants (always-on) + + - [design/single-responsibility] One reason to change per unit. + → skills/single-responsibility/ + + ## Knowledge (on-demand) + + - [ruby/typed-errors] Named error classes. → skills/typed-errors/ + INDEX + + # A cached index, the machine-side render target beside it, and a project's + # rules-file link target. + def build_env(dir) + index = Pathname(dir) / "index.md" + index.write(INDEX) + rendered = Pathname(dir) / "org-invariants.mdc" + rules = Pathname(dir) / "repo" / ".cursor" / "rules" / "org-invariants.mdc" + [index, rendered, rules] + end + + test "renders the invariants section as an always-on generated rule beside the cache" do + Given "a cached index and no render yet" + dir = Dir.mktmpdir("dev-invariants-test-") + index, rendered, = build_env(dir) + renderer = Dev::Learnings::InvariantsRenderer.new + + When "rendering" + renderer.render(index_file: index, rendered_file: rendered, repo: "d3mlabs/knowledge") + + Then "the rule is always-on, marked generated, and carries only the invariant lines" + content = rendered.read + content.include?("alwaysApply: true") + content.include?(Dev::Learnings::InvariantsRenderer::GENERATED_MARKER) + content.include?("d3mlabs/knowledge") + content.include?("[design/single-responsibility]") + !content.include?("[ruby/typed-errors]") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "an unchanged render never rewrites the file" do + Given "an already-rendered rule, backdated to detect writes" + dir = Dir.mktmpdir("dev-invariants-test-") + index, rendered, = build_env(dir) + renderer = Dev::Learnings::InvariantsRenderer.new + renderer.render(index_file: index, rendered_file: rendered, repo: "d3mlabs/knowledge") + old = Time.now - 3600 + File.utime(old, old, rendered) + + When "rendering the same content again" + renderer.render(index_file: index, rendered_file: rendered, repo: "d3mlabs/knowledge") + + Then "the file was not touched" + (File.mtime(rendered) - old).abs < 5 + + Cleanup + FileUtils.rm_rf(dir) + end + + test "a vanished index (or one without an invariants section) retracts the render" do + Given "a rendered rule whose index loses its section" + dir = Dir.mktmpdir("dev-invariants-test-") + index, rendered, = build_env(dir) + renderer = Dev::Learnings::InvariantsRenderer.new + renderer.render(index_file: index, rendered_file: rendered, repo: "d3mlabs/knowledge") + index.write("# Org learnings index\n\n## Knowledge (on-demand)\n\n- a line\n") + + When "rendering" + renderer.render(index_file: index, rendered_file: rendered, repo: "d3mlabs/knowledge") + + Then "the render is gone" + !rendered.exist? + + Cleanup + FileUtils.rm_rf(dir) + end + + test "prompt_block is the invariants section plus the skill-pointer note" do + Given "a cached index" + dir = Dir.mktmpdir("dev-invariants-test-") + index, = build_env(dir) + renderer = Dev::Learnings::InvariantsRenderer.new + + When "extracting the Tier-0 prompt block" + block = renderer.prompt_block(index) + + Then "the block carries the invariant lines and the pointer note, nothing else" + block.include?("[design/single-responsibility]") + block.include?(Dev::Learnings::InvariantsRenderer::SKILL_POINTER_NOTE) + !block.include?("[ruby/typed-errors]") + !block.include?("alwaysApply") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "prompt_block is nil without an index or an invariants section" do + Given "an index that lost its invariants section" + dir = Dir.mktmpdir("dev-invariants-test-") + index, = build_env(dir) + index.write("# Org learnings index\n\n## Knowledge (on-demand)\n\n- a line\n") + renderer = Dev::Learnings::InvariantsRenderer.new + + Expect + renderer.prompt_block(index).nil? + renderer.prompt_block(Pathname(dir) / "missing.md").nil? + + Cleanup + FileUtils.rm_rf(dir) + end + + test "link points the project rules file at the render as a symlink" do + Given "a machine-side render and a project without the link" + dir = Dir.mktmpdir("dev-invariants-test-") + index, rendered, rules = build_env(dir) + renderer = Dev::Learnings::InvariantsRenderer.new + renderer.render(index_file: index, rendered_file: rendered, repo: "d3mlabs/knowledge") + + When "linking" + renderer.link(rendered_file: rendered, rules_file: rules) + + Then "the project carries a symlink resolving to the render" + rules.symlink? + rules.readlink == rendered + rules.read.include?("[design/single-responsibility]") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "link replaces a legacy per-project generated file with the symlink" do + Given "a project still carrying an old-style generated regular file" + dir = Dir.mktmpdir("dev-invariants-test-") + index, rendered, rules = build_env(dir) + renderer = Dev::Learnings::InvariantsRenderer.new + renderer.render(index_file: index, rendered_file: rendered, repo: "d3mlabs/knowledge") + FileUtils.mkdir_p(rules.dirname) + rules.write("#{Dev::Learnings::InvariantsRenderer::LEGACY_GENERATED_MARKER} changes land upstream -->\nold render\n") + + When "linking" + renderer.link(rendered_file: rendered, rules_file: rules) + + Then "the file became the symlink" + rules.symlink? + rules.readlink == rendered + + Cleanup + FileUtils.rm_rf(dir) + end + + test "a user-owned file in the link's place survives, with a warning" do + Given "a hand-written file at the link target" + dir = Dir.mktmpdir("dev-invariants-test-") + index, rendered, rules = build_env(dir) + renderer = Dev::Learnings::InvariantsRenderer.new + renderer.render(index_file: index, rendered_file: rendered, repo: "d3mlabs/knowledge") + FileUtils.mkdir_p(rules.dirname) + rules.write("my own rule\n") + old_stderr = $stderr + $stderr = StringIO.new + + When "linking" + renderer.link(rendered_file: rendered, rules_file: rules) + + Then "the file is untouched and the collision reported" + rules.read == "my own rule\n" + !rules.symlink? + $stderr.string.include?("not dev-generated") + + Cleanup + $stderr = old_stderr + FileUtils.rm_rf(dir) + end + + test "a retracted render removes the project link" do + Given "a linked project whose render is retracted" + dir = Dir.mktmpdir("dev-invariants-test-") + index, rendered, rules = build_env(dir) + renderer = Dev::Learnings::InvariantsRenderer.new + renderer.render(index_file: index, rendered_file: rendered, repo: "d3mlabs/knowledge") + renderer.link(rendered_file: rendered, rules_file: rules) + index.delete + renderer.render(index_file: index, rendered_file: rendered, repo: "d3mlabs/knowledge") + + When "linking against the retracted render" + renderer.link(rendered_file: rendered, rules_file: rules) + + Then "the project link is gone" + !rules.symlink? + !rules.exist? + + Cleanup + FileUtils.rm_rf(dir) + end +end diff --git a/test/dev/knowledge/synchronizer_test.rb b/test/dev/learnings/synchronizer_test.rb similarity index 52% rename from test/dev/knowledge/synchronizer_test.rb rename to test/dev/learnings/synchronizer_test.rb index a701e3b..b013d1f 100644 --- a/test/dev/knowledge/synchronizer_test.rb +++ b/test/dev/learnings/synchronizer_test.rb @@ -2,7 +2,7 @@ # frozen_string_literal: true require "test_helper" -require "dev/knowledge" +require "dev/learnings" require "dev/settings" require "dev/skill_installer" require "pathname" @@ -11,20 +11,20 @@ require "stringio" transform!(RSpock::AST::Transformation) -class Dev::Knowledge::SynchronizerTest < Minitest::Test +class Dev::Learnings::SynchronizerTest < Minitest::Test # A real git repo playing the knowledge repo, plus tmpdir-scoped settings, # cache, skills dir, and project. Returns the wired synchronizer parts. - def build_env(dir) + def build_env(dir, refresh_floor: 0) source = build_source_repo(dir) config = File.join(dir, "config.yml") File.write(config, "knowledge_repo: #{source}\n") settings = Dev::Settings.new(config_path: config) - cache = Dev::Knowledge::Cache.new(repo: source, dir: File.join(dir, "cache")) + cache = Dev::Learnings::Cache.new(repo: source, dir: File.join(dir, "cache"), refresh_floor: refresh_floor) installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills")) project = Pathname(dir) / "repo" FileUtils.mkdir_p(project) - synchronizer = Dev::Knowledge::Synchronizer.new(settings: settings, cache: cache, skill_installer: installer) - [synchronizer, cache, project] + synchronizer = Dev::Learnings::Synchronizer.new(settings: settings, cache: cache, skill_installer: installer) + [synchronizer, cache, project, source] end def build_source_repo(dir) @@ -33,61 +33,88 @@ def build_source_repo(dir) File.write(File.join(source, "skills", "typed-errors", "SKILL.md"), "# typed errors\n") File.write(File.join(source, "index.md"), "## Invariants (always-on)\n\n- [design/srp] One reason to change.\n") system("git", "-C", source, "init", "-q", exception: true) + commit_all(source, "seed") + source + end + + def commit_all(source, message) system("git", "-C", source, "add", ".", exception: true) system("git", "-C", source, "-c", "user.email=dev@test", "-c", "user.name=dev", - "commit", "-qm", "seed", exception: true) - source + "commit", "-qm", message, exception: true) end - test "sync! refreshes the cache, links org skills, and renders the invariants rule" do + test "sync! refreshes the cache, links org skills, renders machine-side, and links the project" do Given "a configured synchronizer with an empty cache" - dir = Dir.mktmpdir("dev-knowledge-sync-test-") - synchronizer, cache, project = build_env(dir) + dir = Dir.mktmpdir("dev-learnings-sync-test-") + synchronizer, cache, project, = build_env(dir) When "forcing a sync into the project" synchronizer.sync!(project_root: project) - Then "cache cloned, skill linked user-globally, invariants rendered project-side" + Then "cache cloned, skill linked user-globally, invariants rendered once beside the cache, project symlinked" cache.present? File.symlink?(File.join(dir, "user-skills", "typed-errors")) - (project / ".cursor" / "rules" / "org-invariants.mdc").read.include?("[design/srp]") + synchronizer.rendered_invariants_file.file? + rules = project / ".cursor" / "rules" / "org-invariants.mdc" + rules.symlink? + rules.readlink == synchronizer.rendered_invariants_file + rules.read.include?("[design/srp]") Cleanup FileUtils.rm_rf(dir) end - test "sync distributes from the current cache and only refreshes past the TTL" do - Given "a fresh cache (just synced)" - dir = Dir.mktmpdir("dev-knowledge-sync-test-") - synchronizer, cache, project = build_env(dir) + test "the passive hook pulls inline before distributing — it never renders content it found stale" do + Given "a cloned cache that upstream has since moved past" + dir = Dir.mktmpdir("dev-learnings-sync-test-") + synchronizer, cache, project, source = build_env(dir) cache.refresh - cache.expects(:refresh_async).never + File.write(File.join(source, "index.md"), "## Invariants (always-on)\n\n- [design/newer] A fresh invariant.\n") + commit_all(source, "update") When "the passive hook runs" synchronizer.sync(project_root: project) - Then "no background refresh was kicked, but the artifacts are distributed" - File.symlink?(File.join(dir, "user-skills", "typed-errors")) - (project / ".cursor" / "rules" / "org-invariants.mdc").file? + Then "the project already resolves to the fresh upstream content" + (project / ".cursor" / "rules" / "org-invariants.mdc").read.include?("[design/newer]") Cleanup FileUtils.rm_rf(dir) end - test "sync kicks an async refresh once the TTL lapsed" do - Given "a cache older than the TTL" - dir = Dir.mktmpdir("dev-knowledge-sync-test-") - synchronizer, cache, project = build_env(dir) + test "the passive hook inside the courtesy floor serves the cache without pulling" do + Given "a just-refreshed cache and a newer upstream commit" + dir = Dir.mktmpdir("dev-learnings-sync-test-") + synchronizer, cache, project, source = build_env(dir, refresh_floor: 3600) cache.refresh - old = Time.now - 3600 - File.utime(old, old, File.join(dir, "cache", ".git", "HEAD")) - cache.expects(:refresh_async).once + File.write(File.join(source, "index.md"), "## Invariants (always-on)\n\n- [design/newer] A fresh invariant.\n") + commit_all(source, "update") - When "the passive hook runs" + When "the passive hook runs inside the floor" synchronizer.sync(project_root: project) - Then "the refresh went to the background (distribution still served the cache)" - (project / ".cursor" / "rules" / "org-invariants.mdc").file? + Then "distribution served the current cache (no pull)" + rules = project / ".cursor" / "rules" / "org-invariants.mdc" + rules.read.include?("[design/srp]") + !rules.read.include?("[design/newer]") + + Cleanup + FileUtils.rm_rf(dir) + end + + test "a sync without a project does the machine-global parts and skips the project link" do + Given "a configured synchronizer" + dir = Dir.mktmpdir("dev-learnings-sync-test-") + synchronizer, cache, project, = build_env(dir) + + When "syncing with no project root" + synchronizer.sync! + + Then "cache, skills, and the machine-side render exist; no project was touched" + cache.present? + File.symlink?(File.join(dir, "user-skills", "typed-errors")) + synchronizer.rendered_invariants_file.file? + !(project / ".cursor").exist? Cleanup FileUtils.rm_rf(dir) @@ -95,11 +122,11 @@ def build_source_repo(dir) test "an unconfigured machine has no org sync" do Given "settings without a knowledge repo" - dir = Dir.mktmpdir("dev-knowledge-sync-test-") + dir = Dir.mktmpdir("dev-learnings-sync-test-") saved_env = ENV.delete("DEV_KNOWLEDGE_REPO") settings = Dev::Settings.new(config_path: File.join(dir, "config.yml")) installer = Dev::SkillInstaller.new(skills_dir: File.join(dir, "user-skills")) - synchronizer = Dev::Knowledge::Synchronizer.new(settings: settings, skill_installer: installer) + synchronizer = Dev::Learnings::Synchronizer.new(settings: settings, skill_installer: installer) project = Pathname(dir) / "repo" FileUtils.mkdir_p(project) @@ -118,16 +145,16 @@ def build_source_repo(dir) test "sync! on an unconfigured machine raises with configuration instructions" do Given "settings without a knowledge repo" - dir = Dir.mktmpdir("dev-knowledge-sync-test-") + dir = Dir.mktmpdir("dev-learnings-sync-test-") saved_env = ENV.delete("DEV_KNOWLEDGE_REPO") settings = Dev::Settings.new(config_path: File.join(dir, "config.yml")) - synchronizer = Dev::Knowledge::Synchronizer.new(settings: settings) + synchronizer = Dev::Learnings::Synchronizer.new(settings: settings) When "forcing a sync" synchronizer.sync! Then - raises Dev::Knowledge::Synchronizer::KnowledgeRepoNotConfiguredError + raises Dev::Learnings::Synchronizer::KnowledgeRepoNotConfiguredError Cleanup ENV["DEV_KNOWLEDGE_REPO"] = saved_env if saved_env @@ -135,10 +162,10 @@ def build_source_repo(dir) end test "the passive hook never raises — a broken sync only warns" do - Given "a cache that blows up on the staleness check" - dir = Dir.mktmpdir("dev-knowledge-sync-test-") - synchronizer, cache, project = build_env(dir) - cache.stubs(:stale?).raises(RuntimeError, "boom") + Given "a cache that blows up on the bounded refresh" + dir = Dir.mktmpdir("dev-learnings-sync-test-") + synchronizer, cache, project, = build_env(dir) + cache.stubs(:refresh_bounded).raises(RuntimeError, "boom") old_stderr = $stderr $stderr = StringIO.new @@ -146,7 +173,7 @@ def build_source_repo(dir) synchronizer.sync(project_root: project) Then "the failure is a warning, not an exception" - $stderr.string.include?("org knowledge sync failed") + $stderr.string.include?("org learnings sync failed") Cleanup $stderr = old_stderr diff --git a/test/dev/plan/accessor_test.rb b/test/dev/plan/accessor_test.rb index 7d8dc0b..ef4e711 100644 --- a/test/dev/plan/accessor_test.rb +++ b/test/dev/plan/accessor_test.rb @@ -59,11 +59,11 @@ class FakePlanSettings def plans_repo = "d3mlabs/plans" end unless defined?(FakePlanSettings) -# A knowledge synchronizer stand-in: plan flows are under test here, and the +# A learnings synchronizer stand-in: plan flows are under test here, and the # real synchronizer would read the machine's config and touch user-global dirs. -class NoopKnowledgeSynchronizer +class NoopLearningsSynchronizer def sync(project_root: nil); end -end unless defined?(NoopKnowledgeSynchronizer) +end unless defined?(NoopLearningsSynchronizer) transform!(RSpock::AST::Transformation) class Dev::Plan::AccessorTest < Minitest::Test @@ -84,7 +84,7 @@ def build_env(dir) settings: FakePlanSettings.new, merge_base: Dev::Plan::MergeBase.new(state_dir: File.join(dir, "state")), skill_installer: Dev::SkillInstaller.new(skills_dir: File.join(dir, "skills")), - knowledge: NoopKnowledgeSynchronizer.new, + learnings: NoopLearningsSynchronizer.new, ) [accessor, root, issues] end diff --git a/test/dev/runner_test.rb b/test/dev/runner_test.rb index 7db8e1f..89dbcf6 100644 --- a/test/dev/runner_test.rb +++ b/test/dev/runner_test.rb @@ -328,16 +328,16 @@ class RunnerTest < Minitest::Test FileUtils.rm_rf(root) end - test "knowledge dispatches the subcommand to the knowledge accessor" do + test "learnings dispatches the subcommand to the learnings accessor" do Given "a Runner pinned to an empty project root" - root = Pathname.new(Dir.mktmpdir("runner-knowledge-")) + root = Pathname.new(Dir.mktmpdir("runner-learnings-")) Dev.stubs(:target_project_root).returns(root) runner = build_runner(commands: {}) runner.stubs(:resolve_ruby_version).returns("4.0.1") - Dev::Knowledge::Accessor.any_instance.expects(:run).with(["status"]).once + Dev::Learnings::Accessor.any_instance.expects(:run).with(["status"]).once - When "we run dev knowledge status" - runner.run(["knowledge", "status"], ui: fake_ui) + When "we run dev learnings status" + runner.run(["learnings", "status"], ui: fake_ui) Then "the expectation on the accessor holds" true @@ -346,7 +346,7 @@ class RunnerTest < Minitest::Test FileUtils.rm_rf(root) end - test "install-deps finishes by linking gem skills and syncing org knowledge" do + test "install-deps finishes by linking gem skills and syncing org learnings" do Given "a Runner pinned to an empty project root, with the installer stubbed" root = Pathname.new(Dir.mktmpdir("runner-install-deps-")) Dev.stubs(:target_project_root).returns(root) @@ -355,7 +355,7 @@ class RunnerTest < Minitest::Test ShadowenvRuby.stubs(:ensure!) Dev::Deps::DependencyInstaller.any_instance.stubs(:install) Dev::Deps::GemSkillLinker.any_instance.expects(:link_all).once - Dev::Knowledge::Synchronizer.any_instance.expects(:sync).with(project_root: root).once + Dev::Learnings::Synchronizer.any_instance.expects(:sync).with(project_root: root).once When "we run install-deps" runner.run(["install-deps"], ui: fake_ui) @@ -380,7 +380,7 @@ class RunnerTest < Minitest::Test ShadowenvRuby.expects(:ensure!).with(ruby_version: "4.0.1", project_root: root).once Dev::Deps::DependencyInstaller.any_instance.stubs(:install) Dev::Deps::GemSkillLinker.any_instance.stubs(:link_all) - Dev::Knowledge::Synchronizer.any_instance.stubs(:sync) + Dev::Learnings::Synchronizer.any_instance.stubs(:sync) When "we run install-deps" runner.run(["install-deps"], ui: fake_ui) diff --git a/test/dev/settings_test.rb b/test/dev/settings_test.rb index 8e472ef..99dbbff 100644 --- a/test/dev/settings_test.rb +++ b/test/dev/settings_test.rb @@ -99,24 +99,4 @@ class Dev::SettingsTest < Minitest::Test ENV["DEV_KNOWLEDGE_REPO"] = saved_env if saved_env FileUtils.rm_rf(dir) end - - test "knowledge_ttl is #{expected}s when the config declares #{declared.inspect}" do - Given "a config file" - dir = Dir.mktmpdir("dev-settings-test-") - path = File.join(dir, "config.yml") - File.write(path, declared ? "knowledge_ttl: #{declared.inspect}\n" : "") - settings = Dev::Settings.new(config_path: path) - - Expect - settings.knowledge_ttl == expected - - Cleanup - FileUtils.rm_rf(dir) - - Where - declared | expected - nil | 900 - 60 | 60 - "3600" | 3600 - end end