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/7bcc7dcb692a3f4b34f6cbd84d46e648e055634e/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/6877a03c0381c7dec94a0d8a52c9f8c6a0954016/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
60 changes: 32 additions & 28 deletions UPSTREAM.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions boatstack/cmd/boatstack-helper/coverage_conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ var nonDeliveryVerbs = map[string]bool{
"workspace-sync": true,
// Flow layer itself is read-only navigation over the machine, not a transition.
"flow": true,
// Insight capture is a detached control-plane tenant. Its append-only events
// observe delivery evidence but never transition the delivery machine.
"insight": true,
// Retro derivation reads operator-supplied transcripts and proposes typed
// promotions; it mutates nothing, so it registers no delivery transition.
// control-law: retro-proposes-never-enforces
Expand Down
172 changes: 172 additions & 0 deletions boatstack/cmd/boatstack-helper/insight.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package main

import (
"flag"
"fmt"
"io"
"os"
"strings"

boatstack "github.com/operatorstack/boatstack/boatstack"
)

func readInsightInput(path string) ([]byte, error) {
path = strings.TrimSpace(path)
if path == "" || path == "-" {
return io.ReadAll(os.Stdin)
}
return os.ReadFile(path)
}

func printInsightView(view boatstack.InsightView, jsonOutput bool) int {
if jsonOutput {
return emitJSON(view)
}
fmt.Printf("Insight %s: %s\n", view.Capture.ID, view.Evaluation.State)
fmt.Println(view.Evaluation.Reason)
fmt.Printf("Repository diff: %s\n", view.RepositoryPath)
return 0
}

func insightCommand(arguments []string) int {
if len(arguments) == 0 {
fmt.Fprintln(os.Stderr, "usage: boatstack-helper insight <check|save|list|show|associate|bind|evaluate|frontier|disposition>")
return 2
}
switch arguments[0] {
case "check":
flags := flag.NewFlagSet("insight check", flag.ContinueOnError)
repo := flags.String("repo", ".", "repository whose insight inbox should be checked")
input := flags.String("input", "-", "capture JSON file, or - for stdin")
if err := flags.Parse(arguments[1:]); err != nil {
return 2
}
value, err := readInsightInput(*input)
if err != nil {
return fail(err)
}
result, err := boatstack.CheckInsightCapture(*repo, value)
if err != nil {
return fail(err)
}
return emitJSON(result)
case "save":
flags := flag.NewFlagSet("insight save", flag.ContinueOnError)
repo := flags.String("repo", ".", "repository whose tracked insight inbox should receive the capture")
input := flags.String("input", "-", "capture JSON file, or - for stdin")
nonce := flags.String("preview-nonce", "", "nonce returned by insight check")
fingerprint := flags.String("preview-fingerprint", "", "fingerprint returned by insight check")
jsonOutput := flags.Bool("json", false, "print the structured capture")
if err := flags.Parse(arguments[1:]); err != nil {
return 2
}
value, err := readInsightInput(*input)
if err != nil {
return fail(err)
}
view, err := boatstack.SaveInsightCapture(*repo, value, *nonce, *fingerprint)
if err != nil {
return fail(err)
}
return printInsightView(view, *jsonOutput)
case "list":
flags := flag.NewFlagSet("insight list", flag.ContinueOnError)
repo := flags.String("repo", ".", "repository whose captures should be listed")
if err := flags.Parse(arguments[1:]); err != nil {
return 2
}
views, err := boatstack.ListInsights(*repo)
if err != nil {
return fail(err)
}
return emitJSON(views)
case "show":
flags := flag.NewFlagSet("insight show", flag.ContinueOnError)
repo := flags.String("repo", ".", "repository whose capture should be shown")
id := flags.String("id", "", "insight capture id")
if err := flags.Parse(arguments[1:]); err != nil {
return 2
}
view, err := boatstack.ShowInsight(*repo, *id)
if err != nil {
return fail(err)
}
return emitJSON(view)
case "associate":
flags := flag.NewFlagSet("insight associate", flag.ContinueOnError)
repo := flags.String("repo", ".", "repository whose capture should be associated")
id := flags.String("id", "", "insight capture id")
primary := flags.String("primary-topic", "", "human-confirmed primary feature topic")
var related stringList
flags.Var(&related, "related-topic", "related feature topic (repeatable)")
if err := flags.Parse(arguments[1:]); err != nil {
return 2
}
view, err := boatstack.AssociateInsight(*repo, *id, *primary, related)
if err != nil {
return fail(err)
}
return emitJSON(view)
case "bind":
flags := flag.NewFlagSet("insight bind", flag.ContinueOnError)
repo := flags.String("repo", ".", "repository whose capture should be bound")
id := flags.String("id", "", "insight capture id")
feature := flags.String("feature", "", "managed feature id")
var criteria stringList
flags.Var(&criteria, "criterion", "mapped acceptance criterion id (repeatable)")
if err := flags.Parse(arguments[1:]); err != nil {
return 2
}
view, err := boatstack.BindInsight(*repo, *id, *feature, criteria)
if err != nil {
return fail(err)
}
return emitJSON(view)
case "evaluate":
flags := flag.NewFlagSet("insight evaluate", flag.ContinueOnError)
repo := flags.String("repo", ".", "repository whose capture should be evaluated")
id := flags.String("id", "", "insight capture id")
if err := flags.Parse(arguments[1:]); err != nil {
return 2
}
result, err := boatstack.EvaluateInsight(*repo, *id)
if err != nil {
return fail(err)
}
return emitJSON(result)
case "frontier":
flags := flag.NewFlagSet("insight frontier", flag.ContinueOnError)
repo := flags.String("repo", ".", "repository whose pending insight frontier should be shown")
jsonOutput := flags.Bool("json", false, "print the structured frontier")
if err := flags.Parse(arguments[1:]); err != nil {
return 2
}
report, err := boatstack.InsightFrontier(*repo)
if err != nil {
return fail(err)
}
if *jsonOutput {
return emitJSON(report)
}
fmt.Print(boatstack.FormatInsightFrontier(report))
return 0
case "disposition":
flags := flag.NewFlagSet("insight disposition", flag.ContinueOnError)
repo := flags.String("repo", ".", "repository whose capture should be dispositioned")
id := flags.String("id", "", "insight capture id")
outcome := flags.String("outcome", "", "completed, deferred, rejected, or duplicate")
reason := flags.String("reason", "", "human reason, required for non-ready completion and non-complete outcomes")
duplicateOf := flags.String("duplicate-of", "", "original capture id for duplicate outcomes")
if err := flags.Parse(arguments[1:]); err != nil {
return 2
}
view, err := boatstack.DisposeInsight(*repo, *id, *outcome, *reason, *duplicateOf)
if err != nil {
return fail(err)
}
return emitJSON(view)
default:
fmt.Fprintln(os.Stderr, "unknown insight subcommand:", arguments[0])
return 2
}
}
4 changes: 3 additions & 1 deletion boatstack/cmd/boatstack-helper/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1510,7 +1510,7 @@ func workspaceSyncCommand(arguments []string) int {

func run() int {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: boatstack-helper <attach|detach|detached-status|context|activate|deactivate|init|update|check-update|repair-status|operation-status|prepare-update-pr|publish-update-pr|release-classify|next-patch|export|check-source-plan|planning-write|check-plan|record-approval|activate-plan|delivery-status|next-status|recovery-status|repair-state|mutation-status|undo|run-preflight|record-change|record-journey-results|ignore-delivery|record-delivery-gate|record-pr-visual-evidence|capture-evidence|provision-capability|capability-register|record-pr-visual-publication|attach-evidence|check-safety|migrate-config|safety-hook|ambient-safety-hook|diagnose-hook|render-denial|pr-context|check-pr|publish-pr|workspace-cut|workspace-cleanup|workspace-reap|workspace-status|workspace-sync|flow|retro|doctor|version>")
fmt.Fprintln(os.Stderr, "usage: boatstack-helper <attach|detach|detached-status|context|activate|deactivate|init|update|check-update|repair-status|operation-status|prepare-update-pr|publish-update-pr|release-classify|next-patch|export|check-source-plan|planning-write|check-plan|record-approval|activate-plan|delivery-status|next-status|recovery-status|repair-state|mutation-status|undo|run-preflight|record-change|record-journey-results|ignore-delivery|record-delivery-gate|record-pr-visual-evidence|capture-evidence|provision-capability|capability-register|record-pr-visual-publication|attach-evidence|check-safety|migrate-config|safety-hook|ambient-safety-hook|diagnose-hook|render-denial|pr-context|check-pr|publish-pr|workspace-cut|workspace-cleanup|workspace-reap|workspace-status|workspace-sync|flow|retro|insight|doctor|version>")
return 2
}
switch os.Args[1] {
Expand Down Expand Up @@ -1630,6 +1630,8 @@ func run() int {
return flowCommand(os.Args[2:])
case "retro":
return retroCommand(os.Args[2:])
case "insight":
return insightCommand(os.Args[2:])
case "version":
fmt.Printf("Boatstack %s (%s)\n", boatstack.Version, boatstack.SourceCommit)
return 0
Expand Down
7 changes: 7 additions & 0 deletions boatstack/config_documentation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ func TestPublicConfigurationGuideContainsOnlySupportedUserControls(t *testing.T)
want := []string{
"adapters",
"delivery.terminal",
"insights.capture_mode",
"insights.completion_mode",
"insights.enabled",
"insights.evaluate_on_pr",
"insights.pending_frontier",
"insights.suggest_features",
"insights.value_map",
"project.commands",
"project.context",
"project.default_branch",
Expand Down
12 changes: 10 additions & 2 deletions boatstack/delivery.go
Original file line number Diff line number Diff line change
Expand Up @@ -1241,7 +1241,11 @@ func MarkDeliveryPublished(repo, feature, sliceID, url string) error {
if strings.TrimSpace(state.Slices[sliceIndex].PRState) == "" {
state.Slices[sliceIndex].PRState = "OPEN"
}
return saveDeliveryState(repo, state)
if err := saveDeliveryState(repo, state); err != nil {
return err
}
reconcileInsightsForFeature(repo, feature)
return nil
}
if slice.Status != StatusReviewPassed {
return fmt.Errorf("delivery slice %s is not ready to publish", sliceID)
Expand All @@ -1262,7 +1266,11 @@ func MarkDeliveryPublished(repo, feature, sliceID, url string) error {
state.Mode = "NORMAL"
}
}
return saveDeliveryState(repo, state)
if err := saveDeliveryState(repo, state); err != nil {
return err
}
reconcileInsightsForFeature(repo, feature)
return nil
}

// scanManagedDeliveries partitions the delivery-state store into deliveries
Expand Down
10 changes: 10 additions & 0 deletions boatstack/denial_solutions.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package boatstack

import (
"path/filepath"
"strings"

"github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol"
Expand Down Expand Up @@ -174,6 +175,15 @@ func tamperOwnerVerbs(repo, attempted string) []string {
if err != nil {
continue
}
if entry.Class == ClassCommittedInsight {
root, rootErr := w.InsightDir()
if rootErr == nil {
relative, relErr := filepath.Rel(w.RepoRoot, root)
if relErr == nil && strings.Contains(normalized, filepath_ToSlashLower(relative)) {
return entry.OwnerVerbs
}
}
}
key := boatstackSubtreeKey(filepath_ToSlashLower(sample))
if key != "" && strings.Contains(normalized, "boatstack/"+key) {
return entry.OwnerVerbs
Expand Down
1 change: 1 addition & 0 deletions boatstack/denial_solutions_conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ func TestTamperDenialNamesDeclaredOwnerVerbs(t *testing.T) {
"state-root/boatstack/registry.json": {"attach", "detach"},
".git/boatstack/visual-evidence/x/manifest.json": {"record-pr-visual-evidence", "capture-evidence", "record-pr-visual-publication", "attach-evidence"},
"boatstack/repositories/sample/binding.json": {"attach", "detach", "activate"},
"docs/insights/ins-sample/capture.json": {"insight"},
}
for attempted, want := range cases {
got := tamperOwnerVerbs(repo, attempted)
Expand Down
7 changes: 7 additions & 0 deletions boatstack/detached_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,13 @@ func TestAttachPopulatesExternalRuntimeSlot(t *testing.T) {
t.Fatalf("runtime slot must be external, got %s", p)
}
}
manifest, loadedPath, err := loadSharedRuntime(repo)
if err != nil {
t.Fatalf("detached runtime must load through its external ownership boundary: %v", err)
}
if loadedPath != binaryPath || manifest.BoatstackVersion != Version {
t.Fatalf("loaded detached runtime drifted: path=%s manifest=%+v", loadedPath, manifest)
}
}

// control-law: activation-preserves-existing-host-config
Expand Down
Loading
Loading