From 7c67f827a21fd187778c2a444eca975802537408 Mon Sep 17 00:00:00 2001
From: Fotis Stamatelopoulos
Date: Fri, 8 May 2026 18:43:59 -0700
Subject: [PATCH 1/8] =?UTF-8?q?feat(6.8):=20role-template=20management=20U?=
=?UTF-8?q?I=20=E2=80=94=20versioning=20+=20promote-to-production?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New top-level "Agents" tab in the web UI for editing the instruction
templates each agent role reads. Each role gets:
- A bundled cf² default (always selectable, read-only, never deletable).
- Any number of saved versions stored under
`~/.cfcf/templates-managed//{manifest.json, v_.md}`.
- A "Promote to production" action that writes the selected version's
content to `~/.cfcf/templates/` — the existing user-global
override path that `getTemplate()` already reads. Reverting to default
deletes the override file so cf² falls back to the embedded template.
No runtime changes to agent spawning.
Decoupled from the prior "blocked on 6.11/ADLC" framing: the user's
vision (edit + version + promote) operates at per-role-template
granularity — whatever role abstraction ADLC eventually settles on
can layer on top of this without rework.
**Shipped (round 1)**
- `packages/core/src/role-templates.ts` — versioning manager.
list / get / save / update / delete / promote, manifest self-heal
on corruption, orphan detection, automatic revert when deleting the
promoted version, automatic override-file refresh when editing it.
Bundled defaults sourced via a new `getEmbeddedTemplate(name)`
exported from `templates.ts`.
- HTTP routes at `/api/role-templates/*` with uniform error envelope.
- Web UI: new `/agents` route (Header link between Memory and Settings),
tab strip across managed roles, version selector, monospace textarea
editor, action buttons (Edit / Save as new version / Save changes /
Promote / Revert / Delete), inline help block. Deep-linkable via
`/agents?template=`.
**Managed templates (round 1)**: cfcf-architect-instructions.md,
cfcf-judge-instructions.md, cfcf-documenter-instructions.md,
cfcf-reflection-instructions.md, process.md.
**Out of scope (deferred follow-ups)**: dev-role custom-directions
block (Tier-2 from the original 6.8 design — needs the sentinel-merge
insertion-point design first), Product Architect / Help Assistant
system-prompt management (their prompts are programmatically assembled),
per-project override management UI, diff viewer, search-within-content.
**Tests**: 30 unit tests in `role-templates.test.ts` (every flow +
manifest corruption recovery + cross-template isolation), 15 endpoint
tests in `routes/role-templates.test.ts`, 3 new route-parser tests for
the new `/agents` + `?template=` routes. All 834 core+cli+web tests
pass; clean typecheck; web build clean.
Pre-existing `app.test.ts` config-merge failure on main is unrelated
(verified earlier in this iter).
**Design doc**: `docs/design/role-template-management.md`.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
CHANGELOG.md | 44 ++
docs/design/role-template-management.md | 190 +++++++
docs/plan.md | 4 +-
packages/core/src/index.ts | 2 +
packages/core/src/role-templates.test.ts | 304 +++++++++++
packages/core/src/role-templates.ts | 467 ++++++++++++++++
packages/core/src/templates.ts | 14 +
packages/server/src/app.ts | 5 +
.../server/src/routes/role-templates.test.ts | 243 +++++++++
packages/server/src/routes/role-templates.ts | 143 +++++
packages/web/src/App.tsx | 3 +
packages/web/src/api.ts | 81 +++
packages/web/src/components/Header.tsx | 9 +
packages/web/src/hooks/useRoute.test.ts | 23 +
packages/web/src/hooks/useRoute.ts | 12 +-
packages/web/src/pages/AgentTemplates.tsx | 512 ++++++++++++++++++
16 files changed, 2053 insertions(+), 3 deletions(-)
create mode 100644 docs/design/role-template-management.md
create mode 100644 packages/core/src/role-templates.test.ts
create mode 100644 packages/core/src/role-templates.ts
create mode 100644 packages/server/src/routes/role-templates.test.ts
create mode 100644 packages/server/src/routes/role-templates.ts
create mode 100644 packages/web/src/pages/AgentTemplates.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index decdaf6..beda073 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,50 @@ Changes are tracked via git tags. Each release tag corresponds to an entry here.
## [Unreleased]
+### Added — Item 6.8 (round 1): role-template management UI
+
+- **New top-level "Agents" tab** in the web UI (between Memory and
+ Settings). One sub-tab per managed role template; each tab shows
+ the template content in a scrollable monospace editor with version
+ selector, edit / save / promote / revert / delete actions, and an
+ inline help block.
+- **Versioning + promote-to-production**: every role's bundled cf²
+ default is always selectable (read-only, never deletable). Saving
+ edits creates a new labelled version under
+ `~/.cfcf/templates-managed//`. Promoting a version writes its
+ content to the existing `~/.cfcf/templates/` user-global
+ override path that `getTemplate()` already reads — no runtime
+ changes to agent spawning. Reverting to default deletes the override
+ file so cf² falls back to the embedded default. Editing a
+ promoted version refreshes the override file in-place so the
+ change is live for the next agent run.
+- **Managed templates (round 1)**: `cfcf-architect-instructions.md`,
+ `cfcf-judge-instructions.md`, `cfcf-documenter-instructions.md`,
+ `cfcf-reflection-instructions.md`, `process.md`. Per-project
+ overrides at `/cfcf-templates/` continue to take
+ precedence over the user-global override (the power-user escape
+ hatch, unmanaged from the UI).
+- **HTTP API**: `GET /api/role-templates`, `GET .../:name`,
+ `GET .../:name/versions/:versionId`, `POST .../:name/versions`,
+ `PUT .../:name/versions/:versionId`,
+ `DELETE .../:name/versions/:versionId`,
+ `POST .../:name/promote`. Uniform `{ error: string }` envelope.
+- **New core module** `packages/core/src/role-templates.ts` with
+ list / get / save / update / delete / promote helpers, manifest
+ self-heal on corruption, orphan detection. `getEmbeddedTemplate(name)`
+ newly exported from `templates.ts` for read-only access to the
+ bundled default.
+- **Out of scope for round 1 (deferred follow-ups)**: dev-role
+ custom-directions block (Tier-2 from the original design — needs
+ the sentinel-merge insertion-point design first), Product Architect /
+ Help Assistant system-prompt management (their prompts are
+ programmatically assembled, not file-loaded), per-project override
+ management UI, diff viewer between versions.
+- **Tests**: 30 unit tests in `role-templates.test.ts`, 15 endpoint
+ tests in `routes/role-templates.test.ts`, 3 new route-parser tests
+ for `/agents` + `?template=`.
+- **Design doc**: [`docs/design/role-template-management.md`](docs/design/role-template-management.md).
+
### Added — Item 6.33: ollama-models refresh
- **Auto-refresh on server boot.** `cfcf server start` now calls
diff --git a/docs/design/role-template-management.md b/docs/design/role-template-management.md
new file mode 100644
index 0000000..e313fa6
--- /dev/null
+++ b/docs/design/role-template-management.md
@@ -0,0 +1,190 @@
+# Role-template management (item 6.8) — design
+
+## Problem
+
+cf² ships a fixed set of agent-role instruction templates (`cfcf-architect-
+instructions.md`, `cfcf-judge-instructions.md`, `cfcf-documenter-instructions.md`,
+`cfcf-reflection-instructions.md`, plus the workspace process template
+`process.md`). Today users can override any of them by dropping a file into
+`~/.cfcf/templates/` (user-global) or `/cfcf-templates/`
+(project-local). The override is binary — one file wins; there's no way to
+keep multiple drafts, revert easily, or compare against the bundled default.
+
+This design adds a **versioning + promote-to-production layer** on top of
+the existing override mechanism, plus a web UI for editing.
+
+## Vision (from user, 2026-05-08)
+
+- New top-level **"Agents"** tab in the web UI.
+- One sub-tab per role with the template content visible (scrollable,
+ searchable).
+- Edit-and-save flow that creates **versions** rather than overwriting.
+- Selector to view / load any saved version.
+- **Promote-to-production** button — the promoted version is what cf² uses
+ at runtime.
+- The bundled default is always available + promoteable (never deletable).
+
+## Constraints (existing system this needs to fit)
+
+- `getTemplate(name)` resolves: project-local → user-global → embedded
+ ([packages/core/src/templates.ts](../../packages/core/src/templates.ts)).
+- The embedded default is compiled into the cfcf binary via `import ...
+ with { type: "text" }`.
+- Sentinel-merge of ` ... ` (in
+ `CLAUDE.md` / `AGENTS.md`) is dev-agent-specific and lives in
+ `context-assembler.ts` — separate concern from this item.
+
+## Design
+
+### Two layers
+
+1. **Existing**: `~/.cfcf/templates/` is the single override file
+ `getTemplate()` reads. **Unchanged.** No runtime-code touched.
+2. **New**: `~/.cfcf/templates-managed//` is a managed directory of
+ saved versions + a manifest pointer to which one is "promoted". The
+ manager writes the promoted version's content to
+ `~/.cfcf/templates/` (the existing override path) so the agent
+ runners pick it up automatically without any wiring change.
+
+### Storage layout
+
+```
+~/.cfcf/templates-managed/
+ cfcf-architect-instructions.md/
+ manifest.json {
+ currentVersionId: "v_" | "default",
+ versions: [
+ { id, label, savedAt, contentHash }
+ ]
+ }
+ v_.md each saved version's content
+ cfcf-judge-instructions.md/
+ ...
+```
+
+`"default"` is a sentinel value meaning "no override active". When a user
+"promotes default", `~/.cfcf/templates/` is **deleted** so
+`getTemplate` falls through to the embedded default.
+
+### Templates managed
+
+MVP scope:
+- `cfcf-architect-instructions.md`
+- `cfcf-judge-instructions.md`
+- `cfcf-documenter-instructions.md`
+- `cfcf-reflection-instructions.md`
+- `process.md` (workspace process template)
+
+**Out of scope for MVP**:
+- Dev role: no single template file (instructions are programmatically
+ generated by `context-assembler.generateInstructionContent()`). A
+ separate "custom directions" insertion-point design will follow.
+- Product Architect / Help Assistant: their system prompts are
+ programmatically assembled, not single template files.
+- Per-project (workspace-local) override management. The user-global
+ layer is the simpler scope for MVP; project-local stays the manual
+ power-user path (drop a file into `/cfcf-templates/`).
+
+### API
+
+`packages/core/src/role-templates.ts` exports:
+
+```ts
+type TemplateManaged = {
+ name: string; // e.g. "cfcf-judge-instructions.md"
+ displayName: string; // human label e.g. "Judge"
+ defaultContent: string; // bundled default (read-only)
+ currentVersionId: string; // "default" | "v_"
+ currentContent: string; // resolved content currently in effect
+ versions: TemplateVersion[]; // saved user versions
+};
+
+type TemplateVersion = {
+ id: string; // "v_"
+ label: string; // user-supplied
+ savedAt: string; // ISO timestamp
+ contentHash: string; // sha256 short prefix
+};
+
+listManagedTemplates(): Promise
+getManagedTemplate(name): Promise
+saveVersion(name, { label, content }): Promise
+updateVersion(name, versionId, { label?, content? }): Promise
+deleteVersion(name, versionId): Promise
+promoteVersion(name, versionId | "default"): Promise
+getVersionContent(name, versionId | "default"): Promise
+```
+
+### HTTP endpoints
+
+- `GET /api/role-templates` → list of TemplateManaged (sans full content)
+- `GET /api/role-templates/:name` → full TemplateManaged
+- `GET /api/role-templates/:name/versions/:versionId` → { content } (versionId = "default" or "v_")
+- `POST /api/role-templates/:name/versions` → { label, content }
+- `PUT /api/role-templates/:name/versions/:versionId` → { label?, content? }
+- `DELETE /api/role-templates/:name/versions/:versionId` → 204
+- `POST /api/role-templates/:name/promote` → { versionId } (or "default")
+
+### Web UI
+
+Top-level "Agents" route. Page renders left sidebar (role list) + main
+panel (textarea + version selector + actions).
+
+Main panel sections:
+1. **Heading**: role display name + "Currently in production:
)}
From 7a737d517a812ed3acfe660098f5d3963be7da32 Mon Sep 17 00:00:00 2001
From: Fotis Stamatelopoulos
Date: Fri, 8 May 2026 23:17:46 -0700
Subject: [PATCH 5/8] =?UTF-8?q?feat(6.8=20round=202):=20augmented=20type?=
=?UTF-8?q?=20=E2=80=94=20auto-upgrade-friendly=20customisation?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a second customisation mode alongside round-1's full-template
editing. Each saved version now has a type:
- type="full": body REPLACES the bundled default at promote time.
Maximum flexibility (delete sections, restructure);
doesn't auto-pick-up cf² upgrades. UI shows a
"Forked from cf² vX.Y.Z" badge so the user knows
their version may be drifting.
- type="augmented": body is APPENDED to the live bundled default at
promote time AND every server boot. The bundled
default is read live (never duplicated on disk),
so when cf² ships a new default, the user's
extension automatically rides along on the new
version. THIS is the upgrade-friendly mode.
Composition lives in `composeForOverride(name, type, body)`:
- "full" → body verbatim
- "augmented" → getEmbeddedTemplate(name) + AUGMENTATION_SEPARATOR + body
Boot-time auto-recompose: every cfcf server start runs
`refreshAugmentedOverrides()` which, for every managed template whose
promoted version is augmented, re-writes the override file IF the
on-disk content differs from the live composition. Cheap idempotent
pass, single log line on change. Full versions are explicitly NOT
touched (frozen by design — auto-merging would be wrong).
UI changes:
- New "Augment" button next to "Edit" in the action row. Augment
always creates a new augmented version on top of the bundled
default (regardless of which version is currently selected).
- Split editor view for augmented versions: bundled default
rendered read-only at the top (~25vh, smaller for visual emphasis
on the extension) + extension textarea below (~30vh) with a
placeholder showing example custom directions. Editing toggles
only the extension.
- Forked-from-cf²-vX.Y.Z hint below the selector for full versions.
- Version dropdown suffixes each entry with its type.
- "Creating new version" indicator in the heading during
create flows.
Also addresses the round-1 polish requests:
- Status messages moved above the version-selector row.
- Directional words removed from messages ("click Promote" not
"click Promote below").
- Editor textarea is now editable on the bundled-default tab in
edit mode (was read-only in round 1; the user couldn't type to
draft a fork).
Back-compat: round-1 manifests without a `type` field are read as
type="full" automatically. Existing tests pass unchanged.
API extension: POST /api/role-templates/:name/versions accepts an
optional `type` field defaulting to "full". Invalid values return 400.
Tests:
- 41 unit tests in role-templates.test.ts (was 30) — augmented
composition, boot refresh idempotency, full-versions-untouched,
back-compat with round-1 manifests.
- 17 endpoint tests (was 15) — type validation in POST /versions.
- 845 total core+cli+web pass.
Pre-existing app.test.ts config-merge failure on main is unrelated.
Design doc + plan + CHANGELOG updated.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
CHANGELOG.md | 95 +++-
docs/design/role-template-management.md | 44 +-
docs/plan.md | 4 +-
packages/core/src/role-templates.test.ts | 162 ++++++
packages/core/src/role-templates.ts | 185 ++++++-
.../server/src/routes/role-templates.test.ts | 26 +
packages/server/src/routes/role-templates.ts | 11 +-
packages/server/src/start.ts | 24 +
packages/web/src/api.ts | 8 +-
packages/web/src/pages/AgentTemplates.tsx | 506 ++++++++++++++----
10 files changed, 913 insertions(+), 152 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index beda073..3bc46b0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,9 @@ Changes are tracked via git tags. Each release tag corresponds to an entry here.
## [Unreleased]
-### Added — Item 6.8 (round 1): role-template management UI
+### Added — Item 6.8: role-template management UI (rounds 1 + 2)
+
+**Round 1: full-template editing**
- **New top-level "Agents" tab** in the web UI (between Memory and
Settings). One sub-tab per managed role template; each tab shows
@@ -24,34 +26,79 @@ Changes are tracked via git tags. Each release tag corresponds to an entry here.
override path that `getTemplate()` already reads — no runtime
changes to agent spawning. Reverting to default deletes the override
file so cf² falls back to the embedded default. Editing a
- promoted version refreshes the override file in-place so the
- change is live for the next agent run.
-- **Managed templates (round 1)**: `cfcf-architect-instructions.md`,
- `cfcf-judge-instructions.md`, `cfcf-documenter-instructions.md`,
- `cfcf-reflection-instructions.md`, `process.md`. Per-project
- overrides at `/cfcf-templates/` continue to take
- precedence over the user-global override (the power-user escape
- hatch, unmanaged from the UI).
+ promoted version refreshes the override file in-place.
- **HTTP API**: `GET /api/role-templates`, `GET .../:name`,
`GET .../:name/versions/:versionId`, `POST .../:name/versions`,
`PUT .../:name/versions/:versionId`,
`DELETE .../:name/versions/:versionId`,
`POST .../:name/promote`. Uniform `{ error: string }` envelope.
-- **New core module** `packages/core/src/role-templates.ts` with
- list / get / save / update / delete / promote helpers, manifest
- self-heal on corruption, orphan detection. `getEmbeddedTemplate(name)`
- newly exported from `templates.ts` for read-only access to the
- bundled default.
-- **Out of scope for round 1 (deferred follow-ups)**: dev-role
- custom-directions block (Tier-2 from the original design — needs
- the sentinel-merge insertion-point design first), Product Architect /
- Help Assistant system-prompt management (their prompts are
- programmatically assembled, not file-loaded), per-project override
- management UI, diff viewer between versions.
-- **Tests**: 30 unit tests in `role-templates.test.ts`, 15 endpoint
- tests in `routes/role-templates.test.ts`, 3 new route-parser tests
- for `/agents` + `?template=`.
-- **Design doc**: [`docs/design/role-template-management.md`](docs/design/role-template-management.md).
+- **New core module** `packages/core/src/role-templates.ts` with full
+ CRUD, manifest self-heal on corruption, orphan detection.
+ `getEmbeddedTemplate(name)` newly exported from `templates.ts` for
+ read-only access to the bundled default.
+
+**Round 2: augmented-type versions (auto-upgrade-friendly)**
+
+- **`type: "full" | "augmented"`** added to every saved version.
+ Round-1 manifests (no `type`) are read as `"full"` for back-compat.
+ - **`type: "full"`** — body REPLACES the bundled default at
+ promote time. Maximum flexibility (delete sections, restructure);
+ no auto-upgrade. UI shows a `ℹ Forked from cf² vX.Y.Z` badge so
+ the user knows their version may have drifted from the latest
+ bundled default.
+ - **`type: "augmented"`** — body is APPENDED to the live bundled
+ default at promote time. The harness composes
+ ` + separator + ` and writes that to
+ the override file. The bundled default is read live (never
+ duplicated on disk), so when cf² ships a new default, the user's
+ extension automatically rides along on the new version. **This is
+ upgrade-friendly by default.**
+- **Boot-time auto-recompose**: every server boot re-composes any
+ promoted augmented version (`refreshAugmentedOverrides()` in core,
+ called from `start.ts` after the orphan reaper). Cheap idempotent
+ pass — only writes when the on-disk override actually differs from
+ the live composition. Single log line on change:
+ `[server] Re-composed N augmented role-template override(s)…`.
+ Full versions are NOT touched (frozen by design).
+- **New "Augment" button** in the action row, next to "Edit". Augment
+ always creates a new augmented version on top of the bundled
+ default (regardless of which version is currently selected — keeps
+ the upgrade-friendly contract).
+- **Split editor view** for augmented versions: bundled default
+ rendered read-only at the top (~25vh, smaller so the extension
+ editor has visual prominence), extension textarea below (~30vh)
+ with a placeholder showing example custom directions. Editing
+ toggles only the extension to editable.
+- **Version dropdown** suffixes each entry with its type:
+ `My stricter judge — full (2026-05-09) — promoted`.
+- **API extension**: `POST /api/role-templates/:name/versions` body
+ now accepts an optional `type: "full" | "augmented"` (defaults to
+ `"full"`). Invalid values return 400.
+- **UI polish (also round 2)**: status messages moved above the
+ version-selector row (so "click Promote" reads naturally below the
+ buttons), directional words removed from messages, "creating new
+ X version" indicator added to the heading when appropriate.
+
+**Managed templates (rounds 1 + 2)**: `cfcf-architect-instructions.md`,
+`cfcf-judge-instructions.md`, `cfcf-documenter-instructions.md`,
+`cfcf-reflection-instructions.md`, `process.md`. Per-project overrides
+at `/cfcf-templates/` continue to take precedence over the
+user-global override (the power-user escape hatch, unmanaged from the UI).
+
+**Out of scope (deferred)**: dev-role custom-directions block (dev's
+instructions are programmatically generated by `context-assembler`,
+not file-loaded — needs a different mechanism), Product Architect /
+Help Assistant system-prompt management, per-project override
+management UI, diff viewer between versions.
+
+**Tests**: 41 unit tests in `role-templates.test.ts` (every flow +
+manifest corruption recovery + cross-template isolation +
+back-compat with round-1 manifests + augmented composition + boot
+refresh), 17 endpoint tests in `routes/role-templates.test.ts`
+(including round-2 type validation), 3 route-parser tests for
+`/agents` + `?template=`.
+
+**Design doc**: [`docs/design/role-template-management.md`](docs/design/role-template-management.md).
### Added — Item 6.33: ollama-models refresh
diff --git a/docs/design/role-template-management.md b/docs/design/role-template-management.md
index e313fa6..880150e 100644
--- a/docs/design/role-template-management.md
+++ b/docs/design/role-template-management.md
@@ -1,5 +1,8 @@
# Role-template management (item 6.8) — design
+> **Round 1 shipped 2026-05-08** (full-template editing).
+> **Round 2 shipped 2026-05-09** (augmented-type versions for upgrade-friendly customisation).
+
## Problem
cf² ships a fixed set of agent-role instruction templates (`cfcf-architect-
@@ -54,10 +57,11 @@ the existing override mechanism, plus a web UI for editing.
manifest.json {
currentVersionId: "v_" | "default",
versions: [
- { id, label, savedAt, contentHash }
+ { id, label, savedAt, contentHash, type, cfcfVersion }
]
}
- v_.md each saved version's content
+ v_.md body — full template body for type="full",
+ extension only for type="augmented"
cfcf-judge-instructions.md/
...
```
@@ -66,6 +70,42 @@ the existing override mechanism, plus a web UI for editing.
"promotes default", `~/.cfcf/templates/` is **deleted** so
`getTemplate` falls through to the embedded default.
+### Two version types (round 2)
+
+Each version has a `type` field:
+
+| `type` | What's stored on disk | What gets written to the override file at promote time | Auto-upgrade with cf²? |
+|---|---|---|---|
+| `"full"` | The complete template body | The body verbatim | ❌ Frozen — UI shows "Forked from cf² vX.Y.Z" |
+| `"augmented"` | Just the user's extension | ` + separator + ` | ✅ Boot-time recompose picks up the new default |
+
+**Why both**: full edits give maximum flexibility (delete sections, restructure
+the prompt, etc.) but lose the auto-upgrade benefit. Augmented edits are
+upgrade-friendly but constrained to additions on top of cf²'s default. Each
+serves a real use case; the user picks per version.
+
+**Round-1 manifests** (saved before the type field existed) read back as
+`type: "full"` automatically — no migration needed.
+
+### Composition + boot refresh (augmented type)
+
+`composeForOverride(name, type, body)`:
+- `type === "full"`: pass-through.
+- `type === "augmented"`: `getEmbeddedTemplate(name) + AUGMENTATION_SEPARATOR + body`.
+
+`AUGMENTATION_SEPARATOR` is `\n\n---\n\n## Custom additions\n\n*(Managed via the
+cf² Agents tab — edit this section there, not in this file.)*\n\n` so the
+rendered template has a clean visual + textual demarcation.
+
+`refreshAugmentedOverrides()` runs at every server boot. For every managed
+template whose currently-promoted version is augmented, it re-composes against
+the live bundled default and writes to the override file **only if the on-disk
+content differs**. Cheap, idempotent, transparent to the user. This is the
+mechanism that makes cf² upgrades hands-off for augmented versions.
+
+Full versions are explicitly NOT touched by the boot refresh — the user
+fully owns those, and any auto-merge against a new default would be wrong.
+
### Templates managed
MVP scope:
diff --git a/docs/plan.md b/docs/plan.md
index 1f430e1..6cfed9f 100644
--- a/docs/plan.md
+++ b/docs/plan.md
@@ -296,14 +296,14 @@ The tables below are the authoritative view of iteration progress. The **Notes**
5. **Cerefox-parity Clio improvement** — *Shipped in v0.19.0:* FTS title boosting (**6.24**).
6. **Adapter expansion driven by Anthropic harness policy** (**6.28**, surfaced 2026-05-07) — Anthropic's Jan→Apr 2026 clarification ([Cherny 2026-04-04](https://x.com/bcherny/status/1808066717213728812)) ties subscription OAuth tokens to interactive use only; cfcf's unattended iteration loop is the third-party-harness pattern the rule targets. Add ollama detection + three new adapters (`opencode` direct, `claude-code-ollama`, `opencode-ollama`) so unattended roles (dev / judge / reflection / documenter / auto-architect) can route to local ollama-served models via the `ollama launch --model ` exec wrapper (not via env-var proxy — explicit single command). Skills repository (**6.27**) also entered iter-6 as a research/design item on 2026-05-03.
-**Iter-6 active set after the cleanup**: 6.9, 6.11, 6.13, 6.18, 6.27, 6.28, 6.29, 6.30, 6.31, 6.32 (plus 6.19 partial: pre-warm-during-installer). 6.29 + 6.30 + 6.31 + 6.32 surfaced 2026-05-08 during 6.28's dogfood pass — kept in iter-6 to fix-while-debugging rather than defer. **Shipped in v0.18.0**: 6.20, 6.12, 6.26. **Shipped in v0.19.0**: 6.24. **Shipped 2026-05-02 on `feat/structured-pause-actions`**: 6.25. **Shipped in v0.20.0 (2026-05-08)**: 6.28. **Shipped in v0.21.0 (2026-05-08)**: 6.31, 6.30. **Shipped post-v0.21.0 (2026-05-08)**: 6.33, 6.8 (round 1). Items 6.1, 6.2, 6.10, 6.15 dropped to ⏸ (with rationales in their Notes columns); 6.8 marked ❌ but blocked on 6.11. See the status legend at the end of the section for what each icon means.
+**Iter-6 active set after the cleanup**: 6.9, 6.11, 6.13, 6.18, 6.27, 6.28, 6.29, 6.30, 6.31, 6.32 (plus 6.19 partial: pre-warm-during-installer). 6.29 + 6.30 + 6.31 + 6.32 surfaced 2026-05-08 during 6.28's dogfood pass — kept in iter-6 to fix-while-debugging rather than defer. **Shipped in v0.18.0**: 6.20, 6.12, 6.26. **Shipped in v0.19.0**: 6.24. **Shipped 2026-05-02 on `feat/structured-pause-actions`**: 6.25. **Shipped in v0.20.0 (2026-05-08)**: 6.28. **Shipped in v0.21.0 (2026-05-08)**: 6.31, 6.30. **Shipped post-v0.21.0**: 6.33 (2026-05-08), 6.8 round 1 (2026-05-08), 6.8 round 2 (2026-05-09). Items 6.1, 6.2, 6.10, 6.15 dropped to ⏸ (with rationales in their Notes columns); 6.8 marked ❌ but blocked on 6.11. See the status legend at the end of the section for what each icon means.
| # | Status | Title | Notes |
|---|--------|-------|-------|
| 6.1 | ⏸ | Diff viewer per iteration | **Dropped from iter-6 2026-05-02** — superseded by F.13 (rich diff viewer with annotation, in Backlog). The annotation layer is the differentiating value over plain syntax-highlighted diff; building 6.1 first would require redoing the whole surface as part of F.13 later. CLI users today have `git diff main..cfcf/iteration-N`; web users have the History tab + iteration-logs/handoffs/reviews archives. If demand for a richer in-UI diff materialises, F.13 is the canonical record. |
| 6.2 | ⏸ | `cfcf log ` CLI | **Dropped from iter-6 2026-05-02** — subsumed by 6.12 (CLI ↔ web parity audit). Today's web History tab + `cfcf-docs/iteration-history.md` cover read-on-demand needs; manufacturing a dedicated `cfcf log` command before knowing what gap it closes is feature-shaped. Let the parity audit drive scope: if it surfaces a real CLI deficiency, scope a command then. |
| 6.3 | ⏸ | `cfcf push ` CLI | **Dropped 2026-04-20.** Intended to wrap `git push` with project awareness + notification hooks, but (a) post-SUCCESS push is already handled automatically by the iteration loop (`gitManager.push(project.repoPath)` at end of a successful loop), (b) manual pushes are trivially `git push` from inside the repo -- no cfcf state needed, (c) the project-awareness value disappeared when `ProjectConfig.repoUrl` was removed in v0.7.5 (it was the only cfcf-specific piece the command would have consulted). Retaining the row as ⏸ so the decision is preserved; the surrounding CLI ↔ web parity question is still covered by 6.12. |
-| 6.8 | 🔄 | Role-template management UI (full-template versioning + promote-to-production) | **Shipped 2026-05-08, post-v0.21.0 (round 1)**. Design doc: [`docs/design/role-template-management.md`](../design/role-template-management.md). **Decoupled from 6.11**: the 2026-05-02 "blocked on ADLC" framing assumed customisation needed to wait for the role-model to settle. Re-evaluated — the user's vision (concrete UI for editing + saving versions + promote-to-prod, with the cf²-shipped default always available) is independent of ADLC since it operates at the per-role-template granularity. Whatever role abstraction ADLC eventually settles on can layer on top of this without rework. **Shipped scope (round 1)**: (a) `packages/core/src/role-templates.ts` — versioning manager that hooks into the existing user-global override path (`~/.cfcf/templates/`) without touching `getTemplate()`. Saved versions live under `~/.cfcf/templates-managed//{manifest.json, v_.md}`. **Promoting a version** writes its content to the override file; **promoting "default"** deletes it (revert to bundled). Order-insensitive, self-healing manifest (corrupt JSON or missing version files fall back gracefully). Full CRUD: `listManagedTemplates`, `getManagedTemplate`, `getVersionContent`, `saveVersion`, `updateVersion` (refreshes override file when editing the promoted version), `deleteVersion` (auto-reverts if deleting the promoted), `promoteVersion`, `findOrphanedVersions`. New `getEmbeddedTemplate(name)` exported from `templates.ts` for read-only access to the bundled default. (b) HTTP API at `/api/role-templates/*` — list / get / per-version content / create / update / delete / promote, all backed by the core module with consistent `{ error: string }` response envelopes. (c) Web UI: new top-level **"Agents"** tab in the header (between Memory and Settings); `/agents` route with optional `?template=` query for deep-linking; tab strip for the five managed templates; main panel shows version selector + editor (textarea, monospace, scrollable), promote/revert/delete actions, and an inline help block. The bundled default is always selectable but read-only — "Save as new version" forks it. (d) Tests: 30 unit tests (`role-templates.test.ts`) covering every flow + manifest-corruption recovery + cross-template isolation; 15 endpoint tests (`role-templates.test.ts` in server routes); 3 new route-parser tests for `/agents` + `?template=`. Total 808 core+cli pass + clean web build. **Managed templates (round 1)**: `cfcf-architect-instructions.md`, `cfcf-judge-instructions.md`, `cfcf-documenter-instructions.md`, `cfcf-reflection-instructions.md`, `process.md`. **Out of scope for round 1 (carried over)**: dev-role custom-directions block (the original Tier-2 idea — needs the sentinel-merge insertion-point design first; not blocking the rest of the UI), Product Architect / Help Assistant system-prompt management (their prompts are programmatically assembled rather than file-loaded), per-project (workspace-local) override management UI (the file-based path stays the power-user escape hatch), diff viewer between versions, search-within-content (browser Cmd-F suffices on a textarea). **Original two-tier design (preserved for reference, partly applies to deferred dev-role work)** that avoids fork-vs-upgrade pain: *(Tier 1, default for everyone)* cfcf ships current templates and upgrades them in place -- user gets new defaults on `cfcf` upgrade, no migration step. *(Tier 2, opt-in additional-directions block)* each role template gains a documented insertion point; if the user provides `cfcf-templates/custom-directions/.md` (project-local) or `/templates/custom-directions/.md` (user-global), cfcf splices it verbatim into the generated instruction file at that point, **in addition to** the default template. The block is user-owned -- cfcf never touches it on upgrade, so migration pain goes away because the defaults don't fork. Examples: "Documenter: use our internal report format, include X section"; "Dev: every commit message must reference a JIRA ticket ID"; "Architect: always include an accessibility review section". *(Tier 3, power-user fallback)* the existing full-template override path (`cfcf-templates/.md`) remains available, documented as "you own the upgrade path for any template you fork". The shipped UI implements Tier 3 with versioning + promote-to-prod ergonomics on top; Tier 2 (custom-directions block for dev role) deferred to a follow-up branch. |
+| 6.8 | 🔄 | Role-template management UI (rounds 1 + 2: full-template versioning + augmented type for upgrade-friendly customisation) | **Round 1 shipped 2026-05-08, round 2 shipped 2026-05-09, both post-v0.21.0**. **Round 2 highlights (item 6.8 round 2)**: added a `type: "full" \| "augmented"` field to every saved version. **`type="full"`** behaves like round 1 — body REPLACES the bundled default; max flexibility; doesn't auto-pick-up cf² upgrades; UI shows a "Forked from cf² vX.Y.Z" badge so the user knows their version may be drifting. **`type="augmented"`** is the upgrade-friendly mode: body is APPENDED to the live bundled default at promote time + boot time, so when cf² ships a new bundled default, the user's extension automatically rides along on the new version. Composition is ` + AUGMENTATION_SEPARATOR + `; the bundled default is read live (never duplicated on disk). **Boot-time recompose** (`refreshAugmentedOverrides()` in core, called from `start.ts`) rewrites the override file when on-disk content differs from the live composition — cheap, idempotent, transparent. Full versions are explicitly NOT touched by boot refresh (frozen by design). New "Augment" button in the action row creates augmented versions; new split editor view renders the bundled default read-only on top (~25vh, smaller for visual emphasis on the extension below) + extension textarea (~30vh) with placeholder example directions. Version dropdown suffixes each entry with its type. Round-1 manifests without a `type` field read back as `"full"` for back-compat — no migration needed. **Round 1 highlights (preserved)**: Design doc: [`docs/design/role-template-management.md`](../design/role-template-management.md). **Decoupled from 6.11**: the 2026-05-02 "blocked on ADLC" framing assumed customisation needed to wait for the role-model to settle. Re-evaluated — the user's vision (concrete UI for editing + saving versions + promote-to-prod, with the cf²-shipped default always available) is independent of ADLC since it operates at the per-role-template granularity. Whatever role abstraction ADLC eventually settles on can layer on top of this without rework. **Shipped scope (round 1)**: Design doc: [`docs/design/role-template-management.md`](../design/role-template-management.md). **Decoupled from 6.11**: the 2026-05-02 "blocked on ADLC" framing assumed customisation needed to wait for the role-model to settle. Re-evaluated — the user's vision (concrete UI for editing + saving versions + promote-to-prod, with the cf²-shipped default always available) is independent of ADLC since it operates at the per-role-template granularity. Whatever role abstraction ADLC eventually settles on can layer on top of this without rework. **Shipped scope (round 1)**: (a) `packages/core/src/role-templates.ts` — versioning manager that hooks into the existing user-global override path (`~/.cfcf/templates/`) without touching `getTemplate()`. Saved versions live under `~/.cfcf/templates-managed//{manifest.json, v_.md}`. **Promoting a version** writes its content to the override file; **promoting "default"** deletes it (revert to bundled). Order-insensitive, self-healing manifest (corrupt JSON or missing version files fall back gracefully). Full CRUD: `listManagedTemplates`, `getManagedTemplate`, `getVersionContent`, `saveVersion`, `updateVersion` (refreshes override file when editing the promoted version), `deleteVersion` (auto-reverts if deleting the promoted), `promoteVersion`, `findOrphanedVersions`. New `getEmbeddedTemplate(name)` exported from `templates.ts` for read-only access to the bundled default. (b) HTTP API at `/api/role-templates/*` — list / get / per-version content / create / update / delete / promote, all backed by the core module with consistent `{ error: string }` response envelopes. (c) Web UI: new top-level **"Agents"** tab in the header (between Memory and Settings); `/agents` route with optional `?template=` query for deep-linking; tab strip for the five managed templates; main panel shows version selector + editor (textarea, monospace, scrollable), promote/revert/delete actions, and an inline help block. The bundled default is always selectable but read-only — "Save as new version" forks it. (d) Tests: 30 unit tests (`role-templates.test.ts`) covering every flow + manifest-corruption recovery + cross-template isolation; 15 endpoint tests (`role-templates.test.ts` in server routes); 3 new route-parser tests for `/agents` + `?template=`. Total 845 core+cli+web pass + clean web build (round 2: 41 unit + 17 endpoint + 3 route-parser tests, vs round 1's 30 + 15 + 3). **Managed templates (rounds 1 + 2)**: `cfcf-architect-instructions.md`, `cfcf-judge-instructions.md`, `cfcf-documenter-instructions.md`, `cfcf-reflection-instructions.md`, `process.md`. **Out of scope (carried over)**: dev-role custom-directions block (dev's instructions are programmatically generated by `context-assembler.generateInstructionContent()` not loaded as a single template — the augmented type doesn't apply directly; needs a different sentinel-merge insertion-point design), Product Architect / Help Assistant system-prompt management (their prompts are programmatically assembled rather than file-loaded), per-project (workspace-local) override management UI (the file-based path stays the power-user escape hatch), diff viewer between versions, search-within-content (browser Cmd-F suffices on a textarea). **Original two-tier design (preserved for reference, partly applies to deferred dev-role work)** that avoids fork-vs-upgrade pain: *(Tier 1, default for everyone)* cfcf ships current templates and upgrades them in place -- user gets new defaults on `cfcf` upgrade, no migration step. *(Tier 2, opt-in additional-directions block)* each role template gains a documented insertion point; if the user provides `cfcf-templates/custom-directions/.md` (project-local) or `/templates/custom-directions/.md` (user-global), cfcf splices it verbatim into the generated instruction file at that point, **in addition to** the default template. The block is user-owned -- cfcf never touches it on upgrade, so migration pain goes away because the defaults don't fork. Examples: "Documenter: use our internal report format, include X section"; "Dev: every commit message must reference a JIRA ticket ID"; "Architect: always include an accessibility review section". *(Tier 3, power-user fallback)* the existing full-template override path (`cfcf-templates/.md`) remains available, documented as "you own the upgrade path for any template you fork". The shipped UI implements Tier 3 with versioning + promote-to-prod ergonomics on top; Tier 2 (custom-directions block for dev role) deferred to a follow-up branch. |
| 6.9 | ❌ | Rationalise Clio usage across agent roles | **Reframed 2026-05-02** from "Optional Cerefox memory backend" — Clio shipped in 5.7 (local SQLite-backed memory layer with FTS + hybrid search). The new scope: **standardise how each agent role queries and writes Clio**, document the canonical patterns, ensure roles use semantic/hybrid search rather than grepping repo files. The main Clio benefits are (a) cross-workspace persistent memory and (b) efficient retrieval (semantic + FTS) — much more efficient than `grep` over `*.md` in the repo, especially as cfcf-docs and decision-log grow. **Scope**: audit each role's instructions template (dev / judge / architect / reflection / documenter / PA / HA) for current Clio guidance; rewrite to a consistent "search Clio for X before doing Y" pattern; document agent-side Clio patterns in `docs/research/`; possibly extract a shared "Clio cheat sheet" that gets injected into every role's prompt. **Out of scope**: a remote `CerefoxRemote` backend adapter (the original 6.9 item) — that stays optional/future, the `MemoryBackend` interface already exists for it. **Clio Project namespace convention (surfaced 2026-05-02 during 6.12 dogfooding)**: a few projects today have de-facto special meaning — `default` is the catch-all global bucket; `cfcf-memory-pa` is owned by the Product Architect; `cfcf-memory-global` is the cross-workspace shared bucket. Today every web picker (workspace creation, "Change Clio Project", Memory page sidebar) lists ALL projects unfiltered, including these system-managed ones. 6.9 should formalise the convention: pick a naming prefix (e.g. `cfcf-memory-*`) or an explicit registry of "system projects", document who owns each, and decide which projects are user-pickable vs. system-only at the picker site. Once the convention lands, web pickers add a one-line filter; until then, the unfiltered list is fine but users may pick a system project by mistake. **Read-audit gap (surfaced 2026-05-02 during 6.18 round-3 actor-convention work)**: cfcf's audit log captures mutations only (`ingest`, `update`, `delete`, `restore`, `edit-metadata`). Cerefox additionally maintains a `usage_log` for read operations (each `search` / `docs get` records the requestor + query). 6.9 should evaluate whether cfcf needs read-audit parity — likely yes for the same actor-convention reason (so analytics can answer "which roles search Clio most often?", "which queries return zero hits?"). New event type(s) + a new SQL table or a column on the audit table; CLI surface for query (`cfcf clio audit --reads`); also a privacy review (does logging every search query risk capturing sensitive prompt-leakage if the agent quotes problem.md content into a query?). |
| 6.10 | ⏸ | Sandbox / guardrails research + POC | **Dropped from iter-6 2026-05-02** — moved to Backlog F.5 as durable record. Threat model that would justify sandbox engineering (untrusted Problem Pack from a third party? cfcf-as-a-service?) doesn't exist today: users run cfcf on their own machines under their own account; iteration isolation is already provided by git branches; Codex `--dangerously-skip-permissions` + Claude Code's matched mode give the unattended-loop permission profile cfcf needs. If/when a multi-tenant scenario emerges, macOS `sandbox-exec` / Linux `nsjail` / Anthropic's sandbox concept are the right primitives — research with concrete use cases at that point. F.5 in the Backlog carries the trigger note. |
| 6.11 | ❌ | Generalisation research → Agent Development Life Cycle (ADLC) extension | **Iter-6 headline; research-only scope locked 2026-05-02.** Extend cfcf to non-coding iterative work, framed as researching a **parallel/configurable workflow alongside the current SDLC**, hardwired into the harness rather than bolted on. End game: refactor cfcf into a more dynamic, configurable harness where the SDLC is one of N built-in workflows (ADLC, content production, research projects, data analysis, strategy planning). **Research questions**: Is the Problem Pack shape general enough across workflow types? Does the dev / judge / architect / reflection / documenter role split map to non-coding workers, or do roles need to be workflow-specific too? Does the signal schema need workflow-specific shapes (no tests to pass for content production)? What's the "success criteria" surrogate when the artifact is a report rather than passing tests? **Output (iter-6)**: research doc at `docs/research/adlc-extension-design.md` enumerating options, decision points, and a concrete proposal. **Implementation deferred to iter-7+** — committing to a first-implementation stretch in iter-6 would commit to a deadline before the design is settled; if the research lands fast we'll re-evaluate at that point. Likely overlaps with the Cerefox Agent v0.1 vision. Pairs with 6.13 (scheduled execution) for the periodic-content use case. **6.8 (custom directions per role) is blocked on this** — pick up 6.8 once 6.11 settles the role model. |
diff --git a/packages/core/src/role-templates.test.ts b/packages/core/src/role-templates.test.ts
index a7e8192..47e84fe 100644
--- a/packages/core/src/role-templates.test.ts
+++ b/packages/core/src/role-templates.test.ts
@@ -24,7 +24,9 @@ import {
updateVersion,
deleteVersion,
promoteVersion,
+ refreshAugmentedOverrides,
findOrphanedVersions,
+ AUGMENTATION_SEPARATOR,
DEFAULT_VERSION_ID,
} from "./role-templates.js";
import { getTemplate, getEmbeddedTemplate } from "./templates.js";
@@ -105,6 +107,9 @@ describe("saveVersion", () => {
expect(v.label).toBe("stricter judge");
expect(v.savedAt).toMatch(/T/); // ISO
expect(v.contentHash.length).toBe(12);
+ // Round 2: type defaults to "full" + cfcfVersion stamped
+ expect(v.type).toBe("full");
+ expect(typeof v.cfcfVersion).toBe("string");
const managed = await getManagedTemplate(JUDGE);
expect(managed.versions).toHaveLength(1);
@@ -113,6 +118,24 @@ describe("saveVersion", () => {
expect(managed.currentVersionId).toBe("default");
});
+ test("creates an augmented version when type='augmented' is passed", async () => {
+ const v = await saveVersion(JUDGE, {
+ label: "with project hints",
+ content: "Always reference Linear ticket IDs in summaries.",
+ type: "augmented",
+ });
+ expect(v.type).toBe("augmented");
+ // The body file holds ONLY the extension (no bundled default).
+ const stored = await getVersionContent(JUDGE, v.id);
+ expect(stored).toBe("Always reference Linear ticket IDs in summaries.");
+ });
+
+ test("rejects invalid type values", async () => {
+ expect(
+ saveVersion(JUDGE, { label: "x", content: "y", type: "patch" as never }),
+ ).rejects.toThrow();
+ });
+
test("rejects empty labels", async () => {
expect(saveVersion(JUDGE, { label: " ", content: "x" })).rejects.toThrow();
});
@@ -302,3 +325,142 @@ describe("self-heal: stale promoted-version pointer", () => {
expect(managed.currentContent).toBe(getEmbeddedTemplate(JUDGE));
});
});
+
+describe("augmented type — round 2 composition + boot refresh", () => {
+ test("promoting an augmented version writes + separator + to the override", async () => {
+ const ext = "Project-specific: cite Linear ticket in every summary.";
+ const v = await saveVersion(JUDGE, { label: "linear-augmented", content: ext, type: "augmented" });
+ await promoteVersion(JUDGE, v.id);
+
+ const overridePath = join(tmpDir, "templates", JUDGE);
+ expect(existsSync(overridePath)).toBe(true);
+ const onDisk = readFileSync(overridePath, "utf-8");
+ const expected = getEmbeddedTemplate(JUDGE) + AUGMENTATION_SEPARATOR + ext;
+ expect(onDisk).toBe(expected);
+
+ // getTemplate() returns the composed version too.
+ const resolved = await getTemplate(JUDGE);
+ expect(resolved).toBe(expected);
+ });
+
+ test("editing the promoted augmented version recomposes against the live default", async () => {
+ const v = await saveVersion(JUDGE, {
+ label: "v1",
+ content: "ext-v1",
+ type: "augmented",
+ });
+ await promoteVersion(JUDGE, v.id);
+
+ await updateVersion(JUDGE, v.id, { content: "ext-v2" });
+ const onDisk = readFileSync(join(tmpDir, "templates", JUDGE), "utf-8");
+ expect(onDisk).toBe(getEmbeddedTemplate(JUDGE) + AUGMENTATION_SEPARATOR + "ext-v2");
+ });
+
+ test("promoting 'default' over an augmented version deletes the override file", async () => {
+ const v = await saveVersion(JUDGE, { label: "x", content: "ext", type: "augmented" });
+ await promoteVersion(JUDGE, v.id);
+ expect(existsSync(join(tmpDir, "templates", JUDGE))).toBe(true);
+
+ await promoteVersion(JUDGE, DEFAULT_VERSION_ID);
+ expect(existsSync(join(tmpDir, "templates", JUDGE))).toBe(false);
+ });
+
+ test("getVersionContent returns the EXTENSION only for augmented versions (not the composed text)", async () => {
+ const v = await saveVersion(JUDGE, {
+ label: "x",
+ content: "extension only",
+ type: "augmented",
+ });
+ const content = await getVersionContent(JUDGE, v.id);
+ expect(content).toBe("extension only");
+ });
+});
+
+describe("refreshAugmentedOverrides (boot-time refresh)", () => {
+ test("rewrites a stale augmented override (simulating a cf² upgrade where the default changed)", async () => {
+ const ext = "my custom directions";
+ const v = await saveVersion(JUDGE, { label: "x", content: ext, type: "augmented" });
+ await promoteVersion(JUDGE, v.id);
+ // Simulate cf² upgrade: the override on disk reflects the OLD
+ // composed content. We mimic that by clobbering the override with
+ // a stale composition (the bundled default has "drifted" to a new
+ // value the user hasn't seen).
+ const stalePath = join(tmpDir, "templates", JUDGE);
+ writeFileSync(stalePath, "OLD DEFAULT" + AUGMENTATION_SEPARATOR + ext, "utf-8");
+
+ const result = await refreshAugmentedOverrides();
+ expect(result.refreshed).toContain(JUDGE);
+
+ const fresh = readFileSync(stalePath, "utf-8");
+ expect(fresh).toBe(getEmbeddedTemplate(JUDGE) + AUGMENTATION_SEPARATOR + ext);
+ });
+
+ test("no-op when the on-disk content already matches the composed text (idempotent)", async () => {
+ const v = await saveVersion(JUDGE, {
+ label: "x",
+ content: "ext",
+ type: "augmented",
+ });
+ await promoteVersion(JUDGE, v.id);
+
+ const result = await refreshAugmentedOverrides();
+ // Just promoted → already up to date → not refreshed again.
+ expect(result.refreshed).not.toContain(JUDGE);
+ });
+
+ test("does NOT touch full versions (they're frozen by design)", async () => {
+ const v = await saveVersion(JUDGE, { label: "x", content: "FULL CUSTOM", type: "full" });
+ await promoteVersion(JUDGE, v.id);
+ // Even if the bundled default changes, full versions keep their content.
+ // Simulate a stale override (user's hand-edited the file, etc.).
+ const overridePath = join(tmpDir, "templates", JUDGE);
+ writeFileSync(overridePath, "FULL CUSTOM (out of sync but user owns this)", "utf-8");
+
+ const result = await refreshAugmentedOverrides();
+ expect(result.refreshed).not.toContain(JUDGE);
+ // Override file untouched.
+ expect(readFileSync(overridePath, "utf-8")).toBe(
+ "FULL CUSTOM (out of sync but user owns this)",
+ );
+ });
+
+ test("does NOT touch templates whose currentVersionId is 'default'", async () => {
+ // No version saved + promoted → boot refresh is a no-op.
+ const result = await refreshAugmentedOverrides();
+ expect(result.refreshed).toEqual([]);
+ expect(result.errors).toEqual([]);
+ });
+});
+
+describe("back-compat: round-1 manifests without `type`", () => {
+ test("a manifest written by round-1 code (no type field) loads as type='full'", async () => {
+ // Manually scaffold a round-1-shaped manifest.
+ const dir = join(tmpDir, "templates-managed", JUDGE);
+ mkdirSync(dir, { recursive: true });
+ const v_id = "v_legacy";
+ writeFileSync(join(dir, `${v_id}.md`), "legacy body", "utf-8");
+ writeFileSync(
+ join(dir, "manifest.json"),
+ JSON.stringify({
+ currentVersionId: "default",
+ versions: [
+ {
+ id: v_id,
+ label: "legacy",
+ savedAt: "2026-01-01T00:00:00Z",
+ contentHash: "abc",
+ // no type, no cfcfVersion
+ },
+ ],
+ }),
+ "utf-8",
+ );
+
+ const managed = await getManagedTemplate(JUDGE);
+ expect(managed.versions[0].type).toBe("full");
+ // Promoting a back-compat full version keeps current behaviour.
+ await promoteVersion(JUDGE, v_id);
+ const onDisk = readFileSync(join(tmpDir, "templates", JUDGE), "utf-8");
+ expect(onDisk).toBe("legacy body");
+ });
+});
diff --git a/packages/core/src/role-templates.ts b/packages/core/src/role-templates.ts
index e7a8d13..9656bea 100644
--- a/packages/core/src/role-templates.ts
+++ b/packages/core/src/role-templates.ts
@@ -5,21 +5,51 @@
*
* **Design** (full doc: `docs/design/role-template-management.md`):
*
+ * Two version types (item 6.8 round 2):
+ *
+ * 1. **`type: "full"`** — the user's body REPLACES the bundled default
+ * entirely. Maximum flexibility (delete sections, restructure,
+ * anything). Caveat: when cf² ships a new bundled default, the user
+ * doesn't pick it up automatically — their version is frozen until
+ * they manually re-fork. Surfaced in the UI as a "forked from cf²
+ * vX.Y.Z" badge so the user knows their version may be drifting.
+ *
+ * 2. **`type: "augmented"`** — the user's body is APPENDED to the
+ * bundled default with a separator. The bundled default is always
+ * read live (never duplicated on disk), so when cf² upgrades the
+ * default, the user's extension automatically rides along on the
+ * new version — no migration. The harness recomposes
+ * ` + + ` at promote time
+ * AND at every server boot (cheap, idempotent — only writes if the
+ * composed content actually differs from what's on disk). Less
+ * flexibility than full (you can't delete sections from the
+ * default), but upgrade-friendly by default.
+ *
* - The bundled default for each role is read from the EMBEDDED registry
* in `templates.ts` (already shipped). Read-only, never deletable.
* - User-saved versions live under `~/.cfcf/templates-managed//`:
*
* ```
* ~/.cfcf/templates-managed/cfcf-judge-instructions.md/
- * manifest.json { currentVersionId, versions: [{id, label, savedAt, contentHash}] }
- * v_.md content for each saved version
+ * manifest.json {
+ * currentVersionId,
+ * versions: [{ id, label, savedAt, contentHash, type, cfcfVersion }]
+ * }
+ * v_.md content body — full template body for type="full",
+ * extension only for type="augmented"
* ```
*
- * - When the user **promotes a version to production**, the manager writes
- * that version's content to `~/.cfcf/templates/` (the existing
- * override path that `getTemplate()` already reads). No runtime change.
+ * - When the user **promotes a version to production**, the manager
+ * writes a content file to `~/.cfcf/templates/` (the existing
+ * override path that `getTemplate()` already reads). No runtime change
+ * to the agent-spawn pipeline. For `type="augmented"`, the manager
+ * composes ` + + ` before
+ * writing.
* - **Promoting "default"** deletes that override file so `getTemplate()`
* falls through to the bundled default.
+ * - **Boot-time refresh** (`refreshAugmentedOverrides`) re-composes any
+ * promoted augmented version on every server boot, picking up cf²
+ * upgrades to the bundled default transparently.
*
* **Managed templates (MVP)**: the four iteration-role instruction
* templates + the workspace process template. Dev's instructions are
@@ -31,17 +61,50 @@ import { readFile, writeFile, mkdir, readdir, rm } from "fs/promises";
import { existsSync } from "node:fs";
import { join } from "path";
import { createHash, randomBytes } from "crypto";
-import { getConfigDir } from "./constants.js";
+import { getConfigDir, VERSION } from "./constants.js";
import { listTemplates, getEmbeddedTemplate } from "./templates.js";
+// --- Composition constants ---
+
+/**
+ * Separator inserted between the bundled default and the user extension
+ * when composing a promoted augmented version. The h2 heading is meant
+ * to be unambiguous in the rendered template (any agent reading the
+ * file will see it cleanly), and the parenthetical points editors at
+ * the right surface.
+ */
+export const AUGMENTATION_SEPARATOR =
+ "\n\n---\n\n## Custom additions\n\n*(Managed via the cf² Agents tab — edit this section there, not in this file.)*\n\n";
+
// --- Public types ---
+export type TemplateVersionType = "full" | "augmented";
+
export interface TemplateVersion {
id: string;
label: string;
savedAt: string;
/** Short prefix of sha256(content). Display-only. */
contentHash: string;
+ /**
+ * Version type (item 6.8 round 2).
+ * - `"full"`: body replaces the bundled default entirely.
+ * - `"augmented"`: body is appended to the bundled default at promote time.
+ *
+ * Defaults to `"full"` for back-compat with versions saved before
+ * round 2 (manifests written by the round-1 code don't have this
+ * field — we backfill on read).
+ */
+ type: TemplateVersionType;
+ /**
+ * The cf² version that was running when this version was saved.
+ * Used by the UI's "forked from cf² vX.Y.Z" badge on full versions
+ * so the user knows their version may have drifted from the current
+ * bundled default. Augmented versions don't drift (they always
+ * compose against the live default), so the badge is only shown for
+ * `type="full"`. Optional for back-compat.
+ */
+ cfcfVersion?: string;
}
export interface ManagedTemplate {
@@ -142,6 +205,11 @@ async function readManifest(name: string): Promise {
) {
return emptyManifest();
}
+ // Back-compat: round 1 manifests don't have a `type` field on each
+ // version. Fill it in as "full" since that was the only mode shipped.
+ for (const v of parsed.versions) {
+ if (!v.type) v.type = "full";
+ }
return parsed;
} catch {
return emptyManifest();
@@ -279,16 +347,26 @@ export async function getVersionContent(name: string, versionId: string): Promis
/**
* Save a new user version.
+ *
+ * `type` (item 6.8 round 2):
+ * - `"full"` (default): body replaces the bundled default at promote
+ * time. Maximum flexibility; doesn't auto-pick-up cf² upgrades.
+ * - `"augmented"`: body is appended to the live bundled default at
+ * promote/recompose time. Less flexibility; auto-picks-up upgrades.
*/
export async function saveVersion(
name: string,
- opts: { label: string; content: string },
+ opts: { label: string; content: string; type?: TemplateVersionType },
): Promise {
assertManagedTemplate(name);
const label = opts.label.trim();
if (!label) throw new Error("Version label cannot be empty");
const content = opts.content;
if (typeof content !== "string") throw new Error("Version content must be a string");
+ const type: TemplateVersionType = opts.type ?? "full";
+ if (type !== "full" && type !== "augmented") {
+ throw new Error(`Invalid version type: ${type}. Must be "full" or "augmented".`);
+ }
await mkdir(templateDir(name), { recursive: true });
const id = generateVersionId();
@@ -299,6 +377,8 @@ export async function saveVersion(
label,
savedAt: new Date().toISOString(),
contentHash: hashContent(content),
+ type,
+ cfcfVersion: VERSION,
};
const manifest = await readManifest(name);
@@ -340,9 +420,11 @@ export async function updateVersion(
nextHash = hashContent(opts.content);
// If the user edits the currently-promoted version, refresh the
// override file too so runtime picks up the change without a
- // separate re-promote step.
+ // separate re-promote step. For augmented versions, recompose
+ // against the live bundled default.
if (manifest.currentVersionId === versionId) {
- await writeOverrideFile(name, opts.content);
+ const composed = composeForOverride(name, existing.type, opts.content);
+ await writeOverrideFile(name, composed);
}
}
@@ -396,6 +478,10 @@ export async function deleteVersion(name: string, versionId: string): Promise + separator + `. For `type="full"`
+ * versions, the override file is the body verbatim.
*/
export async function promoteVersion(name: string, versionId: string): Promise {
assertManagedTemplate(name);
@@ -407,11 +493,13 @@ export async function promoteVersion(name: string, versionId: string): Promise v.id === versionId)) {
+ const version = manifest.versions.find((v) => v.id === versionId);
+ if (!version) {
throw new Error(`Version not found: ${versionId}`);
}
- const content = await readFile(versionFilePath(name, versionId), "utf-8");
- await writeOverrideFile(name, content);
+ const body = await readFile(versionFilePath(name, versionId), "utf-8");
+ const composed = composeForOverride(name, version.type, body);
+ await writeOverrideFile(name, composed);
}
const manifest = await readManifest(name);
@@ -419,6 +507,79 @@ export async function promoteVersion(name: string, versionId: string): Promise + separator + `.
+ * The bundled default is read live, so cf² upgrades automatically
+ * propagate the next time this function runs (promote OR boot
+ * refresh — see `refreshAugmentedOverrides`).
+ */
+function composeForOverride(name: string, type: TemplateVersionType, body: string): string {
+ if (type === "augmented") {
+ return getEmbeddedTemplate(name) + AUGMENTATION_SEPARATOR + body;
+ }
+ return body;
+}
+
+/**
+ * Boot-time refresh (item 6.8 round 2): for every managed template
+ * whose promoted version is augmented, re-compose ` +
+ * separator + ` and write to the override file if it
+ * differs from what's already there. This is what makes cf²
+ * upgrades transparent for augmented versions — when the bundled
+ * default changes (because a new cf² version is installed), the
+ * boot refresh picks up the new default automatically without any
+ * user action.
+ *
+ * Only writes when content actually differs (cheap idempotent
+ * recompose). Returns the count of templates whose override was
+ * rewritten + any errors. Best-effort: per-template failures don't
+ * stop the loop.
+ *
+ * Full versions are NOT touched — they're frozen by design (the user
+ * has fully replaced the template; we have no safe way to merge
+ * upgrades).
+ */
+export async function refreshAugmentedOverrides(): Promise<{
+ refreshed: string[];
+ errors: Array<{ name: string; error: string }>;
+}> {
+ const refreshed: string[] = [];
+ const errors: Array<{ name: string; error: string }> = [];
+ for (const t of MANAGED_TEMPLATES) {
+ try {
+ const manifest = await readManifest(t.name);
+ const currentId = manifest.currentVersionId;
+ if (currentId === DEFAULT_VERSION_ID) continue;
+ const version = manifest.versions.find((v) => v.id === currentId);
+ if (!version || version.type !== "augmented") continue;
+
+ const body = await readFile(versionFilePath(t.name, currentId), "utf-8");
+ const composed = composeForOverride(t.name, "augmented", body);
+ const overridePath = overrideFilePath(t.name);
+ let onDisk: string | null = null;
+ try {
+ onDisk = await readFile(overridePath, "utf-8");
+ } catch {
+ onDisk = null;
+ }
+ if (onDisk !== composed) {
+ await writeOverrideFile(t.name, composed);
+ refreshed.push(t.name);
+ }
+ } catch (err) {
+ errors.push({
+ name: t.name,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+ return { refreshed, errors };
+}
+
// --- Override-file helpers ---
async function writeOverrideFile(name: string, content: string): Promise {
diff --git a/packages/server/src/routes/role-templates.test.ts b/packages/server/src/routes/role-templates.test.ts
index 5c7ebce..4253146 100644
--- a/packages/server/src/routes/role-templates.test.ts
+++ b/packages/server/src/routes/role-templates.test.ts
@@ -112,6 +112,32 @@ describe("POST /api/role-templates/:name/versions", () => {
});
expect(res.status).toBe(400);
});
+
+ it("creates an augmented version when type='augmented' is supplied (round 2)", async () => {
+ const app = createApp();
+ const res = await app.request(`/api/role-templates/${JUDGE_ENC}/versions`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ label: "augmented test",
+ content: "Append-only directions.",
+ type: "augmented",
+ }),
+ });
+ expect(res.status).toBe(201);
+ const body = await res.json();
+ expect(body.type).toBe("augmented");
+ });
+
+ it("rejects invalid type with 400 (round 2)", async () => {
+ const app = createApp();
+ const res = await app.request(`/api/role-templates/${JUDGE_ENC}/versions`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ label: "x", content: "y", type: "patch" }),
+ });
+ expect(res.status).toBe(400);
+ });
});
describe("GET /api/role-templates/:name/versions/:versionId", () => {
diff --git a/packages/server/src/routes/role-templates.ts b/packages/server/src/routes/role-templates.ts
index 1b48e4b..649b374 100644
--- a/packages/server/src/routes/role-templates.ts
+++ b/packages/server/src/routes/role-templates.ts
@@ -64,7 +64,7 @@ export function registerRoleTemplatesRoutes(app: Hono): void {
app.post("/api/role-templates/:name/versions", async (c) => {
const name = decodeURIComponent(c.req.param("name"));
- let body: { label?: unknown; content?: unknown };
+ let body: { label?: unknown; content?: unknown; type?: unknown };
try {
body = await c.req.json();
} catch {
@@ -73,8 +73,15 @@ export function registerRoleTemplatesRoutes(app: Hono): void {
if (typeof body.label !== "string" || typeof body.content !== "string") {
return c.json({ error: "Body must include `label` and `content` (both strings)" }, 400);
}
+ let type: "full" | "augmented" = "full";
+ if (body.type !== undefined) {
+ if (body.type !== "full" && body.type !== "augmented") {
+ return c.json({ error: "`type` must be 'full' or 'augmented' if provided" }, 400);
+ }
+ type = body.type;
+ }
try {
- const v = await saveVersion(name, { label: body.label, content: body.content });
+ const v = await saveVersion(name, { label: body.label, content: body.content, type });
return c.json(v, 201);
} catch (err) {
return c.json(errorBody(err), 400);
diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts
index 1d58ce8..7ebd33d 100644
--- a/packages/server/src/start.ts
+++ b/packages/server/src/start.ts
@@ -28,6 +28,7 @@ import {
reapOrphans,
formatOrphanLine,
refreshOllamaModelsInConfig,
+ refreshAugmentedOverrides,
} from "@cfcf/core";
import { closeClioBackend } from "./clio-backend.js";
@@ -157,6 +158,29 @@ export async function startServer(port: number): Promise +
+ // separator + `. When cf² is upgraded, the bundled
+ // default may have changed — this pass detects that and rewrites
+ // the override file with the new composition. Best-effort: errors
+ // are logged but never block boot.
+ try {
+ const result = await refreshAugmentedOverrides();
+ if (result.refreshed.length > 0) {
+ console.log(
+ `[server] Re-composed ${result.refreshed.length} augmented role-template override(s) after default change: ${result.refreshed.join(", ")}`,
+ );
+ }
+ for (const e of result.errors) {
+ console.warn(`[server] augmented-template refresh failed for ${e.name}: ${e.error}`);
+ }
+ } catch (err) {
+ console.warn(
+ `[server] augmented-template refresh pass failed (best-effort): ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+
// Refresh ollama models on every boot (item 6.33). The
// `availableOllamaModels` list is captured at `cfcf init` and never
// re-detected by the server itself. If the user pulls a new model
diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts
index 3cfff8f..5724dfa 100644
--- a/packages/web/src/api.ts
+++ b/packages/web/src/api.ts
@@ -338,11 +338,17 @@ export function refreshOllamaModels(): Promise {
// --- Role-template management (item 6.8) ---
+export type RoleTemplateVersionType = "full" | "augmented";
+
export interface RoleTemplateVersion {
id: string;
label: string;
savedAt: string;
contentHash: string;
+ /** Round-2 type. Optional only for back-compat reads of round-1 manifests. */
+ type: RoleTemplateVersionType;
+ /** cf² version when this version was saved (display-only "forked from cf² vX.Y.Z" badge). */
+ cfcfVersion?: string;
}
export interface RoleTemplateSummary {
@@ -377,7 +383,7 @@ export function getRoleTemplateVersionContent(name: string, versionId: string):
export function createRoleTemplateVersion(
name: string,
- body: { label: string; content: string },
+ body: { label: string; content: string; type?: RoleTemplateVersionType },
): Promise {
return request(
`/api/role-templates/${encodeURIComponent(name)}/versions`,
diff --git a/packages/web/src/pages/AgentTemplates.tsx b/packages/web/src/pages/AgentTemplates.tsx
index ee36a01..71953ee 100644
--- a/packages/web/src/pages/AgentTemplates.tsx
+++ b/packages/web/src/pages/AgentTemplates.tsx
@@ -1,19 +1,35 @@
/**
* Agents page (item 6.8): role-template management UI.
*
- * Top-level layout: a tab strip across the top (one tab per managed
- * role template), then the main panel for the selected role with:
- * - Heading + "currently in production" indicator
- * - Version selector dropdown
- * - Editor (textarea) — read-only by default; toggle Edit to make
- * it editable. Save creates a new version with a label.
- * - Promote-to-production / Revert-to-default actions
- * - Per-version delete affordance (disabled for default)
+ * Round 2 (item 6.8 round 2 — augmented type added):
*
- * State model: the currently-selected version is tracked locally
- * (`selectedVersionId`); when the user picks a different version
- * we re-fetch the content. The "promoted" version is whatever the
- * server says — independent of which version the user is viewing.
+ * Two version types coexist:
+ * - "full" → the version body REPLACES the bundled default.
+ * Maximum flexibility; no auto-upgrade.
+ * - "augmented" → the version body is APPENDED to the bundled default
+ * at promote/recompose time. Auto-picks-up cf² upgrades.
+ *
+ * Two creation entry points:
+ * - "Edit" button → enters full-edit mode (single textarea
+ * prefilled with the currently-selected version).
+ * - "Augment" button → enters augmented-edit mode (split view: bundled
+ * default read-only on top, empty extension below).
+ *
+ * Existing-version display:
+ * - Full version → single textarea, the version's body.
+ * - Augmented version → split view: bundled default (read-only) on top,
+ * the version's extension on bottom (read-only or
+ * editable depending on isEditing).
+ *
+ * Save actions vary by editType:
+ * - Editing existing full → Save changes / Save as new full version
+ * - Editing existing augmented → Save changes / Save as new augmented version
+ * - Creating new full → Save as new full version
+ * - Creating new augmented → Save as new augmented version
+ *
+ * Storage + composition + auto-recompose-on-cf²-upgrade live in
+ * `@cfcf/core/role-templates` (`refreshAugmentedOverrides`); this
+ * component is just the UI surface.
*/
import { useEffect, useState } from "react";
@@ -28,6 +44,7 @@ import {
type RoleTemplateSummary,
type RoleTemplateFull,
type RoleTemplateVersion,
+ type RoleTemplateVersionType,
} from "../api";
import { navigateTo } from "../hooks/useRoute";
@@ -43,8 +60,22 @@ export function AgentTemplatesPage({ initialTemplate }: Props) {
const [activeName, setActiveName] = useState(initialTemplate ?? null);
const [template, setTemplate] = useState(null);
const [selectedVersionId, setSelectedVersionId] = useState(DEFAULT_VERSION_ID);
+ /**
+ * Body content shown in the editor.
+ * - For full-type display: the entire template body.
+ * - For augmented-type display: just the user's EXTENSION
+ * (the bundled default is rendered separately above it).
+ */
const [content, setContent] = useState("");
const [isEditing, setIsEditing] = useState(false);
+ /**
+ * What kind of edit/creation flow the user is in. Only meaningful
+ * while `isEditing === true`. Set by the Edit / Augment buttons.
+ * For editing existing versions it mirrors the version's type.
+ */
+ const [editType, setEditType] = useState("full");
+ /** True when the user clicked Augment (creates new augmented from default). */
+ const [creatingNew, setCreatingNew] = useState(false);
const [editingDirty, setEditingDirty] = useState(false);
const [statusMsg, setStatusMsg] = useState(null);
const [error, setError] = useState(null);
@@ -53,8 +84,7 @@ export function AgentTemplatesPage({ initialTemplate }: Props) {
// Bumped after a save / delete / promote to refresh dependent data.
const [rev, setRev] = useState(0);
- // Initial load: fetch summary list + auto-select first if none chosen
- // (or if the URL points at a template name that doesn't exist).
+ // Initial load: fetch summary list + auto-select first.
useEffect(() => {
listRoleTemplates()
.then((r) => {
@@ -69,10 +99,7 @@ export function AgentTemplatesPage({ initialTemplate }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- // React to back/forward navigation: when the URL's `?template=` query
- // changes, sync activeName so the page actually shows the new tab.
- // Without this effect, only the first mount's initialTemplate was
- // honoured; later hash changes were silently ignored.
+ // React to back/forward navigation through `?template=...`.
useEffect(() => {
if (!initialTemplate) return;
if (initialTemplate === activeName) return;
@@ -82,56 +109,99 @@ export function AgentTemplatesPage({ initialTemplate }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialTemplate, summaries]);
- // Whenever activeName or rev changes, fetch full template state.
+ // Load full state for the active template.
useEffect(() => {
if (!activeName) return;
setError(null);
getRoleTemplate(activeName)
.then((t) => {
setTemplate(t);
- // Default to viewing the currently-promoted version.
setSelectedVersionId(t.currentVersionId);
- setContent(t.currentContent);
+ // Fetch the body for the currently-promoted version (just the
+ // extension if it's augmented; the full body otherwise).
+ return loadBodyForSelection(t, t.currentVersionId);
+ })
+ .then(() => {
setIsEditing(false);
setEditingDirty(false);
- // Update URL (without page reload) so the back button works.
- if (window.location.hash !== `#/agents?template=${encodeURIComponent(t.name)}`) {
- window.history.replaceState(null, "", `#/agents?template=${encodeURIComponent(t.name)}`);
- }
+ setCreatingNew(false);
})
.catch((e) => setError(String(e)));
+
+ if (window.location.hash !== `#/agents?template=${encodeURIComponent(activeName)}`) {
+ window.history.replaceState(null, "", `#/agents?template=${encodeURIComponent(activeName)}`);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeName, rev]);
- // When the user picks a different version (without editing), fetch its content.
+ // When the user picks a different version (without editing), re-fetch
+ // the body for that version.
useEffect(() => {
if (!activeName || !template) return;
if (isEditing) return; // don't blow away in-flight edits
- if (selectedVersionId === template.currentVersionId) {
- setContent(template.currentContent);
- return;
- }
- if (selectedVersionId === DEFAULT_VERSION_ID) {
- setContent(template.defaultContent);
+ loadBodyForSelection(template, selectedVersionId).catch((e) => setError(String(e)));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedVersionId, activeName]);
+
+ async function loadBodyForSelection(t: RoleTemplateFull, versionId: string): Promise {
+ if (versionId === DEFAULT_VERSION_ID) {
+ setContent(t.defaultContent);
return;
}
- getRoleTemplateVersionContent(activeName, selectedVersionId)
- .then((r) => setContent(r.content))
- .catch((e) => setError(String(e)));
- }, [selectedVersionId, activeName, template, isEditing]);
+ const r = await getRoleTemplateVersionContent(t.name, versionId);
+ setContent(r.content);
+ }
// --- Action handlers ---
+ function startEdit() {
+ if (!template) return;
+ const v = template.versions.find((x) => x.id === selectedVersionId);
+ setEditType(v ? v.type : "full");
+ setCreatingNew(selectedVersionId === DEFAULT_VERSION_ID); // Edit on default = creating new
+ setIsEditing(true);
+ setEditingDirty(false);
+ setStatusMsg(null);
+ }
+
+ function startAugment() {
+ if (!template) return;
+ if (editingDirty && !window.confirm("Discard unsaved changes?")) return;
+ // Augment ALWAYS creates a new augmented version on top of the
+ // bundled default — regardless of which version is currently
+ // selected. This keeps the upgrade-friendly contract: augmented
+ // versions ride along on whatever cf² ships next.
+ setSelectedVersionId(DEFAULT_VERSION_ID);
+ setEditType("augmented");
+ setCreatingNew(true);
+ setIsEditing(true);
+ setEditingDirty(false);
+ setContent(""); // empty extension; user types their additions
+ setStatusMsg(null);
+ }
+
async function handleSaveAsNew() {
if (!activeName) return;
- const label = window.prompt("Label for this version (e.g. 'stricter judge', 'opus run')");
+ const promptText =
+ editType === "augmented"
+ ? "Label for this augmented version (e.g. 'jira-hint', 'team-style')"
+ : "Label for this version (e.g. 'stricter judge', 'opus run')";
+ const label = window.prompt(promptText);
if (!label || !label.trim()) return;
setBusy(true);
setStatusMsg(null);
try {
- const v = await createRoleTemplateVersion(activeName, { label: label.trim(), content });
- setStatusMsg(`✓ Saved as "${v.label}". (Not yet promoted — click Promote below.)`);
+ const v = await createRoleTemplateVersion(activeName, {
+ label: label.trim(),
+ content,
+ type: editType,
+ });
+ setStatusMsg(
+ `✓ Saved as "${v.label}" (${v.type}). Not yet promoted — click Promote to make it live.`,
+ );
setIsEditing(false);
setEditingDirty(false);
+ setCreatingNew(false);
setRev((n) => n + 1);
setSelectedVersionId(v.id);
} catch (e) {
@@ -154,7 +224,7 @@ export function AgentTemplatesPage({ initialTemplate }: Props) {
setStatusMsg(
selectedVersionId === template.currentVersionId
? "✓ Saved. (This version is currently promoted, so the change is live for the next agent run.)"
- : "✓ Saved. (Promote this version to make it live.)",
+ : "✓ Saved. Promote this version to make it live.",
);
setIsEditing(false);
setEditingDirty(false);
@@ -176,7 +246,7 @@ export function AgentTemplatesPage({ initialTemplate }: Props) {
setStatusMsg(
selectedVersionId === DEFAULT_VERSION_ID
? "✓ Reverted to bundled default. The next agent run will use cf²'s default template."
- : `✓ Promoted to production. The next agent run will use this version.`,
+ : "✓ Promoted to production. The next agent run will use this version.",
);
setRev((n) => n + 1);
} catch (e) {
@@ -188,7 +258,7 @@ export function AgentTemplatesPage({ initialTemplate }: Props) {
async function handleDelete() {
if (!activeName || !template) return;
- if (selectedVersionId === DEFAULT_VERSION_ID) return; // can't happen UI-wise
+ if (selectedVersionId === DEFAULT_VERSION_ID) return;
const v = template.versions.find((x) => x.id === selectedVersionId);
if (!v) return;
if (!window.confirm(`Delete version "${v.label}"? This cannot be undone.`)) return;
@@ -206,7 +276,7 @@ export function AgentTemplatesPage({ initialTemplate }: Props) {
}
}
- // --- Render ---
+ // --- Derived state ---
if (!activeName) {
return (
@@ -222,11 +292,30 @@ export function AgentTemplatesPage({ initialTemplate }: Props) {
if (!template) return "—";
if (template.currentVersionId === DEFAULT_VERSION_ID) return "Default (built-in)";
const v = template.versions.find((x) => x.id === template.currentVersionId);
- return v ? v.label : template.currentVersionId;
+ return v ? `${v.label} (${v.type})` : template.currentVersionId;
})();
const selectionIsPromoted = template?.currentVersionId === selectedVersionId;
const selectionIsDefault = selectedVersionId === DEFAULT_VERSION_ID;
+ const selectedVersion: RoleTemplateVersion | undefined = template?.versions.find(
+ (x) => x.id === selectedVersionId,
+ );
+ /**
+ * Type the editor should render in.
+ * - When editing: editType (set by the Edit / Augment button).
+ * - When viewing: the selected version's type, defaulting to "full"
+ * for the bundled default (single textarea read-only).
+ */
+ const displayType: RoleTemplateVersionType = isEditing
+ ? editType
+ : (selectedVersion?.type ?? "full");
+ const showSplitView = displayType === "augmented";
+ // The textarea(s) are editable only in edit mode AND when the user
+ // is actually authoring the body (default tab is "view-only" for the
+ // standard template even in augment mode — that's what the read-only
+ // top panel is).
+ const extensionEditable = isEditing && displayType === "augmented";
+ const fullEditable = isEditing && displayType === "full";
return (
@@ -273,7 +362,7 @@ export function AgentTemplatesPage({ initialTemplate }: Props) {
{s.displayName}
{s.currentVersionId !== DEFAULT_VERSION_ID && (
•
@@ -318,6 +407,18 @@ export function AgentTemplatesPage({ initialTemplate }: Props) {
• unsaved changes
)}
+ {creatingNew && isEditing && (
+
+ • creating new {editType} version
+
+ )}
In production:{" "}
@@ -328,10 +429,29 @@ export function AgentTemplatesPage({ initialTemplate }: Props) {
Template file: {template.name}. Override path:{" "}
~/.cfcf/templates/{template.name}{" "}
- (written by cf² when you promote a non-default version; deleted when you revert).
+ (cf² writes the composed override file here when a non-default version is promoted;
+ deletes it when you revert.)
+ {/* Status message — moved ABOVE the version selector so the
+ "Promote to make it live" hint is below the action buttons
+ when the user reads it. */}
+ {statusMsg && (
+
+ ℹ Forked from cf² v{selectedVersion.cfcfVersion}'s bundled default. Full
+ versions don't auto-upgrade — compare against the latest Default
+ (built-in) if cf² has shipped a newer template since.
+
)}
- {/* Content editor.
- readOnly is gated ONLY by `!isEditing` — the bundled default
- "lives" on disk read-only (we never overwrite the embedded
- constant), but the user still needs to TYPE in this editor
- to draft a new version that gets saved via "Save as new
- version". The save action is what's gated, not the typing. */}
-
- {/* Subtle skip-link to Settings (the user might be looking for the global config). */}