diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 13949c0..1811a52 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ # Contributing -Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/e5dbf426e4417f4f2f73c9f855d34808f7fa8f9a/labs/12-product-engineering-loop). +Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/7bcc7dcb692a3f4b34f6cbd84d46e648e055634e/labs/12-product-engineering-loop). The Boatstack repository receives product/runtime changes through a generated pull request. Review the PR's `UPSTREAM.json`, tests, adapter diff, and context-size change; do not hand-edit generated output on `main`. `.github/workflows` is the exception: it is Boatstack's executable control plane, excluded from scheduled projection and changed only through a separate manually reviewed Boatstack PR. diff --git a/UPSTREAM.json b/UPSTREAM.json index 79feb9b..bca1fef 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "c6a8df3d94c8dd4228cbd6e1d85223ebd3f64613b31a534ee34f9d12c4ccee1f", + "CONTRIBUTING.md": "d384c4c6422684eddae5de40a837210dd0a40712caecff478a41d17563e77e7e", "README.md": "3ce3e95e511089b44e946a44b8d5f4f81d019ece5336db65b2cab1f9dc4d4dad", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -36,15 +36,15 @@ "boatstack/atomic_unix.go": "89f2723361591de2bb8bd22ce7e34ec529d3278509f0df78fd5c4a7d4140fbe9", "boatstack/atomic_windows.go": "cefd775cbe7e7c3bd8a3f5673b11cdd784c6d3ebd6de7dcb8f39406b0bee511f", "boatstack/attach.go": "6a855440fac9acc63be857efef9d76210a4619774728684832dfbb08caf42a30", - "boatstack/capability.go": "fc6bccb0ce4df579b81252ecffeb23c68d62f02ff041bfec980d844baf0885a3", - "boatstack/capability_test.go": "e8322903a843970d7f0317d2629466532cb05c3ffcb44b9423adf4219faa8021", - "boatstack/capture.go": "98a29fedcd7f6a95fadc9bac8c1bd601fee24c17cd64a6a76892e068f040ef66", - "boatstack/capture_test.go": "7072d0c5f9ab0ea9ef493f9e6dcb01cf0de544514c8201e30b8e4e6a4065505a", + "boatstack/capability.go": "270288270ac9689551232e772d1656acbb4c3b2e730c441beb7064b53dd21836", + "boatstack/capability_test.go": "4537db261b48311f6fd50f4bdf7c88b0795407dcb2ea627273d8fbd167c40892", + "boatstack/capture.go": "dc94692728f72626f2818fcb0f76383a19254592299b0afbec848bfbd7e1c734", + "boatstack/capture_test.go": "84a74efde7ae6f9f0900c764f0cea6d84b00c1f71efb1a34f62e6f81d5104f7f", "boatstack/changelog.go": "6b06be7cd9738de29ba6e87aa2569f3b027a2e618b04524f5abd7abaa17945bf", "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", "boatstack/cmd/boatstack-helper/coverage_conformance_test.go": "5c10219591d5b9998361ed83eac88debb4d4c338c835c7502b709ed81cf151b6", "boatstack/cmd/boatstack-helper/flow.go": "0d41c7a86b49004e897f59551780220841d5941396202c81de97a4d52f593520", - "boatstack/cmd/boatstack-helper/main.go": "903a233fdbe706e9281b9840abda458e78035b8379a99c9c97dd922832430a64", + "boatstack/cmd/boatstack-helper/main.go": "538f56dc211e2332d6d49b1fe6885b8b1fecc3f6a5a86a6b555f01f096973318", "boatstack/cmd/boatstack-helper/main_test.go": "b36c52d6d5c9dd2428730de10ff18194b7e32a98722e41341c301c6f7a04cad5", "boatstack/cmd/boatstack-helper/retro.go": "68b83e33ade5b5fec126c70ec798fdcbed22fda755dc1cad2758143fead8e187", "boatstack/command.go": "4726ac515dedab4947be7eb48f88c6cb8b53d674124504b69f03e6396b080ee8", @@ -159,19 +159,19 @@ "boatstack/paths.go": "f9a615f35e0f439d6f11c48e881f11db26c0029e1eb7dd5978941cf51c74e710", "boatstack/plan.go": "a130c9e587f97a280b6cfa531bcaab46fc6e58772f27921031be03eb98f04439", "boatstack/plan_test.go": "1b01e7d9d7794eb11c998e19a2f3532d3b8509d984eca934cf5f1662a0a7e573", - "boatstack/plan_validation.go": "99e40806fd579ff72de53f391cd6124acc9ff18707ecf9676c5e507b738d87d0", - "boatstack/plan_validation_test.go": "6cbde4ac719baef6b73aa569515d6a9daadcbf14b33f76fa78159826954e20fa", + "boatstack/plan_validation.go": "48c4fd061257f821ed54a0892b291cc04450e93e3db48f358f8c6599b21f9e41", + "boatstack/plan_validation_test.go": "406c672470e909cb8d54f42175952c581ea872fa21bdcf34675f23187688fe33", "boatstack/planning.go": "63f2dcfcce85a3c7a39d183b5a557f7085f306ed57b130282e0678209b05c6c8", "boatstack/planning_first_write_conformance_test.go": "873097aa9384b75bf01e74a475f3ec2ac7cca4a28f733e82f2c82959032c6a30", "boatstack/planning_test.go": "06ec7022222d926040c3ae28b84ab50c3d2f804ae6473e61b303804dd992d884", "boatstack/post_publish_prescribe_conformance_test.go": "3c20d359ff84648db7dedb227b4d64e6574d9f41d3cdca0adefec1c60bfbf4ae", - "boatstack/pr.go": "57a9f63fc622dc76bd2382cf2ccb9099b447d37eafadc457e3743217eba7a714", + "boatstack/pr.go": "4c6952a9481d0c5d39f4ef92fef0de026f3a5cf1e4c1cf23525626ede766917f", "boatstack/pr_phase.go": "59f8cbb75b6b538a5345474acd6a725450979579bf8ecf9591956cbbe1cc4737", "boatstack/pr_phase_conformance_test.go": "bc9c834e9c4ed43b35d81abafd7b1bf2a264ea2a8c4a4ec9758ee18d1d438968", "boatstack/pr_test.go": "5c0ff03eb21e383026a4e9fbc5671b2e316040e4e4c55ab6583b930e7e117dda", "boatstack/provenance.go": "d44dcd5421306269326f1202ba1d52df8c252490550270ef9d022e8ec2b65210", - "boatstack/provision.go": "eb7333a73331b011adc93f59a2d97415d850c2e588f0e2bbb5116984e2ef927d", - "boatstack/provision_test.go": "214e9edb991a66d5bbb696a7c1b63876d2f799f2cab4e3f40785f4e8f1eac57b", + "boatstack/provision.go": "9531f975f09f61ba3eed120ca7518cdc53b1e0df9f45a4da2bd7f1f8d96984fe", + "boatstack/provision_test.go": "70199eac574cc8843ce12f2bad58b7fea0f86a4205a6d3be0a96594abd967b5d", "boatstack/publication_ignored_repro_test.go": "b6f3aeb8ba22949ff9af7ac5afe8fb828385d9708d5d5893ef41f33a3de873e1", "boatstack/published_slice_routing_test.go": "ea7e7351018bc13dcd31c4b96f50f8bc230e8a1dbf7806fba32a12ae58923e7e", "boatstack/readiness.go": "30ddf87d650c92e66f4393c6d18b6728b0b38f0831f564159998be2dbe12b708", @@ -182,7 +182,7 @@ "boatstack/reexec_unix.go": "ff86157a9aa20c82a56fcd859b70669b7eacf4e0a9f61a4546ef33808437939e", "boatstack/reexec_windows.go": "f5335c8c28cb4e89048b058b1c4d12f78644f99acb4f6167ff60e622dfb9e742", "boatstack/references/artifacts.md": "e362b2663c904beb8daa777c997f9fcb2ed0e70442ed2e7bef3b169895d170f1", - "boatstack/references/config-schema.md": "01a3b7a9269a08a5cd2a86e967940c1e57f78e166db0b7289bff7a98732c8691", + "boatstack/references/config-schema.md": "d295a7ecc5545cffad056ada6b9681ab82545b68ed92a4a8270a775b444f5a18", "boatstack/references/failure-moves.md": "b65ef72035afa6ad0dce589a0b38f84bc40cde3864c9ecf973f08fc687f001c3", "boatstack/references/host-hook-contracts.md": "2a89d44d0e418a53f2e3b6300fed957cdf878f45ea97ce24b55b66065f0eaa1d", "boatstack/references/irreversible-operation-boundary.md": "e0076f0fea3bf729b2e9bdf353eaeaaf7cdafabfaf26b8d9b27287e5414c2441", @@ -218,7 +218,7 @@ "boatstack/update_publication_test.go": "c5f32578db53be65e35452d5e8b4520884354e4e80a370dcec19860ea644d091", "boatstack/update_test.go": "bf5f19f8499db6dd7356917d867b113b790548ab89bfdea59e2adf4999a82a6a", "boatstack/visual_attach_conformance_test.go": "a376f032cfc59615fe4d403bdb8d7c7d3972dc7de334c3f6763edf1531ff1f03", - "boatstack/visual_evidence.go": "f9fbde0e89f0ce8d9503939197d9b92cc8da377053007371d28360e29715a84a", + "boatstack/visual_evidence.go": "682debd135dea3835c5d5a2a8eb4bff1d3bb814c30990a9ea71cf00bbdae9adb", "boatstack/visual_evidence_test.go": "b2e50093080d4c2381c8f441a4e1cfeca714fa3d034e7536835b7077fad0556a", "boatstack/visual_publisher.go": "ec5e95228b48e4ec20731975606048f5e732c4e09a7fd175770d4b15e447cc88", "boatstack/visual_publisher_test.go": "979f600edae00c77569ae753b80530e8cbf2e3b342995efcd992d351ff382eaf", @@ -231,11 +231,11 @@ "docs/account-recovery-walkthrough.md": "676034974594a7d1a559b24dbed31d7ccc429eb81404b203ca07bbdaa19ec3d3", "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", - "docs/configuration.md": "8c5b0a6a8394333165dd0b2b1a70ca74e9d53f784b478e2ef7f1b12d93bc03b0", - "docs/evidence-engineered-coding.md": "a0bc4ce88390774ea8ad188173264024505dc84e554820b3b219944e11cfe870", + "docs/configuration.md": "675cfe8fc5d97bea8d925d47a3c3b852cf6cebbb050d2b4d0266684d2c531d3b", + "docs/evidence-engineered-coding.md": "059d890ee788558964a5ffc2839674194434590ee8360e1b8352c9e94e5a1a8e", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "51c2823f21e35140d31e6d5083dc4b89fddd24721ac6acc474154a4da53ee9f8", - "docs/public-claims.json": "dd049cd3c8e23362bdd0bfcf2090015432f309e6298ab8b45b67741b780d170a", + "docs/public-claims.json": "6495ef482d4d92fa0f10d84e70f36c1e1c5f57dbd801224c6bc25a76d41138a0", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -249,7 +249,7 @@ "labs/diagram-json/compiled/evidence.md": "1ba1c989ade070a8ef9a508fbd788d100d7292f2dbacbb2bce895468019f619d", "labs/diagram-json/compiled/tasks.json": "88f60851abf79d851e9fccc754ff3040034ae595306bc87d64784c19eb403e71", "labs/diagram-json/compiled/test-matrix.json": "424657ff505768e50fa113801fd8363364a18269d5297480907a993d44063a39", - "labs/diagram-json/plan.lock.json": "eebacf19b2b399d8cb6bbd3aad96e9e1705e369b69561f8841aeeb06f5d68e03", + "labs/diagram-json/plan.lock.json": "bdf5a145a1d663b2157e1e8afc19ac4829c6476e10206e312e8d5e32695db37e", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -392,6 +392,7 @@ "release-notes/2026-07-28-retromine-recurrence-detector.md": "27790993a02e73f3a2700dce7340d044aeb0e52f785ded68271ff6673add9e25", "release-notes/2026-07-29-readiness-and-journey-control.md": "2411db76974990ad0128fcd90e795c4c1c5c70d6d6f15fccd452f7e1a242310b", "release-notes/2026-07-30-auto-capture-on-ship.md": "49aa077ac52005d380ba2cae9e406c7d0a9941157c40aa4ed44b9e59195b3837", + "release-notes/2026-07-30-per-surface-capture-harnesses.md": "6489002d0f95088be0d900b7699800d830ba8a1f8e299251405cefe664c91d5b", "release-notes/2026-07-30-plan-approved-scenarios-escalate-to-require.md": "21c3f2be51b834fc2eb7662236db2549e80b1a1a0665a0721e773b7736e2c079", "release-notes/2026-07-30-visual-attach-retry-prescription.md": "38b610685a7a62c0341ba21b273b42c24d82ca803de6d8f694754156eaa606a9", "release-notes/2026-07-30-visual-evidence-survives-preview-commit.md": "71d19e9fabb40e8cb1e939f26bf288911d227979926f43f337046c6cf6b66551" @@ -399,7 +400,7 @@ "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "e5dbf426e4417f4f2f73c9f855d34808f7fa8f9a", + "commit": "7bcc7dcb692a3f4b34f6cbd84d46e648e055634e", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/capability.go b/boatstack/capability.go index 8d4688b..8e7b76f 100644 --- a/boatstack/capability.go +++ b/boatstack/capability.go @@ -68,3 +68,20 @@ func ResolveCapability(name string, config ProjectConfig) (CapabilityResolution, } return CapabilityResolution{Name: capability.Name, Kind: "unavailable"}, nil } + +// ResolveCapabilityForSurface resolves a capability command for one product +// surface: the surface-scoped key ("visual:web") outranks the global alias +// ladder, and an empty or unregistered surface falls back to it exactly — a +// repository with only a global command keeps serving every surface. +func ResolveCapabilityForSurface(name, surface string, config ProjectConfig) (CapabilityResolution, error) { + capability, ok := LookupCapability(name) + if !ok { + return CapabilityResolution{}, fmt.Errorf("unknown evidence capability %q", name) + } + if surface = strings.TrimSpace(surface); surface != "" { + if command := strings.TrimSpace(config.Project.Commands[capability.Name+":"+surface]); command != "" { + return CapabilityResolution{Name: capability.Name, Kind: "repository-command", Command: command}, nil + } + } + return ResolveCapability(name, config) +} diff --git a/boatstack/capability_test.go b/boatstack/capability_test.go index 8e30e72..21b5a66 100644 --- a/boatstack/capability_test.go +++ b/boatstack/capability_test.go @@ -59,3 +59,40 @@ func TestLookupCapabilityExposesRegisteredMetadata(t *testing.T) { t.Fatalf("visual capability metadata is incomplete: %#v", capability) } } + +// Invariant: a surface-scoped command outranks the global alias, and its +// absence falls back to the global ladder exactly — a repository with one +// global harness keeps serving every surface (zero-value behavior). +func TestResolveCapabilityForSurfacePrefersSurfaceScopedCommand(t *testing.T) { + config := testConfig() + delete(config.Project.Commands, "visual") + delete(config.Project.Commands, "screenshot") + delete(config.Project.Commands, "e2e") + config.Project.Commands["visual"] = "npm run capture:visual" + config.Project.Commands["visual:web"] = "npm run capture:web" + + resolution, err := ResolveCapabilityForSurface("visual", "web", config) + if err != nil { + t.Fatal(err) + } + if resolution.Command != "npm run capture:web" { + t.Fatalf("surface key did not outrank the global alias: %#v", resolution) + } + for _, surface := range []string{"", "ops"} { + resolution, err = ResolveCapabilityForSurface("visual", surface, config) + if err != nil { + t.Fatal(err) + } + if resolution.Command != "npm run capture:visual" { + t.Fatalf("surface %q did not fall back to the global alias: %#v", surface, resolution) + } + } + delete(config.Project.Commands, "visual") + resolution, err = ResolveCapabilityForSurface("visual", "ops", config) + if err != nil { + t.Fatal(err) + } + if resolution.Kind != "unavailable" { + t.Fatalf("unregistered surface with no global command must be unavailable: %#v", resolution) + } +} diff --git a/boatstack/capture.go b/boatstack/capture.go index c3481d2..8c7b518 100644 --- a/boatstack/capture.go +++ b/boatstack/capture.go @@ -47,6 +47,7 @@ func (execCaptureRunner) Run(request CaptureRequest) error { "BOATSTACK_CAPTURE_ENTRY="+request.Scenario.Entry, "BOATSTACK_CAPTURE_STATE="+request.Scenario.State, "BOATSTACK_CAPTURE_VIEWPORT="+request.Scenario.Viewport, + "BOATSTACK_CAPTURE_SURFACE="+request.Scenario.Surface, "BOATSTACK_CAPTURE_OUTPUT="+request.OutputPath, ) // The harness's authoritative output is the PNG on disk, not stdout; only @@ -96,14 +97,6 @@ func CaptureEvidence(options CaptureEvidenceOptions) (PRVisualEvidenceManifest, if err != nil { return PRVisualEvidenceManifest{}, fmt.Errorf("capture requires a valid Boatstack project configuration: %w", err) } - resolution, err := ResolveCapability(name, config) - if err != nil { - return PRVisualEvidenceManifest{}, err - } - if resolution.Kind != "repository-command" { - return PRVisualEvidenceManifest{}, fmt.Errorf("evidence capability %q is unavailable: register a repository command (project.commands) or provision it first", name) - } - relevance, source, scenarios, err := planVisualDecision(repo, feature) if err != nil { return PRVisualEvidenceManifest{}, err @@ -114,6 +107,13 @@ func CaptureEvidence(options CaptureEvidenceOptions) (PRVisualEvidenceManifest, if len(scenarios) == 0 { return PRVisualEvidenceManifest{}, fmt.Errorf("no %s scenarios declared in the plan (pr_visual_evidence.scenarios)", name) } + // Every scenario's command must resolve before any capture runs — a + // surface-scoped key outranks the global alias; a missing surface key + // with no global fallback is named exactly, never captured around. + commands, err := resolveScenarioCaptureCommands(name, scenarios, config) + if err != nil { + return PRVisualEvidenceManifest{}, err + } head, err := gitCommand(repo, "rev-parse", "--abbrev-ref", "HEAD") if err != nil { @@ -147,7 +147,7 @@ func CaptureEvidence(options CaptureEvidenceOptions) (PRVisualEvidenceManifest, items := make([]PRVisualEvidenceItem, 0, len(scenarios)) for _, scenario := range scenarios { outputPath := filepath.Join(stagingDir, scenario.ID+".png") - if err := captureScenario(repo, capability, resolution.Command, scenario, outputPath, feature, head, headCommit, diffHash, runner); err != nil { + if err := captureScenario(repo, capability, commands[scenario.ID], scenario, outputPath, feature, head, headCommit, diffHash, runner); err != nil { return PRVisualEvidenceManifest{}, err } items = append(items, PRVisualEvidenceItem{ @@ -182,6 +182,28 @@ func CaptureEvidence(options CaptureEvidenceOptions) (PRVisualEvidenceManifest, return saved, nil } +// resolveScenarioCaptureCommands resolves the harness command for every +// scenario up front (surface key first, global alias fallback), so capture +// either runs with a complete command map or fails naming the exact missing +// registration before any harness executes. +func resolveScenarioCaptureCommands(name string, scenarios []PRVisualScenario, config ProjectConfig) (map[string]string, error) { + commands := make(map[string]string, len(scenarios)) + for _, scenario := range scenarios { + resolution, err := ResolveCapabilityForSurface(name, scenario.Surface, config) + if err != nil { + return nil, err + } + if resolution.Kind != "repository-command" { + if surface := strings.TrimSpace(scenario.Surface); surface != "" { + return nil, fmt.Errorf("evidence capability %q is unavailable for surface %q: register project.commands[%q] (capability-register --capability %s --surface %s --command ) or a global command", name, surface, name+":"+surface, name, surface) + } + return nil, fmt.Errorf("evidence capability %q is unavailable: register a repository command (capability-register --capability %s --command ) or provision it first", name, name) + } + commands[scenario.ID] = resolution.Command + } + return commands, nil +} + // captureProductDiff reproduces the pr-context product-diff fingerprint so a // captured manifest is trusted (PASS) by resolvePRVisualEvidence: same product // diff. The head commit is recorded for provenance only — trust is keyed to diff --git a/boatstack/capture_test.go b/boatstack/capture_test.go index 183e8df..922b092 100644 --- a/boatstack/capture_test.go +++ b/boatstack/capture_test.go @@ -179,3 +179,112 @@ func TestCaptureEvidenceRequiresAResolvedCapabilityCommand(t *testing.T) { t.Fatalf("capture ran without a resolved repository command: %v", err) } } + +// Invariant: capture resolves the harness per scenario surface — each +// scenario runs its own registered command with the surface in the request — +// and an unresolvable surface fails before any harness runs, naming the exact +// missing registration key. +func TestCaptureEvidenceResolvesPerSurfaceCommands(t *testing.T) { + repo := captureTestRepo(t, "reviewer-ready") + directory := filepath.Join(repo, ".product-loop", "features", "reviewer-ready") + plan := validPlan() + plan["feature_id"] = "reviewer-ready" + plan["pr_visual_evidence"] = map[string]any{ + "relevance": "relevant", + "scenarios": []any{ + map[string]any{ + "id": "warning", "entry": "/onboarding", "state": "picker open", "viewport": "1440x900", + "expected": []any{"warning visible"}, "surface": "web", + }, + map[string]any{ + "id": "console", "entry": "/ops/queues", "state": "backlog shown", "viewport": "1280x800", + "expected": []any{"queue depth visible"}, "surface": "ops", + }, + }, + } + writeMarkdownPlan(t, filepath.Join(directory, "plan.md"), plan, true) + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "declare per-surface scenarios") + + configPath := filepath.Join(repo, ".product-loop", "project.json") + config, _, err := LoadConfig(configPath) + if err != nil { + t.Fatal(err) + } + config.Project.Commands["visual:web"] = "run-web-harness" + value, err := MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, value, 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "register web surface harness") + + // One surface key missing and no usable... the global "visual" command is + // still registered in the fixture, so ops falls back to it: capture runs. + commandsSeen := map[string]string{} + surfacesSeen := map[string]string{} + runner := &stubCaptureRunner{write: func(request CaptureRequest) error { + commandsSeen[request.Scenario.ID] = request.Command + surfacesSeen[request.Scenario.ID] = request.Scenario.Surface + writeTestPNG(t, request.OutputPath) + return nil + }} + if _, err := CaptureEvidence(CaptureEvidenceOptions{Repo: repo, Capability: "visual", Feature: "reviewer-ready", Runner: runner}); err != nil { + t.Fatalf("per-surface capture failed: %v", err) + } + if commandsSeen["warning"] != "run-web-harness" || surfacesSeen["warning"] != "web" { + t.Fatalf("web scenario did not run its surface harness: %q (%q)", commandsSeen["warning"], surfacesSeen["warning"]) + } + if commandsSeen["console"] != "exit 1" || surfacesSeen["console"] != "ops" { + t.Fatalf("ops scenario did not fall back to the global command: %q (%q)", commandsSeen["console"], surfacesSeen["console"]) + } + + // Remove the global fallback: the ops surface now has no registration and + // capture refuses up front, naming the exact missing key. + delete(config.Project.Commands, "visual") + delete(config.Project.Commands, "screenshot") + delete(config.Project.Commands, "e2e") + value, err = MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, value, 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "remove global harness") + priorCalls := runner.calls + if _, err := CaptureEvidence(CaptureEvidenceOptions{Repo: repo, Capability: "visual", Feature: "reviewer-ready", Runner: runner}); err == nil || !strings.Contains(err.Error(), "visual:ops") { + t.Fatalf("unresolvable surface was not named: %v", err) + } + if runner.calls != priorCalls { + t.Fatal("capture ran a harness despite an unresolvable surface") + } +} + +// Invariant: capability-register --surface writes the surface-scoped command +// key, and the registered command is what surface resolution selects. +func TestRegisterCapabilityCommandWithSurface(t *testing.T) { + repo := captureTestRepo(t, "reviewer-ready") + registered, err := RegisterCapabilityCommand(repo, "visual", "ops", "npm run capture:ops") + if err != nil { + t.Fatal(err) + } + if registered.Alias != "visual:ops" { + t.Fatalf("surface registration wrote the wrong key: %#v", registered) + } + config, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + if err != nil { + t.Fatal(err) + } + resolution, err := ResolveCapabilityForSurface("visual", "ops", config) + if err != nil || resolution.Command != "npm run capture:ops" { + t.Fatalf("registered surface command did not resolve: %#v %v", resolution, err) + } + if _, err := RegisterCapabilityCommand(repo, "visual", "Web Ops", "x"); err == nil || !strings.Contains(err.Error(), "kebab") { + t.Fatalf("invalid surface slug was not rejected: %v", err) + } +} diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 0e25bcd..5f19ba0 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -662,6 +662,7 @@ func capabilityRegisterCommand(arguments []string) int { flags := flag.NewFlagSet("capability-register", flag.ContinueOnError) repo := flags.String("repo", ".", "repository whose Boatstack configuration owns the command") capability := flags.String("capability", "visual", "evidence capability to register a command for") + surface := flags.String("surface", "", "optional product surface (e.g. web, ops) to scope the command to") command := flags.String("command", "", "repository command that produces the evidence") if err := flags.Parse(arguments); err != nil { return 2 @@ -669,7 +670,7 @@ func capabilityRegisterCommand(arguments []string) int { if *command == "" { return fail(fmt.Errorf("capability-register requires --command")) } - registered, err := boatstack.RegisterCapabilityCommand(*repo, *capability, *command) + registered, err := boatstack.RegisterCapabilityCommand(*repo, *capability, *surface, *command) if err != nil { return fail(err) } diff --git a/boatstack/plan_validation.go b/boatstack/plan_validation.go index 5e57ba3..461e05c 100644 --- a/boatstack/plan_validation.go +++ b/boatstack/plan_validation.go @@ -3,8 +3,14 @@ package boatstack import ( "fmt" "path/filepath" + "regexp" ) +// surfaceSlugPattern names a product surface (web, ops, admin-console): +// lowercase kebab, matching the project.commands["visual:"] key +// convention shared by plan scenarios and capability-register --surface. +var surfaceSlugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + type ValidatePlanOptions struct { PlanPath string RepoRoot string @@ -196,6 +202,9 @@ func validatePRVisualEvidence(plan map[string]any) error { return fmt.Errorf("pr_visual_evidence scenario %s requires %s", id, field) } } + if surface := stringValue(scenario["surface"]); surface != "" && !surfaceSlugPattern.MatchString(surface) { + return fmt.Errorf("pr_visual_evidence scenario %s surface must be a lowercase kebab slug", id) + } expected, ok := stringSlice(scenario["expected"]) if !ok || len(expected) == 0 { return fmt.Errorf("pr_visual_evidence scenario %s requires expected visible outcomes", id) diff --git a/boatstack/plan_validation_test.go b/boatstack/plan_validation_test.go index 9048db7..da05031 100644 --- a/boatstack/plan_validation_test.go +++ b/boatstack/plan_validation_test.go @@ -191,6 +191,20 @@ func TestValidatePRVisualEvidence(t *testing.T) { if err := validatePRVisualEvidence(plan); err == nil || !strings.Contains(err.Error(), "one to three") { t.Fatalf("empty relevant scenarios were not rejected: %v", err) } + plan["pr_visual_evidence"] = map[string]any{ + "relevance": "relevant", + "scenarios": []any{map[string]any{ + "id": "warning", "entry": "/onboarding", "state": "picker open", "viewport": "1440x900", + "expected": []any{"warning visible"}, "surface": "admin-console", + }}, + } + if err := validatePRVisualEvidence(plan); err != nil { + t.Fatalf("valid surface slug was rejected: %v", err) + } + plan["pr_visual_evidence"].(map[string]any)["scenarios"].([]any)[0].(map[string]any)["surface"] = "Web Ops" + if err := validatePRVisualEvidence(plan); err == nil || !strings.Contains(err.Error(), "surface") { + t.Fatalf("invalid surface slug was not rejected: %v", err) + } plan["pr_visual_evidence"] = map[string]any{"relevance": "not_relevant", "scenarios": []any{}} if err := validatePRVisualEvidence(plan); err == nil || !strings.Contains(err.Error(), "reason") { t.Fatalf("missing not-relevant reason was not rejected: %v", err) diff --git a/boatstack/pr.go b/boatstack/pr.go index 5473b9c..16dbb49 100644 --- a/boatstack/pr.go +++ b/boatstack/pr.go @@ -113,7 +113,7 @@ func planVisualDecision(repo, feature string) (string, string, []PRVisualScenari expected, _ := stringSlice(row["expected"]) scenarios = append(scenarios, PRVisualScenario{ ID: stringValue(row["id"]), Entry: stringValue(row["entry"]), State: stringValue(row["state"]), - Viewport: stringValue(row["viewport"]), Expected: expected, + Viewport: stringValue(row["viewport"]), Expected: expected, Surface: stringValue(row["surface"]), }) } return relevance, "managed-plan", scenarios, nil @@ -142,12 +142,12 @@ func ensureCurrentPRVisualEvidence(repo string, config ProjectConfig, mode, feat if loaded, loadErr := LoadPRVisualEvidence(repo, key); loadErr == nil && loaded.Status == "PASS" && loaded.ProductDiffSHA256 == diffHash { return "", nil } - resolution, err := ResolveCapability("visual", config) - if err != nil || resolution.Kind != "repository-command" { - // The agent-mediated capture rungs (host browser, supplied launch) - // are deliberately not automated here; without a repository-owned - // command the prescribed path stays exactly as it was. - return "no visual capture capability is registered; register one with capability-register --capability visual --command ", nil + // Every declared surface must resolve to a repository command for capture + // to be automatic; the agent-mediated rungs (host browser, supplied + // launch) are deliberately not automated here, so any unresolvable + // scenario keeps the prescribed path exactly as it was. + if _, resolveErr := resolveScenarioCaptureCommands("visual", scenarios, config); resolveErr != nil { + return boundedCaptureDetail(resolveErr.Error()), nil } dirtyBefore, err := dirtyPaths(repo) if err != nil { diff --git a/boatstack/provision.go b/boatstack/provision.go index 03ed29f..b0697a9 100644 --- a/boatstack/provision.go +++ b/boatstack/provision.go @@ -122,6 +122,7 @@ var captureContract = []string{ "BOATSTACK_CAPTURE_ENTRY — the scenario entry point (route or component).", "BOATSTACK_CAPTURE_STATE — the scenario state to render.", "BOATSTACK_CAPTURE_VIEWPORT — the required viewport, e.g. 1440x900.", + "BOATSTACK_CAPTURE_SURFACE — the scenario's declared product surface (empty when undeclared).", "BOATSTACK_CAPTURE_OUTPUT — the absolute path the harness must write exactly one PNG to.", "Render fixture or mock data only; never production secrets or PII.", } @@ -210,7 +211,7 @@ type RegisteredCapability struct { // and regenerates the full export so the source and every generated file stay in // sync; otherwise it round-trips the generated project.json alone, matching the // IgnoreDelivery idiom. -func RegisterCapabilityCommand(repo, name, command string) (RegisteredCapability, error) { +func RegisterCapabilityCommand(repo, name, surface, command string) (RegisteredCapability, error) { resolved, err := ResolveRepository(repo) if err != nil { return RegisteredCapability{}, err @@ -223,6 +224,15 @@ func RegisterCapabilityCommand(repo, name, command string) (RegisteredCapability if command == "" { return RegisteredCapability{}, fmt.Errorf("capability-register requires a non-empty --command") } + // A surface scopes the registration to one product surface's harness + // (project.commands["visual:"]); empty keeps the global key. + alias := capability.Name + if surface = strings.TrimSpace(surface); surface != "" { + if !surfaceSlugPattern.MatchString(surface) { + return RegisteredCapability{}, fmt.Errorf("capability-register --surface must be a lowercase kebab slug") + } + alias = capability.Name + ":" + surface + } sourcePath := WorkspaceFor(resolved).SourceConfigPath() if fileExists(sourcePath) { @@ -230,7 +240,7 @@ func RegisterCapabilityCommand(repo, name, command string) (RegisteredCapability if err != nil { return RegisteredCapability{}, err } - setCapabilityCommand(&config, capability.Name, command) + setCapabilityCommand(&config, alias, command) if err := ValidateConfig(config); err != nil { return RegisteredCapability{}, err } @@ -248,7 +258,7 @@ func RegisterCapabilityCommand(repo, name, command string) (RegisteredCapability if err := atomicWriteMode(sourcePath, rawConfig, 0o644); err != nil { return RegisteredCapability{}, err } - return RegisteredCapability{Capability: capability.Name, Alias: capability.Name, Command: command, Source: "source-and-export"}, nil + return RegisteredCapability{Capability: capability.Name, Alias: alias, Command: command, Source: "source-and-export"}, nil } configPath := WorkspaceFor(resolved).ProjectConfigPath() @@ -256,7 +266,7 @@ func RegisterCapabilityCommand(repo, name, command string) (RegisteredCapability if err != nil { return RegisteredCapability{}, err } - setCapabilityCommand(&config, capability.Name, command) + setCapabilityCommand(&config, alias, command) if err := ValidateConfig(config); err != nil { return RegisteredCapability{}, err } @@ -267,7 +277,7 @@ func RegisterCapabilityCommand(repo, name, command string) (RegisteredCapability if err := atomicWriteMode(configPath, value, 0o644); err != nil { return RegisteredCapability{}, err } - return RegisteredCapability{Capability: capability.Name, Alias: capability.Name, Command: command, Source: "generated-only"}, nil + return RegisteredCapability{Capability: capability.Name, Alias: alias, Command: command, Source: "generated-only"}, nil } func setCapabilityCommand(config *ProjectConfig, alias, command string) { diff --git a/boatstack/provision_test.go b/boatstack/provision_test.go index 77bdc83..48ec063 100644 --- a/boatstack/provision_test.go +++ b/boatstack/provision_test.go @@ -121,7 +121,7 @@ func TestRegisterCapabilityCommandSyncsSourceAndExport(t *testing.T) { t.Fatal(err) } - result, err := RegisterCapabilityCommand(repo, "visual", "pnpm run capture:visual") + result, err := RegisterCapabilityCommand(repo, "visual", "", "pnpm run capture:visual") if err != nil { t.Fatal(err) } @@ -153,7 +153,7 @@ func TestRegisterCapabilityCommandSyncsSourceAndExport(t *testing.T) { func TestRegisterCapabilityCommandFallsBackToGeneratedConfig(t *testing.T) { repo := t.TempDir() writeProjectConfig(t, repo, nil) // generated project.json only, no source - result, err := RegisterCapabilityCommand(repo, "visual", "npm run capture:visual") + result, err := RegisterCapabilityCommand(repo, "visual", "", "npm run capture:visual") if err != nil { t.Fatal(err) } diff --git a/boatstack/references/config-schema.md b/boatstack/references/config-schema.md index e5df486..407d98d 100644 --- a/boatstack/references/config-schema.md +++ b/boatstack/references/config-schema.md @@ -66,6 +66,7 @@ This is the exhaustive serialization contract, not a list of recommended user ed - `context` (array of strings, optional): Agent-mediated durable-context hints; the controller does not load every path automatically. - `commands` (object, required): Agent-mediated repository commands: - `test` (string, required): The exact command to execute project-local tests. + - `visual` / `screenshot` / `e2e` (string, optional): The repository-owned visual capture harness Boatstack runs automatically during ship. A surface-scoped key `visual:` (e.g. `visual:web`, `visual:ops`; lowercase kebab surface, registered with `capability-register --surface`) outranks the global key for scenarios that declare that `surface`; scenarios without one, or without a surface key, use the global command exactly as before. - Other command names (string, optional): Additional repository-owned commands such as `build`, `lint`, or `typecheck`. - `high_risk_paths` (array of strings, optional): Glob patterns of files requiring independent reviewer sign-off before shipping. - `migration` (object, optional): Declares how migrations are graded by EFFECT against a disposable database, so a committed migration stays a data artifact for the guard while its real effect is executed and observed by a conformance harness. Both commands run via `sh -c` with the disposable database coordinate in the environment as `BOATSTACK_MIGRATE_DB`; when `apply_command` is absent, grading is skipped. diff --git a/boatstack/visual_evidence.go b/boatstack/visual_evidence.go index 9fc9bfe..5653a68 100644 --- a/boatstack/visual_evidence.go +++ b/boatstack/visual_evidence.go @@ -21,6 +21,11 @@ type PRVisualScenario struct { State string `json:"state"` Viewport string `json:"viewport"` Expected []string `json:"expected"` + // Surface optionally names the product surface this scenario captures + // (e.g. "web", "ops"), selecting a surface-scoped harness command + // (project.commands["visual:"]) over the global one. omitempty + // keeps existing manifest fingerprints byte-stable. + Surface string `json:"surface,omitempty"` } type PRVisualEvidenceItem struct { diff --git a/docs/configuration.md b/docs/configuration.md index ecd7a51..e9619d2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -52,7 +52,7 @@ failed, or stale results. | Permit visible verification gaps | `workflow.allow_pass_with_gaps` | `false` rejects `PASS_WITH_GAPS` at delivery and PR gates; `true` retains the gaps as evidence. | | Maintain reader-facing history | `workflow.maintain_changelog` | Managed delivery and Boatstack-prepared PRs require a categorized `CHANGELOG.md` entry. | | Check for a systemic boundary | `workflow.boundary_analysis` | Planning guidance asks whether the request is a local symptom before scope expands. | -| Add frontend PR screenshots | `workflow.pr_visual_evidence` | `suggest` exposes missing screenshots as a gap; `require` blocks completed publication. A plan that approves visual scenarios lifts `suggest` to require semantics for that feature; `off` and a per-feature `not_relevant` decision (with a reason) are the escapes. Boatstack captures registered scenarios automatically during ship. | +| Add frontend PR screenshots | `workflow.pr_visual_evidence` | `suggest` exposes missing screenshots as a gap; `require` blocks completed publication. A plan that approves visual scenarios lifts `suggest` to require semantics for that feature; `off` and a per-feature `not_relevant` decision (with a reason) are the escapes. Boatstack captures registered scenarios automatically during ship; per-surface harnesses register as `project.commands["visual:"]` (`capability-register --surface`) and scenarios select them with a `surface` field. | | Render screenshots inline on a private PR | `workflow.visual_evidence_publish.*` | `mode: external-host` uploads the captured PNGs to an anonymous expiring host so the comment renders inline even on a private repo; opt-in, never automatic. | | Ignore old ambiguous deliveries | `workflow.ignored_deliveries` | Listed feature slugs are excluded from delivery-ambiguity resolution so past work stops blocking new work; new, unlisted ambiguous deliveries still pause. | | Pursue the PR to merge, not just to open | `delivery.terminal` | `merged` keeps the read-only flow advisors naming post-publish steps (watch checks, route corrections) until the PR is observed merged; the default `published` ends the flow when the PR is open, exactly as before. | diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index c72a6f9..04b5e24 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -146,6 +146,6 @@ Delivery and system improvement also remain separate. A failed task may suggest ## What is evidence-backed -The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`e5dbf426e4417f4f2f73c9f855d34808f7fa8f9a`](https://github.com/operatorstack/intelligence-flow/tree/e5dbf426e4417f4f2f73c9f855d34808f7fa8f9a/labs/12-product-engineering-loop). +The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`7bcc7dcb692a3f4b34f6cbd84d46e648e055634e`](https://github.com/operatorstack/intelligence-flow/tree/7bcc7dcb692a3f4b34f6cbd84d46e648e055634e/labs/12-product-engineering-loop). The evidence supports specific failure mechanisms and guardrails. It does not establish that Boatstack is optimal, that control-theory notation proves software quality, or that one workflow dominates every team. Those are evaluation questions, so the distribution preserves measurements, provenance, gaps, and negative results. diff --git a/docs/public-claims.json b/docs/public-claims.json index 3c22133..7ded123 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "e5dbf426e4417f4f2f73c9f855d34808f7fa8f9a", + "source_commit": "7bcc7dcb692a3f4b34f6cbd84d46e648e055634e", "statuses": ["verified", "observed", "still_being_evaluated"], "claims": [ { @@ -12,7 +12,7 @@ "readable_evidence": "why-these-steps.md#portable-workflow-and-state", "implementation": ["../boatstack/export.go", "../boatstack/references/artifacts.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go"], - "last_verified_version": "source:e5dbf426e4417f4f2f73c9f855d34808f7fa8f9a" + "last_verified_version": "source:7bcc7dcb692a3f4b34f6cbd84d46e648e055634e" }, { "id": "human-decisions", @@ -23,7 +23,7 @@ "readable_evidence": "why-these-steps.md#human-decisions", "implementation": ["../boatstack/references/workflow.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:e5dbf426e4417f4f2f73c9f855d34808f7fa8f9a" + "last_verified_version": "source:7bcc7dcb692a3f4b34f6cbd84d46e648e055634e" }, { "id": "validation-provenance", @@ -34,7 +34,7 @@ "readable_evidence": "why-these-steps.md#validation-provenance", "implementation": ["validation-and-evidence.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go"], - "last_verified_version": "source:e5dbf426e4417f4f2f73c9f855d34808f7fa8f9a" + "last_verified_version": "source:7bcc7dcb692a3f4b34f6cbd84d46e648e055634e" }, { "id": "irreversible-operations", @@ -46,7 +46,7 @@ "readable_evidence": "why-these-steps.md#irreversible-operations", "implementation": ["safety.md", "../boatstack/safety.go", "../boatstack/hooks.go"], "verification": ["../boatstack/safety_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:e5dbf426e4417f4f2f73c9f855d34808f7fa8f9a" + "last_verified_version": "source:7bcc7dcb692a3f4b34f6cbd84d46e648e055634e" }, { "id": "reviewer-ready-pr", @@ -57,7 +57,7 @@ "readable_evidence": "why-these-steps.md#reviewer-ready-pr", "implementation": ["../boatstack/pr.go", "getting-started.md"], "verification": ["../boatstack/pr_test.go"], - "last_verified_version": "source:e5dbf426e4417f4f2f73c9f855d34808f7fa8f9a" + "last_verified_version": "source:7bcc7dcb692a3f4b34f6cbd84d46e648e055634e" }, { "id": "phase-scoped-delivery", @@ -68,7 +68,7 @@ "readable_evidence": "why-these-steps.md#phase-scoped-delivery", "implementation": ["../boatstack/delivery.go", "../boatstack/safety.go", "../boatstack/hooks.go", "../boatstack/references/workflow.md"], "verification": ["../boatstack/delivery_test.go", "../boatstack/pr_test.go"], - "last_verified_version": "source:e5dbf426e4417f4f2f73c9f855d34808f7fa8f9a" + "last_verified_version": "source:7bcc7dcb692a3f4b34f6cbd84d46e648e055634e" }, { "id": "model-neutral-contract", @@ -79,7 +79,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:e5dbf426e4417f4f2f73c9f855d34808f7fa8f9a" + "last_verified_version": "source:7bcc7dcb692a3f4b34f6cbd84d46e648e055634e" }, { "id": "cross-model-failures", @@ -90,7 +90,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:e5dbf426e4417f4f2f73c9f855d34808f7fa8f9a" + "last_verified_version": "source:7bcc7dcb692a3f4b34f6cbd84d46e648e055634e" }, { "id": "lower-cost-outcomes", @@ -101,7 +101,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:e5dbf426e4417f4f2f73c9f855d34808f7fa8f9a" + "last_verified_version": "source:7bcc7dcb692a3f4b34f6cbd84d46e648e055634e" }, { "id": "git-worktree-activation", @@ -112,7 +112,7 @@ "readable_evidence": "why-these-steps.md#git-worktree-activation", "implementation": ["../boatstack/runtime_cache.go", "../boatstack/hooks.go"], "verification": ["../boatstack/runtime_cache_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:e5dbf426e4417f4f2f73c9f855d34808f7fa8f9a" + "last_verified_version": "source:7bcc7dcb692a3f4b34f6cbd84d46e648e055634e" }, { "id": "visible-updates", @@ -123,7 +123,7 @@ "readable_evidence": "why-these-steps.md#visible-updates", "implementation": ["../boatstack/update.go", "../boatstack/init.go"], "verification": ["../boatstack/update_test.go", "../boatstack/init_test.go", "../boatstack/export_test.go"], - "last_verified_version": "source:e5dbf426e4417f4f2f73c9f855d34808f7fa8f9a" + "last_verified_version": "source:7bcc7dcb692a3f4b34f6cbd84d46e648e055634e" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 01a73d9..6572976 100644 --- a/labs/diagram-json/plan.lock.json +++ b/labs/diagram-json/plan.lock.json @@ -6,7 +6,7 @@ "plan_path": "labs/diagram-json/plan.md", "plan_sha256": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "schema_version": 1, - "source_commit": "e5dbf426e4417f4f2f73c9f855d34808f7fa8f9a", + "source_commit": "7bcc7dcb692a3f4b34f6cbd84d46e648e055634e", "source_plan_path": "labs/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "labs/diagram-json/spec.md", diff --git a/release-notes/2026-07-30-per-surface-capture-harnesses.md b/release-notes/2026-07-30-per-surface-capture-harnesses.md new file mode 100644 index 0000000..4a27919 --- /dev/null +++ b/release-notes/2026-07-30-per-surface-capture-harnesses.md @@ -0,0 +1,3 @@ +### Per-surface visual capture harnesses + +A repository with more than one product surface (a web app and an ops console, for example) can now register one capture command per surface: `capability-register --capability visual --surface web --command ` writes `project.commands["visual:web"]`, and a plan scenario selects it with an optional `surface` field (lowercase kebab). The surface-scoped command outranks the global `visual` command; a scenario without a surface, or a surface without its own command, uses the global command exactly as before. Capture resolves every scenario's command before any harness runs, and an unresolvable surface is refused naming the exact missing key. The harness contract gains `BOATSTACK_CAPTURE_SURFACE`.