From cab97f2a878f667b57a87dc81c1c03c72b6ac214 Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 19:22:33 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(engine):=20=E4=BA=8B=E5=AE=9E=E5=9E=8B?= =?UTF-8?q?=E4=BA=A4=E6=8E=A5=E4=B8=8D=E5=86=8D=E4=BE=9D=E8=B5=96=E8=AF=84?= =?UTF-8?q?=E4=BC=B0=E5=90=AF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit status 现在只要有确定性异常就发布事实型交接,同意只影响相邻的 attn 块 (enabled、固定 reason、assessment: assessed/pending/unavailable)。attn_path 的含义从“用户已启用”改为“当前有异常”,agent 不必再读设置文件猜测。 同时修正遗漏的提示文案:tip、doctor 行与固定原因提示改为 config/on,而不是 已删除的 enable/preview。 验证:go test -count=1 -timeout=8m ./...;隔离 BERTH_HOME 验证无配置时 status 仍发布 attn_path 且 attn.enabled=false;verify-attention-handoff.py Co-Authored-By: Claude Code --- engine/internal/attn/model/enable.go | 4 +- engine/internal/attn/model/enable_test.go | 2 +- engine/internal/attn/model/preview.go | 4 +- engine/internal/attn/model/reason.go | 8 +-- engine/internal/cmd/status.go | 64 ++++++++++++------- engine/internal/cmd/status_test.go | 56 ++++++++-------- engine/internal/cmd/status_tip.go | 4 +- engine/internal/cmd/status_tip_test.go | 23 ++++--- engine/internal/doctor/check_attn.go | 6 +- .../scenario_attention_integration_test.go | 10 +-- 10 files changed, 103 insertions(+), 78 deletions(-) diff --git a/engine/internal/attn/model/enable.go b/engine/internal/attn/model/enable.go index 9ceeda8e..950ca90b 100644 --- a/engine/internal/attn/model/enable.go +++ b/engine/internal/attn/model/enable.go @@ -1,6 +1,6 @@ package model -// `oberth attn enable` is the one step that turns the optional assessment on. +// `oberth attn config` is the one step that stores the optional assessment settings. // It stores the key the person chose, proves the key works with a single // redacted probe, and only then writes the owner-only settings file. // @@ -41,7 +41,7 @@ const ( KeyFromPrompt KeySourceKind = "prompt" ) -// EnableOptions is one `oberth attn enable` request. +// EnableOptions is one `oberth attn config` request. type EnableOptions struct { // Preset fills Endpoint and Model from a named pair of defaults. It is a // convenience, not a provider restriction: explicit fields win, and an diff --git a/engine/internal/attn/model/enable_test.go b/engine/internal/attn/model/enable_test.go index 3a23c8b9..55c6e694 100644 --- a/engine/internal/attn/model/enable_test.go +++ b/engine/internal/attn/model/enable_test.go @@ -255,7 +255,7 @@ func TestSendsSummaryCoversBothDirections(t *testing.T) { if !strings.Contains(text, "Will be sent") || !strings.Contains(text, "Will never be sent") { t.Fatalf("human summary = %q", text) } - if !strings.Contains(text, "oberth attn preview") { + if !strings.Contains(text, "oberth attn assess --dry-run") { t.Fatal("the human summary does not point at the exact body") } } diff --git a/engine/internal/attn/model/preview.go b/engine/internal/attn/model/preview.go index 32c57056..ac792435 100644 --- a/engine/internal/attn/model/preview.go +++ b/engine/internal/attn/model/preview.go @@ -1,6 +1,6 @@ package model -// What `oberth attn preview` shows and what `oberth attn enable` prints afterwards. +// What `oberth attn assess --dry-run` shows and what `oberth attn config` prints afterwards. // Both answer the same question in different tenses: "what exactly crosses the // boundary?" A person enabling an optional remote assessment is entitled to a // concrete answer, not a promise in a document. @@ -139,7 +139,7 @@ func HumanSendsSummary() string { writeBullets(&b, sendsNever) b.WriteString("Depends on the event:\n") writeBullets(&b, sendsDepends) - b.WriteString("\nRun `oberth attn preview` to see the exact body for the current event.") + b.WriteString("\nRun `oberth attn assess --dry-run` to see the exact body for the current event.") return b.String() } diff --git a/engine/internal/attn/model/reason.go b/engine/internal/attn/model/reason.go index 8a34348c..51efbeb8 100644 --- a/engine/internal/attn/model/reason.go +++ b/engine/internal/attn/model/reason.go @@ -86,13 +86,13 @@ func ReasonFor(err error) string { func ReasonHint(code string) string { switch code { case ReasonNoKey: - return "run `oberth attn enable` to store a key and turn the assessment on" + return "run `oberth attn config` to store a key, then `oberth attn on`" case ReasonDisabled: - return "the assessment is off; run `oberth attn enable`, or set BERTH_JEV=1 to allow it for this one process" + return "the assessment is off; run `oberth attn on`, or set BERTH_ATTN=1 to allow it for this one process" case ReasonInvalidConfig: - return "attn.json is not usable; run `oberth attn status` and then `oberth attn enable` to rewrite it" + return "attn.json is not usable; run `oberth attn status` and then `oberth attn config` to rewrite it" case ReasonUnauthorized: - return "the provider rejected the key; run `oberth attn enable` to store a current one" + return "the provider rejected the key; run `oberth attn config` to store a current one" case ReasonRateLimited: return "the provider is rate limiting this key; retry later or switch provider" case ReasonTimeout: diff --git a/engine/internal/cmd/status.go b/engine/internal/cmd/status.go index f38567e7..a48e87fb 100644 --- a/engine/internal/cmd/status.go +++ b/engine/internal/cmd/status.go @@ -104,11 +104,13 @@ type statusDocument struct { // AttnRevision lets an agent compare an artifact it read with the // exact deterministic facts represented by this status response. AttnRevision string `json:"attn_revision,omitempty"` + // Attn says whether this handoff can be assessed right now. It is present + // exactly when AttnPath is, so path presence means "there is an anomaly", + // not "the person opted in". + Attn *statusAttn `json:"attn,omitempty"` // attnEvent is whether the deterministic classifier derived a definite - // anomaly, independent of whether it was published. It never reaches the - // wire: consent decides the artifact, this decides the one-line offer of the - // enhancement, and reading consent off the artifact would make that offer - // unreachable precisely when it is due. + // anomaly. It never reaches the wire: it drives the one-line offer of the + // enhancement, which must stay reachable while the assessment is off. attnEvent bool // Changed is absent on the first run: there is nothing to compare against, // which is not the same as nothing having changed (`empty` inside says @@ -363,29 +365,33 @@ func buildStatusDocument(ctx context.Context, dir string, mark bool, only []stri return doc, nil } +// statusAttn is the consent and assessment state of the published handoff. +// Assessment is "assessed" when the artifact already carries a validated +// result, "pending" when the configuration could assess it now, and +// "unavailable" otherwise; Reason carries the fixed code when nothing can run. +type statusAttn struct { + Enabled bool `json:"enabled"` + Reason string `json:"reason,omitempty"` + Assessment string `json:"assessment"` +} + // publishAttention makes the deterministic anomaly visible to an optional // agent adapter without putting a model or network call in status' read path. // Failure to publish the optional artifact never makes the facts unavailable. // -// The artifact only exists once the person has enabled the enhancement. An -// `attn_path` is therefore an unambiguous signal: an agent that sees one -// knows model is on and may run `oberth attn assess`, and an agent that sees none -// does not have to guess whether that is because nothing failed or because the -// user never opted in. Publishing it unconditionally meant every reader had to -// reconstruct that answer from the settings file. -// -// Deriving and publishing are two decisions, not one. The derivation is pure -// fact work and always runs, so `status` knows whether there is an anomaly even -// when it publishes nothing; only publication depends on consent. Collapsing -// them would leave the offer of the enhancement ("there is something to assess -// here") with no evidence to fire on, because the artifact it used to read is -// exactly what consent removes. +// The artifact is a local fact derived by code: publishing it sends nothing +// anywhere, so consent is not what decides whether it exists. An `attn_path` +// means "there is an anomaly"; the adjacent `attn` block says whether an +// assessment is possible and why not. Keeping the two decisions separate means +// an agent can use the fact-only handoff, and offer the enhancement, without +// the person having configured a model first. func publishAttention(doc *statusDocument) string { if doc == nil { return "" } doc.AttnPath = "" doc.AttnRevision = "" + doc.Attn = nil doc.attnEvent = false facts := attn.Facts{ WorktreeRoot: doc.Scope.Root, @@ -462,22 +468,32 @@ func publishAttention(doc *statusDocument) string { return "" } doc.attnEvent = true - if !attnEnabled() { - // The person has not opted in, so nothing is published. Clear a - // leftover artifact from before the enhancement was switched off: - // leaving it on disk would keep telling agents to assess. - _ = attn.Clear(paths.Dir(), doc.Scope.Root) - return "" - } path, err := attn.Write(paths.Dir(), artifact) if err != nil { return "" } doc.AttnPath = path doc.AttnRevision = artifact.StateRevision + doc.Attn = attnState(path) return path } +// attnState reports whether the published artifact can be assessed now. It +// reads consent for this process, never the artifact, and never contacts an +// endpoint. +func attnState(path string) *statusAttn { + consent := attnResolvedConsent() + state := &statusAttn{Enabled: consent.Enabled, Reason: consent.Reason, Assessment: "unavailable"} + if stored, err := attn.Read(path); err == nil && stored.Assessment != nil { + state.Assessment = "assessed" + return state + } + if consent.Enabled && consent.Reason == "" { + state.Assessment = "pending" + } + return state +} + // localStatusGroup is the cold-start fallback for a manifest the daemon has // not indexed yet. It uses the same resolver and state conversion as the // normal scan path, but does not pretend that a local read knows daemon run diff --git a/engine/internal/cmd/status_test.go b/engine/internal/cmd/status_test.go index 54c087a1..15e34778 100644 --- a/engine/internal/cmd/status_test.go +++ b/engine/internal/cmd/status_test.go @@ -13,10 +13,10 @@ import ( "github.com/sheathedsharp/option-berth/engine/internal/state" ) -// enableJev writes the settings file that grants consent. Publishing the +// enableAttention writes the settings file that grants consent. Publishing the // attention artifact is gated on it, so every case that expects a path has to // state the person enabled the enhancement first. -func enableJev(t *testing.T) { +func enableAttention(t *testing.T) { t.Helper() path := paths.AttnConfigPath() if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { @@ -31,7 +31,7 @@ func enableJev(t *testing.T) { func TestPublishAttentionWritesFactArtifact(t *testing.T) { home := t.TempDir() t.Setenv("BERTH_HOME", home) - enableJev(t) + enableAttention(t) code := 1 root := t.TempDir() doc := statusDocument{ @@ -65,7 +65,7 @@ func TestPublishAttentionWritesFactArtifact(t *testing.T) { func TestPublishAttentionPreservesAssessmentOnStableRefresh(t *testing.T) { home := t.TempDir() t.Setenv("BERTH_HOME", home) - enableJev(t) + enableAttention(t) root := t.TempDir() doc := statusDocument{ Scope: display.Scope{Root: root}, @@ -96,7 +96,7 @@ func TestPublishAttentionPreservesAssessmentOnStableRefresh(t *testing.T) { func TestPublishAttentionCarriesScopedPortsAndHistory(t *testing.T) { home := t.TempDir() t.Setenv("BERTH_HOME", home) - enableJev(t) + enableAttention(t) root := t.TempDir() declared, actual := 3000, 3001 projectRoot := root @@ -129,7 +129,7 @@ func TestPublishAttentionCarriesScopedPortsAndHistory(t *testing.T) { func TestPublishAttentionClearsAfterRecovery(t *testing.T) { home := t.TempDir() t.Setenv("BERTH_HOME", home) - enableJev(t) + enableAttention(t) root := t.TempDir() failed := statusDocument{ Scope: display.Scope{Root: root}, @@ -335,11 +335,9 @@ func TestPrintStatusServicesUsesOneRowPerService(t *testing.T) { } } -// An `attn_path` is a signal in itself: its presence means the person -// enabled the enhancement. Without the gate every agent had to reconstruct -// that from the settings file before it could decide whether to run `oberth jev -// assess`, and a disabled install still left an artifact telling it to. -func TestPublishAttentionIsSilentUntilEnabled(t *testing.T) { +// The artifact is a local fact, not a consent decision: an agent can use the +// fact-only handoff and offer the enhancement before any model is configured. +func TestPublishAttentionDoesNotDependOnConsent(t *testing.T) { home := t.TempDir() t.Setenv("BERTH_HOME", home) root := t.TempDir() @@ -349,23 +347,28 @@ func TestPublishAttentionIsSilentUntilEnabled(t *testing.T) { Name: "api", LastExit: &state.ServiceExit{Code: 1, Reason: "start_failed"}, }}}, } - if path := publishAttention(&doc); path != "" { - t.Fatalf("published %q while model is disabled", path) + if path := publishAttention(&doc); path == "" { + t.Fatal("no artifact was published without consent") + } + if doc.AttnPath == "" || doc.AttnRevision == "" || doc.Attn == nil { + t.Fatalf("doc is missing the handoff: %+v", doc) } - if doc.AttnPath != "" || doc.AttnRevision != "" { - t.Fatalf("doc carries an artifact: %q / %q", doc.AttnPath, doc.AttnRevision) + if doc.Attn.Enabled || doc.Attn.Assessment != "unavailable" || doc.Attn.Reason == "" { + t.Fatalf("attn state without consent = %+v", doc.Attn) } - // Enabling it makes the same facts publish; the gate is the only difference. - enableJev(t) + // Storing consent changes the state, not whether the facts are published. + enableAttention(t) if path := publishAttention(&doc); path == "" { t.Fatal("enabled model published no artifact") } + if doc.Attn == nil || !doc.Attn.Enabled || doc.Attn.Assessment != "pending" || doc.Attn.Reason != "" { + t.Fatalf("attn state with consent = %+v", doc.Attn) + } } -// Turning the enhancement off must also clear what is already on disk, or a -// stale artifact keeps telling agents to assess after the person said no. -func TestPublishAttentionClearsWhenDisabled(t *testing.T) { +// Turning the assessment off must not take the fact-only handoff away. +func TestPublishAttentionSurvivesTurningItOff(t *testing.T) { home := t.TempDir() t.Setenv("BERTH_HOME", home) root := t.TempDir() @@ -375,7 +378,7 @@ func TestPublishAttentionClearsWhenDisabled(t *testing.T) { Name: "api", LastExit: &state.ServiceExit{Code: 1, Reason: "start_failed"}, }}}, } - enableJev(t) + enableAttention(t) path := publishAttention(&doc) if path == "" { t.Fatal("enabled model published no artifact") @@ -383,11 +386,14 @@ func TestPublishAttentionClearsWhenDisabled(t *testing.T) { if err := os.Remove(paths.AttnConfigPath()); err != nil { t.Fatal(err) } - if got := publishAttention(&doc); got != "" { - t.Fatalf("still published %q after the settings file was removed", got) + if got := publishAttention(&doc); got != path { + t.Fatalf("path after disabling = %q, want %q", got, path) } - if _, err := attn.Read(path); err == nil { - t.Fatal("the artifact outlived the consent it was published under") + if _, err := attn.Read(path); err != nil { + t.Fatalf("the fact-only artifact disappeared with consent: %v", err) + } + if doc.Attn == nil || doc.Attn.Enabled { + t.Fatalf("attn state after disabling = %+v", doc.Attn) } } diff --git a/engine/internal/cmd/status_tip.go b/engine/internal/cmd/status_tip.go index 98794dcf..2bb94d4f 100644 --- a/engine/internal/cmd/status_tip.go +++ b/engine/internal/cmd/status_tip.go @@ -120,7 +120,7 @@ func recordAttnTip(path string, now time.Time) { // turn it on. One line, because it sits under a status table a reader is // scanning. func attnTipLine() string { - return display.Dim("Attention: an optional model can assess this anomaly; `oberth attn enable` turns it on") + return display.Dim("Attention: an optional model can assess this anomaly; `oberth attn config` and `oberth attn on` turn it on") } // isTerminalStdout follows the same character-device test the rest of the CLI @@ -133,7 +133,7 @@ func isTerminalStdout() bool { return info.Mode()&os.ModeCharDevice != 0 } -// attnEnabled reports the committed consent only: a one-shot BERTH_JEV in the +// attnEnabled reports the committed consent only: a one-shot BERTH_ATTN in the // current shell is a decision for that process, not a reason to stop telling // the next person the feature exists. func attnEnabled() bool { diff --git a/engine/internal/cmd/status_tip_test.go b/engine/internal/cmd/status_tip_test.go index 99f32acb..98f67220 100644 --- a/engine/internal/cmd/status_tip_test.go +++ b/engine/internal/cmd/status_tip_test.go @@ -60,7 +60,7 @@ func TestTipDueTreatsAGarbledRecordAsDue(t *testing.T) { } } -func TestRecordJevTipIsOwnerOnly(t *testing.T) { +func TestRecordAttnTipIsOwnerOnly(t *testing.T) { path := filepath.Join(t.TempDir(), "nested", "jev-tip.json") recordAttnTip(path, time.Now().UTC()) info, err := os.Stat(path) @@ -126,13 +126,13 @@ func TestTipFiresWithAttnOffAndAnAnomaly(t *testing.T) { } } -// The published artifact gates on consent, so a read that derives an anomaly -// while the assessment is off must hold the verdict and publish nothing. This is the pair -// the tip reads: verdict present, path absent. -func TestAnomalyVerdictSurvivesTheConsentGate(t *testing.T) { +// The published artifact no longer gates on consent: a read that derives an +// anomaly while the assessment is off still publishes the fact-only handoff and +// marks it as unavailable. The tip reads the verdict, not the consent. +func TestAnomalyVerdictSurvivesWithoutConsent(t *testing.T) { t.Setenv("BERTH_HOME", t.TempDir()) if attnEnabled() { - t.Fatal("this case needs model to be off") + t.Fatal("this case needs the assessment to be off") } exited := time.Now().UTC() doc := &statusDocument{Scope: display.Scope{Root: t.TempDir()}} @@ -146,8 +146,11 @@ func TestAnomalyVerdictSurvivesTheConsentGate(t *testing.T) { ExitedAt: exited.Format(time.RFC3339Nano), }} publishAttention(doc) - if doc.AttnPath != "" { - t.Fatalf("published %q without consent", doc.AttnPath) + if doc.AttnPath == "" { + t.Fatal("the fact-only handoff was not published without consent") + } + if doc.Attn == nil || doc.Attn.Enabled || doc.Attn.Assessment != "unavailable" { + t.Fatalf("attn state without consent = %+v", doc.Attn) } if !doc.attnEvent { t.Fatal("the derived anomaly was discarded with the publication") @@ -155,10 +158,10 @@ func TestAnomalyVerdictSurvivesTheConsentGate(t *testing.T) { } // The hint is one dim line: it must name the feature, say it is optional, and -// give the one command that turns it on. +// give the setup and consent commands that turn it on. func TestTipLineIsActionableAndShort(t *testing.T) { line := attnTipLine() - for _, want := range []string{"model", "optional", "oberth attn enable"} { + for _, want := range []string{"model", "optional", "oberth attn config", "oberth attn on"} { if !strings.Contains(line, want) { t.Fatalf("tip %q does not mention %q", line, want) } diff --git a/engine/internal/doctor/check_attn.go b/engine/internal/doctor/check_attn.go index 2865df81..85bedfcd 100644 --- a/engine/internal/doctor/check_attn.go +++ b/engine/internal/doctor/check_attn.go @@ -22,7 +22,7 @@ func checkAttn(_ context.Context, env *Env) rpc.DoctorCheck { // outside: it is present, so nothing falls back, and the reason is only // visible here. return warn("attention settings are not usable", err.Error(), - "run `oberth attn status` to see the resolved state, then `oberth attn enable` to rewrite it") + "run `oberth attn status` to see the resolved state, then `oberth attn config` to rewrite it") } // OneShot is deliberately empty: this row reports the committed setting, so // an unrelated BERTH_ATTN export cannot make doctor claim an endpoint is @@ -31,12 +31,12 @@ func checkAttn(_ context.Context, env *Env) rpc.DoctorCheck { if !consent.Enabled { return skipWithFix("Attention: off (optional)", "the optional model assessment is not enabled; runtime facts, services and every control command work without it", - "run `oberth attn enable` to turn it on, or ignore this row") + "run `oberth attn config` once, then `oberth attn on`, or ignore this row") } if consent.Key == "" { return warn("Attention: on, but no usable key", "enabled in settings but no key resolved, so every assessment will report no_key", - "run `oberth attn enable` to store a key, or `oberth attn test` to see the current reason") + "run `oberth attn config` to store a key, or `oberth attn test` to see the current reason") } return ok("Attention: on · "+consent.Model, "endpoint "+consent.Endpoint+", key from "+string(consent.KeySource)) diff --git a/engine/internal/scenario/scenario_attention_integration_test.go b/engine/internal/scenario/scenario_attention_integration_test.go index ff33e5ff..b787b507 100644 --- a/engine/internal/scenario/scenario_attention_integration_test.go +++ b/engine/internal/scenario/scenario_attention_integration_test.go @@ -64,7 +64,7 @@ func readAttnDoc(t *testing.T, status statusDoc) attnDoc { return artifact } -// enableJevIn is the person's consent, written where the daemon's child +// enableAttentionIn is the person's consent, written where the daemon's child // processes read it. `status` publishes `attn_path` only once the // enhancement is enabled, so every test here that expects an artifact has to // state that the person turned it on — the same precondition a real install @@ -74,7 +74,7 @@ func readAttnDoc(t *testing.T, status statusDoc) attnDoc { // The key is a placeholder: this file never calls a provider (an injectable // HTTP server covers model itself), and the artifact is derived from runtime // facts before any network step. -func enableJevIn(t *testing.T, e *env) { +func enableAttentionIn(t *testing.T, e *env) { t.Helper() dir := filepath.Join(e.home, ".option-berth") if err := os.MkdirAll(dir, 0o700); err != nil { @@ -103,7 +103,7 @@ func attnServices(a attnDoc) map[string]string { func TestAttentionFailureMatrix(t *testing.T) { e := newEnv(t) e.serve() - enableJevIn(t, e) + enableAttentionIn(t, e) b, err := FailureMatrix(filepath.Join(e.home, "attention-failures")) if err != nil { t.Fatalf("provision: %v", err) @@ -200,7 +200,7 @@ func TestAttentionFailureMatrix(t *testing.T) { func TestAttentionNormalAndUndeclaredBoundaries(t *testing.T) { e := newEnv(t) e.serve() - enableJevIn(t, e) + enableAttentionIn(t, e) b, err := APIServerAndWorker(filepath.Join(e.home, "attention-normal")) if err != nil { t.Fatalf("provision: %v", err) @@ -271,7 +271,7 @@ func TestAttentionNormalAndUndeclaredBoundaries(t *testing.T) { func TestAttentionManifestConflictIsNotActionable(t *testing.T) { e := newEnv(t) e.serve() - enableJevIn(t, e) + enableAttentionIn(t, e) b, err := APIServerAndWorker(filepath.Join(e.home, "attention-conflict")) if err != nil { t.Fatalf("provision: %v", err) From 15dab6965f5e42f47d1e1c4524d84fb55fb5535b Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 19:22:33 +0800 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20=E8=AF=B4=E6=98=8E=20attn=5Fpath=20?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E5=BC=82=E5=B8=B8=E8=80=8C=E9=9D=9E=E5=90=8C?= =?UTF-8?q?=E6=84=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli 双语对与 architecture 更新为“事实型交接总是发布,attn 块说明能否评估”, 并在 agent 字段表加入 attn 块。 验证:python3 scripts/check_docs.py;check_skill_contract.py(85 个锚点) Co-Authored-By: Claude Code --- docs/agent/architecture.md | 15 +++++++-------- docs/cli.md | 22 +++++++++++++--------- docs/cli.zh.md | 12 ++++++++---- 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index 2fa0e9dd..b6c339b4 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -53,8 +53,8 @@ Deleting data remains a person's job. `internal/attn` holds only the deterministic part: fact input, event classification, derivation, atomic publication, expiry and reads. It does not open a network connection, read a key or call a model. -`internal/attn/model` holds the optional enrichment: the the model client, local settings, telemetry, preflight and -the runner. +`internal/attn/model` holds the optional enrichment: the model client, local settings, telemetry, +preflight and the runner. `internal/cmd/attention_boundary_test.go` checks that boundary instead of leaving it as a comment: @@ -66,12 +66,11 @@ the runner. So with no key, no `attn.json` and an unreachable provider, the control path still works: nothing leaves the machine, and no service start, stop or readiness decision depends on the enrichment. -The artifact itself is gated on consent. `status` publishes it only once the person has enabled the model, -which makes `attn_path` a signal an agent can act on directly: present means the enhancement is -on; absent means either nothing needs assessing or the user never opted in, and the agent no longer -has to tell those apart by reading the settings file. An install that turns the enhancement off also -clears what was published before, so a stale artifact cannot keep inviting an assessment the person -declined. +The artifact is not gated on consent: it is a local, code-derived fact, and publishing it sends +nothing anywhere. `attn_path` present means there is an anomaly; the adjacent `attn` block (consent, +fixed reason, assessment state) is what an agent reads to decide whether it may run `oberth attn +assess`. Turning the assessment off therefore keeps the fact-only handoff and changes only that +block — the person's refusal is never inferred from whether a local file exists. ## Read-only addresses and shared environment diff --git a/docs/cli.md b/docs/cli.md index 4d86c277..9215d84b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -259,15 +259,18 @@ file exists. Use logs for detailed output. `worktree.services[]`'s `pid`, `run_i are provided while the service is being run by option-berth, so even a portless worker can be verified against the actual run record through these fields; `log_path` is always given by the engine. -Once the optional attention enhancement is enabled, a definite abnormal event in the state also puts -`attn_path` in the JSON. It points at a recomputable, expirable agent handoff file under -`BERTH_HOME/attention/`; with no current abnormality the field is absent. **Its presence is the -signal that the person enabled the enhancement** — an agent that sees one may run `oberth attn -assess`, and an agent that sees none does not have to guess whether that is because nothing is wrong -or because the model was never turned on. Without consent `status` publishes nothing and clears an artifact -left over from before the enhancement was switched off. When there is an abnormality, -`attn_revision` is provided as well and must equal the file's `state_revision`, so the agent can -check inside the same `status` response whether the file still corresponds to current facts. An +A definite abnormal event in the state puts a fact-only handoff in the JSON, whether or not the +optional assessment is enabled. `attn_path` points at a recomputable, expirable agent handoff file +under `BERTH_HOME/attention/`; with no current abnormality the field is absent. **Its presence means +"there is an anomaly", not "the person opted in"** — publishing a local fact sends nothing anywhere, +so consent cannot decide whether it exists. The adjacent `attn` block says whether that handoff can +be assessed now: `enabled` is the consent bit, `reason` is the fixed code when nothing can run, and +`assessment` is `assessed` (the file already carries a validated result), `pending` (an enabled, +complete configuration could assess it), or `unavailable`. An agent may use the fact-only handoff +without a configured model; it must check `attn.enabled` before running `oberth attn assess`. +When there is an abnormality, `attn_revision` is provided as well and must equal the file's +`state_revision`, so the agent can check inside the same `status` response whether the file still +corresponds to current facts. An undeclared listener is not an abnormality: `status` already lists the port with its owner, and a process the agent did not start is not one it should stop. The core file is `oberth.attn/v1`, containing `event_id`, `state_revision`, `worktree_root`, optional `branch`, @@ -369,6 +372,7 @@ or renaming one. | `observation.source` | `status` | The collector that answered when it was not the platform's primary one; non-empty means a missing port is unknown, not free. | | `machine[].listening_known` | `status` | Whether `listening` was observed; false means the table cannot see that port's owner. | | `attn_path` | `status` | The derived handoff file for the current abnormality; absent when there is none. | +| `attn` | `status` | Whether that handoff can be assessed now: `enabled`, fixed `reason`, `assessment` (`assessed`, `pending` or `unavailable`). | | `services[].starting` | `status` | Instantaneous facts of an in-progress start; not a run and not the same as `running`. | | `starting.phase` | `status` | `waiting_dependency`, `prepare` or `spawning`. | | `starting.waiting_on` | `status` | Dependencies still being waited on; keeps the last known set when observation fails. | diff --git a/docs/cli.zh.md b/docs/cli.zh.md index 4473b9c7..441da6d3 100644 --- a/docs/cli.zh.md +++ b/docs/cli.zh.md @@ -224,11 +224,14 @@ changed 是第一次运行时缺省的可选字段。ports 仍是给脚本核对 `worktree.services[]` 的 `pid`、`run_id` 和 `started_at` 在服务由 option-berth 正在运行时提供, 无端口 worker 也能靠这些字段核对实际运行记录;`log_path` 始终由引擎给出。 -在用户启用可选的模型评估之后,状态里出现确定的异常事件才会带 `attn_path`。它指向 +状态里出现确定的异常事件就会带事实型交接文件,与是否启用可选评估无关。`attn_path` 指向 `BERTH_HOME/attention/` 下的一个可重算、可过期的 agent 交接文件;没有当前异常时该字段缺省。 -**这个字段出现本身就是「用户已启用增强」的信号**:看到它的 agent 可以运行 `oberth attn assess`, -看不到的 agent 也不必再猜是「没有异常」还是「从没开过该评估」。未启用时 `status` 不发布,并清掉 -关闭之前留下的文件。有异常时同时提供 `attn_revision`,它必须与文件中的 `state_revision` +**它出现表示「有异常」,不表示「用户已同意」**——发布一个本地事实不向任何地方发送数据, +所以同意不能决定它是否存在。相邻的 `attn` 块说明这份交接现在能否被评估:`enabled` 是同意位, +`reason` 是当前无法运行时的固定原因码,`assessment` 取 `assessed`(文件已带校验过的结果)、 +`pending`(已启用且配置完整,可以评估)或 `unavailable`。agent 可以在没有配置模型时使用 +事实型交接文件,但在运行 `oberth attn assess` 前必须检查 `attn.enabled`。 +有异常时同时提供 `attn_revision`,它必须与文件中的 `state_revision` 相等,供 agent 在同一份 `status` 响应中核对文件是否仍对应当前事实。 未声明的监听不是异常:`status` 已经列出该端口及其属主,agent 没有启动它,也不该去停它。 核心文件是 `oberth.attn/v1`,包含 `event_id`、`state_revision`、`worktree_root`、 @@ -315,6 +318,7 @@ oberth attn assess --project "$PWD" \ | `observation.source` | `status` | 不是主采集器时回答本轮采集器;非空表示缺失的端口是未知,不是空闲。 | | `machine[].listening_known` | `status` | `listening` 是否是观察到的;false 表示这张表看不到该端口的所有者。 | | `attn_path` | `status` | 当前异常的派生交接文件;没有异常时缺省。 | +| `attn` | `status` | 这份交接现在能否被评估:`enabled`、固定 `reason`、`assessment`(`assessed`、`pending` 或 `unavailable`)。 | | `services[].starting` | `status` | 正在启动的瞬时事实;不是 run,也不等于 `running`。 | | `starting.phase` | `status` | `waiting_dependency`、`prepare` 或 `spawning`。 | | `starting.waiting_on` | `status` | 仍在等的依赖;观察失败时保留上次已知集合。 | From 0e1858bcdcf9d13eef66079fe62df6980fb06b61 Mon Sep 17 00:00:00 2001 From: zayJu Date: Thu, 12 Nov 2026 19:29:16 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test(scripts):=20agent=20=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E9=94=9A=E7=82=B9=E5=8A=A0=E5=85=A5=20attn=20=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit status 新增 attn 块后,锚点提取测试的期望集合需要包含它;skill 文档已通过 `attn_path` 覆盖该锚点。 验证:python3 scripts/check_skill_contract_test.py;check_skill_contract.py Co-Authored-By: Claude Code --- scripts/check_skill_contract_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/check_skill_contract_test.py b/scripts/check_skill_contract_test.py index aab044be..cab3f51c 100644 --- a/scripts/check_skill_contract_test.py +++ b/scripts/check_skill_contract_test.py @@ -63,6 +63,7 @@ def test_json_field_anchors_match_the_agent_field_table(self): "observation.source", "machine[].listening_known", "attn_path", + "attn", "services[].starting", "starting.phase", "starting.waiting_on",