From 27a8ff49d1e720def95fde0b38affaf35e3db514 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Wed, 19 Aug 2026 12:44:17 -0500 Subject: [PATCH 1/2] Ship default plan types with templates, installable via rake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven default plan types as markdown files (YAML front matter + template body) under engine/db/default_plan_types/: Engineering Design, Exploration, PRD, Project 1-Pager, Research, Technical Documentation, Implementation Plan, Test Plan, Handoff, Scratchpad, General. Each description names the audience and moment of reading; templates carry per-section guidance as HTML comments (invisible on render, visible to agents reading template_content). Scratchpad and General deliberately ship without templates; no default_tags are set (tags stay orthogonal to types). PlanTypes::InstallDefaults creates missing types and fills blank fields on existing ones — a host's hand-edited descriptions and templates are never overwritten unless force. Exposed as `rails coplan:plan_types:install_defaults` (FORCE=1 to overwrite) and wired into the engine seed, so fresh installs get the full set. Co-Authored-By: Claude Fable 5 --- .../coplan/plan_types/install_defaults.rb | 84 ++++++++++++++++++ .../default_plan_types/engineering-design.md | 52 ++++++++++++ engine/db/default_plan_types/exploration.md | 29 +++++++ engine/db/default_plan_types/general.md | 7 ++ engine/db/default_plan_types/handoff.md | 38 +++++++++ .../default_plan_types/implementation-plan.md | 37 ++++++++ engine/db/default_plan_types/prd.md | 35 ++++++++ .../db/default_plan_types/project-1-pager.md | 36 ++++++++ engine/db/default_plan_types/research.md | 38 +++++++++ engine/db/default_plan_types/scratchpad.md | 8 ++ .../technical-documentation.md | 27 ++++++ engine/db/default_plan_types/test-plan.md | 34 ++++++++ engine/db/seeds.rb | 22 ++--- engine/lib/tasks/coplan_plan_types.rake | 13 +++ spec/lib/engine_seed_spec.rb | 18 ++-- .../plan_types/install_defaults_spec.rb | 85 +++++++++++++++++++ 16 files changed, 544 insertions(+), 19 deletions(-) create mode 100644 engine/app/services/coplan/plan_types/install_defaults.rb create mode 100644 engine/db/default_plan_types/engineering-design.md create mode 100644 engine/db/default_plan_types/exploration.md create mode 100644 engine/db/default_plan_types/general.md create mode 100644 engine/db/default_plan_types/handoff.md create mode 100644 engine/db/default_plan_types/implementation-plan.md create mode 100644 engine/db/default_plan_types/prd.md create mode 100644 engine/db/default_plan_types/project-1-pager.md create mode 100644 engine/db/default_plan_types/research.md create mode 100644 engine/db/default_plan_types/scratchpad.md create mode 100644 engine/db/default_plan_types/technical-documentation.md create mode 100644 engine/db/default_plan_types/test-plan.md create mode 100644 engine/lib/tasks/coplan_plan_types.rake create mode 100644 spec/services/plan_types/install_defaults_spec.rb 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..b44ac9a --- /dev/null +++ b/engine/app/services/coplan/plan_types/install_defaults.rb @@ -0,0 +1,84 @@ +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. 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. + def apply(type, attrs) + attrs.except(:name).each do |field, value| + next if value.blank? + 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..ebe9c09 --- /dev/null +++ b/spec/services/plan_types/install_defaults_spec.rb @@ -0,0 +1,85 @@ +require "rails_helper" + +RSpec.describe CoPlan::PlanTypes::InstallDefaults do + 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 "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 From 9a33a2ea19962e14cf25eb51318d4e3541e6da62 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Wed, 19 Aug 2026 16:43:51 -0500 Subject: [PATCH 2/2] Address review: deterministic installer specs, force restores blank shipped values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installer specs assumed an empty coplan_plan_types table, but the PG CI job seeds before rspec and a data migration installs General — start from a clean table like engine_seed_spec does. FORCE=1 now means "back to the shipped defaults" on every field: a custom template or default_tags on a type that ships without them (Scratchpad, General) is cleared instead of surviving the overwrite. Co-Authored-By: Claude Fable 5 --- .../coplan/plan_types/install_defaults.rb | 10 ++++++---- .../plan_types/install_defaults_spec.rb | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/engine/app/services/coplan/plan_types/install_defaults.rb b/engine/app/services/coplan/plan_types/install_defaults.rb index b44ac9a..31aaacd 100644 --- a/engine/app/services/coplan/plan_types/install_defaults.rb +++ b/engine/app/services/coplan/plan_types/install_defaults.rb @@ -9,8 +9,9 @@ module PlanTypes # 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. Types unknown to the defaults are never - # touched either way. + # 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 @@ -66,10 +67,11 @@ def parse(path) end # Assigns default values onto an existing type; returns whether - # anything changed. Only blank fields are filled unless forcing. + # 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? + next if value.blank? && !@force next unless @force || type[field].blank? type[field] = value diff --git a/spec/services/plan_types/install_defaults_spec.rb b/spec/services/plan_types/install_defaults_spec.rb index ebe9c09..fcfeb23 100644 --- a/spec/services/plan_types/install_defaults_spec.rb +++ b/spec/services/plan_types/install_defaults_spec.rb @@ -1,6 +1,12 @@ 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 @@ -64,6 +70,18 @@ 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")