feat(templates): upgrade site-status-checker to v1.1.0 with config schema

First real exercise of the v2.3 configuration feature. The template no
longer asks the agent to bootstrap sites.txt on first run — instead,
users enter their list of URLs through the Configure form during
install, and change them later via the dashboard's Configuration
button. This makes the template a complete round-trip test of the
new feature end-to-end.

Schema (manifest.config.schema):
- `sites` — list<string>, required, 1–25 items, default two example
  URLs. This is the list the cron job hits.
- `timeout_seconds` — number, 1–60, default 10. Per-URL HTTP timeout.
- `modelRecommendation.preferred = claude-haiku-4` — rationale: simple
  tool-use task, Haiku is cost-effective for daily cron.

Manifest bumped: schemaVersion 1 → 2, version 1.0.0 → 1.1.0,
minScarfVersion 2.2.0 → 2.3.0, contents.config = 2.

AGENTS.md rewritten for the config-driven flow:
- Reads values from `.scarf/config.json` at run time (values.sites +
  values.timeout_seconds). No more sites.txt bootstrap.
- "Add a site" / "Remove a site" no longer mean the agent edits a
  file — they mean "open the Configuration button on the dashboard."
  The agent points the user there rather than trying to mutate
  config.json itself. A future Scarf release may expose a tool for
  agents to write config programmatically; until then, config is
  strictly a user action.
- First-run bootstrap now only creates status-log.md (if absent).

README.md rewritten to walk users through the new form-based flow,
explain the Configuration button, and document the model
recommendation. Uninstall instructions point at the right-click
Uninstall Template action rather than manual steps.

Cron prompt updated to reference config.json (values.sites,
values.timeout_seconds) instead of sites.txt.

ProjectTemplateExampleTemplateTests.siteStatusCheckerParsesAndPlans
extended with v2-specific assertions: manifest.schemaVersion == 2,
contents.config == 2, schema.fields.count == 2, per-field
constraints (sites type/itemType/minItems/maxItems, timeout
min/max), modelRecommendation.preferred, plan.configSchema +
plan.manifestCachePath are populated, plan.projectFiles includes
both config.json + manifest.json destinations. Cron-prompt assertion
swapped from sites.txt to config.json/values.sites.

Three suites that touch ~/.hermes/scarf/projects.json now carry
.serialized — the new Phase B install-with-config tests stressed the
parallel-execution race in the snapshot/restore helpers. Serializing
within each suite deflakes without any architectural change.

Swift 50/50, Python 24/24, catalog validator accepts the upgraded
bundle. Site detail page now has manifest.json for renderConfigSchema
to pick up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-23 02:21:14 +02:00
parent bb750e237e
commit 81e8da91d6
7 changed files with 157 additions and 53 deletions
+33 -6
View File
@@ -253,7 +253,7 @@ import Foundation
/// are exhaustively tested; global-state side effects (skills namespace,
/// cron CLI, memory append) are covered by manual verification per the
/// plan's step 7.
@Suite struct ProjectTemplateInstallerTests {
@Suite(.serialized) struct ProjectTemplateInstallerTests {
@Test func installsMinimalBundleAndWritesLockFile() throws {
let scratch = try ProjectTemplateServiceTests.makeTempDir()
@@ -370,7 +370,7 @@ import Foundation
/// it, verify every tracked file is gone, the registry is restored to its
/// pre-install state, and user-added files (if any) are preserved. Scoped
/// to bundles with no skills/cron/memory so no global state is touched.
@Suite struct ProjectTemplateUninstallerTests {
@Suite(.serialized) struct ProjectTemplateUninstallerTests {
@Test func roundTripsInstallThenUninstall() throws {
let scratch = try ProjectTemplateServiceTests.makeTempDir()
@@ -496,7 +496,7 @@ import Foundation
/// against a synthesized schemaful bundle. Uses an isolated Keychain
/// service suffix so no leftover login-Keychain items remain after the
/// test every secret we write is deleted on teardown.
@Suite struct ProjectTemplateConfigInstallTests {
@Suite(.serialized) struct ProjectTemplateConfigInstallTests {
/// Minimal schemaful manifest with one non-secret field + one
/// secret field. Written into the synthesized `.scarftemplate`
@@ -781,13 +781,31 @@ import Foundation
defer { service.cleanupTempDir(inspection.unpackedDir) }
#expect(inspection.manifest.id == "awizemann/site-status-checker")
#expect(inspection.manifest.schemaVersion == 2) // config-enabled
#expect(inspection.manifest.contents.dashboard)
#expect(inspection.manifest.contents.agentsMd)
#expect(inspection.manifest.contents.cron == 1)
#expect(inspection.manifest.contents.config == 2)
#expect(inspection.cronJobs.count == 1)
#expect(inspection.cronJobs.first?.name == "Check site status")
#expect(inspection.cronJobs.first?.schedule == "0 9 * * *")
// Schema assertions the two fields we declared should survive
// unzip + parse + validate with their constraints intact.
let schema = try #require(inspection.manifest.config)
#expect(schema.fields.count == 2)
let sitesField = try #require(schema.field(for: "sites"))
#expect(sitesField.type == .list)
#expect(sitesField.itemType == "string")
#expect(sitesField.required == true)
#expect(sitesField.minItems == 1)
#expect(sitesField.maxItems == 25)
let timeoutField = try #require(schema.field(for: "timeout_seconds"))
#expect(timeoutField.type == .number)
#expect(timeoutField.minNumber == 1)
#expect(timeoutField.maxNumber == 60)
#expect(schema.modelRecommendation?.preferred == "claude-haiku-4")
let scratch = try ProjectTemplateServiceTests.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: scratch) }
let plan = try service.buildPlan(inspection: inspection, parentDir: scratch)
@@ -795,6 +813,12 @@ import Foundation
#expect(plan.skillsFiles.isEmpty)
#expect(plan.memoryAppendix == nil)
#expect(plan.cronJobs.count == 1)
#expect(plan.configSchema?.fields.count == 2)
#expect(plan.manifestCachePath?.hasSuffix("/.scarf/manifest.json") == true)
// Plan queues both config.json + manifest.json in projectFiles.
let destinations = plan.projectFiles.map(\.destinationPath)
#expect(destinations.contains { $0.hasSuffix("/.scarf/config.json") })
#expect(destinations.contains { $0.hasSuffix("/.scarf/manifest.json") })
// Cron job name gets prefixed with the template tag so users can
// find + remove it later.
#expect(plan.cronJobs.first?.name == "[tmpl:awizemann/site-status-checker] Check site status")
@@ -820,10 +844,13 @@ import Foundation
#expect(statTitles.contains("Sites Down"))
#expect(statTitles.contains("Last Checked"))
// The cron prompt mentions sites.txt and dashboard.json if it
// ever stops doing that, the agent won't know what files to touch.
// Cron prompt references .scarf/config.json (where values.sites
// + values.timeout_seconds live) and the dashboard/log it writes.
// If either stops being referenced, the cron wouldn't know which
// data to read or where to write results.
let cronPrompt = inspection.cronJobs.first?.prompt ?? ""
#expect(cronPrompt.contains("sites.txt"))
#expect(cronPrompt.contains("config.json"))
#expect(cronPrompt.contains("values.sites"))
#expect(cronPrompt.contains("dashboard.json"))
#expect(cronPrompt.contains("status-log.md"))
}