Phase 5: scaffold Crucible (sow-tools) — dispatcher + builder shims, container, PR-first CI, fail-closed (D11, D19).

This commit is contained in:
2026-06-11 20:36:13 +02:00
parent ff264f0d7c
commit 97f8427c4b
154 changed files with 948 additions and 76902 deletions
@@ -1,812 +0,0 @@
# Generated Wiki Stale Purge 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:** Add an opt-in `purge` stale policy that permanently removes only manifest-tracked generated NodeBB wiki topics that no longer exist in current generator output.
**Architecture:** Keep stale ownership and candidate selection inside the existing `deploy-wiki` manifest planner. Extend the deploy result/plan model with purge counts and purge actions, add a thin NodeBB topic purge client call, remove successfully purged entries from the next manifest, and surface the policy through existing toolkit validation and module release/docs paths.
**Tech Stack:** Go toolkit code and `httptest` deploy tests, YAML-backed toolkit project config validation, POSIX shell module release wrapper, Markdown docs.
---
## File Map
Toolkit files:
- Modify `internal/project/project.go`: validate `purge` as a supported stale policy and remove the unsupported `unpublish` config assumption.
- Modify `internal/project/project_test.go`: prove project config accepts `purge` and rejects unsupported stale policies.
- Modify `internal/topdata/wiki_deploy.go`: add purge deploy state, stale purge planning, manifest removal, and NodeBB topic purge requests.
- Modify `internal/topdata/wiki_deploy_test.go`: cover dry-run/live purge and missing-topic-id refusal without changing archive/report behavior.
- Modify `internal/app/app.go`: expose `purge` in deploy CLI help and print `purged` deploy counts.
- Modify `internal/app/app_test.go`: cover CLI output/help parsing for the new count and policy text.
- Modify `README.md`: document the destructive generated-manifest-scoped purge mode in the toolkit CLI docs.
Module files:
- Modify `../module/scripts/release-all.sh`: allow `SOW_MODULE_WIKI_DEPLOY_STALE_POLICY=purge`.
- Modify `../module/README.md`: document release usage and the destructive boundary.
- Modify `../module/topdata/README.md`: document `deploy-wiki --stale-policy purge`.
- Modify `../module/tests/release-all-cache-cleanup.sh`: prove release automation forwards `purge` into both wiki deploy phases.
## Task 1: Align Stale Policy Validation
**Files:**
- Modify: `internal/project/project.go`
- Modify: `internal/project/project_test.go`
- [ ] **Step 1: Write the failing project-config test**
Add a project validation case in `internal/project/project_test.go` that loads stale policy config with `purge` and expects validation to pass:
```go
func TestProjectValidationAcceptsWikiStalePurgePolicy(t *testing.T) {
root := t.TempDir()
writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
name: Test Module
resref: testmod
topdata:
wiki:
stale_pages:
default: report
live_cleanup: purge
`)
proj, err := Load(root)
if err != nil {
t.Fatalf("load project: %v", err)
}
if err := proj.ValidateLayout(); err != nil {
t.Fatalf("validate purge stale policy: %v", err)
}
}
```
- [ ] **Step 2: Run the focused test to verify it fails**
Run:
```bash
go test ./internal/project -run TestProjectValidationAcceptsWikiStalePurgePolicy -count=1
```
Expected: FAIL because `topdata.wiki.stale_pages.live_cleanup "purge" is not supported`.
- [ ] **Step 3: Replace unsupported project validation vocabulary**
In `internal/project/project.go`, update the stale-policy switch from:
```go
switch policy {
case "report", "archive", "unpublish":
default:
failures = append(failures, fmt.Errorf("%s %q is not supported", key, policy))
}
```
to:
```go
switch policy {
case "report", "archive", "purge":
default:
failures = append(failures, fmt.Errorf("%s %q is not supported", key, policy))
}
```
This removes the config-only `unpublish` value that the deploy command cannot execute.
- [ ] **Step 4: Add the unsupported-policy regression**
Add or extend a project validation test so `unpublish` and an arbitrary bad value fail:
```go
func TestProjectValidationRejectsUnsupportedWikiStalePolicies(t *testing.T) {
for _, policy := range []string{"unpublish", "remove-everything"} {
t.Run(policy, func(t *testing.T) {
root := t.TempDir()
writeProjectFile(t, filepath.Join(root, ConfigFile), fmt.Sprintf(`
module:
name: Test Module
resref: testmod
topdata:
wiki:
stale_pages:
live_cleanup: %s
`, policy))
proj, err := Load(root)
if err != nil {
t.Fatalf("load project: %v", err)
}
err = proj.ValidateLayout()
if err == nil || !strings.Contains(err.Error(), policy) {
t.Fatalf("expected %q stale policy validation failure, got %v", policy, err)
}
})
}
}
```
Add `fmt` to the existing `internal/project/project_test.go` imports for the `Sprintf` YAML fixture.
- [ ] **Step 5: Run project tests**
Run:
```bash
go test ./internal/project -count=1
```
Expected: PASS.
- [ ] **Step 6: Commit the validation change**
```bash
git add internal/project/project.go internal/project/project_test.go
git commit -m "feat: validate wiki stale purge policy"
```
## Task 2: Plan Manifest-Scoped Purge Actions
**Files:**
- Modify: `internal/topdata/wiki_deploy.go`
- Modify: `internal/topdata/wiki_deploy_test.go`
- [ ] **Step 1: Write failing deploy tests for dry-run purge planning**
In `internal/topdata/wiki_deploy_test.go`, add:
```go
func TestDeployWikiDryRunPlansTrackedStalePagePurge(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"skills:retired": {Hash: "old-hash", Title: "Retired Skill", Namespace: "skills", TID: 7, PID: 42, CID: 3},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("dry-run purge must not call NodeBB, got %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
DryRun: true,
StalePolicy: "purge",
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions stale purge dry run failed: %v", err)
}
if result.Stale != 1 || result.Purged != 1 {
t.Fatalf("expected one planned stale purge, got %#v", result)
}
if _, ok := loadDeployManifest(manifestPath).Pages["skills:retired"]; !ok {
t.Fatalf("dry-run purge must not change the deploy manifest")
}
}
```
- [ ] **Step 2: Write the failing missing-topic-id test**
Add:
```go
func TestDeployWikiPurgeRefusesStaleManifestEntryWithoutTopicID(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"skills:retired": {Hash: "old-hash", Title: "Retired Skill", Namespace: "skills", PID: 42, CID: 3},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("missing-topic-id purge must not call NodeBB, got %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
DryRun: true,
StalePolicy: "purge",
}, nil)
if err == nil || !strings.Contains(err.Error(), "topic id") {
t.Fatalf("expected stale purge to require tracked topic id, got %v", err)
}
}
```
- [ ] **Step 3: Run the focused tests to verify they fail**
Run:
```bash
go test ./internal/topdata -run 'TestDeployWiki.*Purge' -count=1
```
Expected: FAIL because `purge` is not yet accepted and `deployResult` has no `Purged` field.
- [ ] **Step 4: Add purge plan/result model fields**
In `internal/topdata/wiki_deploy.go`, extend `deployResult`:
```go
type deployResult struct {
LocalPages int
Created int
Updated int
Stale int
Archived int
Purged int
Skipped int
Drifted int
Renamed int
Manifest string
}
```
Allow `purge` during deploy option validation:
```go
if opts.StalePolicy != "" &&
opts.StalePolicy != "report" &&
opts.StalePolicy != "archive" &&
opts.StalePolicy != "purge" {
return deployResult{}, fmt.Errorf("wiki stale policy %q is not supported", opts.StalePolicy)
}
```
Add the purge count to the deploy plan progress line so dry-run logs show the destructive action explicitly:
```go
progress(fmt.Sprintf("NodeBB wiki plan: create %d, update %d, rename %d, skip %d, stale %d, archive %d, purge %d, drift %d",
result.Created,
result.Updated,
result.Renamed,
result.Skipped,
result.Stale,
result.Archived,
result.Purged,
result.Drifted,
))
```
- [ ] **Step 5: Add stale purge planning**
In the stale manifest loop inside `planNodeBBDeploy`, keep the current report/archive branches and add the purge branch:
```go
case "purge":
if entry.TID == 0 {
return nil, result, next, fmt.Errorf("stale generated wiki page %q cannot be purged without tracked NodeBB topic id", pageID)
}
result.Purged++
delete(next.Pages, pageID)
plans = append(plans, wikiDeployPlan{
Page: wikiDeployPage{PageID: pageID, Title: entry.Title, Namespace: entry.Namespace},
Entry: entry,
Action: "purge",
})
```
Use a `switch policy` or equivalent structure so `report`, `archive`, and `purge` remain explicit. Do not add namespace search or title matching to find purge targets.
- [ ] **Step 6: Run the focused purge planning tests**
Run:
```bash
go test ./internal/topdata -run 'TestDeployWiki.*Purge' -count=1
```
Expected: the dry-run and missing-topic-id purge tests pass once they compile.
- [ ] **Step 7: Commit purge planning**
```bash
git add internal/topdata/wiki_deploy.go internal/topdata/wiki_deploy_test.go
git commit -m "feat: plan stale generated wiki purges"
```
## Task 3: Execute NodeBB Topic Purges
**Files:**
- Modify: `internal/topdata/wiki_deploy.go`
- Modify: `internal/topdata/wiki_deploy_test.go`
- [ ] **Step 1: Write the failing live purge test**
Add a live stale purge test in `internal/topdata/wiki_deploy_test.go`:
```go
func TestDeployWikiPurgesTrackedStaleGeneratedTopic(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"skills:retired": {Hash: "old-hash", Title: "Retired Skill", Namespace: "skills", TID: 7, PID: 42, CID: 3},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete || r.URL.Path != "/api/v3/topics/7" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}})
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
StalePolicy: "purge",
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions stale purge failed: %v", err)
}
if result.Stale != 1 || result.Purged != 1 {
t.Fatalf("expected one stale purge, got %#v", result)
}
if _, ok := loadDeployManifest(manifestPath).Pages["skills:retired"]; ok {
t.Fatalf("expected purged stale manifest entry to be removed")
}
}
```
This route is the NodeBB core purge route. `DELETE /api/v3/topics/:tid/state` is the separate soft-delete route and must not be used here.
- [ ] **Step 2: Run the live purge test to verify it fails**
Run:
```bash
go test ./internal/topdata -run 'TestDeployWiki.*Live.*Purge|TestDeployWiki.*Purges.*Stale' -count=1
```
Expected: FAIL because purge plans are not yet executed by the NodeBB client.
- [ ] **Step 3: Add the NodeBB purge client method**
Place a thin method near `updatePost` and `renameWikiPage` in `internal/topdata/wiki_deploy.go`:
```go
func (c *nodeBBClient) purgeTopic(tid int) error {
if tid == 0 {
return fmt.Errorf("NodeBB topic purge requires topic id")
}
return c.request(http.MethodDelete, fmt.Sprintf("/api/v3/topics/%d", tid), nil, nil)
}
```
- [ ] **Step 4: Execute purge plans**
In the live plan execution switch, add:
```go
case "purge":
if err := client.purgeTopic(plan.Entry.TID); err != nil {
return result, fmt.Errorf("deploy wiki page %q: purge NodeBB topic %d: %w", plan.Page.PageID, plan.Entry.TID, err)
}
```
Do not acquire wiki edit locks for purge: the NodeBB topic purge endpoint owns its own privilege checks.
- [ ] **Step 5: Keep current generated pages out of purge coverage**
Add this test so a current generated page with the same manifest page id is skipped instead of purged:
```go
func TestDeployWikiPurgeDoesNotTargetCurrentGeneratedPages(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil {
t.Fatalf("create source dir: %v", err)
}
generated := "[//]: # (sow-topdata-wiki:managed:start)\n# Athletics\n\nGenerated athletics page\n[//]: # (sow-topdata-wiki:managed:end)\n"
if err := os.WriteFile(filepath.Join(sourceDir, "skills", "athletics.md"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{
"skills:athletics": {
Hash: computeManagedHash(generated),
Title: "Athletics",
Namespace: "skills",
TID: 7,
PID: 42,
CID: 3,
},
},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("current generated page must not call NodeBB during matching-hash deploy, got %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: sourceDir,
Endpoint: server.URL,
Token: "nodebb-token",
ManifestPath: manifestPath,
StalePolicy: "purge",
}, nil)
if err != nil {
t.Fatalf("DeployWikiWithOptions current page purge deploy failed: %v", err)
}
if result.Stale != 0 || result.Purged != 0 || result.Skipped != 1 {
t.Fatalf("expected current generated page to skip purge, got %#v", result)
}
if _, ok := loadDeployManifest(manifestPath).Pages["skills:athletics"]; !ok {
t.Fatalf("expected current generated page to remain in deploy manifest")
}
}
```
- [ ] **Step 6: Run deploy tests**
Run:
```bash
go test ./internal/topdata -run 'TestDeployWiki' -count=1
```
Expected: PASS.
- [ ] **Step 7: Commit purge execution**
```bash
git add internal/topdata/wiki_deploy.go internal/topdata/wiki_deploy_test.go
git commit -m "feat: purge stale generated wiki topics"
```
## Task 4: Surface Purge In Toolkit CLI And Docs
**Files:**
- Modify: `internal/app/app.go`
- Modify: `internal/app/app_test.go`
- Modify: `README.md`
- [ ] **Step 1: Write failing CLI-output coverage**
In `internal/app/app_test.go`, extend `TestTopdataConsoleDebugProgressAndRelativePaths` so the sample progress and emit call include a purge count:
```go
console.progress("NodeBB wiki plan: create 1, update 2, skip 3, stale 4, archive 5, purge 6, drift 0")
console.emitWikiDeployResult(10, 1, 2, 3, 4, 5, 6, 0, "/workspace/project/build/wiki/deploy-manifest.json")
```
Assert:
```go
if !strings.Contains(output, "purged: 6") {
t.Fatalf("expected purged deploy count, got %q", output)
}
```
Update the debug progress expectation to include `purge 6`.
- [ ] **Step 2: Run focused app tests to verify failure**
Run:
```bash
go test ./internal/app -run TestTopdataConsoleDebugProgressAndRelativePaths -count=1
```
Expected: FAIL because `emitWikiDeployResult` does not yet take a purge count.
- [ ] **Step 3: Print purge counts**
Update the emit signature and output in `internal/app/app.go`:
```go
func (c *topdataConsole) emitWikiDeployResult(localPages, created, updated, skipped, stale, archived, purged, drifted int, manifest string) {
spin.linebreak()
fmt.Fprintln(c.stdout, "Deploy Wiki ----------")
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
fmt.Fprintf(c.stdout, "local pages: %d\n", localPages)
fmt.Fprintf(c.stdout, "created: %d\n", created)
fmt.Fprintf(c.stdout, "updated: %d\n", updated)
fmt.Fprintf(c.stdout, "skipped: %d\n", skipped)
fmt.Fprintf(c.stdout, "stale: %d\n", stale)
fmt.Fprintf(c.stdout, "archived: %d\n", archived)
fmt.Fprintf(c.stdout, "purged: %d\n", purged)
fmt.Fprintf(c.stdout, "drifted: %d\n", drifted)
fmt.Fprintf(c.stdout, "manifest: %s\n", c.relPath(manifest))
}
```
Pass `result.Purged` from the deploy command call site.
- [ ] **Step 4: Update CLI usage text**
Change deploy-wiki help text in `internal/app/app.go` from:
```go
[--stale-policy <report|archive>]
```
to:
```go
[--stale-policy <report|archive|purge>]
```
Add this focused help assertion in `internal/app/app_test.go`:
```go
func TestParseDeployWikiHelpListsPurgeStalePolicy(t *testing.T) {
_, err := parseDeployWikiArgs("deploy-wiki", []string{"--help"})
if err == nil || !strings.Contains(err.Error(), "--stale-policy <report|archive|purge>") {
t.Fatalf("expected deploy-wiki help to list purge stale policy, got %v", err)
}
}
```
- [ ] **Step 5: Update toolkit README**
In `README.md`, update the `deploy-wiki` usage and stale-page explanation so it states:
```markdown
`--stale-policy purge` permanently removes only stale generated topics already
tracked in the wiki deploy manifest. Dry-run that policy before the live run;
it never discovers manual wiki pages from NodeBB for purge.
```
Keep `report` as the documented default and `archive` as the nondestructive cleanup option.
- [ ] **Step 6: Run app tests**
Run:
```bash
go test ./internal/app -count=1
```
Expected: PASS.
- [ ] **Step 7: Commit toolkit CLI/docs**
```bash
git add internal/app/app.go internal/app/app_test.go README.md
git commit -m "docs: expose wiki stale purge policy"
```
## Task 5: Expose Purge Through Module Release Workflow
**Files:**
- Modify: `../module/scripts/release-all.sh`
- Modify: `../module/README.md`
- Modify: `../module/topdata/README.md`
- Modify: `../module/tests/release-all-cache-cleanup.sh`
- [ ] **Step 1: Write failing release wrapper coverage**
In `../module/tests/release-all-cache-cleanup.sh`, set the stale policy in the existing release invocation:
```bash
SOW_MODULE_WIKI_DEPLOY_STALE_POLICY="purge" \
TAG_NAME="v-test" \
"${CHECKOUT}/scripts/release-all.sh" >"${TEST_TMP}/stdout.log" 2>"${TEST_TMP}/stderr.log"
```
Update the wiki log expectations from:
```bash
grep -Fxq "deploy-wiki --dry-run --create" "${TEST_TMP}/wiki.log" \
|| fail "wiki dry run was not invoked"
grep -Fxq "deploy-wiki --create" "${TEST_TMP}/wiki.log" \
|| fail "wiki deploy was not invoked"
```
to:
```bash
grep -Fxq "deploy-wiki --dry-run --create --stale-policy purge" "${TEST_TMP}/wiki.log" \
|| fail "wiki stale purge dry run was not invoked"
grep -Fxq "deploy-wiki --create --stale-policy purge" "${TEST_TMP}/wiki.log" \
|| fail "wiki stale purge deploy was not invoked"
```
Update the API ordering assertion to match the new dry-run log line:
```awk
$0 == "WIKI deploy-wiki --dry-run --create --stale-policy purge" { wiki_dry_run = NR }
```
- [ ] **Step 2: Run the release wrapper test to verify it fails**
Run from `../module`:
```bash
./tests/release-all-cache-cleanup.sh
```
Expected: FAIL because `scripts/release-all.sh` rejects `SOW_MODULE_WIKI_DEPLOY_STALE_POLICY=purge`.
- [ ] **Step 3: Update release wrapper validation**
In `../module/scripts/release-all.sh`, change:
```bash
if [[ "${SOW_MODULE_WIKI_DEPLOY_STALE_POLICY}" != "report" && "${SOW_MODULE_WIKI_DEPLOY_STALE_POLICY}" != "archive" ]]; then
echo "Unsupported SOW_MODULE_WIKI_DEPLOY_STALE_POLICY=${SOW_MODULE_WIKI_DEPLOY_STALE_POLICY}; expected report or archive" >&2
return 1
fi
```
to:
```bash
if [[ "${SOW_MODULE_WIKI_DEPLOY_STALE_POLICY}" != "report" && "${SOW_MODULE_WIKI_DEPLOY_STALE_POLICY}" != "archive" && "${SOW_MODULE_WIKI_DEPLOY_STALE_POLICY}" != "purge" ]]; then
echo "Unsupported SOW_MODULE_WIKI_DEPLOY_STALE_POLICY=${SOW_MODULE_WIKI_DEPLOY_STALE_POLICY}; expected report, archive, or purge" >&2
return 1
fi
```
The existing dry-run/live deploy argument plumbing already forwards every non-`report` stale policy through `--stale-policy`.
- [ ] **Step 4: Run the release wrapper test**
Run from `../module`:
```bash
./tests/release-all-cache-cleanup.sh
```
Expected: PASS and both release wiki deploy phases include `--stale-policy purge`.
- [ ] **Step 5: Document release controls**
In `../module/README.md`, revise the environment variable bullet to name both destructive modes explicitly:
```markdown
- `SOW_MODULE_WIKI_DEPLOY_STALE_POLICY` (defaults to `report`; use `archive`
after a controlled dry run to retire stale generated pages in place, or
`purge` after a controlled dry run to permanently remove stale generated
topics already tracked in the wiki deploy manifest)
```
In the wiki deployment paragraph, replace the archive-only sentence with a sentence that keeps these boundaries:
- `report` is default
- `archive` rewrites stale generated pages in place
- `purge` permanently removes only manifest-tracked stale generated topics
- manual NodeBB wiki pages are not purge candidates
- [ ] **Step 6: Document local deploy controls**
In `../module/topdata/README.md`, replace:
```markdown
reports stale manifest entries by default and can archive them in place with `--stale-policy archive`; it does not delete topics
```
with:
```markdown
reports stale manifest entries by default, can archive them in place with
`--stale-policy archive`, and can permanently purge only manifest-tracked stale
generated topics with `--stale-policy purge` after a dry run
```
- [ ] **Step 7: Check module diffs**
Run:
```bash
git -C ../module diff --check
git -C ../module diff -- scripts/release-all.sh tests/release-all-cache-cleanup.sh README.md topdata/README.md
```
Expected: no whitespace failures; diff shows only wrapper policy vocabulary, wrapper coverage, and docs.
- [ ] **Step 8: Commit module wrapper/docs**
Run from `../module`:
```bash
git add scripts/release-all.sh tests/release-all-cache-cleanup.sh README.md topdata/README.md
git commit -m "docs: allow generated wiki stale purges"
```
## Task 6: Full Verification And Operator Notes
**Files:**
- Verify: toolkit and module working trees
- [ ] **Step 1: Format Go changes**
Run from toolkit:
```bash
gofmt -w internal/project/project.go internal/project/project_test.go internal/topdata/wiki_deploy.go internal/topdata/wiki_deploy_test.go internal/app/app.go internal/app/app_test.go
```
Expected: command exits 0.
- [ ] **Step 2: Run toolkit verification**
Run from toolkit:
```bash
go test ./internal/project ./internal/topdata ./internal/app
go test ./...
git diff --check
```
Expected: all tests pass and diff check emits no whitespace errors.
- [ ] **Step 3: Build the toolkit development binary**
Run from toolkit:
```bash
./build-tool.sh
```
Expected: `tools/sow-toolkit` is rebuilt successfully for module wrapper verification.
- [ ] **Step 4: Run module verification with the development binary**
Run from module:
```bash
SOW_TOOLS_DEV_BINARY=../toolkit/tools/sow-toolkit ./validate-topdata.sh
SOW_TOOLS_DEV_BINARY=../toolkit/tools/sow-toolkit ./build-wiki.sh --force
git diff --check
```
Expected: topdata validation and wiki build pass; module diff check emits no whitespace errors.
- [ ] **Step 5: Record the cleanup command in the final handoff**
The final implementation response must state that destructive cleanup is opt-in and show the operator sequence:
```bash
./deploy-wiki.sh --dry-run --stale-policy purge
./deploy-wiki.sh --stale-policy purge
```
For tag-triggered module release automation, state the equivalent variable:
```bash
SOW_MODULE_WIKI_DEPLOY_STALE_POLICY=purge
```
The handoff must mention that purge targets only stale generated pages already tracked in the deploy manifest and permanently removes their NodeBB topics/posts.
@@ -1,200 +0,0 @@
# Class Progression Feat Projections 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:** Make class progression feat grouping configurable from wiki YAML so the module template can render granted feats and level-gated available feats separately.
**Architecture:** Extend the `class_progression` data provider config with named class-feat projection fields. The toolkit evaluates each configured projection against class feat rows and groups resolved feat links onto progression rows by level, while the module wiki config declares the project-specific `List` rules and the class template owns output wording.
**Tech Stack:** Go `sow-toolkit`, native topdata wiki YAML, HTML wiki templates, Go tests.
---
## File Map
- `internal/topdata/wiki_data_providers.go`: parse and validate data-provider projection configuration and pass it into class progression row assembly.
- `internal/topdata/wiki_tables.go`: share class progression row assembly with configured class feat projections and keep default compatibility behavior.
- `internal/topdata/wiki_native_test.go`: regression coverage for provider projection parsing, filtering, grouping, ordering, invalid config, and rendered template fields.
- `../module/topdata/wiki/data.yaml`: declare module-owned `GrantedFeats` and `AvailableFeats` projections for class progression.
- `../module/topdata/wiki/templates/pages/classes.html`: render bonus feats inline and use projected available feats in the Notes cell.
- `../module/topdata/wiki/README.md`: document configurable data-provider projections for template authors.
### Task 1: Add Configured Class Feat Projection Tests
**Files:**
- Modify: `internal/topdata/wiki_native_test.go`
- [ ] **Step 1: Write failing provider projection tests**
Add tests that build `wikiDataProviderDefinition` from YAML such as:
```yaml
providers:
ClassProgression:
source: class_progression
levels: 1-4
class_feats:
fields:
GrantedFeats:
when: "{{GrantedOnLevel > 0 && List == 3}}"
AvailableFeats:
when: "{{GrantedOnLevel > 0 && List != 3}}"
```
Assert the parsed provider can render a progression row where `GrantedFeats`
contains `List == 3` feat links, `AvailableFeats` contains positive-level
non-`3` feat links, matched link order follows fixture source row order, and
`BonusFeat` is still present.
- [ ] **Step 2: Add failing invalid configuration coverage**
Add a test that loads a provider field with an empty projection name or empty
`when` expression and expects an error containing `data.yaml`, the provider
name, and the projection field context.
- [ ] **Step 3: Run focused tests to verify RED**
Run:
```bash
go test ./internal/topdata -run 'TestWiki.*ClassProgression.*Projection|TestWikiData.*Provider.*Projection' -count=1
```
Expected: FAIL because `wikiDataProviderDefinition` does not yet accept
`class_feats.fields` and progression rows do not expose `AvailableFeats`.
### Task 2: Implement Generic Provider Projections
**Files:**
- Modify: `internal/topdata/wiki_data_providers.go`
- Modify: `internal/topdata/wiki_tables.go`
- Test: `internal/topdata/wiki_native_test.go`
- [ ] **Step 1: Extend provider config types and validation**
Add typed YAML support for:
```go
type wikiDataProviderClassFeatConfig struct {
Fields map[string]wikiDataProviderProjectionField `json:"fields" yaml:"fields"`
}
type wikiDataProviderProjectionField struct {
When string `json:"when" yaml:"when"`
}
```
Validate each configured projection field name and `when` expression before
provider execution.
- [ ] **Step 2: Carry projections into progression row assembly**
Change the provider path so `ClassProgression` passes provider-owned class feat
projection fields into progression row assembly. Preserve table-based
`class_progression` callers by giving them the current default `GrantedFeats`
behavior.
- [ ] **Step 3: Evaluate and group projections**
In progression row assembly, resolve class feat links once per matching row,
evaluate configured `when` expressions against class feat rows, and append
matched links to the configured progression field for each positive
`GrantedOnLevel` value in source row order.
- [ ] **Step 4: Run focused tests to verify GREEN**
Run:
```bash
go test ./internal/topdata -run 'TestWiki.*ClassProgression.*Projection|TestWikiData.*Provider.*Projection' -count=1
```
Expected: PASS.
### Task 3: Configure Module Wiki Output
**Files:**
- Modify: `../module/topdata/wiki/data.yaml`
- Modify: `../module/topdata/wiki/templates/pages/classes.html`
- Modify: `../module/topdata/wiki/README.md`
- [ ] **Step 1: Declare module projections**
Update `ClassProgression` in `../module/topdata/wiki/data.yaml` so module YAML
owns `GrantedFeats` and `AvailableFeats` selection:
```yaml
ClassProgression:
source: class_progression
levels: 1-12
class_feats:
fields:
GrantedFeats:
when: "{{GrantedOnLevel > 0 && List == 3}}"
AvailableFeats:
when: "{{GrantedOnLevel > 0 && List != 3}}"
```
- [ ] **Step 2: Render projected fields in the class template**
Keep bonus feats in the Feats cell with a conditional comma. Render
`AvailableFeats` in the Notes cell with module-owned “becomes available”
wording using supported template loop/filter syntax.
- [ ] **Step 3: Document the projection surface**
Add a concise `data.yaml` note to the wiki README explaining that data-provider
projection fields can expose configured per-level class feat lists to page
templates and that wording stays in templates.
### Task 4: Verify The Cross-Repo Wiki Path
**Files:**
- Verify: `internal/topdata/...`
- Verify: `../module/topdata/wiki/...`
- [ ] **Step 1: Run toolkit regression tests**
Run:
```bash
go test ./internal/topdata -count=1
```
Expected: PASS.
- [ ] **Step 2: Build the local toolkit**
Run:
```bash
./build-tool.sh
```
Expected: exit `0` and refresh `tools/sow-toolkit` when source changes require
it.
- [ ] **Step 3: Build module wiki with the local toolkit**
Run from `../module`:
```bash
SOW_TOOLS_DEV_BINARY=../toolkit/tools/sow-toolkit ./build-wiki.sh --force
```
Expected: exit `0` and regenerate class wiki pages under `.cache/wiki/pages/`.
- [ ] **Step 4: Inspect fighter output**
Check generated fighter progression output for inline bonus feat text in Feats
cells and `becomes available` notes for level-gated selectable feat links.
- [ ] **Step 5: Run diff checks**
Run:
```bash
git diff --check
```
in both toolkit and module repos.
@@ -1,47 +0,0 @@
# Wiki List Columns 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:** Add a template-driven topdata wiki option for splitting provider-backed lists into configured columns with a fixed item count and optional forced empty columns.
**Architecture:** The toolkit wiki renderer gains a generic `columns` data-provider source that wraps another provider and exposes one row per rendered column. Each column row exposes `Items`, `Index`, and count metadata so page templates own markup while YAML owns row selection and chunking behavior. The module class page uses this provider for `ClassSkills`.
**Tech Stack:** Go `sow-toolkit`, YAML topdata wiki config, local HTML templates, Go tests.
---
### Task 1: Renderer Provider
**Files:**
- Modify: `toolkit/internal/topdata/wiki_data_providers.go`
- Modify: `toolkit/internal/topdata/wiki_template.go`
- Test: `toolkit/internal/topdata/wiki_native_test.go`
- [ ] Write failing tests for a `columns` provider that chunks `ClassSkills` into rows with nested `Items`, including forced empty columns.
- [ ] Run `go test ./internal/topdata -run 'TestWikiTemplate.*Column|TestBuildNativeWikiRendersClassSkillColumns'` and confirm the new assertions fail because `columns` is not supported.
- [ ] Add `provider`, `columns`, `items_per_column`, and `force_columns` fields to `wikiDataProviderDefinition`.
- [ ] Validate that `columns` providers name an existing provider, use positive integer `columns` and `items_per_column`, and do not point at themselves.
- [ ] Allow nested `{{#each Items}}` loops by teaching template `#each` to iterate an array field from the current row before falling back to named providers.
- [ ] Implement deterministic chunking: each column receives up to `items_per_column`; extra source items continue into additional overflow columns; `force_columns: true` emits empty configured columns when there are fewer items.
### Task 2: Module Template
**Files:**
- Modify: `module/topdata/wiki/data.yaml`
- Modify: `module/topdata/wiki/templates/pages/classes.html`
- Modify: `module/topdata/wiki/README.md`
- [ ] Add `ClassSkillColumns` using `source: columns`, `provider: ClassSkills`, `columns: 3`, `items_per_column: 12`, and `force_columns: true`.
- [ ] Replace the flat class skill `<ul>` with a `.wiki-list-columns .wiki-list-columns--3` wrapper and nested `{{#each Items}}` loops.
- [ ] Document the generic `columns` provider fields for template authors.
### Task 3: Verification
**Files:**
- Verify: `toolkit/internal/topdata/...`
- Verify: `module/topdata/wiki/...`
- [ ] Run `go test ./internal/topdata -run 'TestWikiTemplate|TestBuildNativeWiki'`.
- [ ] Run `./build-tool.sh` from `toolkit/`.
- [ ] Run `SOW_TOOLS_DEV_BINARY=../toolkit/tools/sow-toolkit ./build-wiki.sh --force` from `module/`.
- [ ] Inspect generated class HTML for `wiki-list-columns--3`, three configured class-skill lists for shorter classes, and overflow columns for long lists.
@@ -1,153 +0,0 @@
# Generated Wiki Stale Purge Design
## Goal
Add an explicit bulk purge path for generated NodeBB wiki pages that have become
stale after generator output changes, such as topdata visibility rules hiding
previously generated pages.
## Context
The topdata wiki deployer already owns generated page state through
`.cache/wiki/.wiki_deploy_manifest.json`. It currently recognizes stale
generated pages that remain in the deploy manifest but no longer exist in the
current generated wiki output. Existing stale policies can either report those
pages or archive them by rewriting their managed content.
That is not sufficient when the stale generated wiki topics should be removed
from NodeBB entirely. The Westgate Wiki plugin already treats a normal wiki topic
delete as an irreversible purge, so the deployer should use NodeBB's explicit
topic purge API when the operator asks for destructive generated-page cleanup.
## Scope
### In Scope
- Add `purge` as an explicit stale policy for topdata wiki deployment.
- Plan purge candidates only from generated page entries already tracked in the
wiki deploy manifest.
- Purge the exact tracked NodeBB topic for each stale generated page.
- Report purge counts in dry-run and live deploy output.
- Remove successfully purged page entries from the deploy manifest so later
deploys do not repeatedly target removed topics.
- Allow release automation to opt into purge through the existing stale-policy
environment variable path.
- Update toolkit and module documentation for the destructive cleanup mode.
### Out Of Scope
- Purging manually-authored NodeBB wiki pages.
- Discovering purge candidates by namespace browsing, title, slug, or wiki
search.
- Adding a soft-delete or restore workflow for stale generated wiki pages.
- Changing the Westgate Wiki plugin delete behavior.
- Adding a separate stale cleanup command outside `deploy-wiki`.
## Operator Interface
The existing `deploy-wiki` stale-policy surface gains a third explicit policy:
```bash
./deploy-wiki.sh --dry-run --stale-policy purge
./deploy-wiki.sh --stale-policy purge
```
Release automation may use the same policy through:
```bash
SOW_MODULE_WIKI_DEPLOY_STALE_POLICY=purge
```
`purge` remains opt-in. Existing `report` and `archive` behavior remain
unchanged.
## Purge Eligibility
A page may be purged only when all of these are true:
1. The page exists in `.cache/wiki/.wiki_deploy_manifest.json`.
2. The page is absent from the current generated wiki page set in the deploy
scope.
3. The deploy manifest entry has the tracked NodeBB topic id required to call
the topic purge API.
The deployer must not search NodeBB for candidate pages when purging stale
generated output. It must not purge pages that are currently generated. It must
not purge pages that only exist as manually-authored NodeBB wiki content.
If a stale manifest entry has no topic id, purge planning fails clearly instead
of guessing a remote target.
## Deploy Flow
Stale planning stays inside the existing `deploy-wiki` deploy plan:
- `report` increments the stale count only.
- `archive` produces the existing archive post-update action.
- `purge` produces a destructive topic-purge action for the manifest entry's
tracked topic id.
Dry runs calculate the same stale and purge counts as live runs but do not call
NodeBB and do not mutate the deploy manifest.
Live purge execution calls NodeBB's topic purge API for each planned stale
generated page. NodeBB auth, permission, missing-topic, or transport failures
abort the deploy with the failing generated page id and remote topic context.
## Manifest State
Archive retains stale entries in the manifest because the archived post remains
managed remotely.
Purge removes each successfully purged page entry from the next deploy manifest.
This records that the generated page no longer has a managed remote NodeBB topic
and prevents later deploys from attempting to purge an already removed topic.
If a purge deployment fails partway through, only state written after the
existing deploy execution succeeds should reach the manifest. A later rerun may
retry the stale manifest entries still present, matching the deployer's current
all-or-error manifest persistence behavior.
## Reporting And Validation
Toolkit deploy output gains a `purged` count alongside the existing `stale` and
`archived` counts. Dry-run summaries must make the destructive plan visible
before a live purge run.
`purge` must be accepted wherever stale policy is validated:
- project config stale-page settings
- `deploy-wiki --stale-policy`
- module release wrapper validation for
`SOW_MODULE_WIKI_DEPLOY_STALE_POLICY`
Unsupported stale policy values must keep failing early with clear error text.
## Testing
Toolkit tests should cover:
- stale policy validation accepts `purge`
- unsupported stale policy values still fail
- dry-run purge reports stale and purged counts without calling NodeBB or
changing the manifest
- live purge calls the NodeBB topic purge endpoint for a stale tracked generated
topic
- live purge removes the manifest entry after success
- purge refuses a stale manifest entry without a tracked topic id
- current generated pages are never purge candidates
- archive/report behavior remains unchanged
Module wrapper/docs coverage should verify:
- release stale-policy validation accepts `purge`
- docs describe purge as destructive, generated-manifest-scoped cleanup
## Compatibility
Default behavior remains `report`. Existing archive cleanup remains available and
unchanged. The destructive purge path activates only when the operator selects
`purge`.
The purge boundary is intentionally stricter than normal page adoption logic:
generated manifest state authorizes the target; NodeBB discovery never does.
@@ -1,105 +0,0 @@
# Class Progression Feat Availability Design
## Goal
Class progression wiki providers should let module-owned wiki configuration
distinguish automatically granted class feats from level-gated selectable feats
while also exposing bonus feat gains declared by class `bfeat` tables in the
progression table.
## Ownership
The authored wiki page layout and progression projection policy stay in
`sow-module/topdata/wiki/`. The toolkit owns generic progression row data
assembly from configured class feat and bonus-feat sources. The change should
stay inside those boundaries:
- module wiki YAML decides which class feat rows project into progression
fields;
- module page templates decide how progression row fields are rendered;
- toolkit progression row assembly groups configured projections by level
without hardcoding the module's class feat list semantics or note wording.
## Behavior
Class feat rows with positive `GrantedOnLevel` values are not all equivalent.
This module uses rows in class feat tables with `List` value `3` as
automatically granted feats and positive-level selectable rows as feats that
become available at the declared level. That distinction is repository policy
and must be declared in wiki YAML rather than hidden in toolkit code.
The `ClassProgression` data provider should support configured class feat
projection fields. This module can declare a shape such as:
```yaml
providers:
ClassProgression:
source: class_progression
levels: 1-12
class_feats:
fields:
GrantedFeats:
when: "{{GrantedOnLevel > 0 && List == 3}}"
AvailableFeats:
when: "{{GrantedOnLevel > 0 && List != 3}}"
```
For each progression row, the toolkit should:
- continue exposing the per-level bonus feat count from the class `bfeat` table
as `BonusFeat`;
- expose each configured class feat field as a deterministic list of resolved
wiki feat links for class feat rows whose configured expression matches the
progression level;
- leave non-matching class feat rows out of that configured field.
The default `class_progression` provider behavior should preserve the current
`GrantedFeats` projection when no custom field configuration is declared.
## Rendering
The class progression template can render `BonusFeat` inline in its existing
Feats cell rather than adding another progression column. It should emit the
separator only when `GrantedFeats` already rendered content so bonus-only rows
do not start with a comma.
The existing Notes column should render `AvailableFeats` in the module template
with module-owned wording such as `<feat link> becomes available.`. The lower
class feat table remains useful for listing class feat membership and list
metadata; its level label should not be interpreted as an automatic grant for
selectable rows.
## Data Flow
The toolkit resolves the class `FeatsTable` while building progression rows.
Each configured class feat field evaluates its `when` expression against class
feat rows, groups matched rows by positive `GrantedOnLevel`, resolves feat
references to wiki link text, and writes the grouped list onto the progression
row under the configured field name. Output order follows source table row
order so repeated builds are deterministic.
The class `BonusFeatsTable` lookup continues to populate `BonusFeat` from the
row whose id matches the progression level.
## Errors And Compatibility
The change should preserve current behavior for missing class feat tables,
missing bonus feat tables, unresolved feat references, and classes without
custom class feat projections. Invalid projection configuration should fail
early with the provider file path and field name. Existing module templates
that only read `GrantedFeats`, `BonusFeat`, and `Notes` continue to use the same
progression row fields.
## Tests
Toolkit coverage should verify:
- default progression behavior still exposes `GrantedFeats`;
- configured class feat fields filter by expression and group by level;
- configured output order follows source table row order;
- invalid configured class feat fields fail clearly;
- bonus feat counts remain available to the progression template.
Module validation should rebuild the generated wiki output with the local
toolkit binary after the toolkit change and inspect a class that has both
granted feats and level-gated selectable feats, such as fighter.