diff --git a/engine/app/services/coplan/plan_types/install_defaults.rb b/engine/app/services/coplan/plan_types/install_defaults.rb new file mode 100644 index 0000000..31aaacd --- /dev/null +++ b/engine/app/services/coplan/plan_types/install_defaults.rb @@ -0,0 +1,86 @@ +module CoPlan + module PlanTypes + # Installs the plan types shipped with the engine + # (engine/db/default_plan_types/*.md — YAML front matter for the + # attributes, Markdown body as the template). + # + # Admin edits are data, defaults are code, and the defaults must never + # silently clobber the data: without `force`, existing types only gain + # values for fields that are currently blank (the common upgrade case — + # a type created before templates existed gets the default template, + # but a hand-written description survives). With `force`, the shipped + # defaults win on every field — blank shipped values included, so a + # custom template on a type that ships without one (Scratchpad, General) + # is cleared. Types unknown to the defaults are never touched either way. + class InstallDefaults + DEFAULTS_DIR = CoPlan::Engine.root.join("db", "default_plan_types") + FRONT_MATTER = /\A---\n(?.*?)\n---\n?(?.*)\z/m + + Result = Struct.new(:created, :updated, :skipped, keyword_init: true) + + def self.call(force: false, dir: DEFAULTS_DIR) + new(force:, dir:).call + end + + def initialize(force: false, dir: DEFAULTS_DIR) + @force = force + @dir = Pathname(dir) + end + + def call + result = Result.new(created: [], updated: [], skipped: []) + + @dir.glob("*.md").sort.each do |path| + attrs = parse(path) + type = PlanType.find_by_name(attrs[:name]) + + if type.nil? + PlanType.create!(**attrs) + result.created << attrs[:name] + elsif apply(type, attrs) + result.updated << attrs[:name] + else + result.skipped << attrs[:name] + end + end + + result + end + + private + + def parse(path) + match = FRONT_MATTER.match(path.read) + raise ArgumentError, "#{path.basename}: missing YAML front matter" unless match + + meta = YAML.safe_load(match[:yaml]) || {} + name = meta["name"].to_s.strip + raise ArgumentError, "#{path.basename}: front matter needs a name" if name.empty? + + { + name: name, + description: meta["description"].to_s.strip.presence, + icon: meta["icon"].to_s.strip.presence, + default_tags: Array(meta["default_tags"]).map(&:to_s), + template_content: match[:body].strip.presence + } + end + + # Assigns default values onto an existing type; returns whether + # anything changed. Only blank fields are filled unless forcing; + # forcing restores the shipped value even when it's blank. + def apply(type, attrs) + attrs.except(:name).each do |field, value| + next if value.blank? && !@force + next unless @force || type[field].blank? + + type[field] = value + end + return false unless type.changed? + + type.save! + true + end + end + end +end diff --git a/engine/db/default_plan_types/engineering-design.md b/engine/db/default_plan_types/engineering-design.md new file mode 100644 index 0000000..d0bf204 --- /dev/null +++ b/engine/db/default_plan_types/engineering-design.md @@ -0,0 +1,52 @@ +--- +name: Engineering Design +icon: scroll +description: >- + A formal record of a technical design decision, written for reviewers + deciding whether to build it and maintainers later asking why it was. + Alternatives weighed, risks named. Still exploring options? Use + Exploration instead. +--- + + +## Problem + + + +## Constraints + + + +## Design + + + +## Alternatives considered + + + +## Risks + + + +## Rollout + + + +## Open questions + + diff --git a/engine/db/default_plan_types/exploration.md b/engine/db/default_plan_types/exploration.md new file mode 100644 index 0000000..7ea6501 --- /dev/null +++ b/engine/db/default_plan_types/exploration.md @@ -0,0 +1,29 @@ +--- +name: Exploration +icon: compass +description: >- + Working through a problem that is not decided yet - candidate + approaches, sketches, code samples, tradeoffs. The reader is you, your + collaborators, and the agent working alongside you. When one approach + wins, retype this as an Engineering Design and restructure. +--- + + +## Question + + + +## Approaches + + + +## Current leaning + + + +## What would change my mind + + diff --git a/engine/db/default_plan_types/general.md b/engine/db/default_plan_types/general.md new file mode 100644 index 0000000..8bcabc2 --- /dev/null +++ b/engine/db/default_plan_types/general.md @@ -0,0 +1,7 @@ +--- +name: General +icon: file-text +description: >- + A plan that fits no other type. Check the type list before choosing + this - a more specific type almost always exists. +--- diff --git a/engine/db/default_plan_types/handoff.md b/engine/db/default_plan_types/handoff.md new file mode 100644 index 0000000..009916a --- /dev/null +++ b/engine/db/default_plan_types/handoff.md @@ -0,0 +1,38 @@ +--- +name: Handoff +icon: file-text +description: >- + State transfer at the end of a work session - agent or human - for + whoever picks the work up next. Optimize their first ten minutes. + Archive it once it has been picked up. +--- + + +## Goal of the work + + + +## Done + + + +## Not done + + + +## Decisions made + + + +## Landmines + + + +## Next steps + + diff --git a/engine/db/default_plan_types/implementation-plan.md b/engine/db/default_plan_types/implementation-plan.md new file mode 100644 index 0000000..6365369 --- /dev/null +++ b/engine/db/default_plan_types/implementation-plan.md @@ -0,0 +1,37 @@ +--- +name: Implementation Plan +icon: map +description: >- + A step-by-step plan for building a specific change - the document an + agent or engineer executes. Steps are checkboxes with a verification + each. The reader is whoever does the work, and whoever approves it + first. +--- + + +## Goal + + + +## Current state + + + +## Steps + + + +- [ ] First step — verify: … +- [ ] Second step — verify: … + +## Risks & rollback + + + +## Out of scope + + diff --git a/engine/db/default_plan_types/prd.md b/engine/db/default_plan_types/prd.md new file mode 100644 index 0000000..acdd50a --- /dev/null +++ b/engine/db/default_plan_types/prd.md @@ -0,0 +1,35 @@ +--- +name: PRD +icon: scale +description: >- + A product requirements document: what to build and why, for the team + building it and the stakeholders agreeing to it. The how belongs in an + Engineering Design. +--- + + +## Problem + + + +## Goals + + + +## Non-goals + + + +## Requirements + + + +## Success metrics + + + +## Open questions diff --git a/engine/db/default_plan_types/project-1-pager.md b/engine/db/default_plan_types/project-1-pager.md new file mode 100644 index 0000000..5c52739 --- /dev/null +++ b/engine/db/default_plan_types/project-1-pager.md @@ -0,0 +1,36 @@ +--- +name: Project 1-Pager +icon: rocket +description: >- + A one-page pitch for a project: the problem, the bet, what it costs, + what changes if it works. The reader has ten minutes and decides + whether this deserves investment. Persuasive language is welcome in + this type - but every claim still needs a specific behind it. +--- + + +## The problem + + + +## The bet + + + +## What it takes + + + +## What changes if it works + + + +## Why now + + + +## Open questions diff --git a/engine/db/default_plan_types/research.md b/engine/db/default_plan_types/research.md new file mode 100644 index 0000000..945aff1 --- /dev/null +++ b/engine/db/default_plan_types/research.md @@ -0,0 +1,38 @@ +--- +name: Research +icon: flask +description: >- + Findings from an information-gathering run - internal systems, + history, competitive analysis, legal, external sources. Every claim + carries a footnote citation to a durable source and the date it was + confirmed. The reader acts on the findings without redoing the work. +--- + + +## Question + + + +## Answer + + + +## Findings + + + +## What we still don't know + + + +## Method + + diff --git a/engine/db/default_plan_types/scratchpad.md b/engine/db/default_plan_types/scratchpad.md new file mode 100644 index 0000000..66d3c6e --- /dev/null +++ b/engine/db/default_plan_types/scratchpad.md @@ -0,0 +1,8 @@ +--- +name: Scratchpad +icon: lightbulb +description: >- + A brainstorming space with no structure required - not expected to be + readable by anyone else yet. When it firms up, retype it (usually as + an Exploration or Engineering Design) and restructure. +--- diff --git a/engine/db/default_plan_types/technical-documentation.md b/engine/db/default_plan_types/technical-documentation.md new file mode 100644 index 0000000..b463edc --- /dev/null +++ b/engine/db/default_plan_types/technical-documentation.md @@ -0,0 +1,27 @@ +--- +name: Technical Documentation +icon: wrench +description: >- + Reference for something that exists: an interface, a command set, a + data model, how a system behaves today. Describes what IS. The reader + is mid-task and needs the fact fast. Proposing a change instead? + That's an Engineering Design. +--- + + +## What this covers + + + +## Reference + + + +## Gotchas + + diff --git a/engine/db/default_plan_types/test-plan.md b/engine/db/default_plan_types/test-plan.md new file mode 100644 index 0000000..cc9b104 --- /dev/null +++ b/engine/db/default_plan_types/test-plan.md @@ -0,0 +1,34 @@ +--- +name: Test Plan +icon: shield +description: >- + How a change gets verified before it is trusted: scope, cases, pass + criteria. The reader is whoever runs the tests and whoever signs off + on the result. +--- + + +## Scope + + + +## Environments + + + +## Cases + + + +## Pass criteria + + + +## Rollback triggers + + diff --git a/engine/db/seeds.rb b/engine/db/seeds.rb index be87262..9445729 100644 --- a/engine/db/seeds.rb +++ b/engine/db/seeds.rb @@ -1,15 +1,15 @@ # Required reference data for a CoPlan installation. Idempotent — safe to run # repeatedly, and never touches rows the host has customized. # -# This exists because the SeedGeneralPlanType data migration only runs on -# databases initialized by replaying migrations. Hosts that initialize via -# `db:schema:load` / `db:prepare` / `db:setup` get the tables and the migration -# marked as applied, but not the data — so required reference data must also -# be installable after the fact. Load with `bin/rails coplan:seed` (or +# This exists because data migrations only run on databases initialized by +# replaying migrations. Hosts that initialize via `db:schema:load` / +# `db:prepare` / `db:setup` get the tables and the migrations marked as +# applied, but not the data — so required reference data must also be +# installable after the fact. Load with `bin/rails coplan:seed` (or # `CoPlan::Engine.load_seed` from the host's own db/seeds.rb). - -# find_by_name is case-insensitive, so a host that renamed the type to -# "general" doesn't get a near-duplicate "General" recreated beside it. -unless CoPlan::PlanType.find_by_name("General") - CoPlan::PlanType.create!(name: "General", description: "General-purpose plan") -end +# +# Installs the default plan types (engine/db/default_plan_types/*.md): +# creates missing types and fills blank fields on existing ones, never +# overwriting a host's edits. To overwrite with the shipped defaults, run +# `bin/rails coplan:plan_types:install_defaults FORCE=1` explicitly. +CoPlan::PlanTypes::InstallDefaults.call diff --git a/engine/lib/tasks/coplan_plan_types.rake b/engine/lib/tasks/coplan_plan_types.rake new file mode 100644 index 0000000..9af280c --- /dev/null +++ b/engine/lib/tasks/coplan_plan_types.rake @@ -0,0 +1,13 @@ +namespace :coplan do + namespace :plan_types do + desc "Install the default plan types (fills blank fields on existing types; FORCE=1 overwrites edited fields with the shipped defaults)" + task install_defaults: :environment do + result = CoPlan::PlanTypes::InstallDefaults.call(force: ENV["FORCE"] == "1") + + puts "coplan:plan_types:install_defaults#{" (FORCE)" if ENV["FORCE"] == "1"}" + puts " created: #{result.created.any? ? result.created.join(", ") : "(none)"}" + puts " updated: #{result.updated.any? ? result.updated.join(", ") : "(none)"}" + puts " skipped: #{result.skipped.any? ? result.skipped.join(", ") : "(none)"} (already match or hand-edited; FORCE=1 overwrites)" + end + end +end diff --git a/spec/lib/engine_seed_spec.rb b/spec/lib/engine_seed_spec.rb index 676555f..12d2d79 100644 --- a/spec/lib/engine_seed_spec.rb +++ b/spec/lib/engine_seed_spec.rb @@ -2,25 +2,26 @@ # Covers the engine's required-reference-data seed (engine/db/seeds.rb), # exposed to hosts as `bin/rails coplan:seed`. Schema-loaded databases skip -# the SeedGeneralPlanType data migration, so this seed is the supported way -# to guarantee the built-in General plan type exists. +# the data migrations, so this seed is the supported way to guarantee the +# built-in plan types exist. It delegates to PlanTypes::InstallDefaults — +# fill-blanks-only semantics are specced in detail there; this covers the +# seed-level contract. RSpec.describe "CoPlan::Engine.load_seed" do # A migration-built database (the PG CI job) already contains General via # the SeedGeneralPlanType data migration; these examples are about the - # schema-loaded case where it's absent, so start from a clean table. + # schema-loaded case where types are absent, so start from a clean table. # Transactional fixtures roll the delete back after each example. before { CoPlan::PlanType.delete_all } - it "creates the General plan type when missing" do + it "installs the default plan types, General included" do expect(CoPlan::PlanType.find_by_name("General")).to be_nil CoPlan::Engine.load_seed general = CoPlan::PlanType.find_by_name("General") expect(general).to be_present - expect(general.description).to eq("General-purpose plan") expect(general.default_tags).to eq([]) - expect(general.metadata).to eq({}) + expect(CoPlan::PlanType.find_by_name("Engineering Design").template_content).to be_present end it "is idempotent" do @@ -28,10 +29,11 @@ expect { CoPlan::Engine.load_seed }.not_to change(CoPlan::PlanType, :count) end - it "does not overwrite a host-customized General plan type" do + it "does not overwrite a host-customized type, while still adding missing ones" do customized = create(:plan_type, name: "general", description: "Ours, thanks") - expect { CoPlan::Engine.load_seed }.not_to change(CoPlan::PlanType, :count) + expect { CoPlan::Engine.load_seed }.to change(CoPlan::PlanType, :count) expect(customized.reload.description).to eq("Ours, thanks") + expect(CoPlan::PlanType.find_by_name("General")).to eq(customized) end end diff --git a/spec/services/plan_types/install_defaults_spec.rb b/spec/services/plan_types/install_defaults_spec.rb new file mode 100644 index 0000000..fcfeb23 --- /dev/null +++ b/spec/services/plan_types/install_defaults_spec.rb @@ -0,0 +1,103 @@ +require "rails_helper" + +RSpec.describe CoPlan::PlanTypes::InstallDefaults do + # The database may already contain plan types when the suite runs — the PG + # CI job seeds before rspec, and a data migration installs General. These + # examples are about what the installer does from a known starting state, + # so start from a clean table. Transactional fixtures roll the delete back. + before { CoPlan::PlanType.delete_all } + + describe "the shipped defaults" do + it "creates the full default set on a fresh install" do + result = described_class.call + + expect(result.created).to include( + "Engineering Design", "Exploration", "PRD", "Project 1-Pager", + "Research", "Technical Documentation", "Implementation Plan", + "Test Plan", "Handoff", "Scratchpad", "General" + ) + expect(CoPlan::PlanType.count).to eq(result.created.size) + + design = CoPlan::PlanType.find_by_name("Engineering Design") + expect(design.description).to include("decision") + expect(design.template_content).to include("## Alternatives considered") + expect(design.icon).to eq("scroll") + + # The catch-alls deliberately ship without templates. + expect(CoPlan::PlanType.find_by_name("Scratchpad").template_content).to be_nil + expect(CoPlan::PlanType.find_by_name("General").template_content).to be_nil + end + + it "is idempotent" do + described_class.call + result = described_class.call + + expect(result.created).to be_empty + expect(result.updated).to be_empty + expect(result.skipped).not_to be_empty + end + + it "fills blank fields on existing types without touching edited ones" do + # A pre-templates instance: type exists with a custom description and + # no template (Square's situation before this shipped). + create(:plan_type, name: "Research", description: "Hand-written description", template_content: nil, icon: nil) + + result = described_class.call + + research = CoPlan::PlanType.find_by_name("Research") + expect(result.updated).to include("Research") + expect(research.description).to eq("Hand-written description") + expect(research.template_content).to include("## Findings") + expect(research.icon).to eq("flask") + end + + it "matches existing types case-insensitively" do + create(:plan_type, name: "handoff", description: "custom", template_content: nil) + + result = described_class.call + + expect(result.created).not_to include("Handoff") + expect(CoPlan::PlanType.find_by_name("Handoff").description).to eq("custom") + end + + it "overwrites edited fields with force" do + create(:plan_type, name: "Research", description: "Hand-written description", template_content: "custom template") + + described_class.call(force: true) + + research = CoPlan::PlanType.find_by_name("Research") + expect(research.description).not_to eq("Hand-written description") + expect(research.template_content).to include("## Findings") + end + + it "restores blank shipped values with force" do + # Scratchpad deliberately ships without a template; force means "back + # to the shipped defaults", so a custom template must be cleared too. + create(:plan_type, name: "Scratchpad", template_content: "custom template", default_tags: ["wip"]) + + described_class.call(force: true) + + scratchpad = CoPlan::PlanType.find_by_name("Scratchpad") + expect(scratchpad.template_content).to be_nil + expect(scratchpad.default_tags).to eq([]) + end + + it "never touches types the defaults don't know about" do + custom = create(:plan_type, name: "Marketing Pitch", description: "Sell it", template_content: "persuade") + + described_class.call(force: true) + + expect(custom.reload).to have_attributes(description: "Sell it", template_content: "persuade") + end + end + + describe "parsing" do + it "raises on a defaults file without front matter" do + Dir.mktmpdir do |dir| + File.write(File.join(dir, "broken.md"), "no front matter here") + + expect { described_class.call(dir: dir) }.to raise_error(ArgumentError, /front matter/) + end + end + end +end