What's New Injection Block — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: After sync pulls new pack content, inject prepends a one-shot ## What's New block to the AI context so agents learn what changed.
Architecture: Curated changelog entries in pack.yaml are collected by sync into a one-shot sync-changelog.json cache file. inject reads the file, renders it as a ## What's New section at the top of the injected markdown, and deletes the file after successful injection.
Tech Stack: Go, YAML (gopkg.in/yaml.v3), JSON (encoding/json), testify
Spec: docs/superpowers/specs/2026-04-19-whats-new-injection-design.md
Task 1: Schema + packMeta — allow changelog in pack.yaml
Files:
- Modify:
content/schemas/pack.schema.json - Modify:
internal/content/pack.go
The schema must land before any pack.yaml uses the field (additionalProperties: false rejects unknown keys).
- [ ] Step 1: Add
changelogto JSON schema
In content/schemas/pack.schema.json, add inside the "properties" object (after the "versions" field):
"changelog": {
"type": "array",
"items": { "type": "string" },
"description": "Human-curated change notes shown once after sync in the injected What's New block"
}- [ ] Step 2: Add
ChangelogtopackMetastruct
In internal/content/pack.go, add to the packMeta struct (after the Versions field, before the closing }):
Changelog []string `yaml:"changelog,omitempty"`This field is parsed but not copied to Pack. It exists so a future strict YAML decoder won't reject the key.
- [ ] Step 3: Verify it compiles
Run: go build ./... Expected: success, no errors
- [ ] Step 4: Commit
git add content/schemas/pack.schema.json internal/content/pack.go
git commit -m "feat: add changelog field to pack.yaml schema and packMeta"Task 2: Sync-side changelog collection and file writing
Files:
Create:
internal/sync/changelog.goCreate:
internal/sync/changelog_test.go[ ] Step 1: Write failing tests for changelog functions
Create internal/sync/changelog_test.go:
package sync_test
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
sapSync "github.com/SAP-samples/sap-devs-cli/internal/sync"
)
func TestWriteReadChangelog_Roundtrip(t *testing.T) {
dir := t.TempDir()
entries := []sapSync.ChangelogEntry{
{Pack: "cap", Text: "CAP 9.8: native SQLite support"},
{Pack: "abap", Text: "New Tier-1 API for business partner"},
}
syncedAt := time.Date(2026, 4, 17, 15, 4, 5, 0, time.UTC)
require.NoError(t, sapSync.WriteChangelog(dir, syncedAt, entries))
gotEntries, gotTime, err := sapSync.ReadChangelog(dir)
require.NoError(t, err)
assert.Equal(t, entries, gotEntries)
assert.True(t, syncedAt.Equal(gotTime))
}
func TestReadChangelog_MissingFile(t *testing.T) {
dir := t.TempDir()
entries, ts, err := sapSync.ReadChangelog(dir)
assert.NoError(t, err)
assert.Nil(t, entries)
assert.True(t, ts.IsZero())
}
func TestConsumeChangelog_DeletesFile(t *testing.T) {
dir := t.TempDir()
entries := []sapSync.ChangelogEntry{{Pack: "cap", Text: "test"}}
require.NoError(t, sapSync.WriteChangelog(dir, time.Now(), entries))
require.NoError(t, sapSync.ConsumeChangelog(dir))
_, err := os.Stat(filepath.Join(dir, "sync-changelog.json"))
assert.True(t, os.IsNotExist(err))
}
func TestConsumeChangelog_MissingFile_NoOp(t *testing.T) {
dir := t.TempDir()
assert.NoError(t, sapSync.ConsumeChangelog(dir))
}
func TestWriteChangelog_EmptyEntries_NoFile(t *testing.T) {
dir := t.TempDir()
require.NoError(t, sapSync.WriteChangelog(dir, time.Now(), nil))
_, err := os.Stat(filepath.Join(dir, "sync-changelog.json"))
assert.True(t, os.IsNotExist(err))
}
func TestCollectChangelog_ReadsFromMultipleDirs(t *testing.T) {
// Create two pack directories with changelog entries
dir1 := t.TempDir()
dir2 := t.TempDir()
capDir := filepath.Join(dir1, "cap")
require.NoError(t, os.MkdirAll(capDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(capDir, "pack.yaml"), []byte(`id: cap
name: CAP
description: test
tags: [test]
changelog:
- "CAP 9.8: native SQLite"
- "CAP 9.8: cds repl --ql"
`), 0644))
abapDir := filepath.Join(dir2, "abap")
require.NoError(t, os.MkdirAll(abapDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(abapDir, "pack.yaml"), []byte(`id: abap
name: ABAP
description: test
tags: [test]
changelog:
- "New Tier-1 API"
`), 0644))
entries, err := sapSync.CollectChangelog([]string{dir1, dir2})
require.NoError(t, err)
require.Len(t, entries, 3)
assert.Equal(t, "cap", entries[0].Pack)
assert.Equal(t, "CAP 9.8: native SQLite", entries[0].Text)
assert.Equal(t, "cap", entries[1].Pack)
assert.Equal(t, "CAP 9.8: cds repl --ql", entries[1].Text)
assert.Equal(t, "abap", entries[2].Pack)
assert.Equal(t, "New Tier-1 API", entries[2].Text)
}
func TestCollectChangelog_SkipsPacksWithoutChangelog(t *testing.T) {
dir := t.TempDir()
capDir := filepath.Join(dir, "cap")
require.NoError(t, os.MkdirAll(capDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(capDir, "pack.yaml"), []byte(`id: cap
name: CAP
description: test
tags: [test]
`), 0644))
entries, err := sapSync.CollectChangelog([]string{dir})
require.NoError(t, err)
assert.Empty(t, entries)
}
func TestCollectChangelog_SkipsMissingDirs(t *testing.T) {
entries, err := sapSync.CollectChangelog([]string{"/nonexistent/path"})
require.NoError(t, err)
assert.Empty(t, entries)
}- [ ] Step 2: Run tests to verify they fail
Run: go build ./internal/sync/... Expected: compilation error — sapSync.ChangelogEntry, sapSync.WriteChangelog, etc. undefined
- [ ] Step 3: Implement changelog.go
Create internal/sync/changelog.go:
package sync
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"time"
"gopkg.in/yaml.v3"
)
// ChangelogEntry is a single human-curated change note from a pack.
type ChangelogEntry struct {
Pack string `json:"pack"`
Text string `json:"text"`
}
type changelogFile struct {
SyncedAt time.Time `json:"synced_at"`
Entries []ChangelogEntry `json:"entries"`
}
type changelogMeta struct {
ID string `yaml:"id"`
Changelog []string `yaml:"changelog"`
}
const changelogFilename = "sync-changelog.json"
// WriteChangelog writes changelog entries to sync-changelog.json in cacheDir.
// Returns nil without writing if entries is empty.
func WriteChangelog(cacheDir string, syncedAt time.Time, entries []ChangelogEntry) error {
if len(entries) == 0 {
return nil
}
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return err
}
cf := changelogFile{SyncedAt: syncedAt, Entries: entries}
data, err := json.MarshalIndent(cf, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(cacheDir, changelogFilename), data, 0600)
}
// ReadChangelog reads sync-changelog.json from cacheDir.
// Returns nil entries and zero time if the file is missing.
func ReadChangelog(cacheDir string) ([]ChangelogEntry, time.Time, error) {
data, err := os.ReadFile(filepath.Join(cacheDir, changelogFilename))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, time.Time{}, nil
}
return nil, time.Time{}, err
}
var cf changelogFile
if err := json.Unmarshal(data, &cf); err != nil {
return nil, time.Time{}, err
}
return cf.Entries, cf.SyncedAt, nil
}
// ConsumeChangelog deletes sync-changelog.json from cacheDir.
// No-op if the file does not exist.
func ConsumeChangelog(cacheDir string) error {
err := os.Remove(filepath.Join(cacheDir, changelogFilename))
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
// CollectChangelog scans pack.yaml files across one or more pack directories
// and extracts changelog entries. Directories that don't exist are silently skipped.
func CollectChangelog(packsDirs []string) ([]ChangelogEntry, error) {
var all []ChangelogEntry
for _, dir := range packsDirs {
entries, err := os.ReadDir(dir)
if err != nil {
continue // skip missing directories
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
packYAML := filepath.Join(dir, entry.Name(), "pack.yaml")
data, err := os.ReadFile(packYAML)
if err != nil {
continue
}
var meta changelogMeta
if err := yaml.Unmarshal(data, &meta); err != nil {
continue
}
for _, text := range meta.Changelog {
if text != "" {
all = append(all, ChangelogEntry{Pack: meta.ID, Text: text})
}
}
}
}
return all, nil
}- [ ] Step 4: Run tests to verify they pass
Run: go build ./internal/sync/... and go vet ./internal/sync/... Expected: success
- [ ] Step 5: Commit
git add internal/sync/changelog.go internal/sync/changelog_test.go
git commit -m "feat: add sync changelog collection and file lifecycle functions"Task 3: DynamicContext — add WhatsNew fields
Files:
Modify:
internal/content/dynamic.go[ ] Step 1: Add WhatsNewEntry type and fields to DynamicContext
In internal/content/dynamic.go, add after the CommandInfo struct (at the end of the file):
// WhatsNewEntry is a single changelog item rendered in the ## What's New block.
type WhatsNewEntry struct {
Pack string
Text string
}Then add two fields to the DynamicContext struct, after ScratchNotes:
WhatsNew []WhatsNewEntry
WhatsNewDate *time.Time- [ ] Step 2: Verify it compiles
Run: go build ./... Expected: success
- [ ] Step 3: Commit
git add internal/content/dynamic.go
git commit -m "feat: add WhatsNewEntry type and fields to DynamicContext"Task 4: Render the ## What's New block
Files:
Modify:
internal/content/render.goModify:
internal/content/render_test.go[ ] Step 1: Write failing tests for What's New rendering
Append to internal/content/render_test.go:
func TestRenderContext_WhatsNew_RenderedWhenPresent(t *testing.T) {
syncDate := time.Date(2026, 4, 17, 0, 0, 0, 0, time.UTC)
dyn := &content.DynamicContext{
WhatsNew: []content.WhatsNewEntry{
{Pack: "cap", Text: "CAP 9.8: native SQLite support"},
{Pack: "abap", Text: "New Tier-1 API"},
},
WhatsNewDate: &syncDate,
}
out := content.RenderContext(nil, nil, dyn)
assert.Contains(t, out, "## What's New (since last sync, 2026-04-17)")
assert.Contains(t, out, "- CAP 9.8: native SQLite support")
assert.Contains(t, out, "- New Tier-1 API")
}
func TestRenderContext_WhatsNew_OmittedWhenEmpty(t *testing.T) {
dyn := &content.DynamicContext{WhatsNew: nil}
out := content.RenderContext(nil, nil, dyn)
assert.NotContains(t, out, "What's New")
}
func TestRenderContext_WhatsNew_BeforeScratchNotes(t *testing.T) {
syncDate := time.Date(2026, 4, 17, 0, 0, 0, 0, time.UTC)
dyn := &content.DynamicContext{
WhatsNew: []content.WhatsNewEntry{{Pack: "cap", Text: "test change"}},
WhatsNewDate: &syncDate,
ScratchNotes: []string{"working on auth"},
}
out := content.RenderContext(nil, nil, dyn)
whatsNewIdx := strings.Index(out, "## What's New")
scratchIdx := strings.Index(out, "## Current Context")
require.NotEqual(t, -1, whatsNewIdx, "What's New must be present")
require.NotEqual(t, -1, scratchIdx, "scratch notes must be present")
assert.Less(t, whatsNewIdx, scratchIdx, "What's New must appear before scratch notes")
}
func TestRenderContext_WhatsNew_BeforeRuntimeContext(t *testing.T) {
syncDate := time.Date(2026, 4, 17, 0, 0, 0, 0, time.UTC)
dyn := &content.DynamicContext{
CLIVersion: "1.0.0",
WhatsNew: []content.WhatsNewEntry{{Pack: "cap", Text: "test change"}},
WhatsNewDate: &syncDate,
}
out := content.RenderContext(nil, nil, dyn)
whatsNewIdx := strings.Index(out, "## What's New")
runtimeIdx := strings.Index(out, "## sap-devs Runtime Context")
require.NotEqual(t, -1, whatsNewIdx, "What's New must be present")
require.NotEqual(t, -1, runtimeIdx, "runtime section must be present")
assert.Less(t, whatsNewIdx, runtimeIdx, "What's New must appear before runtime context")
}- [ ] Step 2: Run tests to verify they fail
Run: go build ./internal/content/... Expected: compilation succeeds (types exist from Task 3), but the test assertions will fail because RenderContext doesn't render the block yet.
Run: go vet ./internal/content/... Expected: success
- [ ] Step 3: Add What's New rendering to RenderContext
In internal/content/render.go, in the RenderContext function, insert a new block between the profile line block (ending at line 36 with }) and the scratch notes block (starting at line 38 with if dynamic != nil && len(dynamic.ScratchNotes) > 0):
if dynamic != nil && len(dynamic.WhatsNew) > 0 {
if dynamic.WhatsNewDate != nil {
b.WriteString(fmt.Sprintf("## What's New (since last sync, %s)\n\n",
dynamic.WhatsNewDate.Format("2006-01-02")))
} else {
b.WriteString("## What's New\n\n")
}
for _, entry := range dynamic.WhatsNew {
b.WriteString("- " + entry.Text + "\n")
}
b.WriteString("\n")
}The insertion point is after line 36 (} closing the profile block) and before line 38 (if dynamic != nil && len(dynamic.ScratchNotes) > 0).
- [ ] Step 4: Run tests to verify they pass
Run: go build ./internal/content/... and go vet ./internal/content/... Expected: success
- [ ] Step 5: Commit
git add internal/content/render.go internal/content/render_test.go
git commit -m "feat: render What's New block in injected context"Task 5: Wire sync — collect changelog after archive fetch
Files:
Modify:
cmd/sync.go[ ] Step 1: Add changelog collection after archive fetch
In cmd/sync.go, inside the if archiveNeedsSync { block, after the marker expansion block (after line 122 }) and before the company repo block (line 124 if cfg.CompanyRepo != ""), restructure to collect changelog from both official and company repos:
Replace the section from line 118 (// Phase 2: marker expansion) through line 137 (end of company repo block }) with:
// Phase 2: marker expansion (Bubbletea progress)
if err := runMarkerExpansion(officialCache, engine); err != nil {
fmt.Fprintf(os.Stderr, "sap-devs: marker expansion warning: %v\n", err)
}
// Collect changelog entries from official packs
changelogDirs := []string{filepath.Join(officialCache, "content", "packs")}
// Sync company repo if configured
if cfg.CompanyRepo != "" {
if !strings.HasPrefix(cfg.CompanyRepo, "https://") {
fmt.Fprintln(out, i18n.Tf(i18n.ActiveLang, "sync.warn_https", map[string]any{"URL": cfg.CompanyRepo}))
} else {
companyCache := filepath.Join(paths.CacheDir, "company")
repoURL := strings.TrimRight(cfg.CompanyRepo, "/")
companyArchive := repoURL + "/archive/refs/heads/main.zip"
fmt.Fprintln(out, i18n.T(i18n.ActiveLang, "sync.syncing_company"))
if err := sapSync.FetchArchive(companyArchive, companyCache, token); err != nil {
fmt.Fprintln(out, i18n.Tf(i18n.ActiveLang, "sync.warn_company_failed", map[string]any{"Err": err}))
} else {
changelogDirs = append(changelogDirs, filepath.Join(companyCache, "content", "packs"))
}
}
}
// Write changelog file for inject to consume
syncedAt := time.Now()
clEntries, err := sapSync.CollectChangelog(changelogDirs)
if err != nil {
fmt.Fprintf(os.Stderr, "sap-devs: changelog collection warning: %v\n", err)
}
if writeErr := sapSync.WriteChangelog(paths.CacheDir, syncedAt, clEntries); writeErr != nil {
fmt.Fprintf(os.Stderr, "sap-devs: changelog write warning: %v\n", writeErr)
}- [ ] Step 2: Verify it compiles
Run: go build ./... Expected: success
- [ ] Step 3: Commit
git add cmd/sync.go
git commit -m "feat: collect pack changelog entries during sync"Task 6: Wire inject — read, populate, and consume changelog
Files:
Modify:
cmd/inject.go[ ] Step 1: Read changelog after inline sync + pack reload, populate DynamicContext
In cmd/inject.go, after the staleness check block (ending at line 200 with }) and before the learning journeys resolution (line 202 // Resolve featured learning journeys), add:
// Read changelog for What's New injection block
clEntries, clTime, _ := sapSync.ReadChangelog(paths.CacheDir)Then after the scratch notes block (after line 273 }) and before the adapter options (line 275 opts := adapter.Options{), add the translation from sync.ChangelogEntry to content.WhatsNewEntry:
// Translate sync changelog entries to content WhatsNewEntry for rendering
if len(clEntries) > 0 {
for _, e := range clEntries {
dynCtx.WhatsNew = append(dynCtx.WhatsNew, content.WhatsNewEntry{
Pack: e.Pack,
Text: e.Text,
})
}
dynCtx.WhatsNewDate = &clTime
}- [ ] Step 2: Consume changelog after successful non-dry-run inject
In cmd/inject.go, immediately after line 296 (fmt.Fprintln(cmd.OutOrStdout(), i18n.Tf(...))), still inside the if !injectDryRun { block (line 295) but before the if injectTool == "" check (line 297), add:
_ = sapSync.ConsumeChangelog(paths.CacheDir)This must stay inside if !injectDryRun but outside the if injectTool == "" guard, so the changelog is consumed regardless of which tool was targeted.
- [ ] Step 3: Verify it compiles
Run: go build ./... Expected: success
- [ ] Step 4: Commit
git add cmd/inject.go
git commit -m "feat: wire inject to read, render, and consume changelog"Task 7: Seed example changelog in cap pack
Files:
Modify:
content/packs/cap/pack.yaml[ ] Step 1: Add changelog entries to cap pack
Append to content/packs/cap/pack.yaml (after the versions block):
changelog:
- "CAP 9.8: native SQLite support via `cds.requires.db.driver: node` (Node 22.5+)"
- "CAP 9.8: new `cds repl --ql` query mode for interactive CQL"- [ ] Step 2: Verify with dry-run
Run: SAP_DEVS_DEV=1 go run . sync --force then SAP_DEVS_DEV=1 go run . inject --dry-run Expected: output contains ## What's New (since last sync, 2026-04-19) with the two CAP entries
- [ ] Step 3: Commit
git add content/packs/cap/pack.yaml
git commit -m "feat: seed changelog entries in CAP pack"Task 8: Build verification and documentation
Files:
Modify:
CLAUDE.mdModify:
TODO.md[ ] Step 1: Full build check
Run: go build ./... and go vet ./... Expected: success with no warnings
- [ ] Step 2: Update CLAUDE.md
In CLAUDE.md, under the ### Content Layer System section, after the paragraph about LoadPacks(), add a brief note about the changelog lifecycle:
**What's New Injection:** Each pack may include a `changelog` list in `pack.yaml` with human-curated change notes. During `sync`, these entries are collected into `~/.cache/sap-devs/sync-changelog.json`. On the next `inject`, the entries are rendered as a `## What's New` block at the top of the injected context, then the file is deleted (one-shot). See `internal/sync/changelog.go` for the file lifecycle functions.- [ ] Step 3: Update TODO.md
Mark the "What's changed since last sync" item as done in TODO.md.
- [ ] Step 4: Commit
git add CLAUDE.md TODO.md
git commit -m "docs: document What's New injection lifecycle"