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/9a20177c48c292c1c6e1b967e0fd710f2e410a7c/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/3c82bbed52b38acc02fd7f7acf353fe60b394440/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,12 +12,12 @@
},
"files": {
".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957",
"CONTRIBUTING.md": "486ff0833aa62521ba078542bc648f4cb868550bc5bd7372393e3befccbde35d",
"CONTRIBUTING.md": "c2c9eda3d29b2f5f5647f23e42aea531601e3a2c4316b609c39ff27c721936e2",
"README.md": "a840004db57a129ecd361bb0421adfe02065d86751736dd91953c8a8b178e5ef",
"assets/boatstack-journey.svg": "c1f7fe2741f5e9ca66bb3fe9b103e6364ba5acbca8b7a8054768ffd85cf325ea",
"assets/boatstack-mark.svg": "ec96165583b15cfd446c27049d49217973f3e9b1defa5771cc08eec0c9542ce4",
"assets/boatstack-portability.svg": "ce648f5581d16586d25824d3a8132ef1b3d88b73329179173120129d4f74fd24",
"boatstack/SKILL.md": "cee940821e2f972070126fc7e041655ff32695602013f1db041d2a125693b599",
"boatstack/SKILL.md": "a63f88eb039c06f34ba018444871b7a0b17c43bdb3ab62f0122732f50345bd6d",
"boatstack/agents/openai.yaml": "68a30a60859556c5a26e16d184594ca243a6043d99c8cf7d66b5dd6d50a93cd1",
"boatstack/assets/templates/adr.md": "c577a3c1c1319061f61deb053597e6e853657022185fe28b8f733327e2a78565",
"boatstack/assets/templates/approval.md": "74b0b816703a6dce3c96c8f95f981af910b020b6908e7f76cf5630778637e9f5",
Expand All @@ -33,16 +33,16 @@
"boatstack/atomic_windows.go": "cefd775cbe7e7c3bd8a3f5673b11cdd784c6d3ebd6de7dcb8f39406b0bee511f",
"boatstack/changelog.go": "c5e1f31440b44d61e6037ad27af0333540af3545d655e35819a0241cbbebd8ec",
"boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7",
"boatstack/cmd/boatstack-helper/main.go": "03637dce9ffd5e36355ec8c09bfdf4a9c34769808cdd7d330634e79b5bb9ac77",
"boatstack/cmd/boatstack-helper/main.go": "c74b18681d3e7804de6888e35f63ad45baddf62e19d4a5b5188e421e55fb7d9d",
"boatstack/cmd/boatstack-helper/main_test.go": "ff73003b6a5157202fa09ddf1129fb13c3d79702b2e05a8721ce5a11bf5ab779",
"boatstack/delivery.go": "565bc8465252a798124a29874e1aeffff16e9027d1a86715e11b3e4bc0743bd2",
"boatstack/delivery_test.go": "2545c26389d330a92c11e7b09fb2342101db5d09a825a2a9e81b4ea9eea9407b",
"boatstack/export.go": "786858fbd1a0172371f310937d0f3244cf1f57246e916fdac581e3da8618e67a",
"boatstack/export_test.go": "fbe19b8627c9a66d6e5231aa0e275dccb28f85046a3f2f242a2ca306bb6c2665",
"boatstack/go.mod": "6086ef1b2a83f5696190dca692c653925f27b61f652f659fd3fca43ed54a1641",
"boatstack/go.sum": "26c315c867b11b886f3c9402fce7f341f6a9115a5d61f54afbb5e1b1fb5f6017",
"boatstack/hooks.go": "ed131c526f434d7c3309a388b97f3d28df2a6bdf9bc7322499405d5174ab8518",
"boatstack/hooks_test.go": "6153759d463203607c50c418eb74017d2dc71118891f7567dec81fef9bd380d0",
"boatstack/hooks.go": "2b7d899f92efef8a68a160c423a46bcd6fd37daa0eba2ac8ad44ffa3dc5f88c9",
"boatstack/hooks_test.go": "d71271c0b9ea59b907cc0407a7173d3542281fb2984e6e3c3062ac39b131ec59",
"boatstack/init.go": "b0619af89a108442decb8848b670bb648e2a81726faa8e8aa22510a522248de5",
"boatstack/init_test.go": "fa48be69d07691fa7842224ec831e5f290504d8b6565263c9735d2dabd9b43b8",
"boatstack/init_transaction.go": "112456c4e1c4db54c4137bcf4f7a9a9e63399a6f5971e9b3dc952d0c4b2aa4b6",
Expand All @@ -68,8 +68,8 @@
"boatstack/runtime.go": "504b7b68e550ed178c2c12bb815aa21e67300cad65cf2311bb5e8b2860747f81",
"boatstack/runtime_cache.go": "60c4eb0c7dde91d40d6ef3f05adc1a1282d17ff1ca12470d0a008454f7ca7489",
"boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85",
"boatstack/safety.go": "6a70f6886a6a599dea60eaeea568c4a8df0f36c8a2f68f1f2c0fc2e28554584b",
"boatstack/safety_test.go": "cf07f67354e96437e72ddbe107a4071a39689bf2e50df4017e28909194be59e4",
"boatstack/safety.go": "6c38272b2ccca3ec634ca1d1b5a0eff1cdca1fef2e146a0127d0f18285a61163",
"boatstack/safety_test.go": "40b100f03d35f6b2d06634be026b482705f6f2898b41ec5491c934ab5f333b36",
"boatstack/skill_frontmatter.go": "85ba7389e5d41f52a716b912ffb6b03c81e575b0b049b2cc11f7cd45c3da93c4",
"boatstack/skill_frontmatter_test.go": "ad92021f44f09d99949bda6670a8e350af85254dee087eeb86f3815dbdf7f500",
"boatstack/testdata/reviewer-pr-body.md": "4c64e3788e5d61a377aeb0f797f7fc8d2316ab6e49572d15636eea7ba9e34ac4",
Expand All @@ -80,14 +80,14 @@
"docs/account-recovery-walkthrough.md": "676034974594a7d1a559b24dbed31d7ccc429eb81404b203ca07bbdaa19ec3d3",
"docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6",
"docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79",
"docs/evidence-engineered-coding.md": "159f34fbd92328ea7f887db304b4fe2d5cbed178859318e3349b68a940615b5f",
"docs/evidence-engineered-coding.md": "e0c5585bfa55a3c938e8be418f551574f00361bd5e18d23a4e64049ecbe61f64",
"docs/generated-files.md": "136422baf0c7fc2bd5100cfe0ebdb3d9d0705dfd7e7d54bf745dd1037e63492c",
"docs/getting-started.md": "eacc814fdffdfa3c7d8052b7cd99a79c04da5c75d88d8b44f3fb68d9afec0316",
"docs/public-claims.json": "8e6ff12e30ae1bc6646814154e91dcd34646f5aad146dbea6f8ca3639dfe8224",
"docs/public-claims.json": "ec9904b1653b7bd761a0e419bf0523c4f482dbef1e8aab259b44c2c0fc44e5e3",
"docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907",
"docs/research-and-design.md": "d65c66e323037bda5d45aacef5d48afa6bf93da55901378891d235aca3a5684f",
"docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6",
"docs/troubleshooting.md": "744786a3df7ff3ff52cb3e64393d1858332f8d178108fdac7b773a75c5bcc313",
"docs/troubleshooting.md": "e74032051d286ab61e31cc73429cb3287601d52f02d3b98ca966f5c1f2e834e5",
"docs/validation-and-evidence.md": "e7d91ad49c6adb44784ebe7d94feceb6abd445857f9a0716f0758bf6b55296c5",
"docs/why-these-steps.md": "80957af13979070e8b2f2a8db78ce06d20d152bbc8ec41c3a8003f28393f6369",
"install.ps1": "960b2b20b406bb2878a560e9ace53fe7226bc510be6ee8466ce4e608beb5625a",
Expand All @@ -97,7 +97,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": "18d2b1c3b0045e8d43e28ad130ddafc2f0edefdea8ffcba437b2ecb9e9cc88e6",
"labs/diagram-json/plan.lock.json": "cf4ff61f97127f1701d4cf8a43be1947305d4dca823918f71c67acba7a3b4866",
"labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51",
"labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d",
"labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed",
Expand All @@ -122,6 +122,7 @@
"release-notes/2026-07-18-stacked-bar-mark.md": "c4d5bd5fb89c280d7fba015384fd795fcb8c31ffe501078aa55a90cbcf66ba7b",
"release-notes/2026-07-18-startup-recovery-routing.md": "c305a1c2b8347e1bc6d7fcbd4a8e629438cdeb4bcf6f181a7d6fc154a88f4318",
"release-notes/2026-07-19-cross-harness-hook-conformance.md": "15dc22cc3b534c6d0bd89f34cd69cabf94a3610d445a039ba111fd8bce727e68",
"release-notes/2026-07-19-malformed-host-event-recovery.md": "b13835ae0d6cd6310c3de45ebadf47afd297b650e70ef2bf7a335945be305833",
"release-notes/2026-07-19-minimal-dated-changelogs.md": "6f525e5c5cf26cc72969a161bbdaeb4a5c1f14759c5985335e97e3d3f45b1daf",
"release-notes/2026-07-19-new-worktree-intent.md": "969beaf8ef7c0635fb45a816cf762a7f66e5ea20b45847a564ee53153863b120",
"release-notes/2026-07-19-optional-repository-changelog.md": "7ae6bed436c269335ac5055e04438489451bef00836fecb3e5fcb06a18501853",
Expand All @@ -130,7 +131,7 @@
"generator": "operatorstack/intelligence-flow:boatstack-distribution",
"schema_version": 1,
"source": {
"commit": "9a20177c48c292c1c6e1b967e0fd710f2e410a7c",
"commit": "3c82bbed52b38acc02fd7f7acf353fe60b394440",
"path": "labs/12-product-engineering-loop",
"repository": "operatorstack/intelligence-flow"
}
Expand Down
2 changes: 2 additions & 0 deletions boatstack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ Before any product edit or explicit `repair`, run `next-status`. Repair requires

If Cursor reports `MainThreadShellExec not initialized`, the host failed before Boatstack's hook process started. Keep the hook fail-closed and make **Developer: Reload Window** the primary recovery, then retry the operation. Recommend the verified installer only when Boatstack itself reports a missing, drifted, unsafe, or checksum-invalid helper/runtime.

If any host reports `HOST_PAYLOAD_MALFORMED`, Boatstack received an event it could not safely decode; no unsafe operation was detected. Retry once with an explicit non-empty command. If the same code repeats, stop shell and tool retries, preserve current edits, and run `.product-loop/bin/boatstack-helper diagnose-hook --host <host> --repo .` from an external terminal. For Cursor, start a new task after the probe. The diagnostic proves the installed guard with a canonical event but cannot inspect the live event supplied by the host. Do not recommend reinstall or hydration unless Boatstack separately reports a missing, drifted, unsafe, or checksum-invalid runtime.

Same-intent repair resumes at the helper-reported stage and reuses the existing gates. A requirement amendment or ambiguous expected behavior blocks product edits and returns to a concise Plan Gate delta. Never edit `changes.md`, ignored delivery state, or receipts directly; those are emitted by controlled transitions. Conversation history is never workflow authority.

A published delivery is immutable. Record the observation against it, then plan the correction under a new feature id whose structured plan sets `parent_delivery` to the published feature. Activation refuses to reset published slices; the corrective child receives its own lock and full gates.
Expand Down
25 changes: 24 additions & 1 deletion boatstack/cmd/boatstack-helper/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,27 @@ func doctorCommand(arguments []string) int {
return 0
}

func diagnoseHookCommand(arguments []string) int {
flags := flag.NewFlagSet("diagnose-hook", flag.ContinueOnError)
host := flags.String("host", "", "cursor, claude, or codex")
repo := flags.String("repo", ".", "repository whose installed hook should be probed")
if err := flags.Parse(arguments); err != nil {
return 2
}
diagnostic, err := boatstack.DiagnoseHook(*repo, *host)
if err != nil {
return fail(err)
}
fmt.Printf("HOOK_CONTRACT_%s=%s\nLIVE_HOST_EVENT=NOT_OBSERVED\n", strings.ToUpper(diagnostic.Host), diagnostic.ContractStatus)
if diagnostic.Host == "cursor" {
fmt.Println("NEXT=If Cursor still reports HOST_PAYLOAD_MALFORMED, preserve edits and start a new Cursor task; this probe cannot inspect Cursor's live event.")
} else {
name := strings.ToUpper(diagnostic.Host[:1]) + diagnostic.Host[1:]
fmt.Printf("NEXT=If %s still reports HOST_PAYLOAD_MALFORMED, preserve edits and start a new host session; this probe cannot inspect the live event.\n", name)
}
return 0
}

func safetyHookCommand(arguments []string) int {
flags := flag.NewFlagSet("safety-hook", flag.ContinueOnError)
host := flags.String("host", "", "cursor, claude, or codex")
Expand Down Expand Up @@ -578,7 +599,7 @@ func publishPRCommand(arguments []string) int {

func run() int {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: boatstack-helper <init|update|check-update|release-classify|next-patch|export|check-source-plan|planning-write|check-plan|record-approval|activate-plan|delivery-status|next-status|run-preflight|record-change|record-delivery-gate|check-safety|safety-hook|pr-context|check-pr|publish-pr|doctor|version>")
fmt.Fprintln(os.Stderr, "usage: boatstack-helper <init|update|check-update|release-classify|next-patch|export|check-source-plan|planning-write|check-plan|record-approval|activate-plan|delivery-status|next-status|run-preflight|record-change|record-delivery-gate|check-safety|safety-hook|diagnose-hook|pr-context|check-pr|publish-pr|doctor|version>")
return 2
}
switch os.Args[1] {
Expand Down Expand Up @@ -622,6 +643,8 @@ func run() int {
return publishPRCommand(os.Args[2:])
case "doctor":
return doctorCommand(os.Args[2:])
case "diagnose-hook":
return diagnoseHookCommand(os.Args[2:])
case "safety-hook":
return safetyHookCommand(os.Args[2:])
case "bootstrap-safety-hook":
Expand Down
81 changes: 81 additions & 0 deletions boatstack/hooks.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,96 @@
package boatstack

import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)

const hookCommandMarker = ".product-loop/hooks/guard"

type HookDiagnostic struct {
Host string
ContractStatus string
LiveEventObserved bool
}

func canonicalHookEvent(host string) ([]byte, error) {
switch host {
case "cursor":
return []byte(`{"hook_event_name":"beforeShellExecution","command":"git status --short"}`), nil
case "claude", "codex":
return []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`), nil
default:
return nil, fmt.Errorf("unsupported hook host %q; expected cursor, claude, or codex", host)
}
}

func validateCanonicalHookOutput(host string, output []byte) error {
if host == "cursor" {
var decision map[string]any
if err := json.Unmarshal(bytes.TrimSpace(output), &decision); err != nil || stringValue(decision["permission"]) != "allow" {
return fmt.Errorf("cursor hook diagnostic returned a malformed or non-allow response")
}
return nil
}
if len(bytes.TrimSpace(output)) != 0 {
return fmt.Errorf("%s hook diagnostic returned unexpected allow output", host)
}
return nil
}

var hookDiagnosticRunner = runInstalledHookDiagnostic

func runInstalledHookDiagnostic(ctx context.Context, repo, host string, input []byte) ([]byte, error) {
var command *exec.Cmd
if runtime.GOOS == "windows" {
path := filepath.Join(repo, ".product-loop", "hooks", "guard.ps1")
command = exec.CommandContext(ctx, "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path, "-HostName", host)
} else {
path := filepath.Join(repo, ".product-loop", "hooks", "guard.sh")
command = exec.CommandContext(ctx, "bash", path, host)
}
command.Dir = repo
command.Stdin = bytes.NewReader(append(input, '\n'))
return command.CombinedOutput()
}

// DiagnoseHook runs the installed guard with a canonical, read-only event. It
// proves the generated wrapper, shared runtime, decoder, and allow contract; it
// deliberately cannot observe the coding host's live event payload.
func DiagnoseHook(repoPath, hostName string) (HookDiagnostic, error) {
host := strings.ToLower(strings.TrimSpace(hostName))
input, err := canonicalHookEvent(host)
if err != nil {
return HookDiagnostic{}, err
}
repo, err := ResolveRepository(repoPath)
if err != nil {
return HookDiagnostic{}, err
}

ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
output, runErr := hookDiagnosticRunner(ctx, repo, host, input)
if ctx.Err() != nil {
return HookDiagnostic{}, fmt.Errorf("%s hook diagnostic timed out", host)
}
if runErr != nil {
return HookDiagnostic{}, fmt.Errorf("%s hook diagnostic failed: %s", host, strings.TrimSpace(string(output)))
}
if err := validateCanonicalHookOutput(host, output); err != nil {
return HookDiagnostic{}, err
}
return HookDiagnostic{Host: host, ContractStatus: "PASS", LiveEventObserved: false}, nil
}

func guardShellScript() []byte {
return []byte(fmt.Sprintf(`#!/usr/bin/env bash
# Generated by Boatstack. Do not edit; change canonical source or .boatstack-project.json.
Expand Down
85 changes: 85 additions & 0 deletions boatstack/hooks_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package boatstack

import (
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -173,6 +175,89 @@ func TestMissingHelperLauncherFailsClosed(t *testing.T) {
}
}

func TestDiagnoseHookAcceptsCanonicalEventsForEveryHost(t *testing.T) {
repo := safetyTestRepo(t)
previous := hookDiagnosticRunner
defer func() { hookDiagnosticRunner = previous }()
hookDiagnosticRunner = func(_ context.Context, _ string, host string, input []byte) ([]byte, error) {
if len(input) == 0 {
t.Fatal("diagnostic omitted canonical input")
}
if host == "cursor" {
return []byte(`{"continue":true,"permission":"allow"}`), nil
}
return nil, nil
}
for _, host := range []string{"cursor", "claude", "codex"} {
t.Run(host, func(t *testing.T) {
diagnostic, err := DiagnoseHook(repo, host)
if err != nil {
t.Fatal(err)
}
if diagnostic.Host != host || diagnostic.ContractStatus != "PASS" || diagnostic.LiveEventObserved {
t.Fatalf("unexpected diagnostic: %+v", diagnostic)
}
})
}
}

func TestDiagnoseHookRejectsUnsupportedHost(t *testing.T) {
repo := safetyTestRepo(t)
if _, err := DiagnoseHook(repo, "other"); err == nil || !strings.Contains(err.Error(), "unsupported hook host") {
t.Fatalf("unsupported host was not rejected: %v", err)
}
}

func TestDiagnoseHookSupportsRepositoryPathsWithSpaces(t *testing.T) {
repo := safetyTestRepo(t)
renamed := repo + " with spaces"
if err := os.Rename(repo, renamed); err != nil {
t.Fatal(err)
}
previous := hookDiagnosticRunner
defer func() { hookDiagnosticRunner = previous }()
hookDiagnosticRunner = func(_ context.Context, observedRepo, _ string, _ []byte) ([]byte, error) {
observedInfo, observedErr := os.Stat(observedRepo)
wantedInfo, wantedErr := os.Stat(renamed)
if observedErr != nil || wantedErr != nil || !os.SameFile(observedInfo, wantedInfo) {
t.Fatalf("diagnostic repo = %q, want %q", observedRepo, renamed)
}
return []byte(`{"continue":true,"permission":"allow"}`), nil
}
if _, err := DiagnoseHook(renamed, "cursor"); err != nil {
t.Fatal(err)
}
}

func TestHookDiagnosticRejectsMalformedAllowOutput(t *testing.T) {
for _, test := range []struct {
host, output string
}{
{"cursor", `{}`},
{"cursor", `not-json`},
{"claude", `{}`},
{"codex", `unexpected`},
} {
if err := validateCanonicalHookOutput(test.host, []byte(test.output)); err == nil {
t.Fatalf("%s malformed output was accepted: %q", test.host, test.output)
}
}
}

func TestDiagnoseHookReportsGuardRuntimeFailures(t *testing.T) {
repo := safetyTestRepo(t)
previous := hookDiagnosticRunner
defer func() { hookDiagnosticRunner = previous }()
for _, message := range []string{"Boatstack shared runtime is missing", "Boatstack shared runtime checksum is invalid"} {
hookDiagnosticRunner = func(_ context.Context, _ string, _ string, _ []byte) ([]byte, error) {
return []byte(message), errors.New("exit status 2")
}
if _, err := DiagnoseHook(repo, "cursor"); err == nil || !strings.Contains(err.Error(), message) {
t.Fatalf("runtime failure %q was not diagnosed: %v", message, err)
}
}
}

func TestGuardRejectsTamperedSharedRuntimeBeforeExecution(t *testing.T) {
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash unavailable")
Expand Down
Loading
Loading