diff --git a/docs/design.md b/docs/design.md index 5cd407d..0a9273e 100644 --- a/docs/design.md +++ b/docs/design.md @@ -13081,6 +13081,83 @@ finding two genuinely stale surveys on its first live run. **Re-measuring those not part of this change** — the loop reports staleness; a re-survey is its own work, with its own live corpus. +#### 2026-08-17: the preflight states each seat's posture, from the room's own claim + +**The gap.** Every council column carries a sandbox badge, and the help panel's posture page +carries the measured argument under each one (§9.13, §9.2's ruling that a claim you cannot see +is not a claim). Both of those are read **inside the room**, which is after the decision they +inform. A user picks a workspace and a posture *before* the room opens, and the only surface +that runs before the room opens said nothing about either. §9.17 settles that the fact belongs +here: what a vendor's own flags buy on this machine is true at launch and stays true, and it is +a property of the vendor and the OS rather than of a turn. + +**One source, two surfaces.** Nothing in the block is written in `internal/doctor`. +`council.DoctorSeats` builds it from `postureClaim` — the same function the room's own columns +are built from — and hands over the badge word off `SandboxClaim.Badge()` and an evidence class +off the claim's `Level`. That routing is the whole design: a preflight with a per-vendor posture +table of its own would agree with the badges on the day it was written and diverge the day a +level moved, and a reader looking at two disagreeing surfaces has no way to tell which is +lying. The capability declaration and the survey pin are attached at the same seam, for the same +stated reason. `TestThePreflightPostureIsTheRoomsOwnBadge` pins it through *different* +construction paths on each side — `DoctorSeats` against the columns `stateWith` builds — because +comparing `doctorPosture` with `postureClaim` would be comparing a call with itself. + +**The badge says what the posture IS; the evidence class says what it RESTS ON.** They are +different questions, and `unsandboxed` is the case that proves it: two seats reach that badge +because a live run **refuted** the flags and because **no flag was ever passed**, and a reader +deciding whether to point council at a worktree needs the second sentence. §4a.1's rule that two +kinds of nothing must not render alike is the same rule one level up. + +| badge | evidence class | +|---|---| +| `ro:tools` | enforced by **construction** — the write and shell tools are absent from the session | +| `ro:enforced` | enforced by an **operating system** — the vendor's own sandbox | +| `ro:requested` | **asked for**, and never observed on this machine — weaker than either above, and says so | +| `unsandboxed` | **measured** not to restrict — refuted by a live run, not merely unestablished | +| `WRITES` | nothing was asked for at all | +| `gated` | **your keystroke** — the seat asks before every tool call that changes anything | + +`evidenceClass` is a table keyed by level, so `TestEveryPostureLevelHasAnEvidenceClass` can walk +the type and fail the build the day a sixth level renders a badge with nothing to classify it — +the guard `helpBadgeGloss` already carries inside the room. `TestNoEvidenceClassSoftensItsBadge` +holds the other half on `TestThePostureLegendDoesNotSoftenAnyClaim`'s terms exactly: these +sentences classify evidence and never weaken it, and none of them may call a posture read-only, +safe or unable to write. + +**The rows are the `--read` room, and the argv is on the block.** The room **WRITES by default** +and `--read` is the opt-out (`cmd/telltale`, and the legend inside the room was already corrected +once for crediting the retired `--write` flag). The rows report the `--read` posture because that +is the only one that is a fact about the *machine* — the default room's badge is a property of an +argv the reader has not typed yet, and five cells all reading `WRITES` would carry nothing per +seat. So the header names `telltale council --read` in its first clause, and **one closing +declaration** states the default: the room writes, *n* of *m* seats can be asked to ask first, +and what contains a writing room is the workspace, not any of these words. The gating half is +**counted off `canGate`**, never written down — that measurement has already moved once, when the +Cursor seat became a live process that can be asked and still does not ask about edits. + +**It is a claim, and it is not one of the three states.** A posture was measured once against a +live run and written into this repository; nothing re-measures it on the reader's machine. So it +renders outside the status column, beside the capability line and the survey pin, and it is wrong +in *both* directions as a check: a `FAILED` would redden a working install over a vendor's own +design decision, and an `ok` would claim this preflight established a containment property it +never probed and could not probe without spending a turn. `TestAPostureIsNotACheck` pins that the +way `TestDriftIsNotAFailedCheck` does — the same seat with and without the data, the three counts +required to be identical — and `TestThePostureBlockCostsNoProbe` pins the other half: the block +adds **no probe, no network call and no login check**, because every string in it arrives with +the seat. The exit code is untouched. + +**A seat council states no posture for gets `no claim`, not a missing row.** A seat absent from a +posture table reads as a seat with nothing to declare; this one has an unanswered question. The +word deliberately is not shaped like `not checked` — the three state words are spoken for, and a +fourth column borrowing one would put this block back inside the block it is outside of. + +**Measured on the reference Mac, 2026-08-17.** The five rows read `claude ro:tools`, +`codex ro:enforced`, `agy unsandboxed`, `cursor ro:requested`, `grok unsandboxed`, and the +declaration reads *1 of the 5 seats above can be asked to ask first: claude*. The `codex` row is +the platform branch working: the same block on Windows reads `unsandboxed` there, because council +passes `-s danger-full-access` on that OS (ADR-008's twelfth amendment) — and it reads it from +`postureClaim`, not from a second platform test in the preflight. + ### 9.43 the agy seat stops pretending a lost thread resumed (2026-08-09) diff --git a/internal/council/doctor.go b/internal/council/doctor.go index 9dfcf6c..8116a0e 100644 --- a/internal/council/doctor.go +++ b/internal/council/doctor.go @@ -2,6 +2,7 @@ package council import ( "path/filepath" + "runtime" "strings" "github.com/sanlee-ys/telltale/internal/adapter/pins" @@ -28,6 +29,7 @@ import ( func DoctorSeats() []doctor.Seat { reg := vendors.Registry() infos := Detect() + windows := runtime.GOOS == "windows" out := make([]doctor.Seat, 0, len(infos)) for _, info := range infos { v := info.Vendor @@ -40,6 +42,7 @@ func DoctorSeats() []doctor.Seat { Note: info.Note, Drivable: info.Avail == AvailInstalled, Capability: declaredCapability(v, reg), + Posture: doctorPosture(v, windows), VersionArgs: versionArgs(info), } if s.Drivable { @@ -151,3 +154,89 @@ func declaredCapability(v model.VendorID, reg map[model.VendorID]vendors.Vendor) } return strings.Join(parts, "; ") } + +// doctorPosture is one seat's sandbox claim, flattened for the preflight's +// posture block (design.md §9.42, amended 2026-08-17). +// +// It goes THROUGH postureClaim rather than beside it, and that is the whole +// design. `postureClaim(v, windows, false, false, false)` is literally what +// `telltale council --read` builds for this column — same function, same +// arguments, same platform branch — so the badge the preflight prints and the +// badge the column wears are one value read twice. A preflight that grew its own +// per-vendor table would agree on the day it was written and diverge the day a +// level moved, and a reader looking at two disagreeing surfaces has no way to +// tell which one is lying. The capability declaration above is attached at this +// seam for the same stated reason. +// +// The `--read` room is the posture reported because it is the only one that is a +// fact about the MACHINE. The default room writes, and what it writes with is a +// property of the argv the reader has not typed yet — so it is stated once, in +// the block's closing declaration, rather than five times in a column where +// every cell would read the same word. +func doctorPosture(v model.VendorID, windows bool) doctor.Posture { + claim := postureClaim(v, windows, false, false, false) + return doctor.Posture{ + Badge: claim.Badge(), + Evidence: evidenceClass(claim.Level), + // Off canGate, not off a list of vendor names. That measurement has + // already moved once — the Cursor seat became a live process that can be + // asked and still does not ask about edits — and a copy here would have + // gone on saying the old thing. + CanGate: canGate(v), + } +} + +// evidenceClass is what KIND of evidence stands behind a badge, keyed by the +// level that renders it. +// +// The badge word says what the posture IS; this says what it RESTS ON, and the +// two are different questions with different answers. `unsandboxed` is the case +// that proves it: two seats can both fail to be read-only because a live run +// refuted the flags and because no flag was ever passed, and a reader deciding +// whether to point council at a worktree needs the second sentence, not the +// first. §4a.1's rule that two kinds of nothing must not render alike is the same +// rule one level up. +// +// A table keyed by level rather than prose, so a test can walk the type and fail +// the build the day a sixth level renders a badge with nothing here to classify +// it — see TestEveryPostureLevelHasAnEvidenceClass, which is the guard +// helpBadgeGloss already carries for the room's own legend, one surface out. +// +// NOTHING HERE MAY WEAKEN A CLAIM, on helpBadgeGloss's terms exactly. These are +// classifications of evidence, not softenings of it: `unsandboxed` still says +// nothing restricts the vendor, `ro:requested` still admits nobody observed what +// it enforces, and no line here may call any posture read-only, safe, or unable +// to write. +func evidenceClass(l SandboxLevel) string { + switch l { + case SandboxTools: + return "enforced by CONSTRUCTION — the write and shell tools are absent from that " + + "session, read off what the session reported about itself rather than off a flag. " + + "The residual is that a deny list cannot cover a tool a future release adds" + case SandboxEnforced: + return "enforced by an OPERATING SYSTEM — the vendor's own sandbox, and the one posture " + + "in this table that a flag is not the last thing standing behind" + case SandboxRequested: + // It does NOT say "weaker than the two above", which is how the room's own + // legend words it. That legend is ordered by level; these rows are ordered + // by seat, so "above" points at whatever vendor happens to sort first. The + // comparison is named instead of pointed at. + return "ASKED FOR, and never observed — the flag was accepted and what it enforces on " + + "this machine is not established. Weaker than a construction or an OS sandbox, " + + "and says so" + case SandboxNone: + return "MEASURED not to restrict — a live run refuted the flags rather than leaving " + + "them unestablished, so treat this seat as able to change your files" + case SandboxWrite: + return "nothing was asked for — this seat may edit and run, and no restriction was " + + "requested of it at all" + case SandboxGated: + return "YOUR KEYSTROKE — this seat asks before every tool call that changes anything, " + + "and nothing runs until you answer" + default: + // SandboxUnknown renders no badge, so there is nothing to classify. An + // invented sentence here would be a claim about a seat council makes none + // about; doctor prints its own honest blank instead. + return "" + } +} diff --git a/internal/council/doctor_test.go b/internal/council/doctor_test.go index f41c976..3033155 100644 --- a/internal/council/doctor_test.go +++ b/internal/council/doctor_test.go @@ -157,3 +157,122 @@ func TestTheDeclaredCapabilityMatchesTheRoomsOwnMeasurements(t *testing.T) { t.Errorf("grok is a batch program per turn and does not say so: %q", got) } } + +// TestThePreflightPostureIsTheRoomsOwnBadge is the "one source, two surfaces" +// assertion, and it is deliberately made through a DIFFERENT construction path +// on each side: DoctorSeats on one, the room's own stateWith on the other. +// +// Comparing doctorPosture against postureClaim would be comparing a call with +// itself. What can actually go wrong is that a preflight grows a per-vendor +// table of its own — right on the day it is written, and silently wrong the day +// a level moves. This walks the columns a `--read` room would open with and +// requires the same word on both surfaces. +func TestThePreflightPostureIsTheRoomsOwnBadge(t *testing.T) { + noVendorOverrides(t) + t.Setenv("PATH", t.TempDir()) + + // Write is false: the room `--read` opens, which is the room the preflight's + // rows describe. + room := map[model.VendorID]string{} + for _, c := range stateWith(Options{}, false).Columns { + room[c.Vendor] = c.Sandbox.Badge() + } + seats := DoctorSeats() + if len(seats) == 0 { + t.Fatal("the preflight lists no seats at all") + } + for _, s := range seats { + v := model.VendorID(s.Vendor) + want, ok := room[v] + if !ok { + t.Errorf("%s is in the preflight and not in the room", s.Vendor) + continue + } + if s.Posture.Badge != want { + t.Errorf("%s: the preflight says %q and the column says %q", + s.Vendor, s.Posture.Badge, want) + } + if s.Posture.Evidence == "" { + t.Errorf("%s carries the badge %q with no evidence class behind it", + s.Vendor, s.Posture.Badge) + } + // The gating fact is read off canGate on both surfaces, so the preflight + // cannot promise a gate the room will not open. + if s.Posture.CanGate != canGate(v) { + t.Errorf("%s: the preflight says gatable = %v, canGate says %v", + s.Vendor, s.Posture.CanGate, canGate(v)) + } + } +} + +// TestEveryPostureLevelHasAnEvidenceClass mirrors TestEveryBadgeIsExplained, and +// closes the same gap one surface further out: a badge that renders with nothing +// to say what KIND of evidence is behind it. That gap comes back the day a sixth +// level lands, which is why this walks the type rather than listing the five. +func TestEveryPostureLevelHasAnEvidenceClass(t *testing.T) { + seen := map[string]SandboxLevel{} + for l := SandboxUnknown; l <= SandboxGated; l++ { + b := SandboxClaim{Level: l}.Badge() + e := evidenceClass(l) + if b == "" { + // SandboxUnknown renders no badge, so there is nothing to classify — + // and it must not invent a sentence about a seat council makes no + // claim about. + if e != "" { + t.Errorf("%v renders no badge and still carries an evidence class: %q", l, e) + } + continue + } + if e == "" { + t.Errorf("the badge %q renders on a column and nothing says what evidence "+ + "stands behind it", b) + continue + } + if prev, dup := seen[e]; dup { + t.Errorf("%v and %v share one evidence class, so the preflight cannot tell "+ + "them apart: %q", prev, l, e) + } + seen[e] = l + } +} + +// TestNoEvidenceClassSoftensItsBadge, on TestThePostureLegendDoesNotSoftenAnyClaim's +// terms exactly. These sentences classify evidence; they never weaken it. The two +// words that mean "this seat can change your files" keep meaning that, the weakest +// badge keeps admitting it is weak, and nothing here may call a posture read-only, +// safe, or unable to write — the badges break the `ro:` prefix on purpose, and a +// classification that put it back would undo that outside the room, where there is +// no legend to correct it. +func TestNoEvidenceClassSoftensItsBadge(t *testing.T) { + for _, tc := range []struct { + level SandboxLevel + want []string + }{ + {SandboxNone, []string{"measured", "change your files"}}, + {SandboxRequested, []string{"never observed"}}, + {SandboxTools, []string{"absent"}}, + {SandboxEnforced, []string{"operating system"}}, + {SandboxWrite, []string{"edit and run"}}, + {SandboxGated, []string{"asks before every tool call"}}, + } { + got := strings.ToLower(evidenceClass(tc.level)) + for _, w := range tc.want { + if !strings.Contains(got, strings.ToLower(w)) { + t.Errorf("the evidence class for %q dropped %q: %q", + SandboxClaim{Level: tc.level}.Badge(), w, got) + } + } + } + for l := SandboxUnknown; l <= SandboxGated; l++ { + if l != SandboxNone && l != SandboxWrite && l != SandboxGated { + continue + } + g := strings.ToLower(evidenceClass(l)) + for _, forbidden := range []string{"read-only", "safe", "cannot write"} { + if strings.Contains(g, forbidden) { + t.Errorf("the evidence class for %q says %q: %q", + SandboxClaim{Level: l}.Badge(), forbidden, g) + } + } + } +} diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index c71bade..c77874f 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -197,6 +197,12 @@ type Seat struct { // inventory already lives. Zero for a seat with no surveyed adapter behind // it, which renders no pin line at all — see pin.go. Pin Pin + // Posture is this seat's sandbox claim, and it is the third DECLARED field + // on this struct for the third time the same reason: council measured it + // once against a live run, council renders it on the column badges, and a + // second copy here would be a table that agrees today and drifts later. See + // posture.go. + Posture Posture } // ProbeResult is what one bounded version probe produced. Out is the vendor's @@ -294,6 +300,10 @@ type SeatReport struct { // why a staleness fact must not become a fourth state. Nothing in Tally or // Ready reads it, so the counts and the exit code are unchanged by it. Drifted bool + // Posture is the seat's sandbox claim, carried through unchanged from the + // Seat. Nothing in Tally or Ready reads it either, for posture.go's reason: + // a claim about what a vendor's flags buy is not a check on this machine. + Posture Posture } // Ready reports that every check that RAN on this seat passed. A seat with @@ -385,7 +395,14 @@ func Run(seats []Seat, probe Probe) Report { } func runSeat(s Seat, probe Probe) SeatReport { - out := SeatReport{Vendor: s.Vendor, Label: s.Label, Capability: s.Capability} + out := SeatReport{ + Vendor: s.Vendor, Label: s.Label, + Capability: s.Capability, + // Copied, never derived. runSeat is where a probe could be reached, and a + // posture that was computed here would be a posture this package invented + // — the one thing posture.go rules out. + Posture: s.Posture, + } // binary — always runs. Detection is a stat, so there is no branch in which // this question goes unasked. diff --git a/internal/doctor/posture.go b/internal/doctor/posture.go new file mode 100644 index 0000000..69f9fd8 --- /dev/null +++ b/internal/doctor/posture.go @@ -0,0 +1,150 @@ +package doctor + +import ( + "fmt" + "strings" +) + +// This file is the preflight's POSTURE block: what each seat's read-only claim +// is actually worth, and what CLASS of evidence stands behind it. +// +// # Why a preflight owes this at all +// +// The room already says it. Every council column carries a badge, and the help +// panel's posture page carries the measured argument under each one (§9.13, +// SandboxClaim.Detail). Both of those are read INSIDE the room — which is after +// the decision they inform. A user picks a workspace and a posture before the +// room opens, and until now the only surface that runs before the room opens +// said nothing about either. §9.17's frame settles that the fact belongs here: +// what a vendor's own flags buy on this machine is true at launch and stays +// true, and it is a property of the vendor and the OS rather than of a turn. +// +// # One source, two surfaces +// +// Nothing in this block is written here. `council.DoctorSeats` builds it from +// `postureClaim` — the same function the room's own columns are built from — +// and hands over the badge word off `SandboxClaim.Badge()` and the evidence +// class off the claim's Level. That is the whole point of routing it through +// council rather than restating it: a preflight that grew its own posture table +// would agree with the badges on the day it was written and diverge on the day +// a level moved, and the reader would have no way to tell which of the two +// surfaces was lying. The capability line above it is attached for the same +// reason and by the same seam. +// +// # It is not a check, and it must never become one +// +// A posture is a CLAIM, exactly like the capability line and the survey pin: it +// was measured once, against a live run, and written into this repository — +// nothing re-measures it on the reader's machine now. So it renders outside the +// three-state block, it carries no status word, it moves no count, and it +// changes no exit code. Making it a check would be wrong in both directions: a +// `FAILED` would redden a working install over a vendor's design decision, and +// an `ok` would claim this preflight established a containment property that it +// did not probe and could not probe without spending a turn. +// `TestAPostureIsNotACheck` pins that the way `TestDriftIsNotAFailedCheck` pins +// the survey pin, and by the same method: the same seat with and without the +// data, and the three counts required to be identical. +// +// # What it deliberately does NOT do +// +// No probe, no network call, no login check, and no new read of anything. The +// block costs the report zero processes: every string in it arrives with the +// seat. That is what keeps this a rendering change rather than a second, wider +// definition of what a preflight is allowed to do (§9.42 draws that line at +// cost and side effect, and this side of it costs nothing). + +// Posture is one seat's sandbox claim, flattened into what a preflight prints. +// +// A plain struct of already-worded strings, like Capability and Survey, so this +// package stays stdlib-only and so a test can synthesize a seat whose posture no +// vendor on this machine has. Nothing here is computed by this package — see the +// file doc. +type Posture struct { + // Badge is council's own badge word for this claim (`ro:tools`, + // `unsandboxed`, …), off SandboxClaim.Badge and not off a copy of it. Empty + // when council states no posture for the seat, which renders as an honest + // blank rather than as a missing row. + Badge string + // Evidence is the CLASS of evidence behind that badge — enforced by + // construction, enforced by an operating system, asked for and never + // observed, measured not to restrict. It is the question the badge word + // alone cannot answer: two seats can both fail to be read-only and have + // arrived there by a refuted measurement and by an unasked question, which + // are not the same fact about the seat. + Evidence string + // CanGate reports that this seat can be asked to ask first, off council's + // own canGate. It is not per-seat prose: the report counts it and states the + // result once, because "one of five" is the shape of that fact and five + // lines each saying "not this one" is not. + CanGate bool +} + +// stated reports that council gave this seat a posture at all. +func (p Posture) stated() bool { return p.Badge != "" } + +// postureHeader introduces the block, and it spends its first clause naming the +// argv these badges belong to. +// +// That matters more here than anywhere else in the report. Every other line +// above describes the machine, which is the same machine whatever the reader +// types next; a posture is not, because the room writes by DEFAULT and these +// words are what `--read` buys. A block of `ro:` badges with no argv on it would +// read as what `telltale council` gives you, which is the opposite of true — +// the §9.17 defect (a surface crediting a posture the typed command does not +// reach), committed in the block that exists to prevent it. +const postureHeader = "posture — what each seat's read-only claim is worth in the room " + + "`telltale council --read` opens, and what class of evidence stands behind it. Nothing " + + "below was probed here: it is read off the same data the room's own column badges are " + + "drawn from, so the two surfaces cannot disagree." + +// The honest blank for a seat council states no posture for — a vendor with no +// adapter behind it, or one detection never reached. Dropping the row would be +// worse than either: a seat missing from a posture table reads as a seat with +// nothing to declare, and this one has an unanswered question instead. +// +// The word is `no claim` rather than anything shaped like `not checked`. The +// three state words are spoken for, and a fourth column borrowing one of them +// would put this block back inside the block it is deliberately outside of. +const ( + postureNoClaimBadge = "no claim" + postureNoClaim = "council states no posture for this seat, so this preflight states none " + + "either — an unanswered question, not a permissive one" +) + +// postureDeclaration is the block's one closing line, and it is the only +// sentence in the block that is about the ROOM rather than about a seat. +// +// It exists because the rows above describe a posture nobody gets by default. +// `telltale council` writes; `--read` is the opt-out (cmd/telltale, and the help +// panel's own legend was corrected for crediting the retired `--write` flag). A +// reader who took the rows for the default would come away believing this room +// cannot touch their files, which is the single most expensive wrong belief this +// product can hand anyone. +// +// The gating half is COUNTED rather than written down. "One of five seats can be +// asked to ask first" is a fact about canGate, and canGate is a measurement that +// has already moved once — the Cursor seat became a live process that can be +// asked and still does not ask about edits, which is exactly the case a +// hand-written "only claude" sentence would have gone on getting wrong. So the +// count and the names come off the report. +func postureDeclaration(r Report) string { + var gating []string + for _, s := range r.Seats { + if s.Posture.CanGate { + gating = append(gating, s.Vendor) + } + } + const tail = " What contains a room that writes is the workspace you point it at, not any " + + "of these words — point council at a throwaway worktree when that matters." + + if len(gating) == 0 { + return "The room `telltale council` opens by default WRITES, and none of the " + + fmt.Sprint(len(r.Seats)) + " seats above can be asked to ask first: every one of " + + "them carries `WRITES` there, whatever it carries under `--read`." + tail + } + return fmt.Sprintf( + "The room `telltale council` opens by default WRITES, and %d of the %d seats above can "+ + "be asked to ask first: %s carries `gated` there and asks before every tool call "+ + "that changes anything, while the rest carry `WRITES`.%s", + len(gating), len(r.Seats), strings.Join(gating, ", "), tail) +} diff --git a/internal/doctor/posture_test.go b/internal/doctor/posture_test.go new file mode 100644 index 0000000..afbb1e1 --- /dev/null +++ b/internal/doctor/posture_test.go @@ -0,0 +1,267 @@ +package doctor + +import ( + "strings" + "testing" + "unicode/utf8" +) + +// Fixtures here are synthesized like every other in this package, and the badge +// words ARE the real vocabulary — `ro:tools`, `ro:enforced`, `ro:requested`, +// `unsandboxed` are what internal/council renders on a column. A block tested +// only against a tidy invented word would pass while breaking on every seat this +// repo actually has. + +// postured returns a seat carrying a sandbox claim. +func postured(name, badge, evidence string, canGate bool) Seat { + s := seat(name) + s.Posture = Posture{Badge: badge, Evidence: evidence, CanGate: canGate} + return s +} + +// postureRows is every rendered line of the posture block's table, which is the +// only place a state word could leak into a claim. +func postureRows(t *testing.T, out string) []string { + t.Helper() + var rows []string + in := false + for _, line := range strings.Split(out, "\n") { + switch { + case strings.HasPrefix(line, "posture — "): + in = true + case in && strings.TrimSpace(line) == "": + in = false + case in && strings.HasPrefix(line, " "): + rows = append(rows, line) + } + } + return rows +} + +// TestAPostureIsNotACheck is the honesty rule for this block, and it is pinned +// the way TestDriftIsNotAFailedCheck pins the survey pin, by the same method: +// the same seat with and without the data, and the three counts required to be +// identical. +// +// A posture is a claim this repository measured once and wrote down. Nothing +// re-measured it here. If it leaked into the three states it would be wrong in +// both directions — a FAILED would redden a working install over a vendor's own +// design decision, and an ok would claim this preflight established a +// containment property it never probed and could not probe without spending a +// turn. +func TestAPostureIsNotACheck(t *testing.T) { + claim := "MEASURED not to restrict — a live run refuted the flags rather than " + + "leaving them unestablished" + r := Run([]Seat{postured("agy", "unsandboxed", claim, false)}, answering("1.1.13")) + s := seatReport(t, r, "agy") + + for _, c := range s.Checks { + if c.Status == Failed { + t.Errorf("a posture failed the %q check: %+v", c.Name, c) + } + } + if !s.Ready() { + t.Error("an unsandboxed seat stopped being ready; every check that ran passed") + } + passed, failed, notChecked := r.Tally() + // The three counts are exactly what they would be with no posture at all. + bare := Run([]Seat{seat("agy")}, answering("1.1.13")) + wp, wf, wn := bare.Tally() + if passed != wp || failed != wf || notChecked != wn { + t.Errorf("tally with a posture = (%d,%d,%d), without one = (%d,%d,%d)", + passed, failed, notChecked, wp, wf, wn) + } + if len(s.Checks) != len(seatReport(t, bare, "agy").Checks) { + t.Error("the posture block added a check row") + } + // And no row of it wears one of the three state words. + for _, row := range postureRows(t, render(r)) { + for _, word := range []string{" ok ", "FAILED", "not checked"} { + if strings.Contains(row, word) { + t.Errorf("a posture row is wearing the state word %q: %q", word, row) + } + } + } +} + +// TestThePostureBlockCostsNoProbe. §9.42 draws the line at cost and side effect, +// and this block is on the free side of it: every string in it arrives with the +// seat. A posture that spawned anything to confirm itself would be a second, +// wider definition of what a preflight may do, arriving as a rendering change. +func TestThePostureBlockCostsNoProbe(t *testing.T) { + spawns := 0 + counting := func(string, []string) ProbeResult { + spawns++ + return ProbeResult{Out: "1.0.0"} + } + Run([]Seat{postured("claude", "ro:tools", "enforced by construction", true)}, counting) + with := spawns + spawns = 0 + Run([]Seat{seat("claude")}, counting) + if with != spawns { + t.Errorf("a seat with a posture spawned %d processes, one without spawned %d", with, spawns) + } +} + +// TestThePostureRowsNameTheArgvTheyBelongTo. Every other line in this report +// describes the machine, and a machine is the same machine whatever the reader +// types next. A posture is not: the room WRITES by default and these badges are +// what `--read` buys, so a block of `ro:` words with no argv on it reads as what +// `telltale council` gives you — which is the opposite of true. +func TestThePostureRowsNameTheArgvTheyBelongTo(t *testing.T) { + out := flat(render(Run( + []Seat{postured("claude", "ro:tools", "enforced by construction", true)}, + answering("2.1.226 (Claude Code)")))) + + if !strings.Contains(out, "telltale council --read") { + t.Errorf("the posture block does not say which room its badges belong to:\n%s", out) + } + if !strings.Contains(out, "opens by default WRITES") { + t.Errorf("the block never says the default room writes:\n%s", out) + } + // And it says outright that it measured none of this here, for the reason the + // capability line is labelled: a claim in a report full of measurements is + // read as a measurement unless it says otherwise. + if !strings.Contains(out, "Nothing below was probed here") { + t.Errorf("the posture block does not say it probed nothing:\n%s", out) + } +} + +// TestTheClosingDeclarationCountsTheSeatsThatCanBeAskedFirst. The count is a +// fact about canGate, and canGate has already moved once — the Cursor seat +// became a live process that can be asked and still does not ask about edits. A +// hand-written "only claude" sentence would have gone on being right by accident +// and wrong the next time. +func TestTheClosingDeclarationCountsTheSeatsThatCanBeAskedFirst(t *testing.T) { + some := []Seat{ + postured("claude", "ro:tools", "enforced by construction", true), + postured("codex", "ro:enforced", "enforced by an operating system", false), + postured("grok", "unsandboxed", "measured not to restrict", false), + } + out := flat(render(Run(some, answering("1.0.0")))) + if !strings.Contains(out, "1 of the 3 seats above can be asked to ask first: claude") { + t.Errorf("the declaration does not count and name the gating seat:\n%s", out) + } + + // The zero branch says so rather than going quiet: a block that simply + // stopped mentioning gating on a machine with no gating seat would leave the + // reader to assume the room asks first, which is the expensive direction. + none := []Seat{ + postured("codex", "ro:enforced", "enforced by an operating system", false), + postured("grok", "unsandboxed", "measured not to restrict", false), + } + out = flat(render(Run(none, answering("1.0.0")))) + if !strings.Contains(out, "none of the 2 seats above can be asked to ask first") { + t.Errorf("a room where nothing can gate does not say so:\n%s", out) + } + if strings.Contains(out, "carries `gated` there") { + t.Errorf("a room with no gating seat still promised a gate:\n%s", out) + } +} + +// TestTheBadgeIsPrintedAsCouncilWordedIt. The whole point of routing this +// through council.DoctorSeats is that the badge on a column and the badge in the +// preflight are one value read twice. A render that re-worded, shortened or +// title-cased it would put the two surfaces back into a position where they can +// disagree, which is the state this block was built to make impossible. +func TestTheBadgeIsPrintedAsCouncilWordedIt(t *testing.T) { + seats := []Seat{ + postured("claude", "ro:tools", "enforced by construction", true), + postured("codex", "ro:enforced", "enforced by an operating system", false), + postured("cursor", "ro:requested", "asked for, and never observed", false), + postured("grok", "unsandboxed", "measured not to restrict", false), + } + out := render(Run(seats, answering("1.0.0"))) + for _, badge := range []string{"ro:tools", "ro:enforced", "ro:requested", "unsandboxed"} { + if !strings.Contains(out, badge) { + t.Errorf("the badge %q is not in the report as council words it:\n%s", badge, out) + } + } + // `unsandboxed` breaks the `ro:` prefix on purpose (state.go). Nothing here + // may put it back. + if strings.Contains(out, "ro:none") || strings.Contains(out, "ro:unsandboxed") { + t.Errorf("the block gave an unsandboxed seat an ro: prefix:\n%s", out) + } +} + +// TestASeatWithNoPostureSaysSoRatherThanGoingQuiet. A seat missing from a +// posture table reads as a seat with nothing to declare. This one has an +// unanswered question instead, and the row has to carry the difference — the +// same distinction Skip carries inside the check block, in a place the three +// state words are not allowed to go. +func TestASeatWithNoPostureSaysSoRatherThanGoingQuiet(t *testing.T) { + seats := []Seat{ + postured("claude", "ro:tools", "enforced by construction", true), + seat("pi"), + } + out := render(Run(seats, answering("1.0.0"))) + row := "" + for _, line := range postureRows(t, out) { + if strings.HasPrefix(strings.TrimSpace(line), "pi ") { + row = line + } + } + if row == "" { + t.Fatalf("the seat with no posture was dropped from the block entirely:\n%s", out) + } + if !strings.Contains(row, "no claim") { + t.Errorf("the blank row does not say the claim is missing: %q", row) + } + if strings.Contains(flat(out), "not checked either") { + t.Errorf("the blank row borrowed a state word:\n%s", out) + } +} + +// TestAReportWithNoPostureRendersNoBlock. Silence here is right for pin.go's +// reason: a header over five `no claim` rows is a block about nothing, and a +// closing declaration explaining a table nobody was shown is the padding that +// stops reports being read to the bottom. +func TestAReportWithNoPostureRendersNoBlock(t *testing.T) { + out := render(Run([]Seat{seat("claude"), seat("codex")}, answering("1.0.0"))) + for _, unwanted := range []string{"posture — ", "opens by default WRITES"} { + if strings.Contains(out, unwanted) { + t.Errorf("a report with no posture data printed %q:\n%s", unwanted, out) + } + } +} + +// TestNoPostureLineRunsPastTheWrapColumn. The block is laid out on the check +// block's own columns, so a badge that overflowed its cell would push the +// evidence text past the wrap rather than truncating — invisible in a terminal, +// obvious in the file this gets piped to. +// +// Only the lines this block ADDED are held to the column, and they are found by +// diffing against the same seats rendered with their postures stripped. The +// alternative — asserting over the whole report — measures the wrap floor that +// was already there (`textWidth` bottoms out at 20, and the title is a fixed 70 +// columns), so it fails on correct output at a narrow width and says nothing +// about this change. +func TestNoPostureLineRunsPastTheWrapColumn(t *testing.T) { + seats := []Seat{ + postured("antigravity", "ro:requested", + "ASKED FOR, and never observed — the flag was accepted and what it enforces on "+ + "this machine is not established. Weaker than a construction or an OS "+ + "sandbox, and says so", false), + postured("claude", "ro:tools", "enforced by construction", true), + } + bare := make([]Seat, len(seats)) + for i, s := range seats { + s.Posture = Posture{} + bare[i] = s + } + with, without := Run(seats, answering("1.0.0")), Run(bare, answering("1.0.0")) + + for _, cols := range []int{80, 60} { + already := map[string]bool{} + for _, line := range strings.Split(Render(without, Options{Width: cols}), "\n") { + already[line] = true + } + for _, line := range strings.Split(Render(with, Options{Width: cols}), "\n") { + n := utf8.RuneCountInString(line) + if already[line] || n <= cols || len(strings.Fields(strings.TrimSpace(line))) <= 1 { + continue + } + t.Errorf("at --width %d a posture line ran to %d columns: %q", cols, n, line) + } + } +} diff --git a/internal/doctor/view.go b/internal/doctor/view.go index 5bd585f..7ecc8ae 100644 --- a/internal/doctor/view.go +++ b/internal/doctor/view.go @@ -38,6 +38,14 @@ const ( // "not checked"), plus a space. Fixed rather than computed from the data: // the columns must not move when a vendor is missing, or two runs of this // report stop being diffable against each other. + // + // The posture block shares them rather than measuring its own, and its two + // vocabularies fit: the longest vendor id is `antigravity` at 11 and the + // longest badge is `ro:requested` at 12. Sharing is the point — a reader has + // just scanned the check rows on this grid, and a second table on a grid of + // its own reads as a statement about a different set of things. `pad` never + // truncates, so a longer word in either vocabulary would push its row out + // rather than clip a claim (§9.11's ruling on what must not happen). nameCol = 12 statusCol = 13 indent = 2 @@ -83,6 +91,14 @@ func Render(r Report, o Options) string { } } + // The posture block, between the seats and the notes, and that position is + // the argument for it being a block at all. It is per-seat data, so it + // cannot live down among the notes; it is one vocabulary compared across + // five seats, so scattering a line of it under each seat would destroy the + // only reading that makes it useful — five badges in one column, where the + // odd one out is visible without holding four screens in your head. + writePostureBlock(&b, r, cols) + // The two standing unknowns, argued once. Printed even for an empty report: // they are properties of what this mode does, not of what it found. notes := []string{authNote, networkNote} @@ -127,6 +143,76 @@ func writeSeatLine(b *strings.Builder, text string, cols int) { } } +// writePostureBlock draws the POSTURE block: one row per seat, then the single +// declaration that is true of the room rather than of a seat (posture.go). +// +// It is laid out on the CHECK block's columns — same indent, same two fixed +// widths — and that is deliberate rather than lazy. A reader has just scanned +// five seats' worth of rows in that shape, and a second table on its own grid +// would read as a different kind of statement about a different set of things. +// What separates the two is the absence of a state word, which is the same +// signal the capability and survey lines use: no `ok`, no `FAILED`, no `not +// checked`, anywhere in here. +// +// It renders nothing at all when no seat carries a posture — a report built by a +// caller that never filled the field. Silence is right there for pin.go's +// reason: a header over five `no claim` rows invents a block about nothing. +func writePostureBlock(b *strings.Builder, r Report, cols int) { + stated := false + for _, s := range r.Seats { + if s.Posture.stated() { + stated = true + break + } + } + if !stated { + return + } + + textCol := indent + nameCol + statusCol + textWidth := max(cols-textCol, 20) + + b.WriteByte('\n') + for _, line := range wrap(postureHeader, cols) { + b.WriteString(line) + b.WriteByte('\n') + } + for _, s := range r.Seats { + badge, evidence := s.Posture.Badge, s.Posture.Evidence + if !s.Posture.stated() { + badge, evidence = postureNoClaimBadge, postureNoClaim + } + writeRow(b, strings.Repeat(" ", indent)+pad(s.Vendor, nameCol)+pad(badge, statusCol), + strings.Repeat(" ", textCol), evidence, textWidth) + } + b.WriteByte('\n') + for _, line := range wrap(postureDeclaration(r), cols) { + b.WriteString(line) + b.WriteByte('\n') + } +} + +// writeRow draws one hanging-indented row: prefix, then text wrapped at width +// with every continuation line under cont. A row whose text is empty still gets +// its line, for writeCheck's reason — a missing line silently drops a seat. +func writeRow(b *strings.Builder, prefix, cont, text string, width int) { + first := true + for _, line := range wrap(text, width) { + if first { + b.WriteString(prefix) + first = false + } else { + b.WriteString(cont) + } + b.WriteString(line) + b.WriteByte('\n') + } + if first { + b.WriteString(strings.TrimRight(prefix, " ")) + b.WriteByte('\n') + } +} + func writeCheck(b *strings.Builder, c Check, textCol, textWidth int) { prefix := strings.Repeat(" ", indent) + pad(c.Name, nameCol) + pad(c.Status.Word(), statusCol) cont := strings.Repeat(" ", textCol)