Skip to content

Commit fec46ef

Browse files
HamptonMakesclaude
andauthored
Teach agents to file, type, and template new plans (#182)
* Teach agents to file, type, and template new plans Agents were creating plans unfiled, untyped, and unstructured — because nothing let or told them do otherwise: - POST /api/v1/plans now accepts folder_path/folder_id (filed via Plans::Place in the create transaction) and tags; the plan type's default_tags are applied automatically. - New GET /api/v1/plan_types returns each type with its description, default_tags, and template_content — previously templates were admin-only and applied nowhere. - /agent-instructions: Create Plan now opens with a three-step pre-flight (pick the folder from your library, pick the most specific type, structure content against its template), the example creates filed+typed in one call using a real configured type, and General is reframed as the fallback of last resort. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review: defer plan_created past commit, escape example payload - Plans::Create now emits plan_created via ActiveRecord.after_all_transactions_commit, so a caller wrapping creation in a larger transaction (the API's create-and-file) can't roll back the plan and still leak the analytics event. - The Create Plan curl example is JSON-serialized and shell-escaped in the controller instead of hand-interpolating the admin-controlled type name into quoted JSON. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 2ac2775 commit fec46ef

9 files changed

Lines changed: 270 additions & 15 deletions

File tree

engine/app/controllers/coplan/agent_instructions_controller.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def show
3333
# carries the request's SCRIPT_NAME.
3434
@base = "#{request.base_url}#{root_path.chomp("/")}"
3535
@plan_types = PlanType.order(:name)
36+
@create_example_json = create_example_json
3637

3738
if prefers_html?
3839
# The page is public, but signed-in visitors should still see their
@@ -62,6 +63,23 @@ def organizing
6263

6364
private
6465

66+
# The Create Plan curl example, with a real configured plan type so
67+
# agents copy an instance-accurate command. Names are admin-controlled
68+
# free text, so the payload is JSON-serialized (never hand-interpolated)
69+
# and single quotes are escaped for the surrounding shell quoting.
70+
def create_example_json
71+
example_type = @plan_types.reject { |t| t.name.casecmp?(PlanType::GENERAL_NAME) }.first
72+
JSON.generate(
73+
{
74+
title: "My Plan",
75+
content: "# My Plan\n\nContent following the type template.",
76+
plan_type: example_type&.name || "general",
77+
folder_path: "Team EBT/Q3"
78+
},
79+
space: " "
80+
).gsub("'", "'\\\\''")
81+
end
82+
6583
def prefers_html?
6684
return true if params[:format] == "html"
6785
return false if params[:format].present?
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
module CoPlan
2+
module Api
3+
module V1
4+
# Read-only catalog of plan types. Agents fetch this before creating a
5+
# plan to pick the most specific type and read its template — the
6+
# template ships here in full because the whole point is that the
7+
# agent structures its draft against it before writing any content.
8+
class PlanTypesController < BaseController
9+
def index
10+
types = PlanType.order(:name)
11+
render json: types.map { |pt|
12+
{
13+
id: pt.id,
14+
name: pt.name,
15+
description: pt.description,
16+
default_tags: pt.default_tags,
17+
template_content: pt.template_content
18+
}
19+
}
20+
end
21+
end
22+
end
23+
end
24+
end

engine/app/controllers/coplan/api/v1/plans_controller.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,27 @@ def create
6464
api_token_id: api_token_id
6565
)
6666

67+
# Filing happens in the same transaction as creation so a bad
68+
# folder param never leaves behind an unfiled plan (or, via
69+
# folder_path, orphaned folders) for a create that failed.
70+
if params.key?(:folder_id) || params.key?(:folder_path)
71+
folder = resolve_folder_params
72+
raise ActiveRecord::Rollback if performed? # resolve rendered an error
73+
if folder
74+
result = Plans::Place.call(plan: plan, folder: folder, actor: current_user, actor_type: api_author_type, agent_name: api_agent_name, api_token_id: api_token_id)
75+
unless result.success?
76+
render json: { error: result.error }, status: :unprocessable_content
77+
raise ActiveRecord::Rollback
78+
end
79+
end
80+
end
81+
82+
# The plan's type contributes its default_tags; explicit tags in
83+
# the request are added on top. plan.plan_type (not the resolved
84+
# param) so the General fallback's defaults apply too.
85+
tags = plan.plan_type&.default_tags.to_a | Array(params[:tags]).map(&:to_s)
86+
plan.tag_names = tags if tags.any?
87+
6788
if params[:references].is_a?(Array)
6889
params[:references].each do |ref_params|
6990
next unless ref_params[:url].present?
@@ -74,6 +95,7 @@ def create
7495
end
7596
end
7697
end
98+
return if performed? # folder error rendered inside the transaction
7799

78100
render json: plan_json(plan).merge(
79101
current_content: plan.current_content,

engine/app/services/coplan/plans/create.rb

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,20 @@ def call
4444
plan
4545
end
4646

47-
CoPlan::Analytics.track(
48-
"plan_created",
49-
user: @user,
50-
plan_id: plan.id,
51-
plan_type_id: plan.plan_type_id,
52-
visibility: plan.visibility,
53-
content_length: @content.to_s.length
54-
)
47+
# Deferred: callers may wrap creation in a larger transaction (the
48+
# API's create-and-file does), and a rollback there must not leave
49+
# behind an analytics event for a plan that never existed. Outside
50+
# any transaction this runs immediately.
51+
ActiveRecord.after_all_transactions_commit do
52+
CoPlan::Analytics.track(
53+
"plan_created",
54+
user: @user,
55+
plan_id: plan.id,
56+
plan_type_id: plan.plan_type_id,
57+
visibility: plan.visibility,
58+
content_length: @content.to_s.length
59+
)
60+
end
5561

5662
plan
5763
end

engine/app/views/coplan/agent_instructions/show.text.erb

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,28 @@ Returns: plan metadata, `current_content`, `current_revision`, `comment_threads`
7070

7171
### Create Plan
7272

73+
Creating a plan is a deliberate act of publishing into a shared, organized library — not dumping a file. **Before the POST, do three cheap checks** (skip any step whose answer you already have from this session):
74+
75+
1. **Pick the folder.** `GET <%= @base %>/api/v1/library` shows your folder tree with each folder's `description`. Choose the folder whose description matches this document; pass it as `folder_path` on create. A plan created without one lands unfiled — someone has to clean up after you.
76+
2. **Pick the type.** `GET <%= @base %>/api/v1/plan_types` (or the [Plan Types](#plan-types) table below) lists every type with its description. Choose the **most specific** type that fits. **General is the fallback of last resort** — reaching for it without reading the list is almost always wrong.
77+
3. **Follow the type's template.** The `plan_types` response includes each type's `template_content` — the document structure readers of that type expect. Structure your content against it: keep its sections (drop one only when it's genuinely inapplicable), fill them with real content rather than placeholder text.
78+
79+
Then create everything in one call:
80+
7381
```bash
7482
<%= @curl %> -X POST \
7583
-H "Content-Type: application/json" \
76-
-d '{"title": "My Plan", "content": "# My Plan\n\nContent here.", "plan_type": "general"}' \
84+
-d '<%= raw @create_example_json %>' \
7785
"<%= @base %>/api/v1/plans" | jq .
7886
```
7987

80-
Optional fields: `plan_type` (string) — the name of a plan type to use; every plan has a type, so omitting this files the plan under the **General** catch-all (see [Plan Types](#plan-types) below); `visibility` (string) — plans are **shared with the whole org by default**; `"draft"` (shown as "private" in the UI) exists as a rare escape hatch, not a normal step (see [Visibility &amp; Archiving](#visibility--archiving)).
88+
Optional fields:
89+
90+
- `plan_type` (string) — the name of a plan type (step 2 above). Omitting it files the plan under **General**, which should be a considered choice, not a default. The plan type's `default_tags` are applied to the plan automatically.
91+
- `folder_path` (string) or `folder_id` (string) — where to file the plan in your library (step 1 above). `folder_path` finds or creates the hierarchy (e.g. `"Team EBT/Q3"`). See [Libraries &amp; Folders](#libraries--folders).
92+
- `tags` (array of strings) — added on top of the type's `default_tags`. See [Tags](#tags).
93+
- `visibility` (string) — plans are **shared with the whole org by default**; `"draft"` (shown as "private" in the UI) exists as a rare escape hatch, not a normal step (see [Visibility &amp; Archiving](#visibility--archiving)).
94+
- `references` (array) — see [References](#references).
8195

8296
#### Diagrams
8397

@@ -184,7 +198,7 @@ Each folder includes `id`, `name`, `library_id`, `parent_id`, `path` (e.g. `"Tea
184198

185199
`parent_id` is optional — omit it for a top-level folder. Rename with `PATCH /api/v1/folders/:id` (`{"name": "..."}`); delete with `DELETE /api/v1/folders/:id` (only empty folders, only in your own library).
186200

187-
**Shelve a plan in a folder:**
201+
**File a plan at creation** (preferred — pass `folder_path` on `POST /api/v1/plans`, see [Create Plan](#create-plan)) so plans never sit unfiled. To move or file an existing plan:
188202

189203
```bash
190204
<%= @curl %> -X PATCH \
@@ -230,15 +244,27 @@ The API also accepts the legacy `status` field (`brainstorm`/`considering`/`deve
230244

231245
### Plan Types
232246

233-
Plan types categorize plans and provide default tags. Every plan has exactly one type. When creating a plan, pass `plan_type` to pick one — plans created without an explicit type get **General**.
247+
Plan types categorize plans, apply default tags, and carry a **content template** — the document structure readers of that type expect. Every plan has exactly one type.
248+
249+
```bash
250+
<%= @curl %> \
251+
"<%= @base %>/api/v1/plan_types" | jq .
252+
```
253+
254+
Returns every type with `name`, `description`, `default_tags`, and `template_content`.
255+
256+
**Guidelines:**
257+
- Pick the **most specific** type that fits before creating a plan. Plans created without an explicit type get **General** — acceptable only when you've reviewed the list and nothing fits.
258+
- **Read the type's `template_content` and structure your document against it** — keep its sections, fill them with substance, drop a section only when it's genuinely inapplicable. The template is the type's contract with its readers.
259+
- The type's `default_tags` are applied automatically on create; add your own on top with `tags`.
234260
<% if @plan_types.any? %>
235261

236262
**Available plan types:**
237263

238-
| Name | Description |
239-
|------|-------------|
264+
| Name | Description | Template |
265+
|------|-------------|----------|
240266
<% @plan_types.each do |pt| %>
241-
| `<%= pt.name %>` | <%= pt.description.present? ? pt.description : "—" %> |
267+
| `<%= pt.name %>` | <%= pt.description.present? ? pt.description : "—" %> | <%= pt.template_content.present? ? "yes — fetch and follow it" : "—" %> |
242268
<% end %>
243269
<% else %>
244270

@@ -569,6 +595,12 @@ For approved changes, the recommended path is: read the snapshot → edit the ma
569595

570596
## Typical Workflow
571597

598+
### Creating a plan
599+
600+
1. **Pick the folder**: `GET /api/v1/library` — match the new document to a folder's description
601+
2. **Pick the type and read its template**: `GET /api/v1/plan_types` — most specific type wins; structure your draft against its `template_content`
602+
3. **Create filed and typed**: `POST /api/v1/plans` with `{title, content, plan_type, folder_path}`
603+
572604
### Recommended (full content replacement)
573605

574606
1. **Read** the plan: `GET /api/v1/plans/:id/snapshot`

engine/config/routes.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@
5353
namespace :api do
5454
namespace :v1 do
5555
resources :tags, only: [:index]
56+
# Plan-type catalog (with templates) — agents read this before
57+
# creating a plan; see the Create Plan section of /agent-instructions.
58+
resources :plan_types, only: [:index]
5659
resources :folders, only: [:index, :create, :update, :destroy]
5760

5861
# The agent organization API: overview (show), bulk read (contents),

spec/requests/agent_instructions_spec.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,48 @@
4242
expect(response.body).to include('"plan_type"')
4343
end
4444

45+
it "walks agents through folder, type, and template before creating" do
46+
get agent_instructions_path
47+
48+
expect(response.body).to include("**Pick the folder.**")
49+
expect(response.body).to include("**Pick the type.**")
50+
expect(response.body).to include("template_content")
51+
expect(response.body).to include("/api/v1/plan_types")
52+
expect(response.body).to include('"folder_path"')
53+
expect(response.body).to include("fallback of last resort")
54+
end
55+
56+
it "uses a real configured type (not General) in the create example" do
57+
create(:plan_type, name: "General", description: "Catch-all")
58+
create(:plan_type, name: "Design Doc", description: "For design documents")
59+
60+
get agent_instructions_path
61+
62+
expect(response.body).to include('"plan_type": "Design Doc"')
63+
end
64+
65+
# Type names are admin-controlled free text; the example must survive a
66+
# name that would break JSON quoting or the surrounding shell quoting.
67+
it "keeps the create example valid for hostile plan type names" do
68+
create(:plan_type, name: %q(Bob's "Special" Doc))
69+
70+
get agent_instructions_path
71+
72+
# JSON-escaped double quotes, shell-escaped single quote.
73+
expect(response.body).to include('\"Special\"')
74+
expect(response.body).to include(%q(Bob'\''s))
75+
end
76+
77+
it "marks which plan types carry a template" do
78+
create(:plan_type, name: "RFC", template_content: "# RFC")
79+
create(:plan_type, name: "Bare", template_content: nil)
80+
81+
get agent_instructions_path
82+
83+
expect(response.body).to match(/`RFC`.*yes — fetch and follow it/)
84+
expect(response.body).to match(/`Bare`.*\| —/)
85+
end
86+
4587
it "distinguishes citations, internal section links, and structured references" do
4688
get agent_instructions_path
4789

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
require "rails_helper"
2+
3+
RSpec.describe "Api::V1::PlanTypes", type: :request do
4+
let(:alice) { create(:coplan_user, :admin) }
5+
let(:alice_token) { create(:api_token, user: alice, raw_token: "test-token-alice") }
6+
let(:headers) { { "Authorization" => "Bearer test-token-alice" } }
7+
8+
before do
9+
alice_token # ensure token exists
10+
end
11+
12+
it "requires auth" do
13+
get api_v1_plan_types_path
14+
expect(response).to have_http_status(:unauthorized)
15+
end
16+
17+
it "returns every plan type with its template and default tags, sorted by name" do
18+
create(:plan_type, name: "RFC", description: "Request for comments", default_tags: ["rfc"], template_content: "# RFC\n\n## Problem\n\n## Proposal")
19+
create(:plan_type, name: "Design Doc", description: "For design documents")
20+
21+
get api_v1_plan_types_path, headers: headers
22+
23+
expect(response).to have_http_status(:success)
24+
types = JSON.parse(response.body)
25+
expect(types.map { |t| t["name"] }).to eq(["Design Doc", "RFC"])
26+
27+
rfc = types.last
28+
expect(rfc["description"]).to eq("Request for comments")
29+
expect(rfc["default_tags"]).to eq(["rfc"])
30+
expect(rfc["template_content"]).to include("## Proposal")
31+
end
32+
end

spec/requests/api/v1/plans_spec.rb

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,82 @@
118118
expect(response).to have_http_status(:unprocessable_content)
119119
end
120120

121+
describe "filing on create" do
122+
it "files the plan via folder_path, creating the hierarchy in the caller's library" do
123+
post api_v1_plans_path, params: { title: "Filed Plan", content: "# Filed", folder_path: "Team EBT/Q3" }, headers: headers, as: :json
124+
expect(response).to have_http_status(:created)
125+
body = JSON.parse(response.body)
126+
expect(body["folder_path"]).to eq("Team EBT/Q3")
127+
128+
placement = alice.library.placements.find_by(plan_id: body.fetch("id"))
129+
expect(placement.folder.path).to eq("Team EBT/Q3")
130+
expect(alice.library.folders.count).to eq(2)
131+
end
132+
133+
it "files the plan via folder_id" do
134+
folder = create(:folder, name: "Infra", created_by_user: alice)
135+
post api_v1_plans_path, params: { title: "Filed Plan", content: "# Filed", folder_id: folder.id }, headers: headers, as: :json
136+
expect(response).to have_http_status(:created)
137+
expect(JSON.parse(response.body)["folder_id"]).to eq(folder.id)
138+
end
139+
140+
it "records the filing in the library audit log with agent attribution" do
141+
post api_v1_plans_path, params: { title: "Filed Plan", content: "# Filed", folder_path: "Infra", agent_name: "Claude" }, headers: headers, as: :json
142+
expect(response).to have_http_status(:created)
143+
144+
event = alice.library.library_events.find_by(event_type: "plan_filed")
145+
expect(event).to be_present
146+
expect(event.actor_type).to eq("local_agent")
147+
expect(event.agent_name).to eq("Claude")
148+
end
149+
150+
it "rolls back the whole create when the folder_id is unknown" do
151+
expect {
152+
post api_v1_plans_path, params: { title: "Doomed Plan", content: "# Doomed", folder_id: "nope" }, headers: headers, as: :json
153+
}.not_to change(CoPlan::Plan, :count)
154+
expect(response).to have_http_status(:unprocessable_content)
155+
expect(JSON.parse(response.body)["error"]).to include("Unknown folder_id")
156+
end
157+
158+
it "does not emit a plan_created analytics event for a rolled-back create" do
159+
events = capture_analytics_events do
160+
post api_v1_plans_path, params: { title: "Doomed Plan", content: "# Doomed", folder_id: "nope" }, headers: headers, as: :json
161+
end
162+
expect(response).to have_http_status(:unprocessable_content)
163+
expect(events.select { |name, _| name == "plan_created" }).to be_empty
164+
end
165+
166+
it "emits plan_created exactly once for a successful filed create" do
167+
events = capture_analytics_events do
168+
post api_v1_plans_path, params: { title: "Filed Plan", content: "# Filed", folder_path: "Infra" }, headers: headers, as: :json
169+
end
170+
expect(response).to have_http_status(:created)
171+
expect(events.select { |name, _| name == "plan_created" }.length).to eq(1)
172+
end
173+
end
174+
175+
describe "tags on create" do
176+
it "applies the plan type's default_tags" do
177+
create(:plan_type, name: "design-doc", default_tags: ["design", "architecture"])
178+
post api_v1_plans_path, params: { title: "Tagged Plan", content: "# Tagged", plan_type: "design-doc" }, headers: headers, as: :json
179+
expect(response).to have_http_status(:created)
180+
expect(JSON.parse(response.body)["tags"]).to match_array(["design", "architecture"])
181+
end
182+
183+
it "merges explicit tags with the type's default_tags" do
184+
create(:plan_type, name: "design-doc", default_tags: ["design"])
185+
post api_v1_plans_path, params: { title: "Tagged Plan", content: "# Tagged", plan_type: "design-doc", tags: ["pricing", "design"] }, headers: headers, as: :json
186+
expect(response).to have_http_status(:created)
187+
expect(JSON.parse(response.body)["tags"]).to match_array(["design", "pricing"])
188+
end
189+
190+
it "accepts explicit tags without a plan_type" do
191+
post api_v1_plans_path, params: { title: "Tagged Plan", content: "# Tagged", tags: ["pricing"] }, headers: headers, as: :json
192+
expect(response).to have_http_status(:created)
193+
expect(JSON.parse(response.body)["tags"]).to eq(["pricing"])
194+
end
195+
end
196+
121197
describe "PATCH /api/v1/plans/:id" do
122198
it "updates plan title" do
123199
patch api_v1_plan_path(plan), params: { title: "New Title" }, headers: headers, as: :json

0 commit comments

Comments
 (0)