From 3cc6c64b8798bde0fcb09e655af2776865108e3c Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 19:00:19 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(engine):=20attn=20=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E9=87=8D=E5=A1=91=E5=B9=B6=E6=94=B6=E6=95=9B=E4=B8=BA=E5=8D=95?= =?UTF-8?q?=E4=BA=8C=E8=BF=9B=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup 面收敛为 status/config/on/off/test:config 写凭据并在落盘前探测,on/off 只翻转一个布尔值,status 合并本地质量数据。协议面为 assess(--follow、 --dry-run)、preflight 和 feedback;preflight 从独立二进制搬进 CLI,session、 report、preview、enable、disable 删除。 删除 engine/cmd/jev-attention 及全部打包、安装、卸载、CI 引用;mage build 只 产出 bin/oberth,发布归档与安装器只处理一个二进制。验收脚本改用 CLI 动词, 并以“无 key 的 follow 立即停止”覆盖无可评估配置的路径。 验证:go test -count=1 -timeout=8m ./...;mage build;scripts/check_docs.py; package_linux_test.py、package_windows_msi_test.py、install_test.py、 uninstall_test.py;verify-attention-handoff.py --choice continue_without_action Co-Authored-By: Claude Code --- .github/workflows/windows.yml | 2 - engine/cmd/jev-attention/consent.go | 90 ----- engine/cmd/jev-attention/main.go | 365 -------------------- engine/cmd/jev-attention/main_test.go | 300 ---------------- engine/internal/attn/model/config.go | 18 + engine/internal/attn/runner/telemetry.go | 9 +- engine/internal/cmd/attn.go | 326 ++++++++++++++---- engine/internal/cmd/attn_ctl.go | 383 ++++++++++++--------- engine/internal/cmd/attn_preflight_test.go | 107 ++++++ engine/internal/cmd/attn_test.go | 8 +- install.ps1 | 8 +- install.sh | 6 +- magefiles/build.go | 37 +- magefiles/workflow.go | 3 - scripts/install_test.py | 4 - scripts/package_linux.py | 8 +- scripts/package_linux_test.py | 8 +- scripts/package_release.py | 4 +- scripts/package_windows_msi.py | 10 +- scripts/package_windows_msi_test.py | 3 +- scripts/uninstall_test.py | 10 +- scripts/verify-attention-handoff.py | 97 +++--- uninstall.ps1 | 6 +- uninstall.sh | 4 +- 24 files changed, 684 insertions(+), 1132 deletions(-) delete mode 100644 engine/cmd/jev-attention/consent.go delete mode 100644 engine/cmd/jev-attention/main.go delete mode 100644 engine/cmd/jev-attention/main_test.go create mode 100644 engine/internal/cmd/attn_preflight_test.go diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index d2408ed1..74c96d0a 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -36,8 +36,6 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } go build -o ../bin/oberth.exe ./cmd/oberth if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - go build -o ../bin/jev-attention.exe ./cmd/jev-attention - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Native CLI smoke without starting services run: | $json = & ../bin/oberth.exe version --json diff --git a/engine/cmd/jev-attention/consent.go b/engine/cmd/jev-attention/consent.go deleted file mode 100644 index dba1a6e6..00000000 --- a/engine/cmd/jev-attention/consent.go +++ /dev/null @@ -1,90 +0,0 @@ -package main - -// The adapter's single consent resolution. Every question the old default* -// helpers answered separately — enabled, provider, endpoint, model, key, -// timeout, reason — is answered once, by model.Resolve, from the settings file -// the setup command writes and the environment. Flags override the fields they -// name and nothing else. - -import ( - "flag" - "strings" - "time" - - "github.com/sheathedsharp/option-berth/engine/internal/attn/model" -) - -// resolveConsent loads the settings, then applies the flags that were actually -// given. explicitKey is the parsed --api-key value, and keyProvided -// distinguishes an explicit empty value (which turns the key off for a -// harness) from an unset flag. -func resolveConsent(explicitKey string, keyProvided bool) model.Resolved { - input := model.ResolveInputFromEnvironment() - input.Settings, input.SettingsPresent, input.SettingsErr = model.LoadLocalConfig() - - resolved := model.Resolve(input) - // --endpoint, --model and --timeout name a field to override. They do not - // grant consent: a flag on a disabled configuration still ends in the fixed - // "disabled" reason. - if flagSet("endpoint") { - resolved.Endpoint = strings.TrimSpace(flagValue("endpoint")) - } - if flagSet("model") { - resolved.Model = strings.TrimSpace(flagValue("model")) - } - if flagSet("timeout") { - if timeout := flagDuration("timeout"); timeout > 0 { - resolved.Timeout = timeout - } - } - if keyProvided { - // An explicit value wins verbatim, including empty. This is the test - // harness path and stays unchanged. - resolved.Key = strings.TrimSpace(explicitKey) - if resolved.Key != "" { - resolved.KeySource = model.SourceFlag - } else { - resolved.KeySource = model.SourceNone - } - } - return resolved -} - -// flagSet, flagValue and flagDuration read the parsed flags through the default -// flag set so the resolution stays one function instead of threading five more -// parameters through every call site. -func flagSet(name string) bool { - found := false - flag.Visit(func(f *flag.Flag) { - if f.Name == name { - found = true - } - }) - return found -} - -func flagValue(name string) string { - f := flag.Lookup(name) - if f == nil { - return "" - } - return f.Value.String() -} - -func flagDuration(name string) time.Duration { - f := flag.Lookup(name) - if f == nil { - return 0 - } - parsed, err := time.ParseDuration(f.Value.String()) - if err != nil { - // flag.Value already rejected a malformed duration at parse time; a - // value that still fails here is not a duration at all. - return 0 - } - return parsed -} - -// flagString and flagBool keep the resolution independent of the *string the -// flag package returned, so consent.go does not need every flag passed in. -func flagString(name string) string { return flagValue(name) } diff --git a/engine/cmd/jev-attention/main.go b/engine/cmd/jev-attention/main.go deleted file mode 100644 index 4e709ff5..00000000 --- a/engine/cmd/jev-attention/main.go +++ /dev/null @@ -1,365 +0,0 @@ -// Command jev-attention is an optional, read-only model adapter. It reads a -// fact-only attention artifact and explicit agent context, asks TypeSafe's -// typed endpoint for an assessment, and atomically writes only the validated -// assessment back to that same artifact. It never starts, stops, or inspects a -// service itself. -package main - -import ( - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "os" - "os/signal" - "path/filepath" - "strings" - "syscall" - "time" - - "github.com/sheathedsharp/option-berth/engine/internal/attn" - "github.com/sheathedsharp/option-berth/engine/internal/attn/model" - "github.com/sheathedsharp/option-berth/engine/internal/attn/runner" -) - -type output struct { - Status string `json:"status"` - AttnPath string `json:"attn_path,omitempty"` - PreflightPath string `json:"preflight_path,omitempty"` - TelemetryPath string `json:"telemetry_path,omitempty"` - Reason string `json:"reason,omitempty"` - Assessment *attn.Assessment `json:"assessment,omitempty"` - TaskRelevant *float64 `json:"task_relevant,omitempty"` - Model string `json:"model,omitempty"` - RequestID string `json:"request_id,omitempty"` -} - -var errMissingOptions = errors.New("task context requires authorized_options or --authorize") - -func main() { - var ( - auto = flag.Bool("auto", false, "run one agent-side status, anomaly assessment, and HIL handoff cycle") - subscribe = flag.Bool("subscribe", false, "keep an agent-side subscription alive and emit HIL events as NDJSON") - session = flag.Bool("session", false, "start a task-scoped attention session (alias for --subscribe)") - watch = flag.Bool("watch", false, "alias for --subscribe") - interval = flag.Duration("interval", 2*time.Second, "poll interval for --subscribe") - oberth = flag.String("oberth", "oberth", "oberth executable for --auto/--session") - project = flag.String("project", ".", "worktree directory for --auto/--session") - attentionPath = flag.String("attention", "", "path from oberth status --json (required)") - statusPath = flag.String("status", "", "fresh status --json file for action preflight") - preflightPath = flag.String("preflight", "", "existing preflight JSON to assess") - feedbackPath = flag.String("feedback", "", "append one agent quality-feedback JSON document") - qualityReport = flag.Bool("quality-report", false, "summarize local attention quality telemetry as JSON") - qualitySince = flag.Duration("since", 0, "quality-report lookback duration (for example 168h)") - qualityProject = flag.String("quality-project", "", "quality-report project label or worktree id filter") - contextPath = flag.String("task-context", "", "JSON file containing task and authorized_options") - task = flag.String("task", "", "task summary (alternative to --task-context)") - authorized = flag.String("authorize", "", "comma-separated authorized option IDs (overrides context file)") - action = flag.String("action", "", "preflight action: start_service, restart_service, stop_service, or adopt_manifest") - service = flag.String("service", "", "declared service target for start_service or restart_service") - draftGroup = flag.String("draft-group", "", "pending draft group for adopt_manifest") - permission = flag.String("permission", "", "explicit user permission context for action preflight") - userApproved = flag.Bool("user-approved", false, "record that the user approved the proposed action (does not execute it)") - _ = flag.String("endpoint", "", "model endpoint (defaults to the resolved provider)") - _ = flag.String("model", "", "model model (defaults to the resolved provider)") - apiKey = flag.String("api-key", "", "API key (defaults to attn.json, then the provider's environment variable; pass an empty value to disable)") - _ = flag.Duration("timeout", 0, "remote request timeout (defaults to attn.json or 10s)") - ) - flag.Parse() - if *session { - *subscribe = true - } - // The configured key must never be a flag default, or `--help` would echo a - // secret. Resolution happens once, here: an explicit --api-key wins verbatim - // (including an empty value, which disables the key for harnesses), and - // everything else comes from model.Resolve so consent has exactly one rule. - apiKeyProvided := false - flag.Visit(func(f *flag.Flag) { - if f.Name == "api-key" { - apiKeyProvided = true - } - }) - consent := resolveConsent(*apiKey, apiKeyProvided) - if *qualityReport { - if *qualitySince < 0 { - fail("--since must not be negative") - } - printQualityReport(*qualitySince, *qualityProject) - return - } - if strings.TrimSpace(*feedbackPath) != "" { - if *auto || *subscribe || *watch || *statusPath != "" || *preflightPath != "" || *action != "" { - fail("--feedback cannot be combined with runner or preflight flags") - } - printFeedback(*feedbackPath, *attentionPath, *project) - return - } - ctx, contextErr := loadContext(*contextPath, *task, *authorized) - if *subscribe || *watch { - if *auto || *attentionPath != "" || *statusPath != "" || *preflightPath != "" || *action != "" { - fail("--subscribe cannot be combined with --auto, manual artifact, or preflight flags") - } - if contextErr != nil { - fail(contextErr.Error()) - } - if *interval <= 0 { - fail("--interval must be positive") - } - subscriptionContext, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - home := runner.TelemetryHome() - sessionID := runner.NewSessionID(*project) - runner.AppendLifecycleTelemetry(home, *project, "subscribed", sessionID, nil) - stats, err := runner.SubscribeWithStats(subscriptionContext, *oberth, *project, *interval, model.Client{ - Endpoint: consent.Endpoint, Model: consent.Model, APIKey: consent.Key, Timeout: consent.Timeout, - }, ctx, runner.ReadStatus, sessionID, func(result runner.Output, latency time.Duration) { - runner.AppendRunnerTelemetry(home, *project, ctx, result, nil, "subscription", latency) - }, func(value any) error { return writeJSON(os.Stdout, value) }) - if err != nil { - runner.AppendLifecycleTelemetry(home, *project, "error", sessionID, &stats) - fail(err.Error()) - } - runner.AppendLifecycleTelemetry(home, *project, "stopped", sessionID, &stats) - return - } - if *auto { - if *attentionPath != "" || *statusPath != "" || *preflightPath != "" || *action != "" { - fail("--auto cannot be combined with manual artifact or preflight flags") - } - if contextErr != nil { - fail(contextErr.Error()) - } - started := time.Now() - result, err := runner.Auto(context.Background(), *oberth, *project, model.Client{ - Endpoint: consent.Endpoint, Model: consent.Model, APIKey: consent.Key, Timeout: consent.Timeout, - }, ctx, runner.ReadStatus) - telemetryPath := runner.AppendRunnerTelemetry(runner.TelemetryHome(), *project, ctx, result, err, "runner", time.Since(started)) - if err != nil { - fail(err.Error()) - } - // The path is stable even when a diagnostic write is unavailable; expose - // it so the agent knows where to inspect local quality data. - result.TelemetryPath = telemetryPath - printJSON(result) - return - } - if strings.TrimSpace(*statusPath) != "" || strings.TrimSpace(*preflightPath) != "" { - if contextErr != nil && strings.TrimSpace(*contextPath) != "" { - fail(contextErr.Error()) - } - if err := runPreflight(*statusPath, *preflightPath, *action, *service, *draftGroup, *permission, *userApproved, consent, ctx); err != nil { - fail(err.Error()) - } - return - } - if strings.TrimSpace(*attentionPath) == "" { - fail("--attention is required unless --auto, --status or --preflight is supplied") - } - if contextErr != nil { - fail(contextErr.Error()) - } - artifact, err := attn.Read(*attentionPath) - if err != nil { - fail(err.Error()) - } - client, clientErr := consent.Client() - if clientErr != nil { - telemetryPath := runner.RecordManualAssessment(runner.TelemetryHome(), *attentionPath, artifact, ctx, nil, clientErr, "manual") - printJSON(output{Status: "unavailable", AttnPath: *attentionPath, TelemetryPath: telemetryPath, Reason: consent.ReasonOr(clientErr)}) - return - } - result, err := client.Evaluate(context.Background(), artifact, ctx) - if err != nil { - if errors.Is(err, model.ErrUnavailable) || errors.Is(err, model.ErrStale) { - telemetryPath := runner.RecordManualAssessment(runner.TelemetryHome(), *attentionPath, artifact, ctx, nil, err, "manual") - printJSON(output{Status: "unavailable", AttnPath: *attentionPath, TelemetryPath: telemetryPath, Reason: model.ReasonFor(err)}) - return - } - fail(err.Error()) - } - if err := model.Persist(*attentionPath, artifact, result); err != nil { - if errors.Is(err, model.ErrStale) { - printJSON(output{Status: "unavailable", AttnPath: *attentionPath, Reason: "artifact became stale"}) - return - } - fail(err.Error()) - } - telemetryPath := runner.RecordManualAssessment(runner.TelemetryHome(), *attentionPath, artifact, ctx, &result, nil, "manual") - printJSON(output{Status: "assessed", AttnPath: *attentionPath, TelemetryPath: telemetryPath, Assessment: &result.Assessment, TaskRelevant: &result.TaskRelevant, Model: result.Model}) -} - -func runPreflight(statusPath, preflightPath, action, service, draftGroup, permission string, userApproved bool, consent model.Resolved, task model.TaskContext) error { - home := strings.TrimSpace(os.Getenv("BERTH_HOME")) - if home == "" { - return errors.New("preflight requires BERTH_HOME so its derived request has an owned destination") - } - var request model.Preflight - var err error - if strings.TrimSpace(preflightPath) != "" { - request, err = model.ReadPreflight(preflightPath) - if err != nil { - return err - } - if filepath.Clean(preflightPath) != filepath.Clean(model.PreflightPath(home, request)) { - return errors.New("preflight path is not the current BERTH_HOME derived request") - } - if strings.TrimSpace(statusPath) != "" { - snapshot, readErr := model.ReadStatusSnapshot(statusPath, time.Time{}) - if readErr != nil { - return readErr - } - if err := model.ValidatePreflight(request, snapshot, home, time.Time{}); err != nil { - return err - } - } - } else { - if strings.TrimSpace(action) == "" { - return errors.New("--action is required with --status") - } - snapshot, readErr := model.ReadStatusSnapshot(statusPath, time.Time{}) - if readErr != nil { - return readErr - } - request, err = model.DerivePreflight(snapshot, model.Intent{ - Action: action, Service: service, DraftGroup: draftGroup, - UserPermission: permission, UserApproved: userApproved, - }, home, time.Time{}) - if err != nil { - return err - } - } - path, err := model.WritePreflight(home, request) - if err != nil { - return err - } - // Writing the request is not consent to send it: without consent the agent - // still gets the bounded preflight document to present directly. - client, clientErr := consent.Client() - if clientErr != nil { - telemetryPath := runner.RecordPreflightAssessment(home, request, task, nil, clientErr) - printJSON(output{Status: "unavailable", PreflightPath: path, TelemetryPath: telemetryPath, RequestID: request.RequestID, Reason: consent.ReasonOr(clientErr)}) - return nil - } - artifact := request.AssessmentArtifactForAdapter() - if strings.TrimSpace(task.Task) == "" { - // Preflight's action intent is the minimum task context when the caller - // intentionally omitted a development summary. The full fixed option - // subset remains available for model to rank; it does not grant permission - // to execute any of those options. - task.Task = "Evaluate whether this proposed worktree action needs the user's choice" - } - task = preflightTaskContext(request, task) - result, callErr := client.Evaluate(context.Background(), artifact, task) - if callErr != nil { - if errors.Is(callErr, model.ErrUnavailable) || errors.Is(callErr, model.ErrStale) { - telemetryPath := runner.RecordPreflightAssessment(home, request, task, nil, callErr) - printJSON(output{Status: "unavailable", PreflightPath: path, TelemetryPath: telemetryPath, RequestID: request.RequestID, Reason: model.ReasonFor(callErr)}) - return nil - } - return callErr - } - if err := request.AttachAssessment(result.Assessment); err != nil { - return err - } - if _, err := model.WritePreflight(home, request); err != nil { - return err - } - telemetryPath := runner.RecordPreflightAssessment(home, request, task, &result, nil) - printJSON(output{Status: "assessed", PreflightPath: path, TelemetryPath: telemetryPath, RequestID: request.RequestID, Assessment: &result.Assessment, TaskRelevant: &result.TaskRelevant, Model: result.Model}) - return nil -} - -func preflightTaskContext(request model.Preflight, task model.TaskContext) model.TaskContext { - base := strings.TrimSpace(task.Task) - target := request.Target.Service - if target == "" { - target = request.Target.Project - } - if request.Target.DraftGroup != "" { - target = request.Target.DraftGroup - } - suffix := fmt.Sprintf(" Proposed fixed action: %s; target: %s; user permission context: %s.", request.Intent.Action, target, request.Intent.UserPermission) - suffixBytes := []byte(suffix) - if len(suffixBytes) > 1800 { - suffixBytes = suffixBytes[:1800] - } - available := 2000 - len(suffixBytes) - if len([]byte(base)) > available { - base = string([]byte(base)[:available]) - } - task.Task = strings.TrimSpace(base + string(suffixBytes)) - if len(task.AuthorizedOptions) == 0 { - task.AuthorizedOptions = make([]string, 0, len(request.Options)) - for _, option := range request.Options { - task.AuthorizedOptions = append(task.AuthorizedOptions, option.ID) - } - if len(task.AuthorizedOptions) == 0 && request.Intent.Action != "" { - task.AuthorizedOptions = []string{request.Intent.Action} - } - } - return task -} - -func loadContext(path, task, authorized string) (model.TaskContext, error) { - var result model.TaskContext - var err error - if strings.TrimSpace(path) != "" { - result, err = model.ReadTaskContext(path) - if err != nil { - return model.TaskContext{}, err - } - } - if strings.TrimSpace(task) != "" { - result.Task = task - } - if strings.TrimSpace(authorized) != "" { - result.AuthorizedOptions = nil - for _, option := range strings.Split(authorized, ",") { - if value := strings.TrimSpace(option); value != "" { - result.AuthorizedOptions = append(result.AuthorizedOptions, value) - } - } - } - if strings.TrimSpace(result.Task) == "" { - return result, errors.New("task context requires --task-context or --task") - } - if len(result.AuthorizedOptions) == 0 { - return result, errMissingOptions - } - return result, nil -} - -func writeJSON(writer io.Writer, value any) error { - encoder := json.NewEncoder(writer) - return encoder.Encode(value) -} - -func printJSON(value any) { - _ = writeJSON(os.Stdout, value) -} - -func fail(message string) { - fmt.Fprintln(os.Stderr, message) - os.Exit(1) -} - -func printFeedback(feedbackPath, artifactPath, project string) { - result, err := runner.Feedback(feedbackPath, artifactPath, project) - if err != nil { - fail(err.Error()) - } - printJSON(output{Status: "feedback_recorded", TelemetryPath: result.Path, RequestID: result.RecordID}) -} - -func printQualityReport(lookback time.Duration, projectFilter string) { - if lookback < 0 { - fail("--since must not be negative") - } - report, err := runner.QualityReport(lookback, projectFilter) - if err != nil { - fail(err.Error()) - } - printJSON(report) -} diff --git a/engine/cmd/jev-attention/main_test.go b/engine/cmd/jev-attention/main_test.go deleted file mode 100644 index d653cb09..00000000 --- a/engine/cmd/jev-attention/main_test.go +++ /dev/null @@ -1,300 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/sheathedsharp/option-berth/engine/internal/attn" - "github.com/sheathedsharp/option-berth/engine/internal/attn/model" -) - -func TestLoadContextRequiresExplicitTaskAndAuthorization(t *testing.T) { - if _, err := loadContext("", "", "inspect_logs"); err == nil { - t.Fatal("missing task was accepted") - } - partial, err := loadContext("", "fix service", "") - if err == nil { - t.Fatal("missing authorization was accepted") - } - if partial.Task != "fix service" { - t.Fatalf("partial task context = %+v, want task preserved for preflight", partial) - } - got, err := loadContext("", "fix service", " inspect_logs, restart_service ") - if err != nil { - t.Fatal(err) - } - if got.Task != "fix service" || len(got.AuthorizedOptions) != 2 { - t.Fatalf("context = %+v", got) - } -} - -func TestLoadContextFileAndFlagOverrides(t *testing.T) { - path := filepath.Join(t.TempDir(), "task.json") - if err := os.WriteFile(path, []byte(`{"task":"old task","authorized_options":["inspect_logs"]}`), 0o600); err != nil { - t.Fatal(err) - } - got, err := loadContext(path, "new task", "restart_service") - if err != nil { - t.Fatal(err) - } - if got.Task != "new task" || len(got.AuthorizedOptions) != 1 || got.AuthorizedOptions[0] != "restart_service" { - t.Fatalf("context = %+v", got) - } -} - -// Consent is the one behavior change in this package: a key is a secret, not -// permission. These tables pin the rule the old default* helpers encoded by -// accident — environment keys used to switch model on by their mere presence. -func TestConsentComesOnlyFromAnExplicitEnable(t *testing.T) { - const endpoint, modelName = "https://assess.example/decisions", "test/model" - enabled := model.LocalConfig{Schema: model.LocalConfigSchema, Enabled: true, Preset: "openrouter", Endpoint: endpoint, Model: modelName, APIKey: "settings-key"} - disabled := model.LocalConfig{Schema: model.LocalConfigSchema, Enabled: false, Preset: "openrouter", Endpoint: endpoint, Model: modelName, APIKey: "settings-key"} - cases := []struct { - name string - input model.ResolveInput - wantEnabled bool - wantReason string - wantKey string - }{ - { - name: "environment key alone is not consent", - input: model.ResolveInput{Key: "env-key"}, - wantEnabled: false, - wantReason: model.ReasonDisabled, - }, - { - name: "settings enabled is consent and supplies its own key", - input: model.ResolveInput{Settings: enabled, SettingsPresent: true}, - wantEnabled: true, - wantKey: "settings-key", - }, - { - name: "settings enabled falls back to the environment key", - input: model.ResolveInput{Settings: enabled, SettingsPresent: true, Key: "env-key"}, - wantEnabled: true, - wantKey: "env-key", - }, - { - name: "explicitly disabled settings are not overridden by a key", - input: model.ResolveInput{Settings: disabled, SettingsPresent: true, Key: "env-key"}, - wantEnabled: false, - wantReason: model.ReasonDisabled, - }, - { - name: "one-shot BERTH_ATTN is consent without a settings file", - input: model.ResolveInput{OneShot: "1", Endpoint: endpoint, Model: modelName, Key: "env-key"}, - wantEnabled: true, - wantKey: "env-key", - }, - { - name: "BERTH_ATTN=0 is not consent", - input: model.ResolveInput{OneShot: "0", Endpoint: endpoint, Model: modelName, Key: "env-key"}, - wantEnabled: false, - wantReason: model.ReasonDisabled, - }, - { - name: "enabled settings with no key anywhere reports no_key", - input: model.ResolveInput{Settings: model.LocalConfig{Schema: model.LocalConfigSchema, Enabled: true, Endpoint: endpoint, Model: modelName}, SettingsPresent: true}, - wantEnabled: true, - wantReason: model.ReasonNoKey, - }, - { - name: "a malformed settings file is a closed door, not a fallthrough", - input: model.ResolveInput{SettingsPresent: true, SettingsErr: errors.New("bad json"), Key: "env-key"}, - wantEnabled: false, - wantReason: model.ReasonInvalidConfig, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := model.Resolve(tc.input) - if got.Enabled != tc.wantEnabled { - t.Fatalf("enabled = %v, want %v (%+v)", got.Enabled, tc.wantEnabled, got) - } - if got.Reason != tc.wantReason { - t.Fatalf("reason = %q, want %q", got.Reason, tc.wantReason) - } - if tc.wantKey != "" && got.Key != tc.wantKey { - t.Fatalf("key = %q, want %q", got.Key, tc.wantKey) - } - if !got.Enabled && got.Key != "" { - t.Fatalf("a disabled configuration still resolved a key: %q", got.Key) - } - }) - } -} - -func TestResolvedClientRefusesWithoutConsent(t *testing.T) { - withoutConsent := model.Resolved{Key: "k", Endpoint: "https://assess.example/decisions", Model: "test/model", Reason: model.ReasonDisabled} - if _, err := withoutConsent.Client(); err == nil { - t.Fatal("a disabled configuration produced a client") - } - enabled := model.Resolved{Enabled: true, Key: "k", Endpoint: "https://assess.example/decisions", Model: "test/model", Timeout: time.Second} - client, err := enabled.Client() - if err != nil { - t.Fatalf("enabled configuration: %v", err) - } - if client.APIKey != "k" || client.Endpoint != "https://assess.example/decisions" { - t.Fatalf("client = %+v", client) - } -} - -func TestRedactedStateNeverCarriesTheKey(t *testing.T) { - resolved := model.Resolve(model.ResolveInput{Settings: model.LocalConfig{ - Schema: model.LocalConfigSchema, Enabled: true, Preset: "openrouter", - Endpoint: "https://assess.example/decisions", Model: "test/model", APIKey: "secret-key", - }, SettingsPresent: true}) - state := resolved.RedactedState() - encoded, err := json.Marshal(state) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(encoded), "secret-key") { - t.Fatalf("redacted state leaks the key: %s", encoded) - } - if state["key_source"] != string(model.SourceSettings) { - t.Fatalf("key_source = %v", state["key_source"]) - } -} - -func TestLocalSettingsSupplyEndpointModelAndKey(t *testing.T) { - home := t.TempDir() - t.Setenv("BERTH_HOME", home) - t.Setenv("BERTH_ATTN_KEY", "") - if err := os.WriteFile(filepath.Join(home, "attn.json"), []byte(`{ - "schema":"oberth.attn-settings/v1", - "enabled":true, - "preset":"openrouter", - "endpoint":"https://local.example/decisions", - "model":"local/model", - "api_key":"local-key", - "timeout_ms":5000 - }`), 0o600); err != nil { - t.Fatal(err) - } - got := resolveConsent("", false) - if !got.Enabled || got.Preset != "openrouter" || got.Endpoint != "https://local.example/decisions" || got.Model != "local/model" { - t.Fatalf("resolved = %+v", got) - } - if got.Key != "local-key" || got.Timeout != 5*time.Second { - t.Fatalf("key/timeout = %q/%s", got.Key, got.Timeout) - } - if got.KeySource != model.SourceSettings { - t.Fatalf("key source = %q", got.KeySource) - } -} - -func TestEnvironmentFieldsSupplyEndpointAndModelButNotConsent(t *testing.T) { - home := t.TempDir() - t.Setenv("BERTH_HOME", home) - t.Setenv("BERTH_ATTN_KEY", "environment-key") - t.Setenv("BERTH_ATTN_ENDPOINT", "https://env.example/decisions") - t.Setenv("BERTH_ATTN_MODEL", "env/model") - got := resolveConsent("", false) - if got.Enabled { - t.Fatalf("an environment key enabled the assessment: %+v", got) - } - if got.Endpoint != "https://env.example/decisions" || got.Model != "env/model" { - t.Fatalf("environment fields = %+v", got) - } - if got.Key != "" { - t.Fatalf("a disabled configuration leaked the key: %q", got.Key) - } -} - -func TestExplicitAPIKeyFlagWinsAndCanDisable(t *testing.T) { - home := t.TempDir() - t.Setenv("BERTH_HOME", home) - t.Setenv("BERTH_ATTN_KEY", "") - if err := os.WriteFile(filepath.Join(home, "attn.json"), []byte(`{"schema":"oberth.attn-settings/v1","enabled":true,"preset":"openrouter","endpoint":"https://assess.example/decisions","model":"test/model","api_key":"local-key"}`), 0o600); err != nil { - t.Fatal(err) - } - if got := resolveConsent("", false); got.Key != "local-key" { - t.Fatalf("unset flag = %q, want the configured key", got.Key) - } - if got := resolveConsent("flag-key", true); got.Key != "flag-key" || got.KeySource != model.SourceFlag { - t.Fatalf("explicit value = %+v, want it verbatim", got) - } - if got := resolveConsent("", true); got.Key != "" { - t.Fatalf("explicit empty value = %q, want the key disabled", got.Key) - } -} - -func TestRunPreflightWritesFactRequestWhenModelUnavailable(t *testing.T) { - home := t.TempDir() - root := filepath.Join(t.TempDir(), "repo") - statusPath := filepath.Join(t.TempDir(), "status.json") - status := map[string]any{ - "scope": map[string]any{"root": root}, - "worktree": map[string]any{ - "name": "demo", "branch": "codex/test", "root_dir": root, - "services": []any{map[string]any{"name": "api", "running": false}}, - }, - "drafts": []any{}, - } - data, err := json.Marshal(status) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(statusPath, data, 0o600); err != nil { - t.Fatal(err) - } - t.Setenv("BERTH_HOME", home) - // No consent: the request must still be written, and no provider contacted. - disabled := model.Resolved{Endpoint: "https://assess.example/decisions", Model: "test/model", Timeout: time.Second, Reason: model.ReasonDisabled} - if err := runPreflight(statusPath, "", "restart_service", "api", "", "ask before restart", false, disabled, model.TaskContext{}); err != nil { - t.Fatal(err) - } - entries, err := filepath.Glob(filepath.Join(home, "attention", "preflight", "*.json")) - if err != nil || len(entries) != 1 { - t.Fatalf("preflight entries = %v, err=%v", entries, err) - } - request, err := model.ReadPreflight(entries[0]) - if err != nil { - t.Fatal(err) - } - if request.Intent.Action != "restart_service" || request.Target.Service != "api" || request.Assessment != nil { - t.Fatalf("request = %+v", request) - } -} - -func TestPreflightContextOffersOnlyFixedRequestOptions(t *testing.T) { - request := model.Preflight{ - Intent: model.Intent{Action: "restart_service"}, - Options: []attn.Option{{ID: "restart_service"}, {ID: "inspect_manifest"}, {ID: "continue_without_action"}}, - } - ctx := preflightTaskContext(request, model.TaskContext{Task: "prepare the integration test"}) - if ctx.Task == "" || len(ctx.AuthorizedOptions) != len(request.Options) { - t.Fatalf("preflight context = %+v", ctx) - } - for _, option := range request.Options { - found := false - for _, authorized := range ctx.AuthorizedOptions { - if authorized == option.ID { - found = true - } - } - if !found { - t.Fatalf("preflight context omitted fixed option %q: %+v", option.ID, ctx) - } - } -} - -func TestPreflightTaskContextIncludesIntentWithoutExceedingBound(t *testing.T) { - request := model.Preflight{ - Intent: model.Intent{Action: "restart_service", UserPermission: "ask first"}, - Target: model.PreflightTarget{Project: "demo", Service: "api"}, - } - got := preflightTaskContext(request, model.TaskContext{Task: string(make([]byte, 4000))}) - if len([]byte(got.Task)) > 2000 || got.AuthorizedOptions[0] != "restart_service" { - t.Fatalf("task context = %+v", got) - } - if !strings.Contains(got.Task, "restart_service") || !strings.Contains(got.Task, "ask first") { - t.Fatalf("intent context omitted: %q", got.Task) - } -} diff --git a/engine/internal/attn/model/config.go b/engine/internal/attn/model/config.go index b185bfb9..69debe89 100644 --- a/engine/internal/attn/model/config.go +++ b/engine/internal/attn/model/config.go @@ -45,6 +45,24 @@ type LocalConfig struct { TimeoutMS int `json:"timeout_ms"` } +// RedactedSettings is the stored shape without the secret, for `attn config` +// with no flags and for diagnostics. +func (c LocalConfig) RedactedSettings() map[string]any { + state := map[string]any{ + "enabled": c.Enabled, + "endpoint": c.Endpoint, + "model": c.Model, + "timeout_ms": c.Timeout().Milliseconds(), + } + if c.Preset != "" { + state["preset"] = c.Preset + } + if c.APIKey != "" { + state["key_present"] = true + } + return state +} + // LocalConfigPath returns the path shared by setup commands and isolated runs. func LocalConfigPath() string { return paths.AttnConfigPath() } diff --git a/engine/internal/attn/runner/telemetry.go b/engine/internal/attn/runner/telemetry.go index 780acde3..050759e1 100644 --- a/engine/internal/attn/runner/telemetry.go +++ b/engine/internal/attn/runner/telemetry.go @@ -69,7 +69,7 @@ func AppendRunnerTelemetry(home, project string, task model.TaskContext, result } path, err := model.AppendTelemetry(home, record) if err != nil { - fmt.Fprintf(os.Stderr, "jev-attention: quality telemetry unavailable: %v\n", err) + fmt.Fprintf(os.Stderr, "oberth attn: quality telemetry unavailable: %v\n", err) return model.TelemetryPath(home) } return path @@ -115,7 +115,7 @@ func RecordManualAssessment(home, artifactPath string, artifact attn.Artifact, t record.Reason = TelemetryErrorReason(callErr) } if _, err := model.AppendTelemetry(home, record); err != nil { - fmt.Fprintf(os.Stderr, "jev-attention: quality telemetry unavailable: %v\n", err) + fmt.Fprintf(os.Stderr, "oberth attn: quality telemetry unavailable: %v\n", err) } return model.TelemetryPath(home) } @@ -143,7 +143,7 @@ func RecordPreflightAssessment(home string, request model.Preflight, task model. record.Reason = TelemetryErrorReason(callErr) } if _, err := model.AppendTelemetry(home, record); err != nil { - fmt.Fprintf(os.Stderr, "jev-attention: quality telemetry unavailable: %v\n", err) + fmt.Fprintf(os.Stderr, "oberth attn: quality telemetry unavailable: %v\n", err) } return model.TelemetryPath(home) } @@ -201,8 +201,7 @@ func bindFeedbackIdentity(input *model.FeedbackInput, root string) error { // Feedback records one agent quality-feedback document and returns where it // landed. Printing belongs to the command; the identity binding and the ledger -// write belong here, so both `jev-attention --feedback` and `oberth jev -// feedback` call exactly one implementation. +// write belong here, so `oberth attn feedback` has exactly one implementation. func Feedback(feedbackPath, artifactPath, project string) (model.FeedbackRecordResult, error) { var input model.FeedbackInput if err := model.ReadJSONValueStrict(feedbackPath, &input); err != nil { diff --git a/engine/internal/cmd/attn.go b/engine/internal/cmd/attn.go index 5689de5f..6177a0cf 100644 --- a/engine/internal/cmd/attn.go +++ b/engine/internal/cmd/attn.go @@ -1,9 +1,8 @@ package cmd -// `oberth attn` carries the attention runner that used to live only in the -// separate `jev-attention` binary. It is the same protocol and the same JSON: -// the standalone command stays as a thin shell over this package during the -// migration, and the scripted acceptance check does not have to change. +// `oberth attn` is the optional model assessment of a runtime anomaly: the +// agent-facing runner, the action preflight, local quality feedback, and the +// person-facing setup verbs. It is one command, not a second binary. // // The layer is Handoff, not Facts: this command asks an optional provider about // a runtime anomaly and hands a choice back to the person. It never starts, @@ -15,12 +14,15 @@ import ( "io" "os" "os/signal" + "path/filepath" "strings" "syscall" "time" + "github.com/sheathedsharp/option-berth/engine/internal/attn" "github.com/sheathedsharp/option-berth/engine/internal/attn/model" "github.com/sheathedsharp/option-berth/engine/internal/attn/runner" + "github.com/sheathedsharp/option-berth/engine/internal/paths" "github.com/spf13/cobra" ) @@ -33,13 +35,22 @@ var ( attnTaskFileFlag string attnAuthorizeFlag []string attnIntervalFlag time.Duration + attnFollowFlag bool + attnDryRunFlag bool attnOberthFlag string attnFeedbackFileFlag string attnArtifactFlag string - attnRawFlag bool - attnQualitySinceFlag time.Duration + attnStatusSinceFlag time.Duration attnQualityProjectFlag string + + attnPreflightStatusFlag string + attnPreflightFileFlag string + attnPreflightActionFlag string + attnPreflightServiceFlag string + attnPreflightDraftFlag string + attnPreflightPermissionFlag string + attnPreflightApprovedFlag bool ) var attnCmd = &cobra.Command{ @@ -50,38 +61,34 @@ var attnCmd = &cobra.Command{ "whether the next step needs the person's choice.\n\n" + "Off by default. Only `enabled: true` in the owner-only attn.json under\n" + "BERTH_HOME, or a one-shot BERTH_ATTN=1 for this process, lets this command\n" + - "contact a provider; a key in the environment supplies the secret but is not\n" + - "consent. `oberth status` only publishes `attn_path` when model is enabled,\n" + - "so its presence already means the person opted in.\n\n" + + "contact a configured endpoint; a key in the environment supplies the secret\n" + + "but is not consent. `oberth status` only publishes `attn_path` when the\n" + + "assessment is enabled, so its presence already means the person opted in.\n\n" + "The command never starts, stops or restarts a service. It reads\n" + "`oberth status --json`, validates the published artifact against that exact\n" + - "revision, sends a redacted summary, and prints one JSON value when model cannot\n" + - "answer, the reason is one fixed code (`no_key`, `disabled`, `unauthorized`,\n" + - "`rate_limited`, `timeout`, `network`, `provider_error`, `bad_response`, `stale`).", + "revision, sends a redacted summary, and prints one JSON value when the model\n" + + "cannot answer; the reason is one fixed code (`no_key`, `disabled`,\n" + + "`unauthorized`, `rate_limited`, `timeout`, `network`, `provider_error`,\n" + + "`bad_response`, `stale`).", + Args: cobra.NoArgs, + RunE: runAttnStatus, } var attnAssessCmd = &cobra.Command{ Use: "assess", Short: "One status read, one anomaly assessment, one handoff", Long: "Read `oberth status --json`, verify the published artifact still matches that\n" + - "revision, ask the provider, and print the handoff. The authorized option IDs\n" + - "are the agent's explicit allow-list; artifact options are candidates, never\n" + - "permission. This is the former `jev-attention --auto`.", + "revision, ask the configured endpoint, and print the handoff. The authorized\n" + + "option IDs are the agent's explicit allow-list; artifact options are\n" + + "candidates, never permission.\n\n" + + "With --follow, keep polling for the lifetime of a task and emit NDJSON: one\n" + + "`subscribed` record, then only changed handoffs, and `stopped` on\n" + + "cancellation. With --dry-run, print the exact redacted request for the\n" + + "current event without contacting anything.", Args: cobra.NoArgs, RunE: runAttnAssess, } -var attnSessionCmd = &cobra.Command{ - Use: "session", - Short: "Keep watching for a task and emit only changed handoffs", - Long: "Poll `oberth status --json` for the lifetime of a task and emit NDJSON: one\n" + - "`subscribed` record, then only changed event/revision/assessment handoffs, and\n" + - "`stopped` when the process is cancelled. Repeated polls of unchanged facts are\n" + - "deliberately silent.", - Args: cobra.NoArgs, - RunE: runAttnSession, -} - var attnFeedbackCmd = &cobra.Command{ Use: "feedback", Short: "Record what a person actually decided about one handoff", @@ -92,14 +99,16 @@ var attnFeedbackCmd = &cobra.Command{ RunE: runAttnFeedback, } -var attnReportCmd = &cobra.Command{ - Use: "report", - Short: "Summarize local attention quality as JSON", - Long: "Read the local quality ledger and report coverage, assessment availability,\n" + - "routing reasons, and the labelled precision/recall of the handoff policy. A\n" + - "metric stays null until there are enough real labels; it is never zero-filled.", +var attnPreflightCmd = &cobra.Command{ + Use: "preflight", + Short: "Derive and optionally assess a bounded action request", + Long: "Turn a freshly captured `oberth status --json` snapshot into a bounded preflight\n" + + "request under BERTH_HOME/attention/preflight. Without consent the request is\n" + + "still written for the agent to present; with consent only needs_human, task\n" + + "relevance and the ranking of the fixed options are attached. The command never\n" + + "executes the action.", Args: cobra.NoArgs, - RunE: runAttnReport, + RunE: runAttnPreflight, } func init() { @@ -112,18 +121,41 @@ func init() { "JSON file containing task and authorized_options") attnCmd.PersistentFlags().StringSliceVar(&attnAuthorizeFlag, "authorize", nil, "Authorized option IDs for this task (comma separated); overrides the context file") - attnSessionCmd.Flags().DurationVar(&attnIntervalFlag, "interval", 2*time.Second, "Poll interval") + + attnAssessCmd.Flags().BoolVar(&attnFollowFlag, "follow", false, + "Keep polling and emit only changed handoffs as NDJSON") + attnAssessCmd.Flags().BoolVar(&attnDryRunFlag, "dry-run", false, + "Print the redacted request for the current event without sending it") + attnAssessCmd.Flags().DurationVar(&attnIntervalFlag, "interval", 2*time.Second, + "Poll interval (with --follow)") + + attnStatusCmd.Flags().DurationVar(&attnStatusSinceFlag, "since", 168*time.Hour, + "Quality ledger lookback window (0 means all records)") + attnStatusCmd.Flags().StringVar(&attnQualityProjectFlag, "quality-project", "", + "Project label or worktree id to filter quality data on") attnFeedbackCmd.Flags().StringVar(&attnFeedbackFileFlag, "file", "", "Feedback JSON document to append") attnFeedbackCmd.Flags().StringVar(&attnArtifactFlag, "attention", "", "Attention artifact the feedback refers to, to check its identity") _ = attnFeedbackCmd.MarkFlagRequired("file") - attnReportCmd.Flags().DurationVar(&attnQualitySinceFlag, "since", 0, "Lookback window (for example 168h)") - attnReportCmd.Flags().StringVar(&attnQualityProjectFlag, "quality-project", "", - "Project label or worktree id to filter on") + attnPreflightCmd.Flags().StringVar(&attnPreflightStatusFlag, "status", "", + "Fresh status --json file to derive or validate the request from") + attnPreflightCmd.Flags().StringVar(&attnPreflightFileFlag, "preflight", "", + "Existing preflight JSON to assess") + attnPreflightCmd.Flags().StringVar(&attnPreflightActionFlag, "action", "", + "Action: start_service, restart_service, stop_service or adopt_manifest") + attnPreflightCmd.Flags().StringVar(&attnPreflightServiceFlag, "service", "", + "Declared service target for start_service or restart_service") + attnPreflightCmd.Flags().StringVar(&attnPreflightDraftFlag, "draft-group", "", + "Pending draft group for adopt_manifest") + attnPreflightCmd.Flags().StringVar(&attnPreflightPermissionFlag, "permission", "", + "Explicit user-permission context for the proposed action") + attnPreflightCmd.Flags().BoolVar(&attnPreflightApprovedFlag, "user-approved", false, + "Record that the user approved the proposed action (does not execute it)") - attnCmd.AddCommand(attnAssessCmd, attnSessionCmd, attnFeedbackCmd, attnReportCmd) + attnCmd.AddCommand(attnAssessCmd, attnFeedbackCmd, attnPreflightCmd, + attnStatusCmd, attnConfigCmd, attnOnCmd, attnOffCmd, attnTestCmd) rootCmd.AddCommand(attnCmd) } @@ -131,6 +163,23 @@ func init() { // an unset allow-list is an error, because a candidate option in an artifact is // not permission to act on it. func attnTaskContext() (model.TaskContext, error) { + task, err := attnOptionalTaskContext() + if err != nil { + return task, err + } + if strings.TrimSpace(task.Task) == "" { + return task, usageError{errors.New("--task or --task-context is required")} + } + if len(task.AuthorizedOptions) == 0 { + return task, usageError{errors.New("--authorize (or authorized_options) is required")} + } + return task, nil +} + +// attnOptionalTaskContext applies the task flags without requiring them. The +// action preflight uses this: its request already names the action and target, +// so an agent may omit the development-task summary. +func attnOptionalTaskContext() (model.TaskContext, error) { var task model.TaskContext var err error if strings.TrimSpace(attnTaskFileFlag) != "" { @@ -145,17 +194,11 @@ func attnTaskContext() (model.TaskContext, error) { if len(attnAuthorizeFlag) > 0 { task.AuthorizedOptions = append([]string(nil), attnAuthorizeFlag...) } - if strings.TrimSpace(task.Task) == "" { - return task, usageError{errors.New("--task or --task-context is required")} - } - if len(task.AuthorizedOptions) == 0 { - return task, usageError{errors.New("--authorize (or authorized_options) is required")} - } return task, nil } -// attnResolvedConsent is the same single resolution the standalone command uses. -// A reason means "do not call the provider", never "guess a default". +// attnResolvedConsent is the one consent resolution every attn verb uses. A +// reason means "do not call the endpoint", never "guess a default". func attnResolvedConsent() model.Resolved { input := model.ResolveInputFromEnvironment() input.Settings, input.SettingsPresent, input.SettingsErr = model.LoadLocalConfig() @@ -163,15 +206,24 @@ func attnResolvedConsent() model.Resolved { } func runAttnAssess(cmd *cobra.Command, _ []string) error { + if attnFollowFlag && attnDryRunFlag { + return usageError{errors.New("--follow and --dry-run are mutually exclusive")} + } task, err := attnTaskContext() if err != nil { return err } + if attnDryRunFlag { + return runAttnDryRun(cmd, task) + } consent := attnResolvedConsent() + if attnFollowFlag { + return runAttnFollow(cmd, task, consent) + } started := time.Now() - // model being off does not mean "no handoff": the runner still reads status - // and builds the fact-only request, then reports the fixed reason. Only the - // provider call is skipped, which is what the person refused. + // The assessment being off does not mean "no handoff": the runner still reads + // status and builds the fact-only request, then reports the fixed reason. Only + // the provider call is skipped, which is what the person refused. client, clientErr := consent.Client() if clientErr != nil { // The client is empty, so the runner treats this as "no key": it never @@ -190,15 +242,12 @@ func runAttnAssess(cmd *cobra.Command, _ []string) error { return writeAttnOutput(cmd.OutOrStdout(), result) } -func runAttnSession(cmd *cobra.Command, _ []string) error { - task, err := attnTaskContext() - if err != nil { - return err - } +// runAttnFollow is the long-lived form of assess. It polls the facts and emits +// only changed handoffs, so an agent does not rebuild the request by hand. +func runAttnFollow(cmd *cobra.Command, task model.TaskContext, consent model.Resolved) error { if attnIntervalFlag <= 0 { return usageError{errors.New("--interval must be positive")} } - consent := attnResolvedConsent() // A session on a disabled configuration is not a silent no-op: it reports // the fixed reason and stops, so an agent does not wait for events that can // never arrive. @@ -224,6 +273,37 @@ func runAttnSession(cmd *cobra.Command, _ []string) error { return nil } +// runAttnDryRun prints exactly what the next assessment would send for the +// current event. It is the honesty check, and it is offline by construction. +func runAttnDryRun(cmd *cobra.Command, task model.TaskContext) error { + status, err := runner.ReadStatus(cmd.Context(), attnOberthFlag, attnProjectFlag) + if err != nil { + return err + } + if status.AttnPath == "" { + return attnPrint(map[string]any{"status": "clear"}, func() string { + return "No current anomaly, so there is nothing to assess." + }) + } + artifact, err := attn.ReadFresh(status.AttnPath, time.Time{}) + if err != nil { + return failHint("stale", "the attention artifact is stale", model.ReasonHint(model.ReasonStale)) + } + if len(task.AuthorizedOptions) == 0 { + for _, option := range artifact.Options { + task.AuthorizedOptions = append(task.AuthorizedOptions, option.ID) + } + } + body, err := model.PreviewRequest(artifact, task) + if err != nil { + return usageError{err} + } + return attnPrint(map[string]any{"status": "preview", "request": body}, func() string { + encoded, _ := json.MarshalIndent(body, "", " ") + return string(encoded) + }) +} + func runAttnFeedback(cmd *cobra.Command, _ []string) error { result, err := runner.Feedback(attnFeedbackFileFlag, attnArtifactFlag, attnProjectFlag) if err != nil { @@ -234,20 +314,144 @@ func runAttnFeedback(cmd *cobra.Command, _ []string) error { }) } -func runAttnReport(cmd *cobra.Command, _ []string) error { - if attnQualitySinceFlag < 0 { - return usageError{errors.New("--since must not be negative")} +// attnPreflightOutput is the JSON an agent reads back from `attn preflight`. +type attnPreflightOutput struct { + Status string `json:"status"` + PreflightPath string `json:"preflight_path,omitempty"` + TelemetryPath string `json:"telemetry_path,omitempty"` + Reason string `json:"reason,omitempty"` + Assessment *attn.Assessment `json:"assessment,omitempty"` + TaskRelevant *float64 `json:"task_relevant,omitempty"` + Model string `json:"model,omitempty"` + RequestID string `json:"request_id,omitempty"` +} + +func runAttnPreflight(cmd *cobra.Command, _ []string) error { + task, err := attnOptionalTaskContext() + if err != nil { + return err } - report, err := runner.QualityReport(attnQualitySinceFlag, attnQualityProjectFlag) + consent := attnResolvedConsent() + home := paths.Dir() + var request model.Preflight + if strings.TrimSpace(attnPreflightFileFlag) != "" { + request, err = model.ReadPreflight(attnPreflightFileFlag) + if err != nil { + return err + } + if filepath.Clean(attnPreflightFileFlag) != filepath.Clean(model.PreflightPath(home, request)) { + return errors.New("preflight path is not the current BERTH_HOME derived request") + } + if strings.TrimSpace(attnPreflightStatusFlag) != "" { + snapshot, readErr := model.ReadStatusSnapshot(attnPreflightStatusFlag, time.Time{}) + if readErr != nil { + return readErr + } + if err := model.ValidatePreflight(request, snapshot, home, time.Time{}); err != nil { + return err + } + } + } else { + if strings.TrimSpace(attnPreflightActionFlag) == "" { + return usageError{errors.New("--action is required with --status")} + } + snapshot, readErr := model.ReadStatusSnapshot(attnPreflightStatusFlag, time.Time{}) + if readErr != nil { + return readErr + } + request, err = model.DerivePreflight(snapshot, model.Intent{ + Action: attnPreflightActionFlag, Service: attnPreflightServiceFlag, + DraftGroup: attnPreflightDraftFlag, UserPermission: attnPreflightPermissionFlag, + UserApproved: attnPreflightApprovedFlag, + }, home, time.Time{}) + if err != nil { + return err + } + } + path, err := model.WritePreflight(home, request) if err != nil { return err } - return writeAttnValue(cmd.OutOrStdout(), report) + // Writing the request is not consent to send it: without consent the agent + // still gets the bounded preflight document to present directly. + client, clientErr := consent.Client() + if clientErr != nil { + telemetryPath := runner.RecordPreflightAssessment(home, request, task, nil, clientErr) + return writeAttnValue(cmd.OutOrStdout(), attnPreflightOutput{ + Status: "unavailable", PreflightPath: path, TelemetryPath: telemetryPath, + RequestID: request.RequestID, Reason: consent.ReasonOr(clientErr), + }) + } + artifact := request.AssessmentArtifactForAdapter() + if strings.TrimSpace(task.Task) == "" { + // The action intent is the minimum task context when the caller omitted a + // development summary. The fixed options remain candidates, not permission. + task.Task = "Evaluate whether this proposed worktree action needs the user's choice" + } + task = preflightTaskContext(request, task) + result, callErr := client.Evaluate(cmd.Context(), artifact, task) + if callErr != nil { + if errors.Is(callErr, model.ErrUnavailable) || errors.Is(callErr, model.ErrStale) { + telemetryPath := runner.RecordPreflightAssessment(home, request, task, nil, callErr) + return writeAttnValue(cmd.OutOrStdout(), attnPreflightOutput{ + Status: "unavailable", PreflightPath: path, TelemetryPath: telemetryPath, + RequestID: request.RequestID, Reason: model.ReasonFor(callErr), + }) + } + return callErr + } + if err := request.AttachAssessment(result.Assessment); err != nil { + return err + } + if _, err := model.WritePreflight(home, request); err != nil { + return err + } + telemetryPath := runner.RecordPreflightAssessment(home, request, task, &result, nil) + return writeAttnValue(cmd.OutOrStdout(), attnPreflightOutput{ + Status: "assessed", PreflightPath: path, TelemetryPath: telemetryPath, + RequestID: request.RequestID, Assessment: &result.Assessment, + TaskRelevant: &result.TaskRelevant, Model: result.Model, + }) +} + +func preflightTaskContext(request model.Preflight, task model.TaskContext) model.TaskContext { + base := strings.TrimSpace(task.Task) + target := request.Target.Service + if target == "" { + target = request.Target.Project + } + if request.Target.DraftGroup != "" { + target = request.Target.DraftGroup + } + suffix := formatPreflightSuffix(request.Intent.Action, target, request.Intent.UserPermission) + available := 2000 - len([]byte(suffix)) + if len([]byte(base)) > available { + base = string([]byte(base)[:available]) + } + task.Task = strings.TrimSpace(base + suffix) + if len(task.AuthorizedOptions) == 0 { + task.AuthorizedOptions = make([]string, 0, len(request.Options)) + for _, option := range request.Options { + task.AuthorizedOptions = append(task.AuthorizedOptions, option.ID) + } + if len(task.AuthorizedOptions) == 0 && request.Intent.Action != "" { + task.AuthorizedOptions = []string{request.Intent.Action} + } + } + return task +} + +func formatPreflightSuffix(action, target, permission string) string { + suffix := " Proposed fixed action: " + action + "; target: " + target + "; user permission context: " + permission + "." + if len([]byte(suffix)) > 1800 { + suffix = string([]byte(suffix)[:1800]) + } + return suffix } // writeAttnOutput prints a runner result. The JSON is the protocol the agent // reads, so it is written whether or not --json was given; the flag stays for -// symmetry with the rest of the CLI and for a future human view. +// symmetry with the rest of the CLI and for the human status view. func writeAttnOutput(writer io.Writer, result runner.Output) error { return writeAttnValue(writer, result) } diff --git a/engine/internal/cmd/attn_ctl.go b/engine/internal/cmd/attn_ctl.go index f998782e..4b14f779 100644 --- a/engine/internal/cmd/attn_ctl.go +++ b/engine/internal/cmd/attn_ctl.go @@ -1,21 +1,18 @@ package cmd -// The setup half of `oberth attn`: status, enable, test, disable and preview. -// These are the only commands that write the owner-only settings file, and the -// only ones that print what would be sent. Everything here is offline except -// `enable` and `test`, which probe once with a synthetic redacted event. +// The person-facing half of `oberth attn`: status, config, on, off and test. +// `config` is the only verb that writes credentials, and it probes once before +// writing; `on` and `off` flip one boolean and never touch the network. import ( "bufio" "context" - "encoding/json" "errors" "fmt" "os" "strings" "time" - "github.com/sheathedsharp/option-berth/engine/internal/attn" "github.com/sheathedsharp/option-berth/engine/internal/attn/model" "github.com/sheathedsharp/option-berth/engine/internal/attn/runner" "github.com/sheathedsharp/option-berth/engine/internal/paths" @@ -23,93 +20,85 @@ import ( ) var ( - attnEnablePreset string - attnEnableEndpoint string - attnEnableModel string - attnEnableTimeout time.Duration - attnKeyStdinFlag bool - attnKeyEnvFlag string - attnDisableForget bool - attnPreviewTaskFlag string - attnPreviewAuthFlag []string + attnConfigPreset string + attnConfigEndpoint string + attnConfigModel string + attnConfigTimeout time.Duration + attnKeyStdinFlag bool + attnKeyEnvFlag string + attnOffForgetFlag bool ) var attnStatusCmd = &cobra.Command{ Use: "status", - Short: "Show whether the assessment is on, and where its settings come from", + Short: "Show the resolved assessment state and its local quality data", Long: "Report the resolved configuration without contacting a provider: on or off,\n" + - "endpoint, model, where the key came from, and the fixed reason when it cannot\n" + - "be used. The key itself is never printed, only a short fingerprint.", + "endpoint, model, where the key came from, the fixed reason when it cannot be\n" + + "used, and a summary of the local quality ledger. The key itself is never\n" + + "printed, only a short fingerprint.", Args: cobra.NoArgs, RunE: runAttnStatus, } -var attnEnableCmd = &cobra.Command{ - Use: "enable", - Short: "Store a key, prove it works, and turn the optional assessment on", - Long: "One step, in this order: choose an endpoint and model (directly, or through a\n" + - "preset that fills both), read the key from standard input (--key-stdin), an\n" + - "environment variable (--key-env NAME), or a hidden terminal prompt; send one\n" + - "synthetic redacted event to prove the key answers; only then write the\n" + - "owner-only attn.json. A probe that fails writes nothing and prints the fixed\n" + - "reason, so a typo cannot leave a configuration that breaks every task.\n\n" + - "The command finishes by printing exactly what will and will not be sent.", +var attnConfigCmd = &cobra.Command{ + Use: "config", + Short: "Store the endpoint, model and key, proving the key answers", + Long: "Write the owner-only attn.json. Give an endpoint and model directly, or use a\n" + + "preset that fills both. The key comes from standard input (--key-stdin), an\n" + + "environment variable (--key-env NAME), a hidden terminal prompt, or the\n" + + "already stored key when no key flag is given. One synthetic redacted request\n" + + "proves the configuration before anything is written; a failed probe leaves the\n" + + "existing file untouched. This verb changes credentials, not consent: it never\n" + + "turns the assessment on.", Args: cobra.NoArgs, - RunE: runAttnEnable, + RunE: runAttnConfig, } -var attnTestCmd = &cobra.Command{ - Use: "test", - Short: "Send one synthetic event and report the fixed reason if it fails", - Long: "Check the current configuration with one network call. It does not change any\n" + - "setting, and it does not need a running service or a worktree.", +var attnOnCmd = &cobra.Command{ + Use: "on", + Short: "Turn the assessment on (offline, one boolean)", + Long: "Set \"enabled\": true in attn.json. This is a consent flip, not a probe: run\n" + + "`oberth attn config` first to store a working configuration, and\n" + + "`oberth attn test` afterwards to verify it.", Args: cobra.NoArgs, - RunE: runAttnTest, + RunE: runAttnOn, } -var attnDisableCmd = &cobra.Command{ - Use: "disable", - Short: "Stop using the assessment but keep the stored key", - Long: "Turn the assessment off. The key stays in attn.json unless --forget is given, so\n" + - "enabling again does not need it typed a second time. --forget removes it.", +var attnOffCmd = &cobra.Command{ + Use: "off", + Short: "Turn the assessment off but keep the stored key", + Long: "Set \"enabled\": false in attn.json. The key stays unless --forget is given, so\n" + + "turning it back on does not need it typed again.", Args: cobra.NoArgs, - RunE: runAttnDisable, + RunE: runAttnOff, } -var attnPreviewCmd = &cobra.Command{ - Use: "preview", - Short: "Print the redacted request body for this worktree, without sending it", - Long: "Show exactly what would cross the boundary for the current status event: the\n" + - "worktree hash, the event summary, the evidence references and the fixed option\n" + - "IDs. It never contacts a provider and never includes logs, paths or the key.", +var attnTestCmd = &cobra.Command{ + Use: "test", + Short: "Send one synthetic event and report the fixed reason if it fails", + Long: "Check the current configuration with one network call. It does not change any\n" + + "setting, and it does not need a running service or a worktree.", Args: cobra.NoArgs, - RunE: runAttnPreview, + RunE: runAttnTest, } func init() { - attnEnableCmd.Flags().StringVar(&attnEnablePreset, "preset", "", + attnConfigCmd.Flags().StringVar(&attnConfigPreset, "preset", "", "Fill endpoint and model from a preset ("+model.PresetNames()+"); optional") - attnEnableCmd.Flags().StringVar(&attnEnableEndpoint, "endpoint", "", "Endpoint URL (required unless --preset fills it)") - attnEnableCmd.Flags().StringVar(&attnEnableModel, "model", "", "Model name (required unless --preset fills it)") - attnEnableCmd.Flags().DurationVar(&attnEnableTimeout, "timeout", 0, "Request timeout (default 10s)") - attnEnableCmd.Flags().BoolVar(&attnKeyStdinFlag, "key-stdin", false, + attnConfigCmd.Flags().StringVar(&attnConfigEndpoint, "endpoint", "", "Endpoint URL (required unless --preset fills it)") + attnConfigCmd.Flags().StringVar(&attnConfigModel, "model", "", "Model name (required unless --preset fills it)") + attnConfigCmd.Flags().DurationVar(&attnConfigTimeout, "timeout", 0, "Request timeout (default 10s)") + attnConfigCmd.Flags().BoolVar(&attnKeyStdinFlag, "key-stdin", false, "Read the key from the first line of standard input") - attnEnableCmd.Flags().StringVar(&attnKeyEnvFlag, "key-env", "", + attnConfigCmd.Flags().StringVar(&attnKeyEnvFlag, "key-env", "", "Read the key from this environment variable") - attnDisableCmd.Flags().BoolVar(&attnDisableForget, "forget", false, "Also delete the stored key") - - attnPreviewCmd.Flags().StringVar(&attnPreviewTaskFlag, "task", "", - "Task summary to include (defaults to a generic one)") - attnPreviewCmd.Flags().StringSliceVar(&attnPreviewAuthFlag, "authorize", nil, - "Authorized option IDs to include") - - attnCmd.AddCommand(attnStatusCmd, attnEnableCmd, attnTestCmd, attnDisableCmd, attnPreviewCmd) + attnOffCmd.Flags().BoolVar(&attnOffForgetFlag, "forget", false, "Also delete the stored key") } -// attnPrint prints the document a script reads, or a short human block. The -// default is human: these commands are run once by a person during setup, and -// the JSON forms exist for the client and for tests. +// attnPrint prints the document a script reads, or a short human block. Setup +// verbs are run by a person, so the default is human; JSON exists for the client +// and for tests. func attnPrint(value any, human func() string) error { if attnJSONFlag { return writeAttnValue(os.Stdout, value) @@ -129,6 +118,14 @@ func runAttnStatus(cmd *cobra.Command, _ []string) error { state["key_fingerprint"] = fingerprint } } + if attnStatusSinceFlag < 0 { + return usageError{errors.New("--since must not be negative")} + } + report, err := runner.QualityReport(attnStatusSinceFlag, attnQualityProjectFlag) + if err != nil { + return err + } + state["quality"] = report return attnPrint(state, func() string { var b strings.Builder if consent.Enabled { @@ -142,6 +139,7 @@ func runAttnStatus(cmd *cobra.Command, _ []string) error { fmt.Fprintf(&b, " endpoint %s\n", consent.Endpoint) fmt.Fprintf(&b, " model %s\n", consent.Model) fmt.Fprintf(&b, " key %s\n", keySourceLine(consent.KeySource, state["key_fingerprint"])) + fmt.Fprintf(&b, " quality %s\n", qualitySummary(report, attnStatusSinceFlag)) if !consent.Enabled { fmt.Fprintf(&b, " next %s\n", model.ReasonHint(consent.Reason)) } @@ -149,6 +147,25 @@ func runAttnStatus(cmd *cobra.Command, _ []string) error { }) } +func qualitySummary(report model.QualityReport, since time.Duration) string { + window := "all" + if since > 0 { + window = strings.TrimSuffix(strings.TrimSuffix(since.String(), "0m0s"), "0s") + } + parts := []string{ + fmt.Sprintf("window %s", window), + fmt.Sprintf("assessments %d", report.AssessmentRecords), + fmt.Sprintf("model calls %d", report.Sessions.ModelCalls), + fmt.Sprintf("cache hits %d", report.Sessions.CacheHits), + fmt.Sprintf("labeled %d/%d", report.LabeledCases, report.UniqueCases), + } + if report.HumanDecision.Precision != nil && report.HumanDecision.Recall != nil { + parts = append(parts, fmt.Sprintf("precision %.2f recall %.2f", + *report.HumanDecision.Precision, *report.HumanDecision.Recall)) + } + return strings.Join(parts, " · ") +} + func keySourceLine(source model.Source, fingerprint any) string { if source == model.SourceNone { return "not configured" @@ -159,186 +176,224 @@ func keySourceLine(source model.Source, fingerprint any) string { return string(source) } -func runAttnEnable(cmd *cobra.Command, _ []string) error { +func runAttnConfig(cmd *cobra.Command, _ []string) error { if attnKeyStdinFlag && attnKeyEnvFlag != "" { return usageError{errors.New("--key-stdin and --key-env are mutually exclusive")} } - current, _, err := model.LoadLocalConfig() + current, present, err := model.LoadLocalConfig() if err != nil { - // A malformed file is replaceable: enable is the repair path. + // A malformed file is replaceable: config is the repair path. current = model.LocalConfig{} } + if noAttnConfigFlags() { + return attnPrint(current.RedactedSettings(), func() string { + if !present { + return "Attention: not configured. Run `oberth attn config` with --preset or --endpoint/--model." + } + return humanSettings(current) + }) + } options := model.EnableOptions{ - Preset: attnEnablePreset, Endpoint: attnEnableEndpoint, Model: attnEnableModel, - Timeout: attnEnableTimeout, KeyKind: model.KeyFromPrompt, + Preset: attnConfigPreset, Endpoint: attnConfigEndpoint, + Model: attnConfigModel, Timeout: attnConfigTimeout, } + next := options.SettingsFrom(current) // Validate the endpoint and model before asking for a secret: a person who // forgot --preset should not type a key only to be told the shape is wrong. - next := options.SettingsFrom(current) if err := next.Validate(); err != nil { return usageError{err} } - switch { - case attnKeyStdinFlag: - options.KeyKind = model.KeyFromStdin - case attnKeyEnvFlag != "": - options.KeyKind = model.KeyFromEnv - options.KeyEnv = attnKeyEnvFlag - default: - key, err := readSecretFromTerminal() - if err != nil { - return err - } - options.KeyValue = key - } - key, err := options.ResolveKey(os.Getenv, func() (string, error) { - return bufio.NewReader(os.Stdin).ReadString('\n') - }) - if err != nil { - return err + key, keyErr := attnConfigKey(next.APIKey) + if keyErr != nil { + return keyErr } next.APIKey = key if err := next.Validate(); err != nil { return usageError{err} } - ctx, cancel := context.WithTimeout(cmd.Context(), next.Timeout()+time.Second) defer cancel() probe := model.ProbeOnce(ctx, next) if !probe.OK { - // Nothing is written: a configuration that does not work must not - // replace one that might. + // Nothing is written: a configuration that does not work must not replace + // one that might. return failHint("attn_probe_failed", - fmt.Sprintf("the provider did not accept this key (%s)", probe.Reason), - probe.Text) + fmt.Sprintf("the endpoint did not accept this key (%s)", probe.Reason), probe.Text) } path := paths.AttnConfigPath() if err := model.WriteSettings(path, next); err != nil { return err } state := model.Resolved{ - Enabled: true, ConsentSource: model.SourceSettings, Preset: next.Preset, + Enabled: next.Enabled, ConsentSource: model.SourceSettings, Preset: next.Preset, Endpoint: next.Endpoint, Model: next.Model, Key: next.APIKey, KeySource: model.SourceSettings, Timeout: next.Timeout(), } return attnPrint(map[string]any{ - "status": "enabled", + "status": "configured", "path": path, "settings": state.RedactedState(), "probe": map[string]any{"ok": true, "model": probe.Model, "latency_ms": probe.Latency.Milliseconds()}, "sends": model.SendsSummary(), }, func() string { - return fmt.Sprintf("Attention: enabled (%s, key %s)\n %s\n\n%s", + return fmt.Sprintf("Attention: configured (%s, key %s)\n %s\n\n%s", next.Model, keySourceLine(model.SourceSettings, model.KeyFingerprint(next.APIKey)), path, model.HumanSendsSummary()) }) } -func runAttnTest(cmd *cobra.Command, _ []string) error { - consent := attnResolvedConsent() - if !consent.Enabled { - return failHint("attn_disabled", "the assessment is off", model.ReasonHint(firstNonEmptyString(consent.Reason, model.ReasonDisabled))) +func noAttnConfigFlags() bool { + return strings.TrimSpace(attnConfigPreset) == "" && strings.TrimSpace(attnConfigEndpoint) == "" && + strings.TrimSpace(attnConfigModel) == "" && attnConfigTimeout == 0 && + !attnKeyStdinFlag && strings.TrimSpace(attnKeyEnvFlag) == "" +} + +// attnConfigKey answers which secret to store. An explicit source wins; no key +// flag keeps the stored key, or prompts when there is none to keep. +func attnConfigKey(stored string) (string, error) { + switch { + case attnKeyStdinFlag: + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return "", err + } + line = strings.TrimSpace(line) + if line == "" { + return "", usageError{errors.New("standard input did not contain a key")} + } + return line, nil + case attnKeyEnvFlag != "": + value := strings.TrimSpace(os.Getenv(attnKeyEnvFlag)) + if value == "" { + return "", usageError{fmt.Errorf("environment variable %s is empty", attnKeyEnvFlag)} + } + return value, nil + case strings.TrimSpace(stored) != "": + return strings.TrimSpace(stored), nil + default: + key, err := readSecretFromTerminal() + if err != nil { + return "", err + } + return key, nil } - cfg, _, err := model.LoadLocalConfig() +} + +func humanSettings(cfg model.LocalConfig) string { + var b strings.Builder + state := "off" + if cfg.Enabled { + state = "on" + } + fmt.Fprintf(&b, "Attention: %s\n", state) + if cfg.Preset != "" { + fmt.Fprintf(&b, " preset %s\n", cfg.Preset) + } + fmt.Fprintf(&b, " endpoint %s\n", cfg.Endpoint) + fmt.Fprintf(&b, " model %s\n", cfg.Model) + fmt.Fprintf(&b, " key %s\n", keySourceLine(model.SourceSettings, model.KeyFingerprint(cfg.APIKey))) + return strings.TrimRight(b.String(), "\n") +} + +func runAttnOn(cmd *cobra.Command, _ []string) error { + cfg, present, err := model.LoadLocalConfig() if err != nil { - return err + return failHint("attn_invalid_config", "attention settings are not usable", + "run `oberth attn config` to rewrite them") } - // The probe must use the same effective fields a real assessment would: - // environment overrides are already merged into the resolved consent. - cfg.Endpoint, cfg.Model, cfg.APIKey = consent.Endpoint, consent.Model, consent.Key - if cfg.TimeoutMS == 0 { - cfg.TimeoutMS = int(consent.Timeout / time.Millisecond) + if !present { + return failHint("attn_not_configured", "attention is not configured", + "run `oberth attn config` first") } - ctx, cancel := context.WithTimeout(cmd.Context(), consent.Timeout+time.Second) - defer cancel() - probe := model.ProbeOnce(ctx, cfg) - document := map[string]any{ - "status": "ok", "endpoint": consent.Endpoint, "model": probe.Model, - "latency_ms": probe.Latency.Milliseconds(), + if cfg.Enabled { + return attnPrint(map[string]any{"status": "already_on"}, func() string { + return "Attention: already on" + }) } - if !probe.OK { - document["status"] = "unavailable" - document["reason"] = probe.Reason - document["hint"] = probe.Text + cfg.Enabled = true + if err := cfg.Validate(); err != nil { + return failHint("attn_invalid_config", "attention is not fully configured", + "run `oberth attn config` to supply the endpoint and model") } - if err := attnPrint(document, func() string { - if probe.OK { - return fmt.Sprintf("Attention: the provider answered (%s, %dms)", probe.Model, probe.Latency.Milliseconds()) - } - return fmt.Sprintf("Attention: unavailable (%s)\n next: %s", probe.Reason, probe.Text) - }); err != nil { - return err + if strings.TrimSpace(cfg.APIKey) == "" { + return failHint("attn_no_key", "no key is stored", + "run `oberth attn config --key-stdin` (or --key-env) to store one") } - if !probe.OK { - return errSilent + path := paths.AttnConfigPath() + if err := model.WriteSettings(path, cfg); err != nil { + return err } - return nil + return attnPrint(map[string]any{"status": "enabled", "path": path}, func() string { + return "Attention: on\n run `oberth attn test` to verify it answers" + }) } -func runAttnDisable(cmd *cobra.Command, _ []string) error { +func runAttnOff(cmd *cobra.Command, _ []string) error { cfg, present, err := model.LoadLocalConfig() if err != nil { cfg = model.LocalConfig{} } - if !present && cfg.APIKey == "" && !attnDisableForget { - // Nothing to turn off. Saying so is better than writing a file that - // looks like a decision nobody made. + if !present && cfg.APIKey == "" && !attnOffForgetFlag { return attnPrint(map[string]any{"status": "already_off"}, func() string { return "Attention: already off" }) } path := paths.AttnConfigPath() - if err := model.DisableSettings(path, cfg, attnDisableForget); err != nil { + if err := model.DisableSettings(path, cfg, attnOffForgetFlag); err != nil { return err } status := "disabled" - if attnDisableForget { + if attnOffForgetFlag { status = "disabled_and_forgotten" } - return attnPrint(map[string]any{"status": status, "path": path, "key_kept": !attnDisableForget}, + return attnPrint(map[string]any{"status": status, "path": path, "key_kept": !attnOffForgetFlag}, func() string { - if attnDisableForget { - return "Attention: disabled, stored key removed" + if attnOffForgetFlag { + return "Attention: off, stored key removed" } - return "Attention: disabled, stored key kept (use --forget to remove it)" + return "Attention: off, stored key kept (use --forget to remove it)" }) } -// runAttnPreview builds the request the adapter would send for the current -// worktree event and prints it. It is the honesty test a person runs before -// enabling: if what is shown is not acceptable, do not enable. -func runAttnPreview(cmd *cobra.Command, _ []string) error { - status, err := runner.ReadStatus(cmd.Context(), attnOberthFlag, attnProjectFlag) +func runAttnTest(cmd *cobra.Command, _ []string) error { + consent := attnResolvedConsent() + if !consent.Enabled { + return failHint("attn_disabled", "the assessment is off", model.ReasonHint(firstNonEmptyString(consent.Reason, model.ReasonDisabled))) + } + cfg, _, err := model.LoadLocalConfig() if err != nil { return err } - if status.AttnPath == "" { - return attnPrint(map[string]any{"status": "clear"}, func() string { - return "No current anomaly, so there is nothing to assess." - }) + // The probe must use the same effective fields a real assessment would: + // environment overrides are already merged into the resolved consent. + cfg.Endpoint, cfg.Model, cfg.APIKey = consent.Endpoint, consent.Model, consent.Key + if cfg.TimeoutMS == 0 { + cfg.TimeoutMS = int(consent.Timeout / time.Millisecond) } - artifact, err := attn.ReadFresh(status.AttnPath, time.Time{}) - if err != nil { - return failHint("stale", "the attention artifact is stale", model.ReasonHint(model.ReasonStale)) + ctx, cancel := context.WithTimeout(cmd.Context(), consent.Timeout+time.Second) + defer cancel() + probe := model.ProbeOnce(ctx, cfg) + document := map[string]any{ + "status": "ok", "endpoint": consent.Endpoint, "model": probe.Model, + "latency_ms": probe.Latency.Milliseconds(), } - task := model.TaskContext{ - Task: firstNonEmptyString(attnPreviewTaskFlag, - "Describe what an assessment would send for this event"), - AuthorizedOptions: attnPreviewAuthFlag, + if !probe.OK { + document["status"] = "unavailable" + document["reason"] = probe.Reason + document["hint"] = probe.Text } - if len(task.AuthorizedOptions) == 0 { - for _, option := range artifact.Options { - task.AuthorizedOptions = append(task.AuthorizedOptions, option.ID) + if err := attnPrint(document, func() string { + if probe.OK { + return fmt.Sprintf("Attention: the endpoint answered (%s, %dms)", probe.Model, probe.Latency.Milliseconds()) } + return fmt.Sprintf("Attention: unavailable (%s)\n next: %s", probe.Reason, probe.Text) + }); err != nil { + return err } - body, err := model.PreviewRequest(artifact, task) - if err != nil { - return usageError{err} + if !probe.OK { + return errSilent } - return attnPrint(map[string]any{"status": "preview", "request": body}, func() string { - encoded, _ := json.MarshalIndent(body, "", " ") - return string(encoded) - }) + return nil } func firstNonEmptyString(values ...string) string { diff --git a/engine/internal/cmd/attn_preflight_test.go b/engine/internal/cmd/attn_preflight_test.go new file mode 100644 index 00000000..aa4f06a1 --- /dev/null +++ b/engine/internal/cmd/attn_preflight_test.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sheathedsharp/option-berth/engine/internal/attn" + "github.com/sheathedsharp/option-berth/engine/internal/attn/model" +) + +func resetAttnPreflightFlags(t *testing.T) { + t.Helper() + t.Cleanup(func() { + attnPreflightStatusFlag = "" + attnPreflightFileFlag = "" + attnPreflightActionFlag = "" + attnPreflightServiceFlag = "" + attnPreflightDraftFlag = "" + attnPreflightPermissionFlag = "" + attnPreflightApprovedFlag = false + attnTaskFlag = "" + attnTaskFileFlag = "" + attnAuthorizeFlag = nil + }) +} + +// Without consent the bounded request is still the product: the agent gets the +// fact document to present, and the provider is never contacted. +func TestAttnPreflightWritesFactRequestWithoutConsent(t *testing.T) { + resetAttnPreflightFlags(t) + home := t.TempDir() + t.Setenv("BERTH_HOME", home) + root := filepath.Join(t.TempDir(), "repo") + statusPath := filepath.Join(t.TempDir(), "status.json") + status := map[string]any{ + "scope": map[string]any{"root": root}, + "worktree": map[string]any{ + "name": "demo", "branch": "codex/test", "root_dir": root, + "services": []any{map[string]any{"name": "api", "running": false}}, + }, + "drafts": []any{}, + } + data, err := json.Marshal(status) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(statusPath, data, 0o600); err != nil { + t.Fatal(err) + } + attnPreflightStatusFlag = statusPath + attnPreflightActionFlag = "restart_service" + attnPreflightServiceFlag = "api" + attnPreflightPermissionFlag = "ask before restart" + if err := runAttnPreflight(attnPreflightCmd, nil); err != nil { + t.Fatal(err) + } + entries, err := filepath.Glob(filepath.Join(home, "attention", "preflight", "*.json")) + if err != nil || len(entries) != 1 { + t.Fatalf("preflight entries = %v, err=%v", entries, err) + } + request, err := model.ReadPreflight(entries[0]) + if err != nil { + t.Fatal(err) + } + if request.Intent.Action != "restart_service" || request.Target.Service != "api" || request.Assessment != nil { + t.Fatalf("request = %+v", request) + } +} + +func TestPreflightContextOffersOnlyFixedRequestOptions(t *testing.T) { + request := model.Preflight{ + Intent: model.Intent{Action: "restart_service"}, + Options: []attn.Option{{ID: "restart_service"}, {ID: "inspect_manifest"}, {ID: "continue_without_action"}}, + } + ctx := preflightTaskContext(request, model.TaskContext{Task: "prepare the integration test"}) + if ctx.Task == "" || len(ctx.AuthorizedOptions) != len(request.Options) { + t.Fatalf("preflight context = %+v", ctx) + } + for _, option := range request.Options { + found := false + for _, authorized := range ctx.AuthorizedOptions { + if authorized == option.ID { + found = true + } + } + if !found { + t.Fatalf("preflight context omitted fixed option %q: %+v", option.ID, ctx) + } + } +} + +func TestPreflightTaskContextIncludesIntentWithoutExceedingBound(t *testing.T) { + request := model.Preflight{ + Intent: model.Intent{Action: "restart_service", UserPermission: "ask first"}, + Target: model.PreflightTarget{Project: "demo", Service: "api"}, + } + got := preflightTaskContext(request, model.TaskContext{Task: strings.Repeat("x", 4000)}) + if len([]byte(got.Task)) > 2000 || got.AuthorizedOptions[0] != "restart_service" { + t.Fatalf("task context = %+v", got) + } + if !strings.Contains(got.Task, "restart_service") || !strings.Contains(got.Task, "ask first") { + t.Fatalf("intent context omitted: %q", got.Task) + } +} diff --git a/engine/internal/cmd/attn_test.go b/engine/internal/cmd/attn_test.go index aaecffa0..d6b9ba0c 100644 --- a/engine/internal/cmd/attn_test.go +++ b/engine/internal/cmd/attn_test.go @@ -32,20 +32,20 @@ func TestAttnIsInTheHandoffLayer(t *testing.T) { func TestAttnSurfaceIsTheRunnerAndSetupVerbs(t *testing.T) { want := map[string]bool{ // the runner: what an agent calls while working - "assess": false, "session": false, "feedback": false, "report": false, + "assess": false, "feedback": false, "preflight": false, // setup and explanation: what a person calls once - "status": false, "enable": false, "test": false, "disable": false, "preview": false, + "status": false, "config": false, "on": false, "off": false, "test": false, } for _, sub := range attnCmd.Commands() { if _, ok := want[sub.Name()]; !ok { - t.Errorf("unexpected jev subcommand %q", sub.Name()) + t.Errorf("unexpected attn subcommand %q", sub.Name()) continue } want[sub.Name()] = true } for name, seen := range want { if !seen { - t.Errorf("jev %s is missing", name) + t.Errorf("attn %s is missing", name) } } } diff --git a/install.ps1 b/install.ps1 index 9d95a4d9..2e2c50fd 100644 --- a/install.ps1 +++ b/install.ps1 @@ -5,7 +5,7 @@ Install the released option-berth CLI on Windows without a Go toolchain. .DESCRIPTION Resolves the latest engine release, downloads the Windows archive for this -machine, verifies its SHA256 sidecar, and installs oberth.exe + jev-attention.exe +machine, verifies its SHA256 sidecar, and installs oberth.exe into %LOCALAPPDATA%\option-berth\bin. It starts no services and writes no Git data. When the official GitHub host is unreachable, downloads fall back to a short list @@ -185,7 +185,7 @@ function Install-Oberth { Expand-Archive -Path $archivePath -DestinationPath $tmp -Force $root = Join-Path $tmp "option-berth-$Version-windows-$arch" - foreach ($name in 'oberth.exe', 'jev-attention.exe') { + foreach ($name in 'oberth.exe') { if (-not (Test-Path -LiteralPath (Join-Path $root $name) -PathType Leaf)) { throw "archive is missing $name" } @@ -193,13 +193,13 @@ function Install-Oberth { # A running executable is locked on Windows; refuse before replacing # either file so the install cannot end up half old and half new. - $running = @(Get-Process -Name 'oberth', 'jev-attention' -ErrorAction SilentlyContinue) + $running = @(Get-Process -Name 'oberth' -ErrorAction SilentlyContinue) if ($running.Count) { throw 'oberth is running; stop its services before installing' } New-Item -ItemType Directory -Force -Path $Dir | Out-Null - foreach ($name in 'oberth.exe', 'jev-attention.exe') { + foreach ($name in 'oberth.exe') { $target = Join-Path $Dir $name try { Copy-Item -LiteralPath (Join-Path $root $name) -Destination $target -Force diff --git a/install.sh b/install.sh index cda58fe4..c2290c58 100755 --- a/install.sh +++ b/install.sh @@ -5,7 +5,7 @@ # curl -fsSL https://raw.githubusercontent.com/SheathedSharp/option-berth/main/install.sh | sh # # The script resolves the latest engine release, downloads the archive for this -# machine, verifies its SHA256 sidecar, and installs oberth + jev-attention into +# machine, verifies its SHA256 sidecar, and installs oberth into # ~/.local/bin. Licences and the build manifest stay in ~/.local/share/option-berth. # It starts no services and writes no Git data. # @@ -358,12 +358,12 @@ actual=$(sha256_file "$tmp/$archive") tar -xzf "$tmp/$archive" -C "$tmp" root="$tmp/option-berth-$VERSION-$os-$arch" -for name in oberth jev-attention; do +for name in oberth; do [ -f "$root/$name" ] || fail "archive is missing $name" done mkdir -p "$DIR" -for name in oberth jev-attention; do +for name in oberth; do staged="$DIR/.$name.$$" cp "$root/$name" "$staged" chmod 0755 "$staged" diff --git a/magefiles/build.go b/magefiles/build.go index f23088e9..4c8b4bf4 100644 --- a/magefiles/build.go +++ b/magefiles/build.go @@ -31,19 +31,13 @@ type project struct { date string } -// Build compiles the engine and the optional Jev adapter. +// Build compiles the engine: the CLI, its daemon, and every optional module. func Build() error { p, err := loadProject() if err != nil { return err } - if err := buildEngine(p); err != nil { - return err - } - if err := buildJev(p); err != nil { - return err - } - return nil + return buildEngine(p) } // BuildEngine compiles the Go CLI and daemon. @@ -55,15 +49,6 @@ func BuildEngine() error { return buildEngine(p) } -// BuildJev compiles the optional Jev attention adapter. -func BuildJev() error { - p, err := loadProject() - if err != nil { - return err - } - return buildJev(p) -} - // BuildClient compiles the macOS application. func BuildClient() error { p, err := loadProject() @@ -282,18 +267,6 @@ func buildEngine(p project) error { return nil } -func buildJev(p project) error { - bin := filepath.Join(p.root, "bin", "jev-attention") - if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil { - return err - } - if err := command(p.engine, "go", "build", "-trimpath", "-o", bin, "./cmd/jev-attention"); err != nil { - return err - } - fmt.Printf("built %s\n", bin) - return nil -} - // clientMoved names the product that now owns the macOS interface. Mage stays a // single product's build tool rather than pretending to build a sibling repo. func clientMoved() error { @@ -306,9 +279,6 @@ func installCLI(p project) error { if err := buildEngine(p); err != nil { return err } - if err := buildJev(p); err != nil { - return err - } home, err := os.UserHomeDir() if err != nil { return err @@ -320,9 +290,6 @@ func installCLI(p project) error { if err := command(p.root, "install", "-m", "0755", filepath.Join(p.root, "bin", "oberth"), filepath.Join(localBin, "oberth")); err != nil { return err } - if err := command(p.root, "install", "-m", "0755", filepath.Join(p.root, "bin", "jev-attention"), filepath.Join(localBin, "jev-attention")); err != nil { - return err - } return command(p.root, "sh", filepath.Join(p.root, "scripts", "install-path.sh"), localBin) } diff --git a/magefiles/workflow.go b/magefiles/workflow.go index d66f5bb5..516942c9 100644 --- a/magefiles/workflow.go +++ b/magefiles/workflow.go @@ -9,9 +9,6 @@ import ( // Engine builds only the CLI/daemon; the canonical BuildEngine name remains. func Engine() error { return BuildEngine() } -// Jev builds only the optional attention adapter. -func Jev() error { return BuildJev() } - // Client builds only the macOS client. func Client() error { return BuildClient() } diff --git a/scripts/install_test.py b/scripts/install_test.py index 690078ce..3c81ba77 100644 --- a/scripts/install_test.py +++ b/scripts/install_test.py @@ -101,9 +101,6 @@ def build_release(root: Path, helper=True): oberth = stage / "oberth" oberth.write_text("#!/bin/sh\nif [ \"$1\" = version ]; then echo 'option-berth v9.9.9 (fixture)'; fi\nexit 0\n") oberth.chmod(0o755) - adapter = stage / "jev-attention" - adapter.write_text("#!/bin/sh\nexit 0\n") - adapter.chmod(0o755) if helper: shutil.copyfile(HELPER, stage / "install-path.sh") (stage / "LICENSE").write_text("fixture licence\n") @@ -181,7 +178,6 @@ def test_installs_verified_archive_and_configures_path(self): result = self.install() self.assertEqual(result.returncode, 0, result.stderr) self.assertTrue(os.access(self.installed("oberth"), os.X_OK)) - self.assertTrue(os.access(self.installed("jev-attention"), os.X_OK)) self.assertIn("option-berth v9.9.9 (fixture)", result.stdout) self.assertIn('export PATH="$HOME/.local/bin:$PATH"', rc.read_text()) self.assertIn("PATH added to .zshrc", result.stdout) diff --git a/scripts/package_linux.py b/scripts/package_linux.py index d81fe01c..c2d04ff2 100644 --- a/scripts/package_linux.py +++ b/scripts/package_linux.py @@ -2,8 +2,8 @@ """Build .deb and .rpm packages from one released Linux archive with nfpm. The archive is the source of truth: the script verifies its .sha256 sidecar, -extracts exactly the files the release ships, and installs the same oberth, -jev-attention, licence and build manifest into both packages. nfpm writes the +extracts exactly the files the release ships, and installs the same oberth binary, +licence and build manifest into both packages. nfpm writes the package metadata, so cross-architecture packages do not require rpmbuild. """ from __future__ import annotations @@ -77,10 +77,6 @@ def quoted(path: Path) -> str: dst: /usr/bin/oberth file_info: mode: 0755 - - src: {quoted(root / "jev-attention")} - dst: /usr/bin/jev-attention - file_info: - mode: 0755 - src: {quoted(root / "LICENSE")} dst: /usr/share/doc/option-berth/LICENSE - src: {quoted(root / "LICENSE")} diff --git a/scripts/package_linux_test.py b/scripts/package_linux_test.py index 23c072b9..fb0c4fb8 100644 --- a/scripts/package_linux_test.py +++ b/scripts/package_linux_test.py @@ -21,10 +21,9 @@ def make_archive(directory: Path) -> Path: stem = f"option-berth-v{VERSION}-linux-amd64" stage = directory / "stage" / stem (stage / "licenses").mkdir(parents=True) - for name in ("oberth", "jev-attention"): - path = stage / name - path.write_bytes(f"fixture {name}\n".encode()) - path.chmod(0o755) + path = stage / "oberth" + path.write_bytes(b"fixture oberth\n") + path.chmod(0o755) (stage / "LICENSE").write_text("fixture licence\n") (stage / "build-manifest.json").write_text('{"version": "1.0.0"}\n') (stage / "licenses" / "engine-LICENSE").write_text("fixture engine licence\n") @@ -86,7 +85,6 @@ def test_deb_contains_binaries_notices_and_control_metadata(self): self.assertIn(b"usr/bin/oberth", control["./md5sums"]) data = tar_members(entries["data.tar.gz"]) self.assertEqual(data["./usr/bin/oberth"], b"fixture oberth\n") - self.assertEqual(data["./usr/bin/jev-attention"], b"fixture jev-attention\n") self.assertEqual(data["./usr/share/doc/option-berth/LICENSE"], b"fixture licence\n") self.assertEqual(data["./usr/share/doc/option-berth/licenses/engine-LICENSE"], b"fixture engine licence\n") self.assertIn(b'"version"', data["./usr/share/doc/option-berth/build-manifest.json"]) diff --git a/scripts/package_release.py b/scripts/package_release.py index fa3e0255..6fd2537f 100644 --- a/scripts/package_release.py +++ b/scripts/package_release.py @@ -36,7 +36,7 @@ def json_stream(text: str): def runtime_notices(root: Path, env: dict[str, str]) -> tuple[str, list[dict[str, str]]]: - packages = json_stream(command(root / "engine", env, "go", "list", "-deps", "-json", "./cmd/oberth", "./cmd/jev-attention")) + packages = json_stream(command(root / "engine", env, "go", "list", "-deps", "-json", "./cmd/oberth")) modules = {} for package in packages: module = package.get("Module") @@ -86,7 +86,7 @@ def package(root: Path, system: str, arch: str, output: Path) -> list[Path]: stage = Path(tmp) / stem stage.mkdir() suffix = ".exe" if system == "windows" else "" - for name, source in (("oberth", "./cmd/oberth"), ("jev-attention", "./cmd/jev-attention")): + for name, source in (("oberth", "./cmd/oberth"),): command(root / "engine", env, "go", "build", "-trimpath", "-ldflags", flags, "-o", str(stage / (name + suffix)), source) shutil.copyfile(root / "LICENSE", stage / "LICENSE") shutil.copyfile(root / "scripts" / "install-path.sh", stage / "install-path.sh") diff --git a/scripts/package_windows_msi.py b/scripts/package_windows_msi.py index 846d4f88..4cd20a98 100644 --- a/scripts/package_windows_msi.py +++ b/scripts/package_windows_msi.py @@ -22,7 +22,6 @@ UPGRADE_CODE = "2222ABC8-68C6-4E44-8B8A-6B964F6E9044" COMPONENT_GUIDS = { "Oberth": "F5329347-80B3-4675-9AFC-F17D60016111", - "Jev": "00BA2482-A984-4C7F-B6C6-1E56F01186D6", "Notices": "58A79179-4A21-4E61-8D4B-5ADA001A68A5", } @@ -66,10 +65,9 @@ def file_element(identifier: str, path: Path, key_path: bool = False) -> str: def msixml(root: Path, version: str, permanent: str = "yes") -> str: oberth = root / "oberth.exe" - jev = root / "jev-attention.exe" license_file = root / "LICENSE" manifest = root / "build-manifest.json" - for path in (oberth, jev, license_file, manifest): + for path in (oberth, license_file, manifest): if not path.is_file(): raise ValueError(f"archive is missing {path.name}") notices = sorted(path for path in (root / "licenses").glob("*") if path.is_file()) @@ -91,9 +89,6 @@ def msixml(root: Path, version: str, permanent: str = "yes") -> str: "=-PATH" means set on install and remove only that entry on uninstall. --> - -{file_element('FileJev', jev, True)} - @@ -107,7 +102,6 @@ def msixml(root: Path, version: str, permanent: str = "yes") -> str: - @@ -122,7 +116,7 @@ def verify_msi(path: Path) -> None: files = subprocess.run([msiinfo, "export", str(path), "File"], capture_output=True, text=True, timeout=20) if files.returncode: raise RuntimeError("msiinfo could not read the File table") - for name in ("oberth.exe", "jev-attention.exe", "LICENSE", "build-manifest.json"): + for name in ("oberth.exe", "LICENSE", "build-manifest.json"): if name not in files.stdout: raise RuntimeError(f"MSI File table is missing {name}") environment = subprocess.run([msiinfo, "export", str(path), "Environment"], capture_output=True, text=True, timeout=20) diff --git a/scripts/package_windows_msi_test.py b/scripts/package_windows_msi_test.py index 5085b2fb..2ad52c61 100644 --- a/scripts/package_windows_msi_test.py +++ b/scripts/package_windows_msi_test.py @@ -21,7 +21,6 @@ def make_zip(directory: Path) -> Path: stage = directory / "stage" / stem (stage / "licenses").mkdir(parents=True) (stage / "oberth.exe").write_bytes(b"fixture oberth\n") - (stage / "jev-attention.exe").write_bytes(b"fixture jev\n") (stage / "LICENSE").write_text("fixture licence\n") (stage / "build-manifest.json").write_text("{}\n") (stage / "licenses" / "engine-LICENSE").write_text("fixture engine licence\n") @@ -52,7 +51,7 @@ def test_msi_installs_binaries_notices_and_reversible_path_entry(self): self.assertEqual(package.name, f"option-berth-{VERSION}-windows-amd64.msi") self.assertTrue(package.with_name(package.name + ".sha256").is_file()) files = subprocess.run(["msiinfo", "export", str(package), "File"], capture_output=True, text=True, check=True) - for name in ("oberth.exe", "jev-attention.exe", "LICENSE", "build-manifest.json", "engine-LICENSE"): + for name in ("oberth.exe", "LICENSE", "build-manifest.json", "engine-LICENSE"): self.assertIn(name, files.stdout) environment = subprocess.run( ["msiinfo", "export", str(package), "Environment"], capture_output=True, text=True, check=True diff --git a/scripts/uninstall_test.py b/scripts/uninstall_test.py index 3e4f6dae..0ca78601 100644 --- a/scripts/uninstall_test.py +++ b/scripts/uninstall_test.py @@ -17,10 +17,9 @@ def setUp(self): self.home.mkdir() self.bin = self.home / ".local" / "bin" self.bin.mkdir(parents=True) - for name in ("oberth", "jev-attention"): - binary = self.bin / name - binary.write_text("#!/bin/sh\nexit 0\n") - binary.chmod(0o755) + binary = self.bin / "oberth" + binary.write_text("#!/bin/sh\nexit 0\n") + binary.chmod(0o755) self.data = self.home / ".local" / "share" / "option-berth" (self.data / "licenses").mkdir(parents=True) (self.data / "LICENSE").write_text("licence\n") @@ -61,7 +60,6 @@ def test_removes_binaries_notices_and_installer_path_lines(self): result = self.run_uninstaller() self.assertEqual(result.returncode, 0, result.stderr) self.assertFalse((self.bin / "oberth").exists()) - self.assertFalse((self.bin / "jev-attention").exists()) self.assertFalse(self.data.exists()) self.assertTrue(self.state.is_dir()) text = rc.read_text() @@ -69,7 +67,7 @@ def test_removes_binaries_notices_and_installer_path_lines(self): self.assertIn("export EDITOR=vi", text) self.assertIn('export PATH="$HOME/tools/bin:$PATH"', text) self.assertNotIn("option-berth", text) - self.assertIn("removed oberth jev-attention", result.stdout) + self.assertIn("removed oberth", result.stdout) self.assertIn("state kept", result.stdout) def test_keep_path_leaves_shell_files_alone(self): diff --git a/scripts/verify-attention-handoff.py b/scripts/verify-attention-handoff.py index 544a9aa6..371f7b08 100755 --- a/scripts/verify-attention-handoff.py +++ b/scripts/verify-attention-handoff.py @@ -84,56 +84,36 @@ def choose(explicit: str | None) -> str: return choice -def verify_subscription(adapter: Path, binary: Path, project: Path, home: Path) -> None: - """Exercise the built binary's long-lived NDJSON subscription path.""" - env = os.environ.copy() - env["BERTH_HOME"] = str(home) - process = subprocess.Popen( - [ - str(adapter), - "--subscribe", - "--oberth", - str(binary), - "--project", - str(project), - "--interval", - "100ms", - "--task", - "make the worker available for the development task", - "--authorize", - "inspect_logs,inspect_manifest,restart_service,continue_without_action", - "--api-key", - "", - ], - cwd=project, - env=env, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, +def verify_follow_without_key(binary: Path, project: Path, home: Path) -> None: + """A follow session that can never assess stops with the fixed reason. + + The long-lived NDJSON lifecycle itself is covered by the runner package + tests; this harness must not keep a process alive that has no key to call + anything with, and must not contact a provider. + """ + result = run( + binary, + project, + home, + "attn", + "assess", + "--follow", + "--oberth", + str(binary), + "--project", + str(project), + "--task", + "make the worker available for the development task", + "--authorize", + "inspect_logs,inspect_manifest,restart_service,continue_without_action", ) - time.sleep(0.8) - process.terminate() - try: - stdout, stderr = process.communicate(timeout=3) - except subprocess.TimeoutExpired: - process.kill() - stdout, stderr = process.communicate(timeout=3) - if process.returncode not in (0, -15): - raise RuntimeError(f"subscription failed with {process.returncode}: {stderr}") - records = [json.loads(line) for line in stdout.splitlines() if line.strip()] - statuses = [record.get("status") for record in records] - if "subscribed" not in statuses or "unavailable" not in statuses or "stopped" not in statuses: - raise RuntimeError(f"subscription did not publish the expected lifecycle: {records}") - ledger = home / "quality" / "events.ndjson" - if not ledger.is_file(): - raise RuntimeError("subscription did not create the quality ledger") - observations = [json.loads(line) for line in ledger.read_text().splitlines() if line.strip()] - if not any(item.get("source") == "subscription" and item.get("latency_ms", 0) > 0 for item in observations): - raise RuntimeError("subscription quality observations did not record latency") + record = json.loads(result.stdout) + if record.get("status") != "unavailable" or record.get("reason") != "no_key": + raise RuntimeError(f"a keyless follow session did not stop cleanly: {record}") def verify_quality_telemetry( - adapter: Path, + binary: Path, project: Path, home: Path, artifact: dict, @@ -164,12 +144,12 @@ def verify_quality_telemetry( } ) ) - recorded = run(adapter, project, home, "--feedback", str(feedback), "--project", str(project)) + recorded = run(binary, project, home, "attn", "feedback", "--file", str(feedback), "--project", str(project)) feedback_result = json.loads(recorded.stdout) if feedback_result.get("status") != "feedback_recorded": raise RuntimeError(f"feedback was not recorded: {feedback_result}") - report_result = run(adapter, project, home, "--quality-report", "--quality-project", project.name) - report = json.loads(report_result.stdout) + report_result = run(binary, project, home, "attn", "status", "--json", "--since", "0", "--quality-project", project.name) + report = json.loads(report_result.stdout).get("quality", {}) if report.get("feedback_records", 0) < 1 or report.get("labeled_cases", 0) < 1: raise RuntimeError(f"quality report did not join the labelled event: {report}") if not ledger.read_text().strip(): @@ -184,8 +164,7 @@ def main() -> int: repo = Path(__file__).resolve().parents[1] binary = repo / "bin" / "oberth" - adapter = repo / "bin" / "jev-attention" - if not binary.is_file() or not adapter.is_file(): + if not binary.is_file(): raise RuntimeError("build first with `mage build`") project = Path(tempfile.mkdtemp(prefix="oberth-hil-", dir=Path.home())) @@ -201,10 +180,13 @@ def main() -> int: # optional enhancement; its presence is what tells an agent the # assessment is on. Enable it the way a user would, in this home. settings = home / "attn.json" + # No key is stored: this harness never contacts a provider. With the + # assessment enabled but no key, the runner must still publish the + # fact-only handoff and report the fixed no_key reason. settings.write_text( '{"schema":"oberth.attn-settings/v1","enabled":true,' '"preset":"typesafe","endpoint":"https://assess.example/decisions",' - '"model":"test/model","api_key":"verification-only"}\n' + '"model":"test/model"}\n' ) settings.chmod(0o600) @@ -224,10 +206,11 @@ def main() -> int: raise RuntimeError(f"artifact contains an unknown option: {sorted(options)}") adapter_result = run( - adapter, + binary, project, home, - "--auto", + "attn", + "assess", "--oberth", str(binary), "--project", @@ -236,8 +219,6 @@ def main() -> int: "make the worker available for the development task", "--authorize", "inspect_logs,inspect_manifest,restart_service,continue_without_action", - "--api-key", - "", ) assessed = json.loads(adapter_result.stdout) if assessed.get("status") != "unavailable": @@ -245,7 +226,7 @@ def main() -> int: after_fallback = json.loads(Path(status["attn_path"]).read_text()) if "assessment" in after_fallback: raise RuntimeError("fallback unexpectedly changed the fact-only artifact") - verify_subscription(adapter, binary, project, home) + verify_follow_without_key(binary, project, home) choice = choose(args.choice) if choice not in options: @@ -279,7 +260,7 @@ def main() -> int: time.sleep(0.25) quality = verify_quality_telemetry( - adapter, + binary, project, home, artifact, diff --git a/uninstall.ps1 b/uninstall.ps1 index 99e4fc39..614453d1 100644 --- a/uninstall.ps1 +++ b/uninstall.ps1 @@ -4,7 +4,7 @@ Remove an option-berth CLI installed by install.ps1. .DESCRIPTION -Stops the daemon if it can, removes oberth.exe + jev-attention.exe, removes the +Stops the daemon if it can, removes oberth.exe, removes the notices install.ps1 left in %LOCALAPPDATA%\option-berth, and removes the install directory from the user PATH. It keeps the %USERPROFILE%\.option-berth state directory unless -PurgeState is passed. @@ -44,13 +44,13 @@ function Uninstall-Oberth { Start-Sleep -Milliseconds 300 } - $running = @(Get-Process -Name 'oberth', 'jev-attention' -ErrorAction SilentlyContinue) + $running = @(Get-Process -Name 'oberth' -ErrorAction SilentlyContinue) if ($running.Count) { throw 'oberth is still running; stop its services before uninstalling' } $removed = @() - foreach ($name in 'oberth.exe', 'jev-attention.exe') { + foreach ($name in 'oberth.exe') { $target = Join-Path $Dir $name if (Test-Path -LiteralPath $target -PathType Leaf) { Remove-Item -LiteralPath $target -Force diff --git a/uninstall.sh b/uninstall.sh index 18f0991c..9325fea0 100644 --- a/uninstall.sh +++ b/uninstall.sh @@ -4,7 +4,7 @@ # # curl -fsSL https://raw.githubusercontent.com/SheathedSharp/option-berth/main/uninstall.sh | sh # -# The script stops the daemon if it can, removes oberth + jev-attention and the +# The script stops the daemon if it can, removes oberth and the # notices install.sh placed under ~/.local/share/option-berth, and removes the # PATH lines the installer added. It keeps ~/.option-berth state unless # --purge-state is passed. @@ -85,7 +85,7 @@ if [ -x "$DIR/oberth" ]; then fi removed="" -for name in oberth jev-attention; do +for name in oberth; do if [ -e "$DIR/$name" ]; then rm -f "$DIR/$name" removed="$removed $name" From d608dc04b5a7956b87c277bbe1c1f1bafe7c0c88 Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 19:00:19 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=20attn=20?= =?UTF-8?q?=E7=9A=84=20setup/=E5=8D=8F=E8=AE=AE=E5=8A=A8=E8=AF=8D=E4=B8=8E?= =?UTF-8?q?=E5=8D=95=E4=BA=8C=E8=BF=9B=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli 双语对与适配器文档改为 status/config/on/off/test 与 assess(--follow, --dry-run)/preflight/feedback,说明 status 的 quality 数据与单二进制;安装文档、 发布文档与站点同步移除第二个二进制。历史验证记录保留原文。 验证:python3 scripts/check_docs.py Co-Authored-By: Claude Code --- docs/agent/attn-adapter.md | 97 +++++++++++++++++++------------------- docs/agent/develop.md | 2 +- docs/agent/releasing.md | 5 +- docs/agent/validation.md | 2 +- docs/cli.md | 58 ++++++++++++----------- docs/cli.zh.md | 37 +++++++-------- docs/install.md | 16 +++---- docs/install.zh.md | 15 +++--- site/guide/install.en.html | 2 +- site/guide/install.zh.html | 2 +- 10 files changed, 117 insertions(+), 119 deletions(-) diff --git a/docs/agent/attn-adapter.md b/docs/agent/attn-adapter.md index 0568556c..1890c040 100644 --- a/docs/agent/attn-adapter.md +++ b/docs/agent/attn-adapter.md @@ -7,18 +7,19 @@ current development task and needs the user's choice, then stores only the validated typed assessment in the same artifact. It never reads service logs, starts or stops a process, edits a manifest, or invents a command. -The verbs map one-to-one onto the standalone binary's flags, and the JSON is the -same object the agent already parsed: +It is one `oberth` binary, not a second executable. The verbs split by audience: -| `oberth attn` | standalone shell | -|---|---| -| `assess` | `jev-attention --auto` | -| `session` | `jev-attention --session` / `--subscribe` | -| `feedback --file` | `jev-attention --feedback` | -| `report` | `jev-attention --quality-report` | - -`engine/cmd/jev-attention` remains as a thin shell over the same runner package -(`internal/attn/runner`) while existing scripts and the installed skill catch up. +| `oberth attn` | Who | What | +|---|---|---| +| `status` | person | resolved state, fixed reason, local quality summary | +| `config` | person | store endpoint/model/key after a synthetic probe | +| `on` / `off [--forget]` | person | flip the single consent boolean | +| `test` | person | probe the effective configuration once | +| `assess` | agent | one status read, one assessment, one handoff | +| `assess --follow` | agent | the same loop as NDJSON for a task's lifetime | +| `assess --dry-run` | agent/person | the exact redacted request, not sent | +| `preflight` | agent | derive and optionally assess a bounded action | +| `feedback` | agent | label what a person actually decided | The adapter speaks one wire contract: a structured `state`, `model` and typed `questions` request, answered with typed `answers`. TypeSafe's endpoint and @@ -33,22 +34,25 @@ wire contract. ## Turning it on -`oberth attn enable` is the one step, in this order: give an endpoint and model -(directly, or through a preset that fills both), read the key, prove the key -answers, then write the owner-only file. +`oberth attn config` is the one step that stores credentials, in this order: give +an endpoint and model (directly, or through a preset that fills both), read the +key, prove the key answers, then write the owner-only file. It never turns the +assessment on; `oberth attn on` flips that one boolean, and `oberth attn off` +flips it back (`--forget` also removes the key). ```sh # a script or CI job: the key never appears in an argument list -printf '%s' "$KEY" | oberth attn enable --preset openrouter --key-stdin +oberth attn config --preset openrouter --key-stdin < key.txt +oberth attn on # read it from an existing variable -oberth attn enable --preset typesafe --key-env ATTN_KEY +oberth attn config --preset typesafe --key-env ATTN_KEY # any compatible endpoint: no preset, explicit fields -oberth attn enable --endpoint https://assess.example/decisions --model team/model --key-env ATTN_KEY +oberth attn config --endpoint https://assess.example/decisions --model team/model --key-env ATTN_KEY # a person at a terminal: hidden input -oberth attn enable --preset openrouter +oberth attn config --preset openrouter ``` The proof is one synthetic, redacted assessment — the same request shape a real @@ -59,15 +63,15 @@ will not be sent. | Command | What it does | Network | |---|---|---| -| `oberth attn status` | resolved state: on or off, provider, key source, fingerprint, reason | no | -| `oberth attn enable` | store a key, probe once, then write the settings | once | -| `oberth attn test` | one synthetic probe against the current settings | once | -| `oberth attn preview` | the exact redacted body for this worktree's current event | no | -| `oberth attn disable` | turn it off, keeping the key; `--forget` removes it | no | +| `oberth attn status` | resolved state, reason, fingerprint, quality summary | no | +| `oberth attn config` | store endpoint/model/key, probe once, then write | once | +| `oberth attn test` | one synthetic probe against the effective settings | once | +| `oberth attn on` / `off` | flip the single consent boolean | no | +| `oberth attn assess --dry-run` | the exact redacted request for this worktree | no | `status` never prints a key, only a short fingerprint so a person can tell two -keys apart. `preview` is the honesty check to run before enabling: if the body it -shows is not acceptable, do not enable. +keys apart. `assess --dry-run` is the honesty check to run before turning it on: +if the body it shows is not acceptable, do not enable. Hidden terminal input uses the platform's own termios through `golang.org/x/sys`, which the engine already depends on; `golang.org/x/term` is deliberately not @@ -77,20 +81,19 @@ surface is a native build and a smoke test, not full acceptance. ## Run it -For a task that keeps services running, prefer the task-scoped session form: +For a task that keeps services running, prefer the task-scoped follow form: ```sh -oberth attn session --project "$PWD" --task-context "${TMPDIR:-/tmp}/oberth-task-$$.json" +oberth attn assess --follow --project "$PWD" --task-context "${TMPDIR:-/tmp}/oberth-task-$$.json" ``` The path is the agent's to choose: nothing on the machine creates this file, so give each task its own — a fixed `/tmp` name lets two parallel agents overwrite each other's allow-list. -`session` is the long-lived form (`--subscribe` in the standalone shell). The -agent starts it after the initial status read and stops it with the task. It -polls facts continuously but calls the model only for a new event/revision or an -uncached assessment. +`--follow` is the long-lived form of `assess`. The agent starts it after the +initial status read and stops it with the task. It polls facts continuously but +calls the model only for a new event/revision or an uncached assessment. The agent-side runner performs the status read and exact artifact lookup in one operation. Do not scan the `BERTH_HOME/attention` directory: @@ -120,10 +123,10 @@ for this task/allow-list, and the local `telemetry_path` used for quality observations. The agent must map a user's explicit choice to an existing documented CLI command. The runner never executes the selected option. -For a task that remains active while services run, use the subscription mode: +For a task that remains active while services run, use the follow mode: ```sh -oberth attn session --project "$PWD" --interval 2s \ +oberth attn assess --follow --project "$PWD" --interval 2s \ --task-context "${TMPDIR:-/tmp}/oberth-task-$$.json" ``` @@ -165,7 +168,7 @@ The code is what a caller branches on, so a person gets a next step written for that code rather than a provider's error body. A failure this client does not recognise is `provider_error`; it is never left blank. -`oberth attn enable` is the persistent setup path. It writes an owner-only file +`oberth attn config` writes the settings; `oberth attn on` is the consent flip. It writes an owner-only file at `~/.option-berth/attn.json` (or `$BERTH_HOME/attn.json` in an isolated run): ```json @@ -273,12 +276,12 @@ permission. Inspect the accumulated evidence with: ```sh -oberth attn report -oberth attn report --since 168h --quality-project example-worker -oberth attn report --since 168h --quality-project example-api +oberth attn status --json --since 0 +oberth attn status --json --since 168h --quality-project example-worker +oberth attn status --json --since 168h --quality-project example-api ``` -The report includes session lifecycle and polling counts, observation/status counts, labelled/unlabelled-case +The `quality` object includes session lifecycle and polling counts, observation/status counts, labelled/unlabelled-case coverage, assessment availability, routing-reason and model/option breakdowns, user decisions, the conservative human-routing precision/recall/F1, the model's `needs_human` and `task_relevant` Brier scores and mean absolute errors, @@ -291,15 +294,13 @@ Ledger write failures are diagnostic only and do not change the runner result. When an agent is preparing a side effect that is not itself a runtime fact, a preflight request can be derived from a freshly saved `oberth status --json` -response. In this release it is still reached through the standalone shell: -`oberth attn` carries the runner verbs above, not the preflight flags, so the -command below is `jev-attention`, not `oberth attn`. The request is an external -adapter artifact under `BERTH_HOME/attention/preflight/`; it never enters the -daemon or replaces the runtime attention artifact: +response. The request is an external adapter artifact under +`BERTH_HOME/attention/preflight/`; it never enters the daemon or replaces the +runtime attention artifact: ```sh oberth status --json > /tmp/oberth-status.json -jev-attention --status /tmp/oberth-status.json \ +oberth attn preflight --status /tmp/oberth-status.json \ --action restart_service --service api \ --permission 'show this choice to the user before restarting' \ --task 'Get the API ready for the integration test' @@ -314,10 +315,10 @@ that is present in the same snapshot, belongs to the current worktree, and resolves to a regular file under `BERTH_HOME/drafts`; the request records its SHA-256 so a changed draft becomes stale. -The command writes the bounded request before contacting the model. Without a key or -when the model is unavailable it returns `status: "unavailable"` while leaving the -preflight request available for the agent to present directly. With the model, only -`needs_human`, task relevance, and the ranking of the bounded follow-up options +The command writes the bounded request before contacting the endpoint. Without a +key or when the assessment is disabled it returns `status: "unavailable"` while +leaving the preflight request available for the agent to present directly. With +consent, only `needs_human`, task relevance, and the ranking of the bounded follow-up options are written as an assessment; the action target and user-permission context are included in the redacted task context so the model cannot assess a generic action detached from the proposed target. The adapter never invokes `up`, `restart`, diff --git a/docs/agent/develop.md b/docs/agent/develop.md index da17bd9e..edb8624b 100644 --- a/docs/agent/develop.md +++ b/docs/agent/develop.md @@ -31,7 +31,7 @@ this page covers only how to move these files. From the repository root: ```sh -mage build # engine + optional adapter +mage build # engine (CLI + daemon + optional modules) mage buildEngine # engine only → ./bin/oberth mage runCLI "status --json" mage runDaemon # start a daemon with the CLI just built (isolated use) diff --git a/docs/agent/releasing.md b/docs/agent/releasing.md index a1eda8b1..baa44e8f 100644 --- a/docs/agent/releasing.md +++ b/docs/agent/releasing.md @@ -25,8 +25,7 @@ curl -fsSL https://raw.githubusercontent.com/SheathedSharp/option-berth/main/ins ``` The script resolves `releases/latest` (or pins a tag with `--version`), downloads the archive and its -same-named `.sha256` for the platform, verifies before extracting, installs `oberth` and -`jev-attention` into `~/.local/bin` (`%LOCALAPPDATA%\option-berth\bin` on Windows), leaves `LICENSE`, +same-named `.sha256` for the platform, verifies before extracting, installs `oberth` into `~/.local/bin` (`%LOCALAPPDATA%\option-berth\bin` on Windows), leaves `LICENSE`, `licenses/` and the build manifest in `~/.local/share/option-berth` (`%LOCALAPPDATA%\option-berth` on Windows), configures PATH through the bundled `install-path.sh`, and finally runs `oberth version` as a self-check. It starts no service, writes no Git state and needs no @@ -142,7 +141,7 @@ A genuine failure is never handled by deleting assertions, relaxing timeouts or fact/persistence checks. 3. Build six CLI archives for Darwin/Linux/Windows on amd64 and arm64, and generate amd64/arm64 `.deb` and `.rpm` packages on Linux targets. -4. Each CLI archive contains `oberth`, the optional `jev-attention`, `install-path.sh`, the build +4. Each CLI archive contains `oberth`, `install-path.sh`, the build manifest, the license texts the target actually depends on, and `SHA256SUMS`; Linux packages extract binaries and licenses from that same archive rather than building separate content. 5. After verifying that all targets exist and hashing them, create a GitHub draft release, upload, diff --git a/docs/agent/validation.md b/docs/agent/validation.md index 2e4ad95c..392f9a21 100644 --- a/docs/agent/validation.md +++ b/docs/agent/validation.md @@ -23,7 +23,7 @@ release, use `python3 scripts/release.py --verify-only`. | Git read verification | PRs via `CI`, manual | macOS / Ubuntu: repeated tests and race for Git, worktree, attribution, and observation. | | Checked-in examples | PRs via `CI`, manual | macOS / Ubuntu: the minimal sample `hello-worktree` across two worktrees (distinct ports, a portless worker, stopping one side). No Docker, no benchmark. The complex sample `stack-workshop` is not part of this check; it is the input of the manual benchmark, which runs on Linux with Docker. | | Reused service readiness | PRs via `CI`, manual | macOS / Ubuntu: reuse checks against real healthy and unhealthy services. | -| Windows build verification | PRs via `CI`, release tags, manual | Windows x64: native build, vet, CLI/optional adapter build, version JSON/help page, facts/persistence, and start, repeated start, collector recovery, and scoped stop for an isolated API / portless worker; Ubuntu: Windows arm64 cross build. | +| Windows build verification | PRs via `CI`, release tags, manual | Windows x64: native build, vet, CLI build, version JSON/help page, facts/persistence, and start, repeated start, collector recovery, and scoped stop for an isolated API / portless worker; Ubuntu: Windows arm64 cross build. | | Gate integrity | PR (`pull_request_target`) | Requires the PR author to be a repository maintainer when `.github/workflows/`, the race list, release scripts, or quality-gate check scripts are modified. In a single-maintainer repository this guards against bot and outsider pull requests, not against a second reviewer. | | CI configuration | PRs via `CI`, CI-related pushes to `main`, manual | Ubuntu: actionlint checks every workflow and verifies that the required checks match the workflow job names. The push trigger stays because this check also compares the live ruleset after a settings change outside a pull request. | | Bounded ancestry comparison | Manual (evidence) | Ubuntu: compare a branch against a specified baseline with a bounded benchmark on the same runner; not a merge gate. | diff --git a/docs/cli.md b/docs/cli.md index dd8cc0b2..4d86c277 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -293,22 +293,27 @@ oberth attn assess --project "$PWD" \ --authorize inspect_logs,inspect_port,restart_service,continue_without_action,ask_user_for_context ~~~ -`oberth attn enable` is the one step that turns the assessment on: it reads a key -(`--key-stdin`, `--key-env NAME`, or hidden terminal input), sends one synthetic -redacted probe, and only writes `attn.json` when the probe succeeds — a failed -probe leaves the existing configuration untouched. `oberth attn status` reports -the resolved state without a network call, `oberth attn test` probes once, -`oberth attn preview` prints the exact redacted body for the current event without -sending it, and `oberth attn disable [--forget]` turns it off while keeping the -key unless `--forget` is given. `oberth doctor` gains one offline row for the same -state, and a human `status` read mentions the feature once every two weeks when -it is off and an anomaly is present; `BERTH_NO_TIPS=1` silences that hint. - -`oberth attn assess` is the former `jev-attention --auto`, `oberth attn session` is the former -`--session`/`--subscribe`, and `oberth attn feedback` / `oberth attn report` are the former -`--feedback` and `--quality-report`. The standalone `jev-attention` binary stays as a thin shell over -the same runner during the migration, so `scripts/verify-attention-handoff.py` and the skill keep -working; it is not distributed indefinitely. +`oberth attn config` is the only verb that writes credentials: it takes an endpoint +and model (directly, or through a preset that fills both), reads the key from +`--key-stdin`, `--key-env NAME`, hidden terminal input, or keeps the stored key, +sends one synthetic redacted probe, and only writes `attn.json` when the probe +succeeds — a failed probe leaves the existing configuration untouched. `config` +changes credentials, not consent. `oberth attn on` and `oberth attn off [--forget]` +flip the single `enabled` boolean and never touch the network. `oberth attn test` +probes the current effective configuration once. `oberth attn status` reports the +resolved state, the fixed reason when it cannot be used, and a summary of the +local quality ledger; `--json` carries the same data plus the full `quality` +object. `oberth doctor` gains one offline row for the same state, and a human +`status` read mentions the feature once every two weeks when it is off and an +anomaly is present; `BERTH_NO_TIPS=1` silences that hint. + +`oberth attn assess` is the one-shot runner. `--dry-run` prints the exact redacted +request for the current event without sending it, and `--follow` keeps the same +loop alive for a task, emitting NDJSON (`subscribed`, only changed handoffs, and +`stopped` on cancellation). `oberth attn feedback` labels what a person actually +decided, and `oberth attn preflight` derives a bounded action request from a fresh +status snapshot. There is one `oberth` binary; none of these is a separate +executable. The context file contains `task` and the required `authorized_options`, and is **written by the agent itself**: nothing on the machine generates it for you, and the path is chosen by the agent too @@ -320,30 +325,27 @@ outputs `clear`, `continue`, `human_required`, `unavailable` or `stale`; on succ writes the validated `assessment` back to the same file and re-reads the state before returning. When there is no usable environment endpoint, model or key, or on network failure, invalid response, expired file or changed state revision, the fact file is left unchanged. The -`~/.option-berth/attn.json` written by `oberth attn enable` (`$BERTH_HOME/attn.json` for isolated +`~/.option-berth/attn.json` written by `oberth attn config` (`$BERTH_HOME/attn.json` for isolated runs) is read automatically by `oberth attn` and is the only persistent consent: `enabled: true` turns the model on, `enabled: false` keeps it off even when a key is present. An explicit `BERTH_ATTN_ENDPOINT` / `BERTH_ATTN_MODEL` / `BERTH_ATTN_KEY` supplies those fields but does not enable the model; `BERTH_ATTN=1` enables it for one process. Each unavailable answer carries one fixed reason (`no_key`, `disabled`, `invalid_config`, `unauthorized`, `rate_limited`, `timeout`, `network`, -`provider_error`, `bad_response`, `stale`) rather than a provider's error text. `--auto`, `--session` and -`--subscribe` must be given `authorized_options` explicitly; the artifact's `options` is only a -candidate set and does not automatically become the agent's authorization. To keep discovering -abnormalities over a task's lifetime, the agent should use -`oberth attn session --interval 2s`, which emits -only new event/revision/assessment changes as NDJSON and ends on cancellation. Full arguments and the +`provider_error`, `bad_response`, `stale`) rather than a provider's error text. `assess` and +`assess --follow` must be given `authorized_options` explicitly; the artifact's `options` is only a +candidate set and does not automatically become the agent's authorization. Full arguments and the privacy boundary are in [`attn-adapter.md`](agent/attn-adapter.md). -`oberth attn assess` / `oberth attn session` leave redacted local quality observations in +`oberth attn assess` / `assess --follow` leave redacted local quality observations in `$BERTH_HOME/quality/events.ndjson`. After a real interaction, a development agent can write back human labels and action outcomes with `oberth attn feedback --file `, then use -`oberth attn report [--since 168h]` to view label coverage, assessment availability, +`oberth attn status --json [--since 168h]` to view label coverage, assessment availability, routing reasons and the model/option distribution, human-escalation precision/recall/F1, the model's `needs_human` / `task_relevant` probability error, recommendation hit rate, action success rate, -resolution rate and detection latency. Quality records are not `status` facts and change no execution -permission; the report's `sessions` object explains the number of sessions, polls, events, the model calls, -cache hits and no-event polls; with no real feedback the metrics stay `null`. +resolution rate and detection latency under `quality`. Quality records are not `status` facts and +change no execution permission; the `sessions` object explains the number of sessions, polls, events, +the model calls, cache hits and no-event polls; with no real feedback the metrics stay `null`. ### Agent-facing JSON fields diff --git a/docs/cli.zh.md b/docs/cli.zh.md index 6536bd0c..4473b9c7 100644 --- a/docs/cli.zh.md +++ b/docs/cli.zh.md @@ -253,43 +253,42 @@ oberth attn assess --project "$PWD" \ --authorize inspect_logs,inspect_port,restart_service,continue_without_action,ask_user_for_context ~~~ -`oberth attn enable` 是一步开启:读密钥(`--key-stdin`、`--key-env NAME` 或终端隐藏 -输入),发一次合成的脱敏探测,**只有探测成功才写** `attn.json`;探测失败时已有配置保持不动。 -`oberth attn status` 不联网报告解析结果,`oberth attn test` 联网探测一次,`oberth attn preview` -打印当前事件将要发送的脱敏请求体但不发送,`oberth attn disable [--forget]` 关闭(默认保留密钥, -`--forget` 才会删除)。`oberth doctor` 增加一行离线状态;人读 `status` 在未启用且当前有异常时, -每两周提示一次,`BERTH_NO_TIPS=1` 可关闭。 - -`oberth attn assess` 即原来的 `jev-attention --auto`,`oberth attn session` 即 -`--session`/`--subscribe`,`oberth attn feedback` / `oberth attn report` 即 `--feedback` 与 -`--quality-report`。独立二进制 `jev-attention` 在迁移期是同一 runner 的薄壳, -`scripts/verify-attention-handoff.py` 和 skill 因此不用改;它不会长期分发。 +`oberth attn config` 是唯一写凭据的动词:给出 endpoint 与 model(直接给,或用 preset +一并填入),从 `--key-stdin`、`--key-env NAME`、终端隐藏输入读取密钥,或保留已存密钥, +发一次合成的脱敏探测,**只有探测成功才写** `attn.json`;探测失败时已有配置保持不动。 +`config` 改的是凭据,不是同意。`oberth attn on` 与 `oberth attn off [--forget]` 只翻转 +一个 `enabled` 布尔值,不联网。`oberth attn test` 用当前有效配置探测一次。 +`oberth attn status` 报告解析结果、不可用时的固定原因,以及本地质量账本摘要;`--json` +同时输出完整 `quality` 对象。`oberth doctor` 增加一行离线状态;人读 `status` 在未启用且 +当前有异常时,每两周提示一次,`BERTH_NO_TIPS=1` 可关闭。 + +`oberth attn assess` 是一次性 runner。`--dry-run` 打印当前事件将要发送的脱敏请求体但不发送; +`--follow` 让同一循环在任务生命周期内持续运行,以 NDJSON 输出 `subscribed`、仅变化的手递 +和取消时的 `stopped`。`oberth attn feedback` 记录人实际做出的决定,`oberth attn preflight` +从新鲜的状态快照派生一个有界的动作请求。只有一个 `oberth` 二进制,它们都不是独立可执行文件。 上下文文件包含 `task` 和必填的 `authorized_options`,**由 agent 自己写**:机器上没有任何东西 替你生成它,路径也由 agent 选(并行的任务各用一个文件,不要共用一个 `/tmp` 路径)。 `--task` 与 `--authorize` 覆盖文件里的同名字段;两者都没有时 runner 直接报错,不会替 agent -猜一个授权集。适配器只向选定的提供商发送脱敏的事实摘要, +猜一个授权集。适配器只向选定的 endpoint 发送脱敏的事实摘要, 不会读取日志或起停服务。runner 输出 `clear`、`continue`、`human_required`、`unavailable` 或 `stale`;成功时把校验后的 `assessment` 原子写回同一文件,并在返回前复读状态。 没有可用的环境 endpoint、model 或 key、网络失败、响应无效、文件过期或状态修订变化时,事实文件保持不变。 -`oberth attn enable` 写入的 `~/.option-berth/attn.json`(隔离运行时为 `$BERTH_HOME/attn.json`)由 +`oberth attn config` 写入的 `~/.option-berth/attn.json`(隔离运行时为 `$BERTH_HOME/attn.json`)由 `oberth attn` 自动读取,并且是唯一的持久同意:`enabled: true` 开启,`enabled: false` 即使环境 里有密钥也保持关闭。显式 `BERTH_ATTN_ENDPOINT` / `BERTH_ATTN_MODEL` / `BERTH_ATTN_KEY` 只提供这三个 字段,不开启评估;`BERTH_ATTN=1` 只为当前进程开启。每次不可用都带固定原因码(`no_key`、`disabled`、 `invalid_config`、`unauthorized`、`rate_limited`、`timeout`、`network`、`provider_error`、 `bad_response`、`stale`),而不是 provider 的错误原文。 -`--auto`、`--session` 和 `--subscribe` 必须显式提供 `authorized_options`;artifact 的 `options` 只是候选集, +`assess` 与 `assess --follow` 必须显式提供 `authorized_options`;artifact 的 `options` 只是候选集, 不会自动变成 agent 的授权。 -需要在任务生命周期内持续发现异常时,agent 应使用 `oberth attn session --interval 2s`(底层别名仍为 -`--subscribe`), -它以 NDJSON 只输出新的 event/revision/assessment 变化,并在取消时结束。 完整参数和隐私边界见 [`attn-adapter.md`](agent/attn-adapter.md)。 -`oberth attn assess` / `oberth attn session` 会在 `$BERTH_HOME/quality/events.ndjson` +`oberth attn assess` / `assess --follow` 会在 `$BERTH_HOME/quality/events.ndjson` 留下脱敏的本地质量观测。开发 agent 在真实交互后可用 `oberth attn feedback --file ` 回写人工标签和动作结果,再用 -`oberth attn report [--since 168h]` 查看标注覆盖率、assessment +`oberth attn status --json [--since 168h]` 查看 `quality` 中的标注覆盖率、assessment 可用率、路由原因与模型/选项分布、人工升级 precision/recall/F1、模型的 `needs_human` / `task_relevant` 概率误差、推荐命中率、动作成功率、解决率和发现延迟。 质量记录不属于 `status` 事实,也不改变任何执行权限;报告的 `sessions` 对象会说明会话、轮询、事件、 diff --git a/docs/install.md b/docs/install.md index 18cf0eca..f2474ce1 100644 --- a/docs/install.md +++ b/docs/install.md @@ -16,7 +16,7 @@ The script resolves the latest release, picks darwin/linux and amd64/arm64 from | Content | Location | |---|---| -| Binaries | `~/.local/bin/oberth`, `~/.local/bin/jev-attention` | +| Binaries | `~/.local/bin/oberth` | | Licenses and build manifest | `~/.local/share/option-berth` | | PATH | One line added to an existing shell startup file (never a new one) | | Runtime state | `~/.option-berth` (the installer neither creates nor modifies it) | @@ -76,20 +76,19 @@ irm https://raw.githubusercontent.com/SheathedSharp/option-berth/main/install.ps | Content | Location | |---|---| -| Binaries | `%LOCALAPPDATA%\option-berth\bin\oberth.exe`, `jev-attention.exe` | +| Binaries | `%LOCALAPPDATA%\option-berth\bin\oberth.exe` | | Licenses and build manifest | `%LOCALAPPDATA%\option-berth` | | PATH | The current user's PATH (the system PATH is never modified) | | Runtime state | `%USERPROFILE%\.option-berth` | Pin a version or leave PATH alone: `.\install.ps1 -Version v1.0.0 -NoModifyPath`. -Before replacing files, the Windows installer checks whether `oberth` / `jev-attention` are running; if -they are, it runs `oberth daemon stop` first and then reinstalls. +Before replacing files, the Windows installer checks whether `oberth` is running; if it is, it runs `oberth daemon stop` first and then reinstalls. ## Download a release manually When you would rather not run a script, download the archive for your platform from [Releases](https://github.com/SheathedSharp/option-berth/releases), verify `SHA256SUMS` first, then put -`oberth` and `jev-attention` in a directory on PATH: +`oberth` in a directory on PATH: ```sh # darwin-arm64 and v1.0.0 as an example @@ -98,7 +97,6 @@ curl -fLO https://github.com/SheathedSharp/option-berth/releases/download/v1.0.0 shasum -a 256 -c option-berth-v1.0.0-darwin-arm64.tar.gz.sha256 tar -xzf option-berth-v1.0.0-darwin-arm64.tar.gz install -m 0755 option-berth-v1.0.0-darwin-arm64/oberth ~/.local/bin/oberth -install -m 0755 option-berth-v1.0.0-darwin-arm64/jev-attention ~/.local/bin/jev-attention ``` Keeping the archive's `LICENSE`, `licenses/` and `build-manifest.json` is recommended; a manual install @@ -119,7 +117,7 @@ sudo dnf install ./option-berth-1.0.0-1.x86_64.rpm sudo dnf remove option-berth ``` -The packages put `oberth` and `jev-attention` in `/usr/bin` and the licenses and build manifest in +The package puts `oberth` in `/usr/bin` and the licenses and build manifest in `/usr/share/doc/option-berth`. `apt remove` / `dnf remove` do not delete `~/.option-berth` runtime state; once you are sure it is no longer needed, run `oberth daemon stop` and delete that directory. arm64 uses the `_arm64.deb` and `.aarch64.rpm` file names. @@ -141,7 +139,7 @@ irm https://raw.githubusercontent.com/SheathedSharp/option-berth/main/uninstall. The uninstaller: - Tries `oberth daemon stop` first, so no old process keeps running; -- Removes `oberth` and `jev-attention`; +- Removes `oberth`; - Removes the licenses and build manifest the installer placed in `~/.local/share/option-berth`; - Removes the exact line the installer wrote into a shell startup file (only that line; other PATH configuration is untouched). @@ -168,7 +166,7 @@ A manual uninstall is equivalent to: ```sh oberth daemon stop -rm -f ~/.local/bin/oberth ~/.local/bin/jev-attention +rm -f ~/.local/bin/oberth rm -rf ~/.local/share/option-berth # Remove runtime state only when needed rm -rf ~/.option-berth diff --git a/docs/install.zh.md b/docs/install.zh.md index e8f69b16..f190e594 100644 --- a/docs/install.zh.md +++ b/docs/install.zh.md @@ -16,7 +16,7 @@ curl -fsSL https://raw.githubusercontent.com/SheathedSharp/option-berth/main/ins | 内容 | 位置 | |---|---| -| 二进制 | `~/.local/bin/oberth`、`~/.local/bin/jev-attention` | +| 二进制 | `~/.local/bin/oberth` | | 许可与构建清单 | `~/.local/share/option-berth` | | PATH | 已有的 shell 启动文件里加一行(不新建启动文件) | | 运行状态 | `~/.option-berth`(安装器不创建、不改动) | @@ -71,19 +71,19 @@ irm https://raw.githubusercontent.com/SheathedSharp/option-berth/main/install.ps | 内容 | 位置 | |---|---| -| 二进制 | `%LOCALAPPDATA%\option-berth\bin\oberth.exe`、`jev-attention.exe` | +| 二进制 | `%LOCALAPPDATA%\option-berth\bin\oberth.exe` | | 许可与构建清单 | `%LOCALAPPDATA%\option-berth` | | PATH | 当前用户的 PATH(不改系统 PATH) | | 运行状态 | `%USERPROFILE%\.option-berth` | 固定版本或不改 PATH:`.\install.ps1 -Version v1.0.0 -NoModifyPath`。 -Windows 安装器在替换文件前会检查 `oberth` / `jev-attention` 是否正在运行; +Windows 安装器在替换文件前会检查 `oberth` 是否正在运行; 正在运行就先执行 `oberth daemon stop`,再重新安装。 ## 手动下载发布包 不想执行脚本时,从 [Releases](https://github.com/SheathedSharp/option-berth/releases) -下载对应平台的压缩包,先核对 `SHA256SUMS`,再把 `oberth` 与 `jev-attention` 放到 PATH 中的目录: +下载对应平台的压缩包,先核对 `SHA256SUMS`,再把 `oberth` 放到 PATH 中的目录: ```sh # darwin-arm64 and v1.0.0 as an example @@ -92,7 +92,6 @@ curl -fLO https://github.com/SheathedSharp/option-berth/releases/download/v1.0.0 shasum -a 256 -c option-berth-v1.0.0-darwin-arm64.tar.gz.sha256 tar -xzf option-berth-v1.0.0-darwin-arm64.tar.gz install -m 0755 option-berth-v1.0.0-darwin-arm64/oberth ~/.local/bin/oberth -install -m 0755 option-berth-v1.0.0-darwin-arm64/jev-attention ~/.local/bin/jev-attention ``` 压缩包内的 `LICENSE`、`licenses/` 与 `build-manifest.json` 建议保留;手动安装时它们不会自动复制。 @@ -112,7 +111,7 @@ sudo dnf install ./option-berth-1.0.0-1.x86_64.rpm sudo dnf remove option-berth ``` -安装包把 `oberth`、`jev-attention` 放到 `/usr/bin`,许可与构建清单放到 +安装包把 `oberth` 放到 `/usr/bin`,许可与构建清单放到 `/usr/share/doc/option-berth`。`apt remove` / `dnf remove` 不删除 `~/.option-berth` 运行状态; 确认不再需要后再执行 `oberth daemon stop` 并删除该目录。arm64 使用 `_arm64.deb` 与 `.aarch64.rpm` 文件名。 @@ -134,7 +133,7 @@ irm https://raw.githubusercontent.com/SheathedSharp/option-berth/main/uninstall. 卸载器会: - 尽量先执行 `oberth daemon stop`,避免留下正在运行的旧进程; -- 删除 `oberth` 与 `jev-attention`; +- 删除 `oberth`; - 删除安装器放在 `~/.local/share/option-berth` 的许可与构建清单; - 删除安装器写进 shell 启动文件的那一行(只删自己写的精确行,不动其他 PATH 配置)。 @@ -158,7 +157,7 @@ curl -fsSL https://raw.githubusercontent.com/SheathedSharp/option-berth/main/uni ```sh oberth daemon stop -rm -f ~/.local/bin/oberth ~/.local/bin/jev-attention +rm -f ~/.local/bin/oberth rm -rf ~/.local/share/option-berth # Remove runtime state only when needed rm -rf ~/.option-berth diff --git a/site/guide/install.en.html b/site/guide/install.en.html index 2fcd6445..e429aca9 100644 --- a/site/guide/install.en.html +++ b/site/guide/install.en.html @@ -7,7 +7,7 @@

One line, macOS and Linux

The script reads uname, picks the matching darwin or linux archive for amd64 or arm64, checks the .sha256 file published beside it, and installs. It needs no administrator rights.

    -
  • Binaries: ~/.local/bin/oberth and ~/.local/bin/jev-attention.
  • +
  • Binary: ~/.local/bin/oberth.
  • Licenses and build manifest: ~/.local/share/option-berth.
  • PATH: one line added to a shell startup file you already have. The script never creates a new one.
  • Runtime state: ~/.option-berth, which the installer neither creates nor modifies.
  • diff --git a/site/guide/install.zh.html b/site/guide/install.zh.html index 2ae9c59a..5cd0ff60 100644 --- a/site/guide/install.zh.html +++ b/site/guide/install.zh.html @@ -7,7 +7,7 @@

    一行命令,macOS 与 Linux

    脚本读取 uname,挑出对应的 darwin 或 linux、amd64 或 arm64 压缩包,核对发布在同一目录下的 .sha256,然后安装。不需要管理员权限。

      -
    • 二进制:~/.local/bin/oberth 与 ~/.local/bin/jev-attention。
    • +
    • 二进制:~/.local/bin/oberth。
    • 许可证与构建清单:~/.local/share/option-berth。
    • PATH:在你已有的一份 shell 启动文件里加一行。脚本从不新建文件。
    • 运行状态:~/.option-berth,安装脚本既不创建也不修改它。