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
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ boatstack-user-config-field:workflow.visual_evidence_publish.host
boatstack-user-config-field:workflow.visual_evidence_publish.expiry
boatstack-user-config-field:workflow.ignored_deliveries
boatstack-user-config-field:delivery.terminal
boatstack-user-config-field:insights.enabled
boatstack-user-config-field:insights.capture_mode
boatstack-user-config-field:insights.value_map
boatstack-user-config-field:insights.suggest_features
boatstack-user-config-field:insights.evaluate_on_pr
boatstack-user-config-field:insights.pending_frontier
boatstack-user-config-field:insights.completion_mode
boatstack-user-config-field:workspace.enabled
boatstack-user-config-field:workspace.mode
boatstack-user-config-field:workspace.cleanup
Expand Down Expand Up @@ -56,6 +63,7 @@ failed, or stale results.
| Render screenshots inline on a private PR | `workflow.visual_evidence_publish.*` | `mode: external-host` uploads the captured PNGs to an anonymous expiring host so the comment renders inline even on a private repo; opt-in, never automatic. |
| Ignore old ambiguous deliveries | `workflow.ignored_deliveries` | Listed feature slugs are excluded from delivery-ambiguity resolution so past work stops blocking new work; new, unlisted ambiguous deliveries still pause. |
| Pursue the PR to merge, not just to open | `delivery.terminal` | `merged` keeps the read-only flow advisors naming post-publish steps (watch checks, route corrections) until the PR is observed merged; the default `published` ends the flow when the PR is open, exactly as before. |
| Preserve and evaluate product insights | `insights.*` | Manual, fingerprint-bound captures and events become tracked `docs/insights/` diffs; PR evidence can update readiness, but only a human completes an insight. |
| Use fresh feature workspaces | `workspace.*` | Boatstack creates and cleans branches or linked worktrees under the selected policy. |
| Limit generated host surfaces | `adapters` | Export generates only the selected supported adapters. |

Expand Down Expand Up @@ -170,6 +178,24 @@ Workspace `mode` is `worktree` or `branch`; cleanup is `confirm`, `auto`, or `of

`delivery.terminal` names the state a delivery pursues before the flow reports nothing left to do. The default `published` ends the flow when the slice's pull request is open, exactly as before. `merged` keeps the read-only flow advisors (`next-status`, `flow next`, `flow frontier`, `flow watch`) naming post-publish steps — watch the checks, route a correction, surface merge eligibility — until the pull request is observed merged. The goal a delivery starts under is snapshotted with the delivery, so changing this value never changes an in-progress delivery's goal. Boatstack itself never merges a pull request under any setting.

## Independent insight controls

```json
{
"insights": {
"enabled": true,
"capture_mode": "manual",
"value_map": "required",
"suggest_features": true,
"evaluate_on_pr": true,
"pending_frontier": true,
"completion_mode": "human_confirmed"
}
}
```

Every save follows a complete Value Map preview, a warning that the exact content will enter Git history, and a separate confirmation bound to that preview. Captures, their human-readable projections, and append-only events live below `docs/insights/<id>/`. Each mutation is therefore a reviewable repository diff. Boatstack stores no insight content in detached or Git control state. Topic suggestions do not create deliveries. PR publication or terminal observation may append evaluation evidence, but never completes an insight. The insight frontier remains separate from Boatstack's authoritative delivery next action.

## Installer-owned fields

The installer maintains `schema_version`, `project.name`, and integration records. Select gstack or Spec Kit through installation and update flows. Their `requested`, `status`, `version`, and `detail` values are receipts and provenance, not hand-edited workflow switches.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Independent insight captures

Boatstack can now preserve a vague message as an independent, fingerprint-bound Product Value Map under `docs/insights/`. The exact source, human-readable projection, and every later event become reviewable Git diffs so nontechnical product context can enter engineering through a pull request. Topic suggestions and delivery evidence remain linked, while final completion stays with a human.
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
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
}
}
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
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
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
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
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
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