Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
25 changes: 13 additions & 12 deletions UPSTREAM.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@
},
"files": {
".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957",
"CONTRIBUTING.md": "c99b0fec066ec2218915911b89f37fe5382ca66f6a987d858b137be3273a9e62",
"CONTRIBUTING.md": "2417adaabc13a2cd494dda791ac9c1b078c462212e236f36601acc1c8d7813d3",
"README.md": "3ce3e95e511089b44e946a44b8d5f4f81d019ece5336db65b2cab1f9dc4d4dad",
"assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63",
"assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5",
"assets/boatstack-portability.svg": "66dfdfa85db857b3bd18b32047a6975f1fbbfc4dc091158e8277193f9969a346",
"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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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"
}
Expand Down
236 changes: 232 additions & 4 deletions boatstack/activation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
9 changes: 9 additions & 0 deletions boatstack/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading