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/64d586468baf5a7b45a2debbfc747b0d9ec9a233/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/e960b4ccd5929bac729d10ea9a800bad3dc572fd/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
62 changes: 35 additions & 27 deletions UPSTREAM.json

Large diffs are not rendered by default.

158 changes: 158 additions & 0 deletions boatstack/activation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package boatstack

import (
"fmt"
"os"
"path/filepath"
)

// 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
// repositories and no-ops everywhere else (RepositoryIsManaged / AmbientHookDecision).
//
// activation deliberately does NOT silently rewrite a developer's global host
// configuration. It emits the exact per-host config location and the precise
// Boatstack-owned snippet to add, so activation is transparent and never clobbers
// existing global hooks. The snippet is host-neutral in intent: every supported
// agent gets the same ambient guard, shaped for that agent's hook schema.

// HostActivation is the activation instruction for one coding agent.
type HostActivation struct {
Host string `json:"host"`
ConfigPath string `json:"config_path"`
Snippet string `json:"snippet"`
Instruction string `json:"instruction"`
}

// ActivationPlan is the set of per-host activation instructions for a repository.
type ActivationPlan struct {
SchemaVersion int `json:"schema_version"`
Mode string `json:"mode"`
Attached bool `json:"attached"`
RepoRoot string `json:"repo_root"`
HelperPath string `json:"helper_path"`
Hosts []HostActivation `json:"hosts"`
Reason string `json:"reason"`
}

// userHostConfigPath returns the developer-level (not repo-level) config path a
// host reads across all projects. BOATSTACK_USER_CONFIG_ROOT overrides the base for
// tests and for a launcher that keeps host state external.
func userHostConfigPath(host string) (string, error) {
base := os.Getenv("BOATSTACK_USER_CONFIG_ROOT")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
base = home
}
switch host {
case "cursor":
return filepath.Join(base, ".cursor", "hooks.json"), nil
case "claude":
return filepath.Join(base, ".claude", "settings.json"), nil
case "codex":
return filepath.Join(base, ".codex", "hooks.json"), nil
case "gemini":
return filepath.Join(base, ".gemini", "settings.json"), nil
default:
return "", fmt.Errorf("unsupported host %q", host)
}
}

// ambientHookCommand builds the shell command a user-level hook runs: the absolute
// helper binary invoking the ambient guard for the current repository. claude
// exposes the project directory as ${CLAUDE_PROJECT_DIR}; the others resolve it
// from Git at hook time.
func ambientHookCommand(host, helper string) string {
if host == "claude" {
return fmt.Sprintf(`%q ambient-safety-hook --host claude --repo "${CLAUDE_PROJECT_DIR}"`, helper)
}
return fmt.Sprintf(`%q ambient-safety-hook --host %s --repo "$(git rev-parse --show-toplevel)"`, helper, host)
}

// ambientHostFragment shapes the ambient guard into a host's hook schema, reusing
// the embedded entry shape and overriding only the command so the guard runs from
// the external helper rather than an in-repo guard script.
func ambientHostFragment(host, helper string) ([]byte, error) {
command := ambientHookCommand(host, helper)
events := map[string]any{}
for _, event := range hookEvents(host) {
entry := desiredHostHookForEvent(host, event)
overrideHookCommand(entry, command)
events[event] = entry
}
return GeneratedJSON(map[string]any{"schema_version": 1, "host": host, "scope": "user", "events": events})
}

// overrideHookCommand replaces the command in a desired-hook entry (both the flat
// cursor form and the nested hooks[] form) with the ambient command.
func overrideHookCommand(entry map[string]any, command string) {
if _, ok := entry["command"]; ok {
entry["command"] = command
delete(entry, "commandWindows")
}
if nested, ok := entry["hooks"].([]any); ok {
for _, item := range nested {
if hook, ok := item.(map[string]any); ok {
hook["command"] = command
delete(hook, "commandWindows")
}
}
}
}

// DetachedActivationPlan returns the per-host activation instructions for a
// repository. It is read-only. It requires the repository to be attached in
// detached mode (an unattached repository has nothing to activate).
func DetachedActivationPlan(repoPath string, hosts []string) (ActivationPlan, error) {
root, err := ResolveRepository(repoPath)
if err != nil {
return ActivationPlan{}, err
}
plan := ActivationPlan{SchemaVersion: detachedSchemaVersion, Mode: string(SupervisionEmbedded), RepoRoot: root}
ctx, ok, verifyErr := detachedContextFor(root)
if verifyErr != nil {
plan.Mode = string(SupervisionDetached)
plan.Attached = true
plan.Reason = verifyErr.Error()
return plan, nil
}
if !ok {
plan.Reason = "This repository is not attached in detached mode. Run `boatstack-helper attach --repo . --mode detached` first."
return plan, nil
}
plan.Mode = string(SupervisionDetached)
plan.Attached = true
_ = ctx

helper, err := os.Executable()
if err != nil || helper == "" {
helper = "boatstack-helper"
}
plan.HelperPath = helper

if len(hosts) == 0 {
hosts = []string{"cursor", "claude", "codex", "gemini"}
}
for _, host := range hosts {
configPath, pathErr := userHostConfigPath(host)
if pathErr != nil {
continue
}
snippet, fragErr := ambientHostFragment(host, helper)
if fragErr != nil {
return ActivationPlan{}, fragErr
}
plan.Hosts = append(plan.Hosts, HostActivation{
Host: host,
ConfigPath: configPath,
Snippet: string(snippet),
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),
})
}
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
}
229 changes: 229 additions & 0 deletions boatstack/attach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
package boatstack

import (
"fmt"
"os"
"path/filepath"
)

// Attach, detach, and status operations for Detached Supervision. Attaching a
// repository writes Boatstack's controller state to an external control root and a
// binding that identifies the repository; it never writes into the target working
// tree or its Git directory. Detaching removes the attachment (and, unless asked
// to preserve it, the external state). Status reports the binding and verifies it.

// AttachOptions requests a detached attachment. StateRoot, when set, overrides the
// external control-state root for this process (the CLI wires --state-root to it).
type AttachOptions struct {
Repo string
Force bool
}

// AttachResult is the deterministic outcome of an attach request.
type AttachResult struct {
SchemaVersion int `json:"schema_version"`
VerificationStatus string `json:"verification_status"` // VERIFIED | BLOCKED
Mode string `json:"mode,omitempty"`
RepoID string `json:"repo_id,omitempty"`
RepoRoot string `json:"repo_root,omitempty"`
ControlRoot string `json:"control_root,omitempty"`
WorktreeID string `json:"worktree_id,omitempty"`
Reason string `json:"reason"`
}

func blockedAttach(reason string) AttachResult {
return AttachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: reason}
}

// AttachDetached attaches repo in detached mode. It leaves the repository working
// tree and Git directory byte-for-byte unchanged; all controller state is written
// under the external control root.
func AttachDetached(opts AttachOptions) (AttachResult, error) {
root, err := ResolveRepository(opts.Repo)
if err != nil {
return blockedAttach(err.Error()), nil
}
stateRoot, err := detachedStateRoot()
if err != nil {
return blockedAttach(err.Error()), nil
}
identity, err := repoIdentity(root)
if err != nil {
return blockedAttach("Boatstack could not compute a repository identity: " + err.Error()), nil
}

registry, err := loadRegistry(stateRoot)
if err != nil {
return blockedAttach("Boatstack could not read the attachment registry: " + err.Error()), nil
}
if existing, ok := registry.Repositories[root]; ok && !opts.Force {
return blockedAttach(fmt.Sprintf("This repository is already attached (repo_id %s). Detach first, or re-run with --force.", existing)), nil
}

ctx := detachedContextFromIdentity(stateRoot, identity)

// Synthesize configuration from the repository (test command, default branch,
// context) exactly as embedded init does.
config := defaultConfig(root, detectTestCommand(root))
rawConfig, err := MarshalJSON(config)
if err != nil {
return blockedAttach(err.Error()), nil
}

// Generate the controller bundle and write it under the external control root.
// The bundle layout mirrors embedded (.product-loop/** plus host adapter dirs),
// only relocated outside the repository.
bundle, err := BuildExportBundle(ctx.SourceConfigPath(), config, rawConfig, "boatstack")
if err != nil {
return blockedAttach("Boatstack could not build the controller bundle: " + err.Error()), nil
}
if err := os.MkdirAll(ctx.controlRoot, 0o755); err != nil {
return blockedAttach(err.Error()), nil
}
if err := writeExport(ctx.controlRoot, bundle.Files, nil); err != nil {
return blockedAttach("Boatstack could not write the controller bundle: " + err.Error()), nil
}
if err := os.WriteFile(ctx.SourceConfigPath(), rawConfig, 0o644); err != nil {
return blockedAttach(err.Error()), nil
}

// Write the binding and index it in the registry.
binding := DetachedBinding{
SchemaVersion: detachedSchemaVersion,
Mode: string(SupervisionDetached),
RepoID: identity.RepoID,
CanonicalRepoPath: identity.CanonicalRepoPath,
GitCommonIdentity: identity.GitCommonIdentity,
InitialCommit: identity.InitialCommit,
NormalizedOrigin: identity.NormalizedOrigin,
CreatedByVersion: Version,
CreatedAt: nowRFC3339(),
}
bindingRaw, err := MarshalJSON(binding)
if err != nil {
return blockedAttach(err.Error()), nil
}
if err := os.MkdirAll(filepath.Dir(bindingPath(stateRoot, identity.RepoID)), 0o755); err != nil {
return blockedAttach(err.Error()), nil
}
if err := os.WriteFile(bindingPath(stateRoot, identity.RepoID), bindingRaw, 0o644); err != nil {
return blockedAttach(err.Error()), nil
}
registry.Repositories[root] = identity.RepoID
if err := saveRegistry(stateRoot, registry); err != nil {
return blockedAttach(err.Error()), nil
}
invalidateWorkspaceCache()

return AttachResult{
SchemaVersion: detachedSchemaVersion,
VerificationStatus: "VERIFIED",
Mode: string(SupervisionDetached),
RepoID: identity.RepoID,
RepoRoot: root,
ControlRoot: ctx.controlRoot,
WorktreeID: identity.WorktreeID,
Reason: "Attached Boatstack in detached mode. The repository was not modified; all controller state lives under the external control root.",
}, nil
}

// DetachOptions requests removal of a detached attachment.
type DetachOptions struct {
Repo string
PreserveState bool
}

// DetachResult is the deterministic outcome of a detach request.
type DetachResult struct {
SchemaVersion int `json:"schema_version"`
VerificationStatus string `json:"verification_status"` // VERIFIED | BLOCKED
RepoID string `json:"repo_id,omitempty"`
StateRemoved bool `json:"state_removed"`
Reason string `json:"reason"`
}

// DetachDetached removes a repository's detached attachment. It always removes the
// registry entry; it removes the external controller state only when PreserveState
// is false. It never touches the repository itself.
func DetachDetached(opts DetachOptions) (DetachResult, error) {
root, err := ResolveRepository(opts.Repo)
if err != nil {
return DetachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: err.Error()}, nil
}
stateRoot, err := detachedStateRoot()
if err != nil {
return DetachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: err.Error()}, nil
}
registry, err := loadRegistry(stateRoot)
if err != nil {
return DetachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: err.Error()}, nil
}
repoID, ok := registry.Repositories[root]
if !ok {
return DetachResult{
SchemaVersion: detachedSchemaVersion, VerificationStatus: "VERIFIED",
Reason: "This repository is not attached in detached mode; nothing to detach.",
}, nil
}
delete(registry.Repositories, root)
if err := saveRegistry(stateRoot, registry); err != nil {
return DetachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", RepoID: repoID, Reason: err.Error()}, nil
}
stateRemoved := false
if !opts.PreserveState {
if err := os.RemoveAll(repositoryControlRoot(stateRoot, repoID)); err != nil {
return DetachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", RepoID: repoID, Reason: err.Error()}, nil
}
stateRemoved = true
}
invalidateWorkspaceCache()
reason := "Detached Boatstack. The external controller state was removed."
if opts.PreserveState {
reason = "Detached Boatstack. The external controller state was preserved."
}
return DetachResult{
SchemaVersion: detachedSchemaVersion, VerificationStatus: "VERIFIED",
RepoID: repoID, StateRemoved: stateRemoved, Reason: reason,
}, nil
}

// DetachedStatusResult reports whether a repository is attached in detached mode
// and whether its binding verifies.
type DetachedStatusResult struct {
SchemaVersion int `json:"schema_version"`
Attached bool `json:"attached"`
Verified bool `json:"verified"`
Mode string `json:"mode"`
RepoID string `json:"repo_id,omitempty"`
RepoRoot string `json:"repo_root,omitempty"`
ControlRoot string `json:"control_root,omitempty"`
WorktreeID string `json:"worktree_id,omitempty"`
Reason string `json:"reason"`
}

// DetachedStatus reports the detached attachment state for a repository. It is
// read-only.
func DetachedStatus(repoPath string) (DetachedStatusResult, error) {
root, err := ResolveRepository(repoPath)
if err != nil {
return DetachedStatusResult{SchemaVersion: detachedSchemaVersion, Reason: err.Error()}, nil
}
ctx, ok, verifyErr := detachedContextFor(root)
if !ok {
return DetachedStatusResult{
SchemaVersion: detachedSchemaVersion, Attached: false, Mode: string(SupervisionEmbedded),
RepoRoot: root, Reason: "This repository is not attached in detached mode.",
}, nil
}
if verifyErr != nil {
return DetachedStatusResult{
SchemaVersion: detachedSchemaVersion, Attached: true, Verified: false, Mode: string(SupervisionDetached),
RepoRoot: root, Reason: verifyErr.Error(),
}, nil
}
return DetachedStatusResult{
SchemaVersion: detachedSchemaVersion, Attached: true, Verified: true, Mode: string(SupervisionDetached),
RepoID: ctx.RepoID, RepoRoot: ctx.RepoRoot, ControlRoot: ctx.controlRoot, WorktreeID: ctx.WorktreeID,
Reason: "This repository is attached in detached mode and its binding verifies.",
}, nil
}
Loading
Loading