Skip to content

Commit 143a67f

Browse files
Sync Boatstack from Intelligence Flow Labs @ e960b4ccd592 (#119)
Co-authored-by: operator-stack-publisher[bot] <operator-stack-publisher[bot]@users.noreply.github.com>
1 parent 5afb5b7 commit 143a67f

35 files changed

Lines changed: 1726 additions & 112 deletions

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# Contributing
44

5-
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/64d586468baf5a7b45a2debbfc747b0d9ec9a233/labs/12-product-engineering-loop).
5+
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).
66

77
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.
88

UPSTREAM.json

Lines changed: 35 additions & 27 deletions
Large diffs are not rendered by default.

boatstack/activation.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package boatstack
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
)
8+
9+
// Detached activation. A detached repository has no in-repo host hook, so the
10+
// developer installs one user-level (developer-scoped) hook per coding agent. That
11+
// hook runs Boatstack's ambient guard, which enforces policy only on attached
12+
// repositories and no-ops everywhere else (RepositoryIsManaged / AmbientHookDecision).
13+
//
14+
// activation deliberately does NOT silently rewrite a developer's global host
15+
// configuration. It emits the exact per-host config location and the precise
16+
// Boatstack-owned snippet to add, so activation is transparent and never clobbers
17+
// existing global hooks. The snippet is host-neutral in intent: every supported
18+
// agent gets the same ambient guard, shaped for that agent's hook schema.
19+
20+
// HostActivation is the activation instruction for one coding agent.
21+
type HostActivation struct {
22+
Host string `json:"host"`
23+
ConfigPath string `json:"config_path"`
24+
Snippet string `json:"snippet"`
25+
Instruction string `json:"instruction"`
26+
}
27+
28+
// ActivationPlan is the set of per-host activation instructions for a repository.
29+
type ActivationPlan struct {
30+
SchemaVersion int `json:"schema_version"`
31+
Mode string `json:"mode"`
32+
Attached bool `json:"attached"`
33+
RepoRoot string `json:"repo_root"`
34+
HelperPath string `json:"helper_path"`
35+
Hosts []HostActivation `json:"hosts"`
36+
Reason string `json:"reason"`
37+
}
38+
39+
// userHostConfigPath returns the developer-level (not repo-level) config path a
40+
// host reads across all projects. BOATSTACK_USER_CONFIG_ROOT overrides the base for
41+
// tests and for a launcher that keeps host state external.
42+
func userHostConfigPath(host string) (string, error) {
43+
base := os.Getenv("BOATSTACK_USER_CONFIG_ROOT")
44+
if base == "" {
45+
home, err := os.UserHomeDir()
46+
if err != nil {
47+
return "", err
48+
}
49+
base = home
50+
}
51+
switch host {
52+
case "cursor":
53+
return filepath.Join(base, ".cursor", "hooks.json"), nil
54+
case "claude":
55+
return filepath.Join(base, ".claude", "settings.json"), nil
56+
case "codex":
57+
return filepath.Join(base, ".codex", "hooks.json"), nil
58+
case "gemini":
59+
return filepath.Join(base, ".gemini", "settings.json"), nil
60+
default:
61+
return "", fmt.Errorf("unsupported host %q", host)
62+
}
63+
}
64+
65+
// ambientHookCommand builds the shell command a user-level hook runs: the absolute
66+
// helper binary invoking the ambient guard for the current repository. claude
67+
// exposes the project directory as ${CLAUDE_PROJECT_DIR}; the others resolve it
68+
// from Git at hook time.
69+
func ambientHookCommand(host, helper string) string {
70+
if host == "claude" {
71+
return fmt.Sprintf(`%q ambient-safety-hook --host claude --repo "${CLAUDE_PROJECT_DIR}"`, helper)
72+
}
73+
return fmt.Sprintf(`%q ambient-safety-hook --host %s --repo "$(git rev-parse --show-toplevel)"`, helper, host)
74+
}
75+
76+
// ambientHostFragment shapes the ambient guard into a host's hook schema, reusing
77+
// the embedded entry shape and overriding only the command so the guard runs from
78+
// the external helper rather than an in-repo guard script.
79+
func ambientHostFragment(host, helper string) ([]byte, error) {
80+
command := ambientHookCommand(host, helper)
81+
events := map[string]any{}
82+
for _, event := range hookEvents(host) {
83+
entry := desiredHostHookForEvent(host, event)
84+
overrideHookCommand(entry, command)
85+
events[event] = entry
86+
}
87+
return GeneratedJSON(map[string]any{"schema_version": 1, "host": host, "scope": "user", "events": events})
88+
}
89+
90+
// overrideHookCommand replaces the command in a desired-hook entry (both the flat
91+
// cursor form and the nested hooks[] form) with the ambient command.
92+
func overrideHookCommand(entry map[string]any, command string) {
93+
if _, ok := entry["command"]; ok {
94+
entry["command"] = command
95+
delete(entry, "commandWindows")
96+
}
97+
if nested, ok := entry["hooks"].([]any); ok {
98+
for _, item := range nested {
99+
if hook, ok := item.(map[string]any); ok {
100+
hook["command"] = command
101+
delete(hook, "commandWindows")
102+
}
103+
}
104+
}
105+
}
106+
107+
// DetachedActivationPlan returns the per-host activation instructions for a
108+
// repository. It is read-only. It requires the repository to be attached in
109+
// detached mode (an unattached repository has nothing to activate).
110+
func DetachedActivationPlan(repoPath string, hosts []string) (ActivationPlan, error) {
111+
root, err := ResolveRepository(repoPath)
112+
if err != nil {
113+
return ActivationPlan{}, err
114+
}
115+
plan := ActivationPlan{SchemaVersion: detachedSchemaVersion, Mode: string(SupervisionEmbedded), RepoRoot: root}
116+
ctx, ok, verifyErr := detachedContextFor(root)
117+
if verifyErr != nil {
118+
plan.Mode = string(SupervisionDetached)
119+
plan.Attached = true
120+
plan.Reason = verifyErr.Error()
121+
return plan, nil
122+
}
123+
if !ok {
124+
plan.Reason = "This repository is not attached in detached mode. Run `boatstack-helper attach --repo . --mode detached` first."
125+
return plan, nil
126+
}
127+
plan.Mode = string(SupervisionDetached)
128+
plan.Attached = true
129+
_ = ctx
130+
131+
helper, err := os.Executable()
132+
if err != nil || helper == "" {
133+
helper = "boatstack-helper"
134+
}
135+
plan.HelperPath = helper
136+
137+
if len(hosts) == 0 {
138+
hosts = []string{"cursor", "claude", "codex", "gemini"}
139+
}
140+
for _, host := range hosts {
141+
configPath, pathErr := userHostConfigPath(host)
142+
if pathErr != nil {
143+
continue
144+
}
145+
snippet, fragErr := ambientHostFragment(host, helper)
146+
if fragErr != nil {
147+
return ActivationPlan{}, fragErr
148+
}
149+
plan.Hosts = append(plan.Hosts, HostActivation{
150+
Host: host,
151+
ConfigPath: configPath,
152+
Snippet: string(snippet),
153+
Instruction: fmt.Sprintf("Merge the Boatstack ambient guard for %s into %s (developer-level, applies to every repository; it enforces Boatstack only on attached repositories).", host, configPath),
154+
})
155+
}
156+
plan.Reason = "Add the developer-level ambient guard for each coding agent you use. It no-ops on repositories you have not attached."
157+
return plan, nil
158+
}

boatstack/attach.go

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
package boatstack
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
)
8+
9+
// Attach, detach, and status operations for Detached Supervision. Attaching a
10+
// repository writes Boatstack's controller state to an external control root and a
11+
// binding that identifies the repository; it never writes into the target working
12+
// tree or its Git directory. Detaching removes the attachment (and, unless asked
13+
// to preserve it, the external state). Status reports the binding and verifies it.
14+
15+
// AttachOptions requests a detached attachment. StateRoot, when set, overrides the
16+
// external control-state root for this process (the CLI wires --state-root to it).
17+
type AttachOptions struct {
18+
Repo string
19+
Force bool
20+
}
21+
22+
// AttachResult is the deterministic outcome of an attach request.
23+
type AttachResult struct {
24+
SchemaVersion int `json:"schema_version"`
25+
VerificationStatus string `json:"verification_status"` // VERIFIED | BLOCKED
26+
Mode string `json:"mode,omitempty"`
27+
RepoID string `json:"repo_id,omitempty"`
28+
RepoRoot string `json:"repo_root,omitempty"`
29+
ControlRoot string `json:"control_root,omitempty"`
30+
WorktreeID string `json:"worktree_id,omitempty"`
31+
Reason string `json:"reason"`
32+
}
33+
34+
func blockedAttach(reason string) AttachResult {
35+
return AttachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: reason}
36+
}
37+
38+
// AttachDetached attaches repo in detached mode. It leaves the repository working
39+
// tree and Git directory byte-for-byte unchanged; all controller state is written
40+
// under the external control root.
41+
func AttachDetached(opts AttachOptions) (AttachResult, error) {
42+
root, err := ResolveRepository(opts.Repo)
43+
if err != nil {
44+
return blockedAttach(err.Error()), nil
45+
}
46+
stateRoot, err := detachedStateRoot()
47+
if err != nil {
48+
return blockedAttach(err.Error()), nil
49+
}
50+
identity, err := repoIdentity(root)
51+
if err != nil {
52+
return blockedAttach("Boatstack could not compute a repository identity: " + err.Error()), nil
53+
}
54+
55+
registry, err := loadRegistry(stateRoot)
56+
if err != nil {
57+
return blockedAttach("Boatstack could not read the attachment registry: " + err.Error()), nil
58+
}
59+
if existing, ok := registry.Repositories[root]; ok && !opts.Force {
60+
return blockedAttach(fmt.Sprintf("This repository is already attached (repo_id %s). Detach first, or re-run with --force.", existing)), nil
61+
}
62+
63+
ctx := detachedContextFromIdentity(stateRoot, identity)
64+
65+
// Synthesize configuration from the repository (test command, default branch,
66+
// context) exactly as embedded init does.
67+
config := defaultConfig(root, detectTestCommand(root))
68+
rawConfig, err := MarshalJSON(config)
69+
if err != nil {
70+
return blockedAttach(err.Error()), nil
71+
}
72+
73+
// Generate the controller bundle and write it under the external control root.
74+
// The bundle layout mirrors embedded (.product-loop/** plus host adapter dirs),
75+
// only relocated outside the repository.
76+
bundle, err := BuildExportBundle(ctx.SourceConfigPath(), config, rawConfig, "boatstack")
77+
if err != nil {
78+
return blockedAttach("Boatstack could not build the controller bundle: " + err.Error()), nil
79+
}
80+
if err := os.MkdirAll(ctx.controlRoot, 0o755); err != nil {
81+
return blockedAttach(err.Error()), nil
82+
}
83+
if err := writeExport(ctx.controlRoot, bundle.Files, nil); err != nil {
84+
return blockedAttach("Boatstack could not write the controller bundle: " + err.Error()), nil
85+
}
86+
if err := os.WriteFile(ctx.SourceConfigPath(), rawConfig, 0o644); err != nil {
87+
return blockedAttach(err.Error()), nil
88+
}
89+
90+
// Write the binding and index it in the registry.
91+
binding := DetachedBinding{
92+
SchemaVersion: detachedSchemaVersion,
93+
Mode: string(SupervisionDetached),
94+
RepoID: identity.RepoID,
95+
CanonicalRepoPath: identity.CanonicalRepoPath,
96+
GitCommonIdentity: identity.GitCommonIdentity,
97+
InitialCommit: identity.InitialCommit,
98+
NormalizedOrigin: identity.NormalizedOrigin,
99+
CreatedByVersion: Version,
100+
CreatedAt: nowRFC3339(),
101+
}
102+
bindingRaw, err := MarshalJSON(binding)
103+
if err != nil {
104+
return blockedAttach(err.Error()), nil
105+
}
106+
if err := os.MkdirAll(filepath.Dir(bindingPath(stateRoot, identity.RepoID)), 0o755); err != nil {
107+
return blockedAttach(err.Error()), nil
108+
}
109+
if err := os.WriteFile(bindingPath(stateRoot, identity.RepoID), bindingRaw, 0o644); err != nil {
110+
return blockedAttach(err.Error()), nil
111+
}
112+
registry.Repositories[root] = identity.RepoID
113+
if err := saveRegistry(stateRoot, registry); err != nil {
114+
return blockedAttach(err.Error()), nil
115+
}
116+
invalidateWorkspaceCache()
117+
118+
return AttachResult{
119+
SchemaVersion: detachedSchemaVersion,
120+
VerificationStatus: "VERIFIED",
121+
Mode: string(SupervisionDetached),
122+
RepoID: identity.RepoID,
123+
RepoRoot: root,
124+
ControlRoot: ctx.controlRoot,
125+
WorktreeID: identity.WorktreeID,
126+
Reason: "Attached Boatstack in detached mode. The repository was not modified; all controller state lives under the external control root.",
127+
}, nil
128+
}
129+
130+
// DetachOptions requests removal of a detached attachment.
131+
type DetachOptions struct {
132+
Repo string
133+
PreserveState bool
134+
}
135+
136+
// DetachResult is the deterministic outcome of a detach request.
137+
type DetachResult struct {
138+
SchemaVersion int `json:"schema_version"`
139+
VerificationStatus string `json:"verification_status"` // VERIFIED | BLOCKED
140+
RepoID string `json:"repo_id,omitempty"`
141+
StateRemoved bool `json:"state_removed"`
142+
Reason string `json:"reason"`
143+
}
144+
145+
// DetachDetached removes a repository's detached attachment. It always removes the
146+
// registry entry; it removes the external controller state only when PreserveState
147+
// is false. It never touches the repository itself.
148+
func DetachDetached(opts DetachOptions) (DetachResult, error) {
149+
root, err := ResolveRepository(opts.Repo)
150+
if err != nil {
151+
return DetachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: err.Error()}, nil
152+
}
153+
stateRoot, err := detachedStateRoot()
154+
if err != nil {
155+
return DetachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: err.Error()}, nil
156+
}
157+
registry, err := loadRegistry(stateRoot)
158+
if err != nil {
159+
return DetachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: err.Error()}, nil
160+
}
161+
repoID, ok := registry.Repositories[root]
162+
if !ok {
163+
return DetachResult{
164+
SchemaVersion: detachedSchemaVersion, VerificationStatus: "VERIFIED",
165+
Reason: "This repository is not attached in detached mode; nothing to detach.",
166+
}, nil
167+
}
168+
delete(registry.Repositories, root)
169+
if err := saveRegistry(stateRoot, registry); err != nil {
170+
return DetachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", RepoID: repoID, Reason: err.Error()}, nil
171+
}
172+
stateRemoved := false
173+
if !opts.PreserveState {
174+
if err := os.RemoveAll(repositoryControlRoot(stateRoot, repoID)); err != nil {
175+
return DetachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", RepoID: repoID, Reason: err.Error()}, nil
176+
}
177+
stateRemoved = true
178+
}
179+
invalidateWorkspaceCache()
180+
reason := "Detached Boatstack. The external controller state was removed."
181+
if opts.PreserveState {
182+
reason = "Detached Boatstack. The external controller state was preserved."
183+
}
184+
return DetachResult{
185+
SchemaVersion: detachedSchemaVersion, VerificationStatus: "VERIFIED",
186+
RepoID: repoID, StateRemoved: stateRemoved, Reason: reason,
187+
}, nil
188+
}
189+
190+
// DetachedStatusResult reports whether a repository is attached in detached mode
191+
// and whether its binding verifies.
192+
type DetachedStatusResult struct {
193+
SchemaVersion int `json:"schema_version"`
194+
Attached bool `json:"attached"`
195+
Verified bool `json:"verified"`
196+
Mode string `json:"mode"`
197+
RepoID string `json:"repo_id,omitempty"`
198+
RepoRoot string `json:"repo_root,omitempty"`
199+
ControlRoot string `json:"control_root,omitempty"`
200+
WorktreeID string `json:"worktree_id,omitempty"`
201+
Reason string `json:"reason"`
202+
}
203+
204+
// DetachedStatus reports the detached attachment state for a repository. It is
205+
// read-only.
206+
func DetachedStatus(repoPath string) (DetachedStatusResult, error) {
207+
root, err := ResolveRepository(repoPath)
208+
if err != nil {
209+
return DetachedStatusResult{SchemaVersion: detachedSchemaVersion, Reason: err.Error()}, nil
210+
}
211+
ctx, ok, verifyErr := detachedContextFor(root)
212+
if !ok {
213+
return DetachedStatusResult{
214+
SchemaVersion: detachedSchemaVersion, Attached: false, Mode: string(SupervisionEmbedded),
215+
RepoRoot: root, Reason: "This repository is not attached in detached mode.",
216+
}, nil
217+
}
218+
if verifyErr != nil {
219+
return DetachedStatusResult{
220+
SchemaVersion: detachedSchemaVersion, Attached: true, Verified: false, Mode: string(SupervisionDetached),
221+
RepoRoot: root, Reason: verifyErr.Error(),
222+
}, nil
223+
}
224+
return DetachedStatusResult{
225+
SchemaVersion: detachedSchemaVersion, Attached: true, Verified: true, Mode: string(SupervisionDetached),
226+
RepoID: ctx.RepoID, RepoRoot: ctx.RepoRoot, ControlRoot: ctx.controlRoot, WorktreeID: ctx.WorktreeID,
227+
Reason: "This repository is attached in detached mode and its binding verifies.",
228+
}, nil
229+
}

0 commit comments

Comments
 (0)