From a32d6c88f7ee6861350b2d7c047c625e4183506a Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Sun, 26 Jul 2026 15:26:21 +0000 Subject: [PATCH] Sync Boatstack from Intelligence Flow Labs @ 70614614df37 --- CONTRIBUTING.md | 2 +- UPSTREAM.json | 25 +- boatstack/activation.go | 236 +++++++++++++++++- boatstack/attach.go | 9 + .../coverage_conformance_test.go | 1 + boatstack/cmd/boatstack-helper/main.go | 46 +++- boatstack/detached_test.go | 108 ++++++++ boatstack/runtime_cache.go | 44 +++- docs/evidence-engineered-coding.md | 2 +- docs/getting-started.md | 34 +++ docs/public-claims.json | 24 +- labs/diagram-json/plan.lock.json | 2 +- .../2026-07-26-detached-activation.md | 23 ++ 13 files changed, 514 insertions(+), 42 deletions(-) create mode 100644 release-notes/2026-07-26-detached-activation.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9cc8021..acd7357 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/e960b4ccd5929bac729d10ea9a800bad3dc572fd/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/70614614df37db20107c65285b2e1550f9368065/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 b03d6f3..322ee3e 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "c99b0fec066ec2218915911b89f37fe5382ca66f6a987d858b137be3273a9e62", + "CONTRIBUTING.md": "2417adaabc13a2cd494dda791ac9c1b078c462212e236f36601acc1c8d7813d3", "README.md": "3ce3e95e511089b44e946a44b8d5f4f81d019ece5336db65b2cab1f9dc4d4dad", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -20,7 +20,7 @@ "boatstack/AGENTS.md": "bc76221e1fe90a91afbacd7c6bc9b41a70e6c10fc128c275a6a0b9bc094d9506", "boatstack/BUG-worktree-delivery-state.md": "02469cf51c3849dad5743783e248e5c04583e4240507fbef0e3f890cd6a95724", "boatstack/SKILL.md": "b393fe00f23f701e1310d7c1006f339935d3082c7adb9b35c038a3ad1bcc459e", - "boatstack/activation.go": "8d03042a0105ad4b727f6f492e051618c27f05e8e48248b0b3eaae4c925f79a3", + "boatstack/activation.go": "deb7712d20aed37371254596613bfaccfc05fcb59634408b03378d1a0bf693ea", "boatstack/agents/gemini.yaml": "cbf43b387399e456fa6178f86d83e6e35567e6142ff800f8de6ffca306fa963e", "boatstack/agents/openai.yaml": "68a30a60859556c5a26e16d184594ca243a6043d99c8cf7d66b5dd6d50a93cd1", "boatstack/assets/templates/adr.md": "c577a3c1c1319061f61deb053597e6e853657022185fe28b8f733327e2a78565", @@ -35,16 +35,16 @@ "boatstack/assets/templates/test-plan.md": "6db8a9f27dd171fb80222a501cae50eb051e7278c04703fa43b5ff86dd4d2df4", "boatstack/atomic_unix.go": "89f2723361591de2bb8bd22ce7e34ec529d3278509f0df78fd5c4a7d4140fbe9", "boatstack/atomic_windows.go": "cefd775cbe7e7c3bd8a3f5673b11cdd784c6d3ebd6de7dcb8f39406b0bee511f", - "boatstack/attach.go": "f4df8697c804cfa5249830a529a6aa36acbf043bf24bdf62327920af48c84ee5", + "boatstack/attach.go": "6a855440fac9acc63be857efef9d76210a4619774728684832dfbb08caf42a30", "boatstack/capability.go": "9d9a75086e88bb1d9d4170821436c686002fe3a125e6ee7d23eea5779aac5cfe", "boatstack/capability_test.go": "e8322903a843970d7f0317d2629466532cb05c3ffcb44b9423adf4219faa8021", "boatstack/capture.go": "6a279bab615a012de0b2c43aeae9b95d122365068dc54c8923c14c6a4c719449", "boatstack/capture_test.go": "63fa1177738081f1e862364d7a4257f5e259f8e9c36276ba1775b8085b277105", "boatstack/changelog.go": "6b06be7cd9738de29ba6e87aa2569f3b027a2e618b04524f5abd7abaa17945bf", "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", - "boatstack/cmd/boatstack-helper/coverage_conformance_test.go": "c634d99797e20c71416f5d5a3f43d011441e9e0b65b8f1c0bed3e75a0b9a1832", + "boatstack/cmd/boatstack-helper/coverage_conformance_test.go": "347810fec8cc65300ad58cf84570040a001f6dffd9d464f21038034bec6f00e9", "boatstack/cmd/boatstack-helper/flow.go": "5ab24541d3f85c2f442730d3122d18eb6676600bc11fde4805e803a25a8300c7", - "boatstack/cmd/boatstack-helper/main.go": "541201edfe27ce70c3f8aadc0c8ffaf264b1c2b5cca0490dad59da390a22715f", + "boatstack/cmd/boatstack-helper/main.go": "78b6e98cc9f7c8bddaacd7fb906e61a5c6ba6129d7cd691ea83be54c1d13363f", "boatstack/cmd/boatstack-helper/main_test.go": "b36c52d6d5c9dd2428730de10ff18194b7e32a98722e41341c301c6f7a04cad5", "boatstack/command.go": "4726ac515dedab4947be7eb48f88c6cb8b53d674124504b69f03e6396b080ee8", "boatstack/command_test.go": "9f707abba3640add81c3e97ba7e72fedbf98f3394b1c060a9ca4b4a28e919968", @@ -63,7 +63,7 @@ "boatstack/denial.go": "656dc5e71a11daba25e7a23599f2e1aafa7a7439589af943cdccc72154a45816", "boatstack/denial_test.go": "c480c0b2a489838b22b4d5ec20cc30ef1859cc0820a75974bc56a46c31f90041", "boatstack/detached.go": "b0ea2a1f31bf2a2a83f6089a3065a9b47221194b64de48af9120046e6d70dee8", - "boatstack/detached_test.go": "4665a210acd0f5a5c480eb14ca83052c707244a278a9cbc2349bd107d010eeb1", + "boatstack/detached_test.go": "6cc70d15baa9a69afacf66ea29ce112efeb166836acb0a52bf9c4bb4c898cee5", "boatstack/docs/control-law-scoping.md": "0ae984821248eabda8c0eeaf201b367991e6742984e7c718df20ecc24caee475", "boatstack/evidence.go": "497a31e6ff632cb1d7c3adfc9f269af3f6aa84e948dd5d417c162767542a27df", "boatstack/export.go": "1a01d19ac6e8418febf93f9ebf1a466a46da4b09eea7bafe6ccfb6cfb0f73a6d", @@ -157,7 +157,7 @@ "boatstack/run.go": "3127e8c054b80e41413011e423ba9463fe06a7e6c9e1e71750ab98587871d89d", "boatstack/run_test.go": "5b291510fa90cefdc26eb89e18a3443385456a6ebc73408325ac1945b7c084d6", "boatstack/runtime.go": "11f741d45994c336e927121ede2481c94d5c33ed9c3beecf89f760450b304da3", - "boatstack/runtime_cache.go": "1c293b190dcd1dece83681f383d55c8acf44c68fb10b2f4312175396bb89926a", + "boatstack/runtime_cache.go": "e026ffc1906f7e1e98b768bae63e6658164d2826c07169c9121ce0f23c73faf8", "boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85", "boatstack/runtime_provenance_test.go": "1d52f1e6b0691cf4667729cc9b9f3c55c128f0aa3321f3a2843a9aa6fd0e73dc", "boatstack/safety.go": "15ce84911ad4b24e054f4e56ba783613c74d21c2cb136d63585f43dd95a94dbf", @@ -186,10 +186,10 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "060775c73431f28bd16066bdf9e0f89034d2855c7ca0f5544f660d24b91211d0", - "docs/evidence-engineered-coding.md": "c407f22311678c5b36610dfded112fba22de91b247691b3ea272ec6a731ff678", + "docs/evidence-engineered-coding.md": "365b9be5776923e5f4b160b13d52ed5bb457acc2503dde64202a66dbb4e1c34d", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", - "docs/getting-started.md": "1dd4f4e2e636cc5adfc2f79939629701e171087c3d5e558cf919548b9224adfd", - "docs/public-claims.json": "f0dfed02023c588febdc55e60c8da47941a01f4c71a849367c01eaa3f3a7f217", + "docs/getting-started.md": "51c2823f21e35140d31e6d5083dc4b89fddd24721ac6acc474154a4da53ee9f8", + "docs/public-claims.json": "0bdb4de459498a524aefee0f0149de2791fc25ea295fbcbdbc4869371c884169", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -203,7 +203,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": "d47f40e8610d59c458848849d0bdedf05cc069ee12d797351542125148457c45", + "labs/diagram-json/plan.lock.json": "f24f52a34341f69f0fdc54c64029f836421c3f17c985d8901e7f94a152021c8a", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -308,6 +308,7 @@ "release-notes/2026-07-25-runtime-simplified-technical-english.md": "917fbfb51ae56e5c6e0d9c705b84da492ef3b8f8782ea4b62635814259f11bb4", "release-notes/2026-07-25-update-publish-guard-unblock.md": "adf06ee02b8d3c995525bb9673c2f1fea66a147df8751d65885ced83da0e96e2", "release-notes/2026-07-26-calm-denials.md": "9e4a5fc23b02500cf2f124cb462a8d863ed953a899c9485a81e4971dd89c9576", + "release-notes/2026-07-26-detached-activation.md": "97cf968c3bd57d5f88bd91c04672dae3cedba88e460758323cffe44532d2ec2c", "release-notes/2026-07-26-detached-context-and-guard.md": "ed58fc69752bd9b48403e3bdd8e3459fa84a663bc3caa1e8246be3abc3ac6d6c", "release-notes/2026-07-26-detached-supervision.md": "e8062f5f39e5ad86e9104f20b7b6b1581a95215aff17acbe916e0715e2424b11", "release-notes/2026-07-26-guard-etxtbsy-retry.md": "4238591804be62f8b9a76dd5cda18923af60ef67932d40d70cab0d815124bcaf", @@ -318,7 +319,7 @@ "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "e960b4ccd5929bac729d10ea9a800bad3dc572fd", + "commit": "70614614df37db20107c65285b2e1550f9368065", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/activation.go b/boatstack/activation.go index 5fd4060..0c8b805 100644 --- a/boatstack/activation.go +++ b/boatstack/activation.go @@ -4,8 +4,58 @@ import ( "fmt" "os" "path/filepath" + "strings" ) +// ambientHookMarker identifies a user-level hook entry as Boatstack's ambient +// guard. It is distinct from the embedded hookCommandMarker (".product-loop/hooks/ +// guard"): the ambient command runs the external helper's ambient-safety-hook and +// never names the in-repo guard, so ownership is detected by this substring. +const ambientHookMarker = "ambient-safety-hook" + +// containsAmbientHook reports whether a hook value is (or contains) a Boatstack +// ambient-guard entry, by finding the ambient marker in any command string. +func containsAmbientHook(value any) bool { + switch typed := value.(type) { + case string: + return strings.Contains(typed, ambientHookMarker) + case []any: + for _, item := range typed { + if containsAmbientHook(item) { + return true + } + } + case map[string]any: + for _, item := range typed { + if containsAmbientHook(item) { + return true + } + } + } + return false +} + +// detachedHelperPath resolves the helper the ambient hook should invoke: the +// external shared-runtime slot's binary when present (stable across helper +// relocation), else the running executable, else the bare name. +func detachedHelperPath(repo string) string { + if binaryPath, _, err := sharedRuntimePaths(repo, Version, SourceCommit); err == nil && fileExists(binaryPath) { + return binaryPath + } + if exe, err := os.Executable(); err == nil && exe != "" { + return exe + } + return "boatstack-helper" +} + +// ambientDesiredEntry is the per-event ambient-guard entry for a host, shaped like +// the embedded entry but running the external ambient command. +func ambientDesiredEntry(host, event, helper string) map[string]any { + entry := desiredHostHookForEvent(host, event) + overrideHookCommand(entry, ambientHookCommand(host, helper)) + return entry +} + // Detached activation. A detached repository has no in-repo host hook, so the // developer installs one user-level (developer-scoped) hook per coding agent. That // hook runs Boatstack's ambient guard, which enforces policy only on attached @@ -128,10 +178,7 @@ func DetachedActivationPlan(repoPath string, hosts []string) (ActivationPlan, er plan.Attached = true _ = ctx - helper, err := os.Executable() - if err != nil || helper == "" { - helper = "boatstack-helper" - } + helper := detachedHelperPath(root) plan.HelperPath = helper if len(hosts) == 0 { @@ -156,3 +203,184 @@ func DetachedActivationPlan(repoPath string, hosts []string) (ActivationPlan, er plan.Reason = "Add the developer-level ambient guard for each coding agent you use. It no-ops on repositories you have not attached." return plan, nil } + +// AmbientHostResult is the per-host outcome of an install/uninstall. +type AmbientHostResult struct { + Host string `json:"host"` + ConfigPath string `json:"config_path"` + Action string `json:"action"` // installed | removed | unchanged +} + +// AmbientActivationResult is the deterministic outcome of installing or removing +// the developer-level ambient guard. +type AmbientActivationResult struct { + SchemaVersion int `json:"schema_version"` + VerificationStatus string `json:"verification_status"` // VERIFIED | BLOCKED + Mode string `json:"mode,omitempty"` + RepoRoot string `json:"repo_root,omitempty"` + Hosts []AmbientHostResult `json:"hosts,omitempty"` + Reason string `json:"reason"` +} + +func blockedAmbient(reason string) AmbientActivationResult { + return AmbientActivationResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: reason} +} + +func defaultActivationHosts(hosts []string) []string { + if len(hosts) == 0 { + return []string{"cursor", "claude", "codex", "gemini"} + } + return hosts +} + +// mergeAmbientHooks installs exactly one ambient-guard entry per host event, +// preserving every non-ambient entry (a user's own hooks, and any embedded guard) +// verbatim. Stripping then re-adding the single owned entry makes reinstall +// idempotent — the same input config yields the same output. +func mergeAmbientHooks(config map[string]any, host, helper string) error { + hooks, ok := config["hooks"].(map[string]any) + if config["hooks"] == nil { + hooks = map[string]any{} + config["hooks"] = hooks + } else if !ok { + return fmt.Errorf("host hook config has non-object hooks") + } + for _, event := range hookEvents(host) { + var entries []any + if existing := hooks[event]; existing != nil { + list, listOK := existing.([]any) + if !listOK { + return fmt.Errorf("host hook event %s is not a list", event) + } + entries = list + } + kept := []any{} + for _, entry := range entries { + if containsAmbientHook(entry) { + continue + } + kept = append(kept, entry) + } + kept = append(kept, ambientDesiredEntry(host, event, helper)) + hooks[event] = kept + } + if host == "cursor" && config["version"] == nil { + config["version"] = float64(1) + } + return nil +} + +// removeAmbientHooks strips only Boatstack ambient-guard entries, preserving all +// other entries. It reports whether anything changed. +func removeAmbientHooks(config map[string]any, host string) bool { + hooks, ok := config["hooks"].(map[string]any) + if !ok { + return false + } + changed := false + for _, event := range hookEvents(host) { + existing, listOK := hooks[event].([]any) + if !listOK { + continue + } + kept := []any{} + for _, entry := range existing { + if containsAmbientHook(entry) { + changed = true + continue + } + kept = append(kept, entry) + } + if len(kept) == 0 { + delete(hooks, event) + } else { + hooks[event] = kept + } + } + return changed +} + +// InstallAmbientHooks merges the ambient guard into each agent's developer-level +// config. It requires the repository to be attached in detached mode. It preserves +// existing user hooks and is idempotent. +func InstallAmbientHooks(repoPath string, hosts []string) (AmbientActivationResult, error) { + root, err := ResolveRepository(repoPath) + if err != nil { + return blockedAmbient(err.Error()), nil + } + _, ok, verifyErr := detachedContextFor(root) + if verifyErr != nil { + return blockedAmbient(verifyErr.Error() + " Reattach before activating."), nil + } + if !ok { + return blockedAmbient("This repository is not attached in detached mode. Run `boatstack-helper attach --repo . --mode detached` first."), nil + } + helper := detachedHelperPath(root) + result := AmbientActivationResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "VERIFIED", Mode: string(SupervisionDetached), RepoRoot: root} + for _, host := range defaultActivationHosts(hosts) { + configPath, pathErr := userHostConfigPath(host) + if pathErr != nil { + continue + } + config, loadErr := loadHookConfig(configPath) + if loadErr != nil { + return blockedAmbient(fmt.Sprintf("Boatstack could not read %s: %v", configPath, loadErr)), nil + } + before, _ := MarshalJSON(config) + if err := mergeAmbientHooks(config, host, helper); err != nil { + return blockedAmbient(err.Error()), nil + } + after, marshalErr := MarshalJSON(config) + if marshalErr != nil { + return blockedAmbient(marshalErr.Error()), nil + } + action := "unchanged" + if string(before) != string(after) { + if err := atomicWriteMode(configPath, after, 0o644); err != nil { + return blockedAmbient(err.Error()), nil + } + action = "installed" + } + result.Hosts = append(result.Hosts, AmbientHostResult{Host: host, ConfigPath: configPath, Action: action}) + } + result.Reason = "Installed the Boatstack ambient guard into your developer-level host configuration. It enforces Boatstack only on attached repositories and leaves all other repositories uncontrolled." + return result, nil +} + +// RemoveAmbientHooks removes the ambient guard from each agent's developer-level +// config, preserving every other entry. +func RemoveAmbientHooks(repoPath string, hosts []string) (AmbientActivationResult, error) { + root, err := ResolveRepository(repoPath) + if err != nil { + return blockedAmbient(err.Error()), nil + } + result := AmbientActivationResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "VERIFIED", RepoRoot: root} + for _, host := range defaultActivationHosts(hosts) { + configPath, pathErr := userHostConfigPath(host) + if pathErr != nil { + continue + } + if !fileExists(configPath) { + result.Hosts = append(result.Hosts, AmbientHostResult{Host: host, ConfigPath: configPath, Action: "unchanged"}) + continue + } + config, loadErr := loadHookConfig(configPath) + if loadErr != nil { + return blockedAmbient(fmt.Sprintf("Boatstack could not read %s: %v", configPath, loadErr)), nil + } + action := "unchanged" + if removeAmbientHooks(config, host) { + after, marshalErr := MarshalJSON(config) + if marshalErr != nil { + return blockedAmbient(marshalErr.Error()), nil + } + if err := atomicWriteMode(configPath, after, 0o644); err != nil { + return blockedAmbient(err.Error()), nil + } + action = "removed" + } + result.Hosts = append(result.Hosts, AmbientHostResult{Host: host, ConfigPath: configPath, Action: action}) + } + result.Reason = "Removed the Boatstack ambient guard from your developer-level host configuration." + return result, nil +} diff --git a/boatstack/attach.go b/boatstack/attach.go index a8fca0a..7d47a05 100644 --- a/boatstack/attach.go +++ b/boatstack/attach.go @@ -115,6 +115,15 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) { } invalidateWorkspaceCache() + // Populate the external shared-runtime slot from the running helper so the + // developer-level ambient guard has a stable helper to invoke. The binding is + // written above, so WorkspaceFor now resolves detached and the slot is external. + if source, execErr := os.Executable(); execErr == nil { + if _, runtimeErr := installDetachedRuntime(root, source); runtimeErr != nil { + return blockedAttach("Boatstack could not install the external runtime: " + runtimeErr.Error()), nil + } + } + return AttachResult{ SchemaVersion: detachedSchemaVersion, VerificationStatus: "VERIFIED", diff --git a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go index ac5a792..3257765 100644 --- a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go +++ b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go @@ -71,6 +71,7 @@ var nonDeliveryVerbs = map[string]bool{ "detached-status": true, "context": true, "activate": true, + "deactivate": true, // Safety hooks and workspace management (guard/scaffold, not delivery moves). "safety-hook": true, "ambient-safety-hook": true, diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 7111d0a..709739f 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -154,9 +154,10 @@ func detachedStatusCommand(arguments []string) int { func activateCommand(arguments []string) int { flags := flag.NewFlagSet("activate", flag.ContinueOnError) - repo := flags.String("repo", ".", "attached repository to produce activation instructions for") + repo := flags.String("repo", ".", "attached repository to activate") host := flags.String("host", "", "limit to one coding agent (cursor|claude|codex|gemini); default all") stateRoot := flags.String("state-root", "", "external control-state root (overrides the default user state directory)") + print := flags.Bool("print", false, "only print the per-agent config to add; do not install it") if err := flags.Parse(arguments); err != nil { return 2 } @@ -165,11 +166,46 @@ func activateCommand(arguments []string) int { if strings.TrimSpace(*host) != "" { hosts = []string{*host} } - result, err := boatstack.DetachedActivationPlan(*repo, hosts) + if *print { + result, err := boatstack.DetachedActivationPlan(*repo, hosts) + if err != nil { + return fail(err) + } + return emitJSON(result) + } + result, err := boatstack.InstallAmbientHooks(*repo, hosts) if err != nil { return fail(err) } - return emitJSON(result) + code := emitJSON(result) + if result.VerificationStatus == "BLOCKED" { + return 1 + } + return code +} + +func deactivateCommand(arguments []string) int { + flags := flag.NewFlagSet("deactivate", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository to deactivate") + host := flags.String("host", "", "limit to one coding agent (cursor|claude|codex|gemini); default all") + stateRoot := flags.String("state-root", "", "external control-state root (overrides the default user state directory)") + if err := flags.Parse(arguments); err != nil { + return 2 + } + applyStateRoot(*stateRoot) + var hosts []string + if strings.TrimSpace(*host) != "" { + hosts = []string{*host} + } + result, err := boatstack.RemoveAmbientHooks(*repo, hosts) + if err != nil { + return fail(err) + } + code := emitJSON(result) + if result.VerificationStatus == "BLOCKED" { + return 1 + } + return code } func contextCommand(arguments []string) int { @@ -1393,7 +1429,7 @@ func workspaceSyncCommand(arguments []string) int { func run() int { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") + fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") return 2 } switch os.Args[1] { @@ -1407,6 +1443,8 @@ func run() int { return contextCommand(os.Args[2:]) case "activate": return activateCommand(os.Args[2:]) + case "deactivate": + return deactivateCommand(os.Args[2:]) case "init": return initCommand(os.Args[2:]) case "update": diff --git a/boatstack/detached_test.go b/boatstack/detached_test.go index 139f015..ceb4680 100644 --- a/boatstack/detached_test.go +++ b/boatstack/detached_test.go @@ -367,6 +367,114 @@ func TestActivationPlanIsHostNeutralAndExternal(t *testing.T) { } } +// control-law: detached-control-state-never-enters-the-plant +// Attaching populates the external shared-runtime slot so the ambient guard has a +// helper to invoke, without writing into the repository. +func TestAttachPopulatesExternalRuntimeSlot(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/app.git") + if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil { + t.Fatal(err) + } + binaryPath, manifestPath, err := sharedRuntimePaths(repo, Version, SourceCommit) + if err != nil { + t.Fatal(err) + } + for _, p := range []string{binaryPath, manifestPath} { + if !fileExists(p) { + t.Fatalf("external runtime slot missing %s", p) + } + if strings.HasPrefix(p, repo+string(filepath.Separator)) { + t.Fatalf("runtime slot must be external, got %s", p) + } + } +} + +// control-law: activation-preserves-existing-host-config +// Installing the ambient guard adds only a Boatstack-owned entry, preserves the +// developer's existing hooks, and is idempotent. +func TestActivateInstallsAmbientGuardPreservingUserHooks(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/app.git") + userRoot := t.TempDir() + t.Setenv("BOATSTACK_USER_CONFIG_ROOT", userRoot) + if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil { + t.Fatal(err) + } + + // Seed a developer's own Claude hook that Boatstack must never touch. + claudeCfg := filepath.Join(userRoot, ".claude", "settings.json") + if err := os.MkdirAll(filepath.Dir(claudeCfg), 0o755); err != nil { + t.Fatal(err) + } + seed := `{"theme":"dark","hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"my-own-check.sh"}]}]}}` + if err := os.WriteFile(claudeCfg, []byte(seed), 0o644); err != nil { + t.Fatal(err) + } + + result, err := InstallAmbientHooks(repo, []string{"claude"}) + if err != nil { + t.Fatal(err) + } + if result.VerificationStatus != "VERIFIED" || len(result.Hosts) != 1 || result.Hosts[0].Action != "installed" { + t.Fatalf("unexpected install result: %+v", result) + } + body, err := os.ReadFile(claudeCfg) + if err != nil { + t.Fatal(err) + } + text := string(body) + if !strings.Contains(text, "my-own-check.sh") { + t.Fatalf("install clobbered the developer's own hook: %s", text) + } + if !strings.Contains(text, "ambient-safety-hook") { + t.Fatalf("install did not add the ambient guard: %s", text) + } + if !strings.Contains(text, `"theme"`) { + t.Fatalf("install dropped unrelated user settings: %s", text) + } + + // Idempotent: a second install changes nothing. + again, err := InstallAmbientHooks(repo, []string{"claude"}) + if err != nil { + t.Fatal(err) + } + if again.Hosts[0].Action != "unchanged" { + t.Fatalf("second install was not idempotent: %+v", again) + } + + // Deactivate removes only the ambient guard, preserving the developer's hook. + removed, err := RemoveAmbientHooks(repo, []string{"claude"}) + if err != nil { + t.Fatal(err) + } + if removed.Hosts[0].Action != "removed" { + t.Fatalf("deactivate did not remove the ambient guard: %+v", removed) + } + after, err := os.ReadFile(claudeCfg) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(after), "ambient-safety-hook") { + t.Fatalf("deactivate left the ambient guard behind: %s", after) + } + if !strings.Contains(string(after), "my-own-check.sh") { + t.Fatalf("deactivate removed the developer's own hook: %s", after) + } +} + +// control-law: unattached-repositories-are-not-controlled +// Installing the ambient guard requires an attachment; an unattached repo is refused. +func TestActivateRefusesUnattachedRepository(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/app.git") + t.Setenv("BOATSTACK_USER_CONFIG_ROOT", t.TempDir()) + result, err := InstallAmbientHooks(repo, []string{"claude"}) + if err != nil { + t.Fatal(err) + } + if result.VerificationStatus != "BLOCKED" { + t.Fatalf("activation of an unattached repo must be blocked: %+v", result) + } +} + // control-law: detached-control-state-never-enters-the-plant func TestDetachRemovesAttachmentAndState(t *testing.T) { repo := detachedTestRepo(t, "https://github.com/acme/app.git") diff --git a/boatstack/runtime_cache.go b/boatstack/runtime_cache.go index dd08d87..8489440 100644 --- a/boatstack/runtime_cache.go +++ b/boatstack/runtime_cache.go @@ -136,25 +136,55 @@ func atomicWriteMode(path string, content []byte, mode fs.FileMode) error { } func installSharedRuntime(source, repo string, integrations map[string]IntegrationState) (runtimeManifest, error) { - value, err := os.ReadFile(source) + binaryPath, manifestPath, err := sharedRuntimePaths(repo, Version, SourceCommit) if err != nil { return runtimeManifest{}, err } - manifest := runtimeManifest{ - SchemaVersion: 1, BoatstackVersion: Version, SourceCommit: SourceCommit, - Platform: platformKey(), BinarySHA256: SHA256Bytes(value), - ReleaseChecksumsSHA256: ChecksumsSHA256, Integrations: integrations, + common, err := gitCommonDir(repo) + if err != nil { + return runtimeManifest{}, err + } + return writeRuntimeSlot(source, common, binaryPath, manifestPath, integrations) +} + +// installDetachedRuntime populates a detached repository's external shared-runtime +// slot from the running helper, so the developer-level ambient guard has a stable +// helper to invoke. Unlike installSharedRuntime it scopes the symlink check to the +// external control root and does not consult any in-repo generated lock. It +// requires WorkspaceFor(repo) to already resolve detached (attach writes the +// binding and invalidates the cache first). It is idempotent. +func installDetachedRuntime(repo, source string) (runtimeManifest, error) { + ctx := WorkspaceFor(repo) + if ctx.Mode != SupervisionDetached { + return runtimeManifest{}, fmt.Errorf("installDetachedRuntime requires an attached detached repository") } binaryPath, manifestPath, err := sharedRuntimePaths(repo, Version, SourceCommit) if err != nil { return runtimeManifest{}, err } - common, err := gitCommonDir(repo) + root, err := ctx.sharedControlDir() if err != nil { return runtimeManifest{}, err } + return writeRuntimeSlot(source, root, binaryPath, manifestPath, nil) +} + +// writeRuntimeSlot copies a helper binary and its manifest into a version-labeled +// runtime slot atomically, rejecting symlinked components under symlinkRoot and +// verifying the written bytes against the manifest checksum. It is the shared core +// of the embedded and detached runtime installers. +func writeRuntimeSlot(source, symlinkRoot, binaryPath, manifestPath string, integrations map[string]IntegrationState) (runtimeManifest, error) { + value, err := os.ReadFile(source) + if err != nil { + return runtimeManifest{}, err + } + manifest := runtimeManifest{ + SchemaVersion: 1, BoatstackVersion: Version, SourceCommit: SourceCommit, + Platform: platformKey(), BinarySHA256: SHA256Bytes(value), + ReleaseChecksumsSHA256: ChecksumsSHA256, Integrations: integrations, + } for _, path := range []string{binaryPath, manifestPath} { - if err := rejectSymlinkComponents(common, path); err != nil { + if err := rejectSymlinkComponents(symlinkRoot, path); err != nil { return runtimeManifest{}, err } } diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index a6b51b6..cc8d6ed 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 [`e960b4ccd5929bac729d10ea9a800bad3dc572fd`](https://github.com/operatorstack/intelligence-flow/tree/e960b4ccd5929bac729d10ea9a800bad3dc572fd/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 [`70614614df37db20107c65285b2e1550f9368065`](https://github.com/operatorstack/intelligence-flow/tree/70614614df37db20107c65285b2e1550f9368065/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/getting-started.md b/docs/getting-started.md index 23ef3c9..2c3f9ee 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -266,6 +266,40 @@ $env:BOATSTACK_MODE="update"; $env:BOATSTACK_VERSION="v0.5.0"; $env:BOATSTACK_RE Review the diff and open the update PR normally. After that bootstrap, future releases use `/boatstack-update`. +## Supervise a repository without adding Boatstack to it + +Detached Supervision lets you use Boatstack on a repository you do not want to change — an +evaluation, a client checkout, an open-source project, or a large monorepo. Boatstack keeps +its controller state (configuration, plans, delivery state, evidence, runtime) under a +developer-local control root outside the repository; the working tree and `.git` gain no +Boatstack files. + +Attach the repository, then install the developer-level guard once per coding agent: + +```bash +boatstack-helper attach --repo . --mode detached +boatstack-helper activate --repo . +``` + +`attach` inspects the repository and writes the controller state and a binding to the external +control root, leaving the working tree byte-for-byte unchanged. `activate` merges a +developer-level ambient guard into each agent's global configuration; that guard enforces +Boatstack only on repositories you have attached and is a no-op everywhere else, and it never +removes your own hooks. Use `activate --print` to review the exact per-agent configuration +before installing it. + +Check or end supervision at any time: + +```bash +boatstack-helper detached-status --repo . # is this repository attached and verified? +boatstack-helper deactivate --repo . # remove the developer-level guard +boatstack-helper detach --repo . # remove the attachment and its external state +``` + +From then on the ordinary flow — plan, approve, build, prove, review, prepare a PR — works +exactly as in repository-owned mode. When a project decides to adopt Boatstack, install it +normally to promote the controller into the repository. + ## When something blocks - A product decision returns to you rather than being guessed. diff --git a/docs/public-claims.json b/docs/public-claims.json index 001be46..88c05c4 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "e960b4ccd5929bac729d10ea9a800bad3dc572fd", + "source_commit": "70614614df37db20107c65285b2e1550f9368065", "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:e960b4ccd5929bac729d10ea9a800bad3dc572fd" + "last_verified_version": "source:70614614df37db20107c65285b2e1550f9368065" }, { "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:e960b4ccd5929bac729d10ea9a800bad3dc572fd" + "last_verified_version": "source:70614614df37db20107c65285b2e1550f9368065" }, { "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:e960b4ccd5929bac729d10ea9a800bad3dc572fd" + "last_verified_version": "source:70614614df37db20107c65285b2e1550f9368065" }, { "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:e960b4ccd5929bac729d10ea9a800bad3dc572fd" + "last_verified_version": "source:70614614df37db20107c65285b2e1550f9368065" }, { "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:e960b4ccd5929bac729d10ea9a800bad3dc572fd" + "last_verified_version": "source:70614614df37db20107c65285b2e1550f9368065" }, { "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:e960b4ccd5929bac729d10ea9a800bad3dc572fd" + "last_verified_version": "source:70614614df37db20107c65285b2e1550f9368065" }, { "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:e960b4ccd5929bac729d10ea9a800bad3dc572fd" + "last_verified_version": "source:70614614df37db20107c65285b2e1550f9368065" }, { "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:e960b4ccd5929bac729d10ea9a800bad3dc572fd" + "last_verified_version": "source:70614614df37db20107c65285b2e1550f9368065" }, { "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:e960b4ccd5929bac729d10ea9a800bad3dc572fd" + "last_verified_version": "source:70614614df37db20107c65285b2e1550f9368065" }, { "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:e960b4ccd5929bac729d10ea9a800bad3dc572fd" + "last_verified_version": "source:70614614df37db20107c65285b2e1550f9368065" }, { "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:e960b4ccd5929bac729d10ea9a800bad3dc572fd" + "last_verified_version": "source:70614614df37db20107c65285b2e1550f9368065" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 37003df..672095d 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": "e960b4ccd5929bac729d10ea9a800bad3dc572fd", + "source_commit": "70614614df37db20107c65285b2e1550f9368065", "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-26-detached-activation.md b/release-notes/2026-07-26-detached-activation.md new file mode 100644 index 0000000..fc6c165 --- /dev/null +++ b/release-notes/2026-07-26-detached-activation.md @@ -0,0 +1,23 @@ +### Detached Supervision is now something you can turn on + +Detached Supervision lets Boatstack supervise a repository while keeping its controller state +outside that repository. Until now you could attach a repository, but nothing installed a +working guard, so a live coding session could not actually run under it. This change makes +detached mode operational. + +Attaching a repository now also installs Boatstack's runtime into the external control root, +so the guard has a helper to run. A new pair of commands turns the guard on and off for your +coding agents: `boatstack-helper activate --repo .` merges a developer-level guard into each +agent's global configuration, and `boatstack-helper deactivate --repo .` removes it. The guard +enforces Boatstack only on repositories you have attached and does nothing on every other +repository you open. It preserves your existing hooks — installing adds only Boatstack's own +entry, removing takes only that entry away, and re-running either command changes nothing. Use +`activate --print` to review the exact per-agent configuration before it is written. + +The install is host-neutral: cursor, claude, codex, and gemini all receive the same guard, +shaped for each agent's hook format. + +This release also adds an end-to-end evaluation that stands up real repositories and drives +the built helper through the whole flow — attach, activate, guard, work, detach — proving at +every step that no Boatstack file lands in the target repository and that a developer's own +host configuration is never changed.