From 3adcb60d9e84460025bc5bd96497ff7b0c0a4f62 Mon Sep 17 00:00:00 2001 From: test Date: Sun, 23 Aug 2026 11:18:39 +0800 Subject: [PATCH 1/5] fix(delivery): state where commands run instead of a cd nobody can follow (closes #821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every command in SKILL.md was written relative to the skill directory and nothing established that directory. The documented install symlinks the skill into ~/.claude/skills, after which a Claude Code session starts in the user's own project and the relative `cd skills/fomo-kernel` names nothing — so the first command a new user runs fails. review.py resolves its own root from any working directory (verified from an unrelated directory against this tree), so the fix is a deletion plus one statement of where commands run, not a new mechanism. Also deletes the bare mid-session `pip install -r requirements.txt`: README.md already installs into a venv activated before Claude Code launches, and a mid-session bare pip can resolve to a different interpreter than the one the engine runs under. SKILL.md net +13 bytes, which is the entire remaining headroom under the 16384-byte always-loaded budget #507 set. Co-Authored-By: Claude Opus 5 --- skills/fomo-kernel/SKILL.md | 5 +++-- skills/fomo-kernel/mock/SAMPLES.md | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/skills/fomo-kernel/SKILL.md b/skills/fomo-kernel/SKILL.md index 509e70c1..88b9b571 100644 --- a/skills/fomo-kernel/SKILL.md +++ b/skills/fomo-kernel/SKILL.md @@ -11,8 +11,9 @@ Use relevant evidence and the recorded book when portfolio consequences matter; Use `consider` when the user supplies a trade premise and asks what it does to a recorded book. It is the deterministic portfolio-consequence path, never a prerequisite for research, discovery, or a non-portfolio recommendation. +Commands run from this skill's own directory. + ```bash -cd skills/fomo-kernel python3 engine/review.py consider --premise '{"ticker":"NVDA","side":"buy","qty":20}' --language ``` @@ -20,7 +21,7 @@ A premise needs a `ticker`, a `side`, and one of `qty` or `notional`. Everything Pass `--language` as the tag the user is writing in; an unsupported tag falls back to `en`. Keep conversing in their language and never hand-translate engine copy. -First run only: `pip install -r requirements.txt`, then `python3 engine/review.py doctor`. The engine fail-soft degrades without its optional dependencies — silently dropping current prices and market context — so verify once rather than mid-answer. +First run only: `python3 engine/review.py doctor`. The engine fail-soft degrades without its optional dependencies — silently dropping current prices and market context — so verify once rather than mid-answer. ## The response is the contract diff --git a/skills/fomo-kernel/mock/SAMPLES.md b/skills/fomo-kernel/mock/SAMPLES.md index 0fcf76bf..05bb8a7e 100644 --- a/skills/fomo-kernel/mock/SAMPLES.md +++ b/skills/fomo-kernel/mock/SAMPLES.md @@ -4,8 +4,9 @@ These synthetic long-only BUY/SELL CSV files exercise stable behavioral branches ## Run a fixture +Run these from this directory's parent — the skill root. + ```bash -cd skills/fomo-kernel TR_DRIVER_MAP=mock/sample_fundamental.driver_map.json python3 engine/trade_recap.py mock/sample_fundamental.csv TR_DRIVER_MAP=mock/sample_momentum.driver_map.json python3 engine/trade_recap.py mock/sample_momentum.csv TR_DRIVER_MAP=mock/sample_value.driver_map.json python3 engine/trade_recap.py mock/sample_value.csv From 45ecbb5b789a0406ebd9e35e7dbaeb85efbc1b79 Mon Sep 17 00:00:00 2001 From: test Date: Sun, 23 Aug 2026 11:19:09 +0800 Subject: [PATCH 2/5] fix(consider): exploration persists nothing without an explicit user selection (closes #829) The #827 acceptance A/B found both arms writing canonical evaluation rows for candidates the user never selected: the candidate arm reran its own top recommendation twice without --ephemeral after a ranking request where no selection ever happened, and the baseline arm persisted the rejected candidate too. Both traced to one word. "rerun only the user-selected or still-live candidate" reads as "my recommendation still stands", which turns exploration into durable state. The persist path now requires explicit user selection or commitment, and says that a standing recommendation and the user's silence are neither. Both surfaces carrying the sentence move together: SKILL.md's candidate comparison paragraph and docs/decision-fomo-kernel-shape.md's inquiry-layer bullet. The deterministic guard the issue also proposes (a --decision-context carrying user intent, failing a canonical write closed when no selection marker is present) is deliberately not in this commit: it is a new gate, and which user scene it changes is the owner's call. Co-Authored-By: Claude Opus 5 --- docs/decision-fomo-kernel-shape.md | 5 +++-- skills/fomo-kernel/SKILL.md | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/decision-fomo-kernel-shape.md b/docs/decision-fomo-kernel-shape.md index b3b0d474..5cf7b504 100644 --- a/docs/decision-fomo-kernel-shape.md +++ b/docs/decision-fomo-kernel-shape.md @@ -240,8 +240,9 @@ Split the current non-goal in two: recommendation may rest on sourced public facts and labelled agent judgment with or without a book. Explicit discovery reports its universe, filters, as-of date, material exclusions, and never claims exhaustive coverage. When a - portfolio consequence matters, each candidate is evaluated ephemerally and - only the selected or still-live candidate is rerun persistently. + portfolio consequence matters, each candidate is evaluated ephemerally, and a + persistent rerun requires the user's explicit selection — a standing + recommendation is not one. - **Market forecasts — judgment, never engine fact.** "This position takes your semiconductor exposure to 48%" is anchored in the user's record and checkable now. "NVDA reaches $250" is a forecast and must carry assumptions, diff --git a/skills/fomo-kernel/SKILL.md b/skills/fomo-kernel/SKILL.md index 88b9b571..d7e1adb2 100644 --- a/skills/fomo-kernel/SKILL.md +++ b/skills/fomo-kernel/SKILL.md @@ -51,9 +51,9 @@ Label judgment — thesis, valuation, timing, forecast, recommendation, ranking, filters, as-of point, material exclusions, and coverage limits; never imply exhaustive coverage. Stop by marginal decision value, cost, and latency. When book consequence matters, run each candidate with `consider --ephemeral`, rank -from those results plus sourced research, then rerun only the user-selected or -still-live candidate without the flag. Rejected candidates leave no canonical -evaluation row. +from those results plus sourced research. A persistent rerun without the flag +needs the user's explicit selection; a standing recommendation is not one. +Exploration leaves no canonical evaluation row. ## What the response may ask you for From 085561853f73b7a71c842663cbb5a6b676895ef0 Mon Sep 17 00:00:00 2001 From: test Date: Sun, 23 Aug 2026 11:40:37 +0800 Subject: [PATCH 3/5] feat(priors): make the research baseline reachable from the routes that have a book (closes #716) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #716's title is "Cross-route evidence-backed decision priors" and the shipped implementation (#727) wired exactly one route. `references/research-priors.md` was linked by a single file — `decision-framing.md`, the no-recorded-book route — so the product answered a user worse the more evidence they handed over: with nothing recorded they heard that broad diversification is the baseline and that an index label alone does not establish breadth, and after supplying a book they got weight and concentration arithmetic and could no longer reach that baseline at all. The catalogue is now the baseline on every route that answers a decision. `trade-consequence.md` (the `consider` route) and `freeform-answers.md` each link it and say when a prior belongs in the answer; `decision-framing.md` keeps the baseline as its top sentence and stops implying the catalogue is its own. No prior is added, widened, or restated, and the per-prior structure — applicable decision class, bounded directional claim, material exceptions, forbidden overclaims, reviewed date, primary sources — is untouched. Widening the reach is only safe with #716 section 4 written down, so the catalogue gains "An engine fact dominates a prior": a prior may interpret a deterministic result, never replace, substitute for, or fill a gap in one, and it may not invent a cap, an allocation, or a threshold the user has no rule for and the engine did not compute. The two book-bearing routes carry that sentence verbatim rather than paraphrasing it — #834's answer applied to a rule instead of an exemplar — and the drift is mechanical, not forbidden. This is a routing and boundary change. No engine file, schema, JSON payload contract, runtime field, or engine vocabulary moved, and `AGENTS.md` and `SKILL.md` are byte-identical: the always-loaded pair already routes to all three route files, which is what #507's budget exists to force. `tests/test_research_priors.py` replaces its single-route loading assertion with the whole walk (`SKILL.md` -> route file -> catalogue) plus the boundary drift check, and keeps the field-completeness and question-cap regressions. Its mutation proof covers dropping a route's link, unrouting a route file from `SKILL.md`, softening any copy of the boundary sentence, and removing the section the routes point at. Co-Authored-By: Claude Opus 5 --- docs/expression-contract.md | 18 ++- docs/maintainer-guide.md | 1 + .../references/decision-framing.md | 7 +- .../references/freeform-answers.md | 24 +++ .../fomo-kernel/references/research-priors.md | 76 ++++++++- .../references/trade-consequence.md | 28 ++++ tests/run_all.py | 2 +- tests/test_research_priors.py | 145 +++++++++++++++++- 8 files changed, 282 insertions(+), 19 deletions(-) diff --git a/docs/expression-contract.md b/docs/expression-contract.md index 8e01b430..d9fc1fea 100644 --- a/docs/expression-contract.md +++ b/docs/expression-contract.md @@ -415,12 +415,24 @@ and references this file. None of them may restate, narrow, or contradict the pyramid or V/D/C. "Derivation" is what that surface **adds** to §3; an empty derivation is valid and is the default. +**A shared claim authority is not a derivation.** Three of the conversational +surfaces read `references/research-priors.md` for the same research baseline +(#716), and it adds a shape parameter to none of them: it says what a block may +be *backed by*, never which floor a block sits on or that a block must exist. +The derivation column below is unchanged by that ruling; the file appears in the +last column, where a surface's other keeps live. Its own "An engine fact +dominates a prior" section is the single statement of how far a prior may travel +beside a computed number; the two book-bearing routes carry that sentence +**verbatim** rather than a paraphrase of it, and `tests/test_research_priors.py` +fails on drift — the same mechanism #834 used to put one exemplar on each +surface's generation path. + | Surface | Layout authority | Derivation it adds to §3 | Everything else it keeps | |---|---|---|---| | Review card | [output-contract.md](output-contract.md) | The **document incarnation**: keynote + four fixed blocks, in that order, on every committed card. | Module prerequisites, which block the footnote ends. | -| `consider` | `references/trade-consequence.md` | Lead-selection salience order; the answer slots the middle floor may hold; `rule_effects` is never traded away. | What the payload means, the obligation floor. | -| Freeform answers | `references/freeform-answers.md` | None on shape — text-first is a latency default, not a shape. | Proportionate production, reusable engine-backed views. | -| No recorded book | `references/decision-framing.md` | The top sentence is a research-backed baseline when no book exists; the strategy-class map is a middle-floor block set. | Claim boundaries, question heuristics, the invitation set. | +| `consider` | `references/trade-consequence.md` | Lead-selection salience order; the answer slots the middle floor may hold; `rule_effects` is never traded away. | What the payload means, the obligation floor, the research baseline a computed number may be interpreted with. | +| Freeform answers | `references/freeform-answers.md` | None on shape — text-first is a latency default, not a shape. | Proportionate production, reusable engine-backed views, the same research baseline when the question is a decision. | +| No recorded book | `references/decision-framing.md` | The top sentence is a research-backed baseline when no book exists; the strategy-class map is a middle-floor block set. | Claim boundaries, question heuristics, the invitation set; the baseline catalogue it shares with the two routes above. | | Weekly market read | `references/weekly-market-read.md` | Its one optional question comes after the complete brief, never before it. | What the prototype reads, and what it may not invoke. | ## 7. Enforcement diff --git a/docs/maintainer-guide.md b/docs/maintainer-guide.md index efc3cdb0..56fc9d74 100644 --- a/docs/maintainer-guide.md +++ b/docs/maintainer-guide.md @@ -200,6 +200,7 @@ reader-question-chain) while leaving those rows' every other ruling intact. | Positive decision contract and ephemeral comparison (#825, 2026-08-19) | `AGENTS.md` boundary 5 ↔ `skills/fomo-kernel/SKILL.md` answer shape / candidate comparison ↔ `review.py` `consider --ephemeral` ↔ `answer_provenance.agent_case_claims` / `validate_agent_case` ↔ `schemas/answer-provenance.schema.json` / `schemas/evaluation-challenge.schema.json` ↔ `evaluation_challenge.CASE_REQUIRED` ↔ `references/trade-consequence.md` / `decision-framing.md` / `market-lookup.md` ↔ `evals/judge_trade_answers.py` ↔ `tests/test_answer_provenance.py` / `test_consider.py` / `test_trade_answer_judge.py`. New cases use one `recommendation` labelled `agent_judgment`, non-empty `support`, and optional `counter_case`; the legacy `for`/`against` shape remains readable for append-only history. `--ephemeral` is allowed only against the existing recorded book, computes the identical content-addressed evaluation, reports `append.status=ephemeral`, and never touches `trade_evaluations.jsonl`; rerunning the selected candidate without the flag is the canonical write. Research, counter-cases, unchecked disclosures, questions, and resolution invitations are relevance-driven rather than standing quotas. Numeric authority, provenance coverage, private/local state, execution truth, and canonical persistent writes are unchanged. | | Permission boundaries and useful-answer freedom (#827, 2026-08-19) | Supersedes the answer-shape portions of #543, #597, #629, #674, and #697 while preserving their integrity work. Reasoning, research, explicit candidate discovery, comparison, recommendation, and materially useful tools or visuals are allowed with or without a recorded book. A book gates only book-derived claims: weights, concentration, cash effects, rule collisions, and state transitions remain engine-owned. Questions, source count, lookup attempts, option count, sentence shape, and presentation form have no universal numeric ceiling; use decision value, material coverage, marginal value, cost, and latency as stopping criteria. `usable_facts_grounding` owns the frozen numeric allow-set; `single_candidate_integrity` owns supplied context, process leakage, and false action claims rather than banning all digits. Exploration stays `consider --ephemeral`; only the user-selected live candidate is rerun persistently, so rejected exploration adds zero canonical evaluation rows. Historical rows above describe why their slices shipped, but their effort ceilings, answer templates, and recommendation bans are not current authority after this row. | | Deletion-first answer obligations (#830, 2026-08-20) | Supersedes the answer-shape portions of the whitelist era — #479 Wave B cut 2's single `must_state` list, #823's placement-only D-series, and #825's "relevance is governed, placement and form are free" — while leaving every integrity gate those rows built exactly where it was. `engine/evaluation_challenge.py` emits three lists instead of one: `must_state` (the floor), `may_state` (the concentration family and cash — computed, addressed, owed by default on no call), and `machine_state` (`basis.state_version`, never rendered in any register). Its readers: `schemas/evaluation-challenge.schema.json` (additive; new keys are required because the block is computed fresh per call and never stored, and the `must_state` topic enum narrows to match `TOPICS` because `tests/test_evaluation_challenge.py` holds those two as an ordered equality) ↔ `references/trade-consequence.md` "What the answer owes" (which now states a reason per keep and per delete, and defines *stated*: the fact appears with its correct anchor — inline number, table cell, or end-block line all qualify, so fifteen obligations are not fifteen sentences) ↔ `SKILL.md`'s answer shape ↔ `references/freeform-answers.md` rule 4 ↔ `docs/expression-contract.md` D7 ↔ `tools/ux_receipt.py` ↔ `tests/test_evaluation_challenge.py` / `test_consider.py` / `test_expression_contract.py` / `test_interaction_trajectory.py` / `tests/agent/check_expression.py` / `evals/trade_answers/`. **The deletion is of obligations, never of data**: every number is still computed, still anchorable, still citable, and the user can ask for any of it — which is what makes not saying a number this decision does not turn on different from hiding it. **The two silences that could hurt stayed machine-enforced**: `rule_effects` and `required_coverage` are untouched, so a rule the *user* wrote is still named and still refused-for-dropping while the engine's own default threshold became a may-state. **Volume distribution became expression's business** (D7: a fact lives on exactly one floor — opening body, parenthetical beside its number, one end-block line, or not rendered), which §2 had disclaimed and nothing else had claimed; that gap is why fifteen owed facts became fifteen body sentences with every D rule satisfied. The reading-budget rule proposed as V10 in the issue is **not** adopted: a length cap is what #827 had just deleted. The named risk is the other direction — a deciding consequence omitted — and its backstops are the two hard mandates above plus the owner-live `comprehension` verdict, never a checker. | +| Cross-route research baseline (#716, 2026-08-23) | `skills/fomo-kernel/references/research-priors.md` is the single catalogue and the single statement of how far a prior may travel. Its readers: `references/decision-framing.md` (the route that already had it — the baseline is its top sentence) ↔ `references/trade-consequence.md` ("The research baseline, and why it may not fill a gap") ↔ `references/freeform-answers.md` ("The research baseline is available here too") ↔ `docs/expression-contract.md` §6 ↔ `tests/test_research_priors.py`. Four rules. **The catalogue is reachable from every route that answers a decision, not only from the one with no book.** Before this row it was linked by exactly one file, and the user-visible consequence was inverted evidence value: a user with nothing recorded heard that broad diversification is the baseline and that an index label alone does not establish breadth, and the same user, after handing over a book, got weights and concentration and could no longer reach that baseline at all. `test_b_the_catalogue_is_reachable_from_every_book_bearing_route` is what fails when a route's link is dropped, and it walks `SKILL.md → route file → catalogue` rather than asserting the catalogue exists, because a reference nothing names is text nothing loads (the `profile.md` precedent). **An engine fact dominates a prior, and that boundary has one home.** `research-priors.md`'s own "An engine fact dominates a prior" section owns it: a prior may interpret a deterministic result, never replace, substitute for, or fill a gap in one, and it may not invent a cap, an allocation, or a threshold the user has no rule for and the engine did not compute. The two book-bearing routes carry that sentence **verbatim, not paraphrased**, and name the section it comes from: a paraphrase is how five independent phrasings of answer-first happened (#832), and a pointer alone is a boundary the agent has to open a second file to learn. This is #834's answer applied to a rule instead of an exemplar — the same text in three places, made mechanical rather than forbidden, with `test_e_the_engine_fact_boundary_is_one_sentence_in_three_places` failing on any drift and `test_f_reachability_and_boundary_mutations_are_caught` proving it fails. `decision-framing.md`'s "A missing number is never replaced by a general rule" red line stays where it is and gains a pointer: it is the *no-book* form (no computed weight exists at all), and the new section is the form for the routes that do have one. **It is a claim authority, not a derivation.** It adds no shape parameter to any surface, so `expression-contract.md` §6's derivation column is deliberately **unchanged** and the catalogue appears in the last column instead; `freeform-answers.md`'s empty derivation (#832) stays empty and says so in the new section itself. A prior enters an existing block, is subject to §3's increment gate like any other, and a standing paragraph on the value of diversification is the `default_as_insight` ban with a citation stapled to it. **No runtime surface moved**: no engine file, schema, `.json` payload contract, new field, or new engine vocabulary — #716 §1 says the first version needs no runtime schema, and `SKILL.md` is untouched because it already routes to all three route files, which is the whole point of #507's byte budget on the always-loaded pair. Why the row exists: #716's title is "Cross-route evidence-backed decision priors" and the shipped implementation (#727) wired exactly one route, so the issue's own §4 — the boundary that makes the other routes safe — had never been written down anywhere. | Date product assumptions when using them for prioritization. Reconfirm assumptions that are several weeks old or contradicted by new evidence. diff --git a/skills/fomo-kernel/references/decision-framing.md b/skills/fomo-kernel/references/decision-framing.md index 28a2ba6a..e1efd2b9 100644 --- a/skills/fomo-kernel/references/decision-framing.md +++ b/skills/fomo-kernel/references/decision-framing.md @@ -89,6 +89,11 @@ applicable decision class fits the user's own description; state any material exception beside that baseline. A prior may support a strategy recommendation, but never invents a fund choice, allocation, suitability finding, or forecast. +That catalogue is not this route's private property (#716). The book-bearing +routes read the same priors, and what is special here is only *where* the +baseline lands: with no computed consequence to lead with, it is the top +sentence rather than a middle-floor block. + For genuinely long-horizon risk capital with no supplied concentration edge, the available baseline is broad diversification and lower discretionary turnover. State the important limitation with it: a long label does not make @@ -232,7 +237,7 @@ Not "provide your portfolio for a more accurate analysis". rule collisions were not computed. State a target or forecast only when it is decision-relevant, with assumptions and uncertainty; an analyst target never becomes an engine fact or certainty merely because it was found. -- **A missing number is never replaced by a general rule.** A single-position cap is a fact measured against a computed weight and overridable by the user's own `set-cap`. Stated as this user's remaining capacity with no book, the identical sentence becomes fortune telling — the user may already be far past it, and nothing here knows that. A staged-entry, size, or leverage heuristic may still be recommended as labelled judgment; it must not impersonate a computed fact about this book. +- **A missing number is never replaced by a general rule.** A single-position cap is a fact measured against a computed weight and overridable by the user's own `set-cap`. Stated as this user's remaining capacity with no book, the identical sentence becomes fortune telling — the user may already be far past it, and nothing here knows that. A staged-entry, size, or leverage heuristic may still be recommended as labelled judgment; it must not impersonate a computed fact about this book. The cross-route form of the same rule — an engine fact dominates a prior — is stated once in [research-priors.md](research-priors.md) and governs the routes that *do* have a computed weight to point at. - **"So should I buy it?"** gets the best bounded answer available: recommend, delay, or decline based on the stated premise and evidence, then name the portfolio fact most likely to reverse that judgment. Do not hide behind “the decision is yours,” and do not manufacture portfolio precision. - **Brevity is not a licence to drop a fact.** Text-first is a default, never a limit on which claims the answer owes. diff --git a/skills/fomo-kernel/references/freeform-answers.md b/skills/fomo-kernel/references/freeform-answers.md index 0e4497ee..a1d0b2bf 100644 --- a/skills/fomo-kernel/references/freeform-answers.md +++ b/skills/fomo-kernel/references/freeform-answers.md @@ -246,3 +246,27 @@ something. Every *other* ad hoc question in this file's opening paragraph — obligation list of its own, and #823 did not build one. Those answers inherit the placement rules above and select their own disclosures from what the engine response they read actually carried. + +## The research baseline is available here too + +Owner ruling ([#716](https://github.com/atomchung/fomo-kernel/issues/716)). A +freeform question is often a decision wearing a lookup's clothes — whether to +keep holding, whether to add, what to do with cash sitting in the account. When +it is, the baseline in [research-priors.md](research-priors.md) is available on +this route exactly as it is on the one with no recorded book. Having the book is +not a reason to lose it: an answer that reads the engine's computed weights and +drops the baseline the same user would have been given with no book at all is +the defect that ruling names, where more evidence bought a narrower answer. + +The boundary lives once, in that file's own **"An engine fact dominates a +prior"**, and this route quotes it rather than paraphrasing it: **A prior may +interpret a deterministic result. It may never replace one, substitute for one, +or fill a gap in one.** It therefore supplies no cap, no allocation, and no +threshold the user has no rule for and the engine did not compute. + +**This adds no shape parameter, and Rule 4's empty derivation stands.** A claim +authority says what a block may be backed by; it does not say which floor a +block sits on or create one. A prior that does not change the recommendation is +not said, the same as any other block that fails the increment gate — and a +lookup that really was a lookup stays the quick, direct answer Rule 1 defaults +to. diff --git a/skills/fomo-kernel/references/research-priors.md b/skills/fomo-kernel/references/research-priors.md index 65ae6e97..5fd80dd5 100644 --- a/skills/fomo-kernel/references/research-priors.md +++ b/skills/fomo-kernel/references/research-priors.md @@ -1,14 +1,76 @@ -# Research priors for bounded no-book framing +# Research priors — the decision baseline, on every route -This is the small, host-side research authority used by the no-recorded-book -route. It is not portfolio arithmetic, a suitability assessment, or a product -selector. It supplies only a scoped baseline that remains available before the -user has a book; the route still does not choose a fund, geography, allocation, -trade, or final action. +This is the small, host-side research authority behind a normative baseline +this product may state directly. It is not portfolio arithmetic, a suitability +assessment, or a product selector: no route reading it chooses a fund, +geography, allocation, trade, or final action. Recheck the primary sources before widening a claim or applying it to a new decision class. These priors are stable research syntheses, not runtime web -lookups, engine facts, or user rules. +lookups, engine facts, or user rules. Cite one the way any looked-up fact is +cited — the claim and where it comes from — and never as something computed +from the user's own record. + +## One catalogue, every route + +The catalogue was reachable only from the route that has no book, which made +the product answer a user *worse* the more evidence they handed over: with +nothing recorded they heard that broad diversification is the baseline and that +an index label alone does not establish breadth, and once they supplied a book +they got weights and concentration and could no longer reach that baseline at +all. Owner ruling +([#716](https://github.com/atomchung/fomo-kernel/issues/716)): the same +catalogue is the baseline on every route that answers a decision. + +| Route | Where the baseline enters | +|---|---| +| No recorded book ([decision-framing.md](decision-framing.md)) | The answer's top sentence, ahead of the strategy-class map. | +| `consider` ([trade-consequence.md](trade-consequence.md)) | A middle-floor block, interpreting a fact the engine computed. | +| Freeform answers ([freeform-answers.md](freeform-answers.md)) | The same, whenever the question asked is a decision rather than a lookup. | + +Only the *entry point* differs. The claims, their applicable decision classes, +their material exceptions and their forbidden overclaims are identical on all +three, and a route may not hold a narrower or a wider version of a prior than +this file states. + +A prior is a claim authority, not a block, a question, or a standing paragraph: +it adds no shape parameter to any surface, so the increment gate in +[expression-contract.md](../../../docs/expression-contract.md) §3 governs it +like every other block. One that changes nothing about what the user should do +is deleted, not shortened. + +## An engine fact dominates a prior + +**A prior may interpret a deterministic result. It may never replace one, +substitute for one, or fill a gap in one.** Where the engine computed a number, +that number is the fact and the prior is at most the reading of it. Where the +engine computed nothing, the prior does not get to supply the missing number in +words. + +Three consequences, in the order they get broken: + +1. **A prior invents no threshold.** It may not name a position cap, an + allocation, a concentration limit, or an exposure ceiling that this user has + no rule for and the engine did not compute. A cap is a fact measured against + a computed weight and overridable by the user's own `set-cap`; offered as a + general rule it is fortune telling about a book nobody read. +2. **A prior interprets, then points.** It may say broad diversification is the + baseline and then point at the concentration the engine computed for this + book. It may not run that inference backwards and describe the book from the + baseline. +3. **A prior judges no particular act without the record.** It may say low + discretionary turnover is generally supported for a standing long-horizon + policy; it may not call this user's sale excessive without the transaction + record or a rule they stated themselves. + +The dominance is one-directional. An engine fact, a user rule, or a stated +personal fact narrows or disqualifies a prior's **applicability**; a prior never +edits, softens, excuses, or outranks an engine fact. When the two look like they +disagree, the engine fact is the answer and the prior was inapplicable — that is +the whole of the conflict rule. The two book-bearing routes quote the bold +sentence above **verbatim** rather than wording it themselves, and +`tests/test_research_priors.py` fails if the copies drift apart; no surface +writes a version of its own. ## RP-001 — broad diversification baseline diff --git a/skills/fomo-kernel/references/trade-consequence.md b/skills/fomo-kernel/references/trade-consequence.md index 73f18963..610be79d 100644 --- a/skills/fomo-kernel/references/trade-consequence.md +++ b/skills/fomo-kernel/references/trade-consequence.md @@ -480,6 +480,34 @@ Every claim you add carries its own label: state your record says (drawn straigh `consider` measures weight, concentration, driver overlap, cash, and rule collisions. Liquidity, valuation, tax consequences, and position fit are available unchecked dimensions, not mandatory boilerplate. Name the ones that bear on the recommendation or prevent a false impression of coverage. +### The research baseline, and why it may not fill a gap + +A recorded book does not remove the research baseline; it outranks it. The +catalogue in [research-priors.md](research-priors.md) is the same on this route +as on the one with no book (#716), and a prior belongs in the answer when it +changes what the user should do about the number the engine just computed — a +proposed trade that lifts one driver further, a sale that reads as reactive +against a standing long-horizon policy, a staging plan described as a return +improvement. Losing it here is the defect that ruling names: the more evidence +the user hands over, the narrower the answer gets. + +The boundary that makes this safe lives once, in that file's own **"An engine +fact dominates a prior"**, and this route quotes it rather than paraphrasing it: +**A prior may interpret a deterministic result. It may never replace one, +substitute for one, or fill a gap in one.** So it invents no cap, no allocation, +and no threshold this user has no rule for and the engine did not compute; it +may point at the concentration `consequence` carries, and may not describe the +book from the baseline instead. Read that section for the rest of it — +`tests/test_research_priors.py` fails if these copies drift apart. + +It adds no answer slot and no lead. A prior enters the opening body or the +falsifier like any other support, is labelled as the cited synthesis it is +rather than as something the engine computed, and is deleted when the +recommendation does not turn on it — a standing paragraph on the value of +diversification is exactly the `default_as_insight` ban wearing a citation. + +### Structuring the case with `--agent-case` + You may optionally structure this case with `--agent-case`, a path to a JSON file, checked by `engine/answer_provenance.py::validate_agent_case` (#414) before anything is stored or returned: ```json diff --git a/tests/run_all.py b/tests/run_all.py index 3a964c9e..9db75f02 100644 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -167,7 +167,7 @@ # green suites over it on every run. ("Repository hygiene (#575)", "tests/test_repo_hygiene.py", "product"), ("Documentation and agent workflow boundaries", "tests/test_doc_language.py", "product"), - ("Research-aware no-book framing", "tests/test_research_priors.py", "product"), + ("Cross-route research baseline (#716)", "tests/test_research_priors.py", "product"), ("Copy ratchet (#368 Phase 1)", "tests/test_copy_ratchet.py", "product"), # #402 knife 5: the copy branches no persona reaches, rendered on all three # delivery surfaces and compared against a generated golden. Unlike the diff --git a/tests/test_research_priors.py b/tests/test_research_priors.py index 47c2ed90..4d5ba70c 100644 --- a/tests/test_research_priors.py +++ b/tests/test_research_priors.py @@ -1,13 +1,48 @@ #!/usr/bin/env python3 -"""Deterministic contract witnesses for research-aware no-book framing.""" +"""Deterministic contract witnesses for the cross-route research baseline.""" from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SKILL = ROOT / "skills" / "fomo-kernel" / "SKILL.md" -FRAMING = ROOT / "skills" / "fomo-kernel" / "references" / "decision-framing.md" -PRIORS = ROOT / "skills" / "fomo-kernel" / "references" / "research-priors.md" +REFERENCES = ROOT / "skills" / "fomo-kernel" / "references" +FRAMING = REFERENCES / "decision-framing.md" +CONSEQUENCE = REFERENCES / "trade-consequence.md" +FREEFORM = REFERENCES / "freeform-answers.md" +PRIORS = REFERENCES / "research-priors.md" + +# #716. Every route that answers a decision, and the reference file `SKILL.md` +# routes that route through. The catalogue used to be linked from exactly one of +# them -- the one with *no* book -- so the product answered a user worse the +# more evidence they handed over: the baseline was reachable until they supplied +# a book, and then it was not. Reachability is checked as the whole walk +# (`SKILL.md` -> route file -> catalogue) rather than as "the catalogue exists", +# because a reference no runtime surface names is text nothing loads. +ROUTE_FILES = { + "no recorded book": FRAMING, + "consider": CONSEQUENCE, + "freeform answers": FREEFORM, +} +BOOK_BEARING = ("consider", "freeform answers") +CATALOGUE_LINK = "[research-priors.md](research-priors.md)" + +# #716 section 4, the boundary that makes the book-bearing routes safe. It is +# one sentence, and the three files carry it byte-identically rather than each +# wording it locally: five independent phrasings of one rule is the drift #832 +# spent a whole chapter deleting, and a bare pointer would make the agent open a +# second file before it learned the limit. The same answer #834 gave for +# exemplars -- one text in several places, made mechanical instead of forbidden. +BOUNDARY_HEADING = "An engine fact dominates a prior" +BOUNDARY_SENTENCE = ( + "A prior may interpret a deterministic result. It may never replace one, " + "substitute for one, or fill a gap in one." +) + + +def _collapsed(text): + """Whitespace-normalized, so a hard-wrapped copy still compares equal.""" + return " ".join(text.split()) def _section(text, heading): @@ -38,6 +73,35 @@ def _answer_default_is_valid(section): ) +def _reachability_failures(skill_text, route_texts): + """Reasons the catalogue is not reachable from every decision route.""" + problems = [] + for surface, path in ROUTE_FILES.items(): + routed = f"references/{path.name}" + if routed not in skill_text: + problems.append(f"SKILL.md does not route the {surface} route to {routed}") + if CATALOGUE_LINK not in route_texts[surface]: + problems.append(f"{path.name} ({surface}) does not link the research catalogue") + return problems + + +def _boundary_failures(texts): + """Reasons the engine-fact-dominates boundary is not one sentence, everywhere.""" + problems = [] + catalogue = texts[PRIORS.name] + if f"## {BOUNDARY_HEADING}" not in catalogue: + problems.append(f"{PRIORS.name} has no '{BOUNDARY_HEADING}' section") + for name, text in texts.items(): + if BOUNDARY_SENTENCE not in _collapsed(text): + problems.append(f"{name} does not carry the boundary sentence verbatim") + for surface in BOOK_BEARING: + path = ROUTE_FILES[surface] + if BOUNDARY_HEADING not in _collapsed(texts[path.name]): + problems.append( + f"{path.name} ({surface}) does not name the section the boundary comes from") + return problems + + def test_a_the_catalogue_has_only_the_audited_priors_and_the_required_fields(): text = PRIORS.read_text(encoding="utf-8") ids = ("RP-001", "RP-002", "RP-003", "RP-004") @@ -56,10 +120,17 @@ def test_a_the_catalogue_has_only_the_audited_priors_and_the_required_fields(): assert field in section, f"{prior} is missing {field}" -def test_b_the_guaranteed_no_book_loading_path_reaches_the_catalogue(): - assert "references/decision-framing.md" in SKILL.read_text(encoding="utf-8") - assert "[research-priors.md](research-priors.md)" in FRAMING.read_text(encoding="utf-8") - assert PRIORS.is_file() +def test_b_the_catalogue_is_reachable_from_every_book_bearing_route(): + """#716's defect, stated as the thing that must now be true. The shipped + implementation (#727) wired the no-book route only, and the issue's own + title is *cross-route*: a user who hands over a book must not lose the + baseline a user with no book was given.""" + assert PRIORS.is_file(), "the research catalogue is missing" + skill_text = SKILL.read_text(encoding="utf-8") + route_texts = {surface: path.read_text(encoding="utf-8") + for surface, path in ROUTE_FILES.items()} + problems = _reachability_failures(skill_text, route_texts) + assert not problems, "; ".join(problems) def test_c_visible_value_precedes_intake_without_a_question_cap(): @@ -82,6 +153,66 @@ def test_d_numeric_question_cap_regression_reddens(): assert not _answer_default_is_valid(mutated) +def test_e_the_engine_fact_boundary_is_one_sentence_in_three_places(): + """#716 section 4. Widening the catalogue's reach is only safe while a prior + cannot outrank the arithmetic: it may read a computed number, never stand in + for one, and never supply a cap, allocation or threshold the engine did not + compute and the user has no rule for.""" + texts = {path.name: path.read_text(encoding="utf-8") + for path in (PRIORS, CONSEQUENCE, FREEFORM)} + problems = _boundary_failures(texts) + assert not problems, "; ".join(problems) + + +def test_f_reachability_and_boundary_mutations_are_caught(): + """Mutation proof for both checks above, against the real committed text. + A checker that stays green under its own mutation is not evidence + (`docs/maintainer-guide.md`, development discipline).""" + skill_text = SKILL.read_text(encoding="utf-8") + route_texts = {surface: path.read_text(encoding="utf-8") + for surface, path in ROUTE_FILES.items()} + assert not _reachability_failures(skill_text, route_texts), ( + "fixture assumption broken: the committed tree is already unreachable") + + # 1. A route file that stops linking the catalogue -- the exact shape of the + # defect #716 records, one route at a time. + for surface, path in ROUTE_FILES.items(): + mutated = dict(route_texts) + mutated[surface] = route_texts[surface].replace(CATALOGUE_LINK, "the baseline") + assert _reachability_failures(skill_text, mutated), ( + f"dropping the catalogue link from {path.name} left the check green") + + # 2. `SKILL.md` that stops routing a route file at all: the catalogue would + # still be linked, from a document nothing loads. + for surface, path in ROUTE_FILES.items(): + mutated_skill = skill_text.replace(f"references/{path.name}", "references/gone.md") + assert _reachability_failures(mutated_skill, route_texts), ( + f"unrouting {path.name} from SKILL.md left the check green") + + boundary_texts = {path.name: path.read_text(encoding="utf-8") + for path in (PRIORS, CONSEQUENCE, FREEFORM)} + assert not _boundary_failures(boundary_texts), ( + "fixture assumption broken: the committed boundary is already incomplete") + + # 3. Any single copy of the boundary sentence rewritten -- the drift a + # paraphrase would have introduced silently. + softened = BOUNDARY_SENTENCE.replace("never replace one,", "usually not replace one,") + for name in boundary_texts: + mutated = dict(boundary_texts) + collapsed = _collapsed(mutated[name]) + assert BOUNDARY_SENTENCE in collapsed + mutated[name] = collapsed.replace(BOUNDARY_SENTENCE, softened, 1) + assert _boundary_failures(mutated), ( + f"softening the boundary sentence in {name} left the check green") + + # 4. The catalogue losing the section the two routes point at. + mutated = dict(boundary_texts) + mutated[PRIORS.name] = boundary_texts[PRIORS.name].replace( + f"## {BOUNDARY_HEADING}", "## Applying a prior", 1) + assert _boundary_failures(mutated), ( + "removing the boundary section heading left the check green") + + if __name__ == "__main__": tests = [value for name, value in sorted(globals().items()) if name.startswith("test_")] for test in tests: From 814955ca0eb80fec3e7b91b9877ffe98bff2d151 Mon Sep 17 00:00:00 2001 From: test Date: Sun, 23 Aug 2026 12:32:11 +0800 Subject: [PATCH 4/5] fix(delivery): the six non-negotiable boundaries ride inside the installed skill tree (closes #838) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README.md installs this product by symlinking skills/fomo-kernel/ alone, so AGENTS.md — and every rule stated only there — never reached an installed host. The privacy boundary had no copy that host could read at all, and the only sentence citing it pointed at a file the reader does not have. - SKILL.md now states all six boundaries in the section that exercises each; privacy gets its own "Private data stays local" section with key sentences verbatim from the floor. references/agent-boundaries.md gains the general third-party/cloud privacy sentence and the canonical-writer rule's second half (a named later reader must exist). - references/freeform-answers.md states the card-privacy default instead of citing "AGENTS.md invariant 4" (twice). - #507's single 16 KiB budget over the AGENTS.md+SKILL.md pair measured a pair that exists only in a checkout, and sat at exactly 16384 bytes. Split per the #838 owner ruling: SKILL.md 12 KiB (the installed host's entire always-loaded surface) and AGENTS.md 8 KiB (the checkout floor), each documented with its scenario. - New tests/test_installed_skill_tree.py reads only skills/fomo-kernel/ and fails when a boundary's statement retreats to the repository root, when SKILL.md stops naming the routed boundary file (reachability, the profile.md precedent), or when a subtree .md/.json cites AGENTS.md. Adversarially probed: pointer deletion and an exception-clause rewrite of the privacy sentence both redden; full-sentence pins, per-phrase mutation arms, a subdirectory injection arm, and a registration self-check. Out-of-scope findings from the same review moved to #839 (docs/ links that escape the installed tree; engine comments citing AGENTS.md, one mis-cited). Co-Authored-By: Claude Fable 5 --- docs/development-guide.md | 9 +- docs/maintainer-guide.md | 1 + skills/fomo-kernel/SKILL.md | 12 +- .../references/agent-boundaries.md | 4 +- .../references/freeform-answers.md | 11 +- tests/run_all.py | 7 + tests/test_doc_language.py | 164 +++++--- tests/test_installed_skill_tree.py | 379 ++++++++++++++++++ 8 files changed, 525 insertions(+), 62 deletions(-) create mode 100644 tests/test_installed_skill_tree.py diff --git a/docs/development-guide.md b/docs/development-guide.md index 5b542ec6..f772e0ef 100644 --- a/docs/development-guide.md +++ b/docs/development-guide.md @@ -271,8 +271,13 @@ names what actually holds it; where nothing does, the row says so. (#543) is a second instance, weaker in kind: it checks a shared literal phrase rather than a list derived from source, because there is no engine artifact to derive a freeform-answer-shape rule from. Two rules are now - wired this way; a third must-always-land rule forgotten in one entry point - is still caught by nothing. + wired this way. #838 added a gate of a different kind for the same failure: + `README.md` installs the product by symlinking `skills/fomo-kernel/` alone, + so an installed host never receives `AGENTS.md` or anything else at the + repository root — `tests/test_installed_skill_tree.py` reads only that + directory and fails when any of the six non-negotiable boundaries stops + being readable from inside it. A new must-always-land rule forgotten in + one entry point is still caught by nothing. - Do not trust pattern counts of prohibitions ("N occurrences of *never*"). Most hits describe engine behavior the agent relies on to do *less* work; deleting them creates work. Read and classify before concluding. diff --git a/docs/maintainer-guide.md b/docs/maintainer-guide.md index 56fc9d74..cdaf37f3 100644 --- a/docs/maintainer-guide.md +++ b/docs/maintainer-guide.md @@ -201,6 +201,7 @@ reader-question-chain) while leaving those rows' every other ruling intact. | Permission boundaries and useful-answer freedom (#827, 2026-08-19) | Supersedes the answer-shape portions of #543, #597, #629, #674, and #697 while preserving their integrity work. Reasoning, research, explicit candidate discovery, comparison, recommendation, and materially useful tools or visuals are allowed with or without a recorded book. A book gates only book-derived claims: weights, concentration, cash effects, rule collisions, and state transitions remain engine-owned. Questions, source count, lookup attempts, option count, sentence shape, and presentation form have no universal numeric ceiling; use decision value, material coverage, marginal value, cost, and latency as stopping criteria. `usable_facts_grounding` owns the frozen numeric allow-set; `single_candidate_integrity` owns supplied context, process leakage, and false action claims rather than banning all digits. Exploration stays `consider --ephemeral`; only the user-selected live candidate is rerun persistently, so rejected exploration adds zero canonical evaluation rows. Historical rows above describe why their slices shipped, but their effort ceilings, answer templates, and recommendation bans are not current authority after this row. | | Deletion-first answer obligations (#830, 2026-08-20) | Supersedes the answer-shape portions of the whitelist era — #479 Wave B cut 2's single `must_state` list, #823's placement-only D-series, and #825's "relevance is governed, placement and form are free" — while leaving every integrity gate those rows built exactly where it was. `engine/evaluation_challenge.py` emits three lists instead of one: `must_state` (the floor), `may_state` (the concentration family and cash — computed, addressed, owed by default on no call), and `machine_state` (`basis.state_version`, never rendered in any register). Its readers: `schemas/evaluation-challenge.schema.json` (additive; new keys are required because the block is computed fresh per call and never stored, and the `must_state` topic enum narrows to match `TOPICS` because `tests/test_evaluation_challenge.py` holds those two as an ordered equality) ↔ `references/trade-consequence.md` "What the answer owes" (which now states a reason per keep and per delete, and defines *stated*: the fact appears with its correct anchor — inline number, table cell, or end-block line all qualify, so fifteen obligations are not fifteen sentences) ↔ `SKILL.md`'s answer shape ↔ `references/freeform-answers.md` rule 4 ↔ `docs/expression-contract.md` D7 ↔ `tools/ux_receipt.py` ↔ `tests/test_evaluation_challenge.py` / `test_consider.py` / `test_expression_contract.py` / `test_interaction_trajectory.py` / `tests/agent/check_expression.py` / `evals/trade_answers/`. **The deletion is of obligations, never of data**: every number is still computed, still anchorable, still citable, and the user can ask for any of it — which is what makes not saying a number this decision does not turn on different from hiding it. **The two silences that could hurt stayed machine-enforced**: `rule_effects` and `required_coverage` are untouched, so a rule the *user* wrote is still named and still refused-for-dropping while the engine's own default threshold became a may-state. **Volume distribution became expression's business** (D7: a fact lives on exactly one floor — opening body, parenthetical beside its number, one end-block line, or not rendered), which §2 had disclaimed and nothing else had claimed; that gap is why fifteen owed facts became fifteen body sentences with every D rule satisfied. The reading-budget rule proposed as V10 in the issue is **not** adopted: a length cap is what #827 had just deleted. The named risk is the other direction — a deciding consequence omitted — and its backstops are the two hard mandates above plus the owner-live `comprehension` verdict, never a checker. | | Cross-route research baseline (#716, 2026-08-23) | `skills/fomo-kernel/references/research-priors.md` is the single catalogue and the single statement of how far a prior may travel. Its readers: `references/decision-framing.md` (the route that already had it — the baseline is its top sentence) ↔ `references/trade-consequence.md` ("The research baseline, and why it may not fill a gap") ↔ `references/freeform-answers.md` ("The research baseline is available here too") ↔ `docs/expression-contract.md` §6 ↔ `tests/test_research_priors.py`. Four rules. **The catalogue is reachable from every route that answers a decision, not only from the one with no book.** Before this row it was linked by exactly one file, and the user-visible consequence was inverted evidence value: a user with nothing recorded heard that broad diversification is the baseline and that an index label alone does not establish breadth, and the same user, after handing over a book, got weights and concentration and could no longer reach that baseline at all. `test_b_the_catalogue_is_reachable_from_every_book_bearing_route` is what fails when a route's link is dropped, and it walks `SKILL.md → route file → catalogue` rather than asserting the catalogue exists, because a reference nothing names is text nothing loads (the `profile.md` precedent). **An engine fact dominates a prior, and that boundary has one home.** `research-priors.md`'s own "An engine fact dominates a prior" section owns it: a prior may interpret a deterministic result, never replace, substitute for, or fill a gap in one, and it may not invent a cap, an allocation, or a threshold the user has no rule for and the engine did not compute. The two book-bearing routes carry that sentence **verbatim, not paraphrased**, and name the section it comes from: a paraphrase is how five independent phrasings of answer-first happened (#832), and a pointer alone is a boundary the agent has to open a second file to learn. This is #834's answer applied to a rule instead of an exemplar — the same text in three places, made mechanical rather than forbidden, with `test_e_the_engine_fact_boundary_is_one_sentence_in_three_places` failing on any drift and `test_f_reachability_and_boundary_mutations_are_caught` proving it fails. `decision-framing.md`'s "A missing number is never replaced by a general rule" red line stays where it is and gains a pointer: it is the *no-book* form (no computed weight exists at all), and the new section is the form for the routes that do have one. **It is a claim authority, not a derivation.** It adds no shape parameter to any surface, so `expression-contract.md` §6's derivation column is deliberately **unchanged** and the catalogue appears in the last column instead; `freeform-answers.md`'s empty derivation (#832) stays empty and says so in the new section itself. A prior enters an existing block, is subject to §3's increment gate like any other, and a standing paragraph on the value of diversification is the `default_as_insight` ban with a citation stapled to it. **No runtime surface moved**: no engine file, schema, `.json` payload contract, new field, or new engine vocabulary — #716 §1 says the first version needs no runtime schema, and `SKILL.md` is untouched because it already routes to all three route files, which is the whole point of #507's byte budget on the always-loaded pair. Why the row exists: #716's title is "Cross-route evidence-backed decision priors" and the shipped implementation (#727) wired exactly one route, so the issue's own §4 — the boundary that makes the other routes safe — had never been written down anywhere. | +| The six non-negotiable boundaries reach the installed host (#838, 2026-08-23) | `README.md` installs the product by symlinking `skills/fomo-kernel/` alone, so `AGENTS.md` — and any rule stated only there — does not exist on an installed host. The floor's six boundaries therefore live twice: `AGENTS.md` "## Non-negotiable boundaries" (the checkout floor) ↔ `skills/fomo-kernel/SKILL.md` (each boundary stated in the section that exercises it; the privacy boundary as its own "Private data stays local" section, key phrases verbatim) ↔ `skills/fomo-kernel/references/agent-boundaries.md` (the routed may/may-not contract `SKILL.md` names as holding throughout; carries the CLI whitelist, the hand-assembly ban, and the third-party/cloud privacy sentence). No runtime surface under `skills/fomo-kernel/` (every `.md`/`.json`) may cite `AGENTS.md` for a rule — its reader may not have the file; `references/freeform-answers.md` stating the card-privacy default instead of citing "AGENTS.md invariant 4" is the pattern. `.py` comments under `engine/` and `tools/` sit outside that scan deliberately: boundary 1 keeps an installed agent out of engine internals, so those citations are maintainer-facing rationale, not instructions an installed reader follows. `tests/test_installed_skill_tree.py` reads **only** `skills/fomo-kernel/` and fails when a boundary's statement retreats to the repository root — every other suite reads the checkout, which is exactly why #838 shipped unseen. The #507 byte budget split with it: `SKILL.md` has its own ceiling (the whole always-loaded surface of an installed host) and `AGENTS.md` its own (the checkout-only floor), because one budget over a pair that exists only in a checkout let the floor's bytes squeeze the installed contract to zero headroom — the pair sat at exactly 16384 bytes when #838 was found. | Date product assumptions when using them for prioritization. Reconfirm assumptions that are several weeks old or contradicted by new evidence. diff --git a/skills/fomo-kernel/SKILL.md b/skills/fomo-kernel/SKILL.md index d7e1adb2..e161e130 100644 --- a/skills/fomo-kernel/SKILL.md +++ b/skills/fomo-kernel/SKILL.md @@ -5,13 +5,13 @@ description: Help with investment decisions and explicit candidate searches — # fomo-kernel -Use relevant evidence and the recorded book when portfolio consequences matter; then recommend what to do. Missing inputs narrow claims, not Skill engagement. `AGENTS.md` holds throughout. +Use relevant evidence and the recorded book when portfolio consequences matter; then recommend what to do. Missing inputs narrow claims, not Skill engagement. `references/agent-boundaries.md` holds throughout. ## Answer a live decision Use `consider` when the user supplies a trade premise and asks what it does to a recorded book. It is the deterministic portfolio-consequence path, never a prerequisite for research, discovery, or a non-portfolio recommendation. -Commands run from this skill's own directory. +Commands run from this skill's own directory. Product state is reached only through the `engine/review.py` CLI — never another `engine/*` script, never a direct engine import. ```bash python3 engine/review.py consider --premise '{"ticker":"NVDA","side":"buy","qty":20}' --language @@ -33,7 +33,7 @@ The payload is the authority for portfolio facts. External research is optional - `disclosures_display` — each disclosure as a sentence in the user's language. Use it as an end-block line rather than translating a key. - `prior_decision` — present only when the user already resolved one earlier consideration of this same ticker: their own stored words, and what they reported doing about it, never proof they did it. Use `prior_decision` only when it changes the current lead judgment, evidence requirement, process action, or a decision-changing question; otherwise ignore it. -Read portfolio consequence from that payload; never recompute or fill its gaps. An absent portfolio-derived number stays out. Public numbers need source and as-of, and neither they nor a forecast substitutes for portfolio fact. +Read portfolio consequence from that payload — every portfolio-derived number, the portfolio basis, every identity, every `rule_effect`, and every state transition is the engine's; never recompute, adjust, or fill its gaps. An absent portfolio-derived number stays out. Public numbers need source and as-of, and neither they nor a forecast substitutes for portfolio fact. ## Research only what could change the recommendation @@ -70,7 +70,11 @@ Persistent `consider` records the evaluation; `consider --ephemeral` does not. S python3 engine/review.py consider --resolve --decision acted|declined|modified ``` -`acted` is the user's report, not proof. Only a later transaction import proves a trade happened. Never write, imply, or carry forward an execution the user has not reported or the ledger does not show. +`acted` is the user's report, not proof. Four states stay distinct — considered, user-resolved, user-reported execution, transaction-proven execution — and none is promoted to the next: only a transaction record proves a trade happened. Never write, imply, or carry forward an execution the user has not reported or the ledger does not show. + +## Private data stays local + +Trades, holdings, amounts, motives, and cards never reach a third party or cloud memory. The review card is private to the user — local files, terminal output, and private-by-default in-client rendering are fine; publishing is not. Anything public — a shared card, an example, a bug report — carries synthetic data only. ## Other jobs diff --git a/skills/fomo-kernel/references/agent-boundaries.md b/skills/fomo-kernel/references/agent-boundaries.md index ad23d422..4a702690 100644 --- a/skills/fomo-kernel/references/agent-boundaries.md +++ b/skills/fomo-kernel/references/agent-boundaries.md @@ -17,10 +17,10 @@ The agent may not: - Calculate or alter numbers, rankings, weights, P&L, cycle IDs, metrics, driver concentration, or ETF allocation exemptions. Transcription is allowed; derived analysis is not. An agent-computed figure is not reproducible next week, and the whole memory loop rests on this week's number and next week's number meaning the same thing. - Answer on the user's behalf, or present an inference as confirmed. The engine cannot tell that an answer was invented. -- Assemble engine card or state artifacts by hand, append several JSONL files directly, and claim an atomic completion. +- Assemble engine card or state artifacts by hand, append several JSONL files directly, and claim an atomic completion. Persist only through a canonical engine writer, and only when a named later reader exists — no field written for a reader nobody built. - Upload a statement or screenshot for OCR. Snapshot transcription stays local; the engine accepts only the normalized JSON envelope through `review.py`. - Ask whether a holdings view covers the user's whole account, treat which kind of source recorded the book as deciding whether it may anchor, or claim that a later transaction import reconciles a fresh broker view. Every accepted source records the book at the time it arrives; ledger-derived current holdings stay canonical, and a newer holdings view reaches the recorded book through `refresh`. -- Put private data into the public card. +- Put private data into the public card, or let trades, holdings, amounts, motives, or cards reach a third party or cloud memory. Private data and durable state stay local; anything public carries synthetic data only. - Call another `engine/*` script or import engine modules directly. Invoke the engine through the `engine/review.py` CLI (`prepare`, `resume`, `preview`, `finalize`, `capture`, `consider`, `refresh`, `positions`, `render`, `weekly-market-read`, `repair-projections`, `set-cap`, `mute-rule`, `add-cash`, `resolve-market-data`, `doctor`) so lifecycle validation, required-question gates, and canonical session state stay authoritative. `add-cash` stays inside the lifecycle: it recomputes one prepared session with the anchor the user supplied at the card beat and refuses if anything but the anchor moved (`references/data-contract.md`). `capture` and `consider` are the two sanctioned append paths outside that lifecycle — `capture` only for a light-tier review (`flows/light-capture.md`), `consider` only for a trade the user has not placed yet (`references/trade-consequence.md`). `weekly-market-read`, `resolve-market-data`, and `positions` are read-only: the prototype brief only reads the pending plan's frozen market facts and never reaches a provider or writes a session; `resolve-market-data` retrieves current market facts into a `references/price-feed.md` envelope, and `positions` prints the per-position diagnosis of the recorded book (#561, `references/freeform-answers.md`). `prepare` and `consider` resolve their own facts, so reach for `resolve-market-data` only to inspect what retrieval produced, or to capture an envelope for a machine that cannot reach the provider. - Argue a considered trade's case from anything but `consider`'s output. The engine states the consequence; the agent may add judgment about the thesis, the valuation, or the timing, but every such claim carries its own label, and the risks the engine does not measure are named rather than passed over. Silence about what was not checked reads as a clean bill of health. What this particular answer owes is not a list to remember: the response's own `challenge` block computes it per call (`references/trade-consequence.md`, "What the answer owes"). diff --git a/skills/fomo-kernel/references/freeform-answers.md b/skills/fomo-kernel/references/freeform-answers.md index a1d0b2bf..d9417322 100644 --- a/skills/fomo-kernel/references/freeform-answers.md +++ b/skills/fomo-kernel/references/freeform-answers.md @@ -122,9 +122,10 @@ not a new rendering: it is the same engine-rendered artifact rather than a freshly composed one. The card's own P&L sparkline stays exactly as scoped before — part of the card's own rendering, not a detachable chart reachable on its own — and privacy still defaults to -`card-private.*` per AGENTS.md invariant 4 / AGENTS.md invariant 4: asking for the -card in freeform conversation does not loosen that default, and only -`card-public.md` is share-safe, on request. +`card-private.*` — the card never reaches a third party or cloud memory +(SKILL.md, "Private data stays local"): asking for the card in freeform +conversation does not loosen that default, and only `card-public.md` is +share-safe, on request. **Positions view.** Trigger: the user asks, in freeform conversation, to see their current holdings or positions. Shape, revised by owner ruling @@ -145,8 +146,8 @@ rather than a rule this entry restates. Every field in the Positions view must come from an engine-computed current-book snapshot obtained through `engine/review.py` — the same -numbers-from-engine and CLI-only boundary (SKILL.md rules 1 and 2; AGENTS.md -invariants 2 and 1) every other number in this product already obeys, never +numbers-from-engine and CLI-only boundary (SKILL.md and +`references/agent-boundaries.md`) every other number in this product already obeys, never a value the agent recomputes from a CSV, and never one read by importing an engine module directly. The dedicated read-only outlet is `engine/review.py positions` (#561): no CSV, no premise, no supplied diff --git a/tests/run_all.py b/tests/run_all.py index 9db75f02..476bd4ca 100644 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -167,6 +167,13 @@ # green suites over it on every run. ("Repository hygiene (#575)", "tests/test_repo_hygiene.py", "product"), ("Documentation and agent workflow boundaries", "tests/test_doc_language.py", "product"), + # #838: README.md installs the product by symlinking skills/fomo-kernel/ + # alone, so an installed host never receives AGENTS.md. Every suite above + # reads a full checkout and could not tell a root-only rule from an + # installed one; this suite reads only skills/fomo-kernel/ and fails when + # one of the six non-negotiable boundaries stops being readable from + # inside it. + ("Installed skill tree carries the six boundaries (#838)", "tests/test_installed_skill_tree.py", "product"), ("Cross-route research baseline (#716)", "tests/test_research_priors.py", "product"), ("Copy ratchet (#368 Phase 1)", "tests/test_copy_ratchet.py", "product"), # #402 knife 5: the copy branches no persona reaches, rendered on all three diff --git a/tests/test_doc_language.py b/tests/test_doc_language.py index 797d5db6..d83d4b3a 100644 --- a/tests/test_doc_language.py +++ b/tests/test_doc_language.py @@ -289,12 +289,33 @@ # AGENTS.md on the root-to-cwd path plus the user's global file. Half of it is # this repository's share; the global file is not ours to measure. CODEX_INSTRUCTION_BUDGET_BYTES = 16 * 1024 -# #507: the same 16 KiB, applied to a different failure. Codex's cap is about -# one client's discovery limit; this one is about what every host pays before -# it can answer a live decision at all. The pair measured here was 30,431 bytes -# on `main@4e7a15e` and instructed a further ~156 KB of routed prose; the slice -# that shrank it left this behind so the loading chain cannot quietly grow back. -ALWAYS_LOADED_RUNTIME_BUDGET_BYTES = 16 * 1024 +# #507 set one 16 KiB budget over the *pair* `AGENTS.md` + `SKILL.md` -- +# applied to a different failure than Codex's cap above: not one client's +# discovery limit, but what every host pays before it can answer a live +# decision at all. That pair measured 30,431 bytes on `main@4e7a15e` and +# instructed a further ~156 KB of routed prose; the slice that shrank it left +# the shared budget behind so the loading chain could not quietly grow back. +# +# #838's owner ruling split that one number in two: the pair budget bound two +# independent scenarios together, and it sat at exactly 16,384 bytes -- zero +# headroom -- when #838 needed to add boundary copy that had to ride inside +# the installed contract. Changing either constant below needs an owner +# ruling, not an edit. +# +# Scenario: an installed host. README.md installs the product by symlinking +# `skills/fomo-kernel/` alone, so an installed host never receives +# `AGENTS.md` -- `skills/fomo-kernel/SKILL.md` is that host's entire +# always-loaded surface before it can answer a live decision. +INSTALLED_CONTRACT_BUDGET_BYTES = 12 * 1024 +# Scenario: a repository checkout. `AGENTS.md` is the always-on floor every +# client loads there -- Codex natively, Claude Code through the CLAUDE.md +# import. SKILL.md is deliberately not in this budget: in a checkout it still +# loads only when the skill fires, and it already has its own ceiling above. +CHECKOUT_FLOOR_BUDGET_BYTES = 8 * 1024 +# The sum, 20 KiB, is the checkout host's decision-turn tax if both surfaces +# load in the same turn: 4 KiB over #507's original 16 KiB, spent +# deliberately on the boundary copy that must ride inside the installed +# contract. # `CLAUDE.md` may still be *named* where a file legitimately describes the # adapter's role or the authority split. Everywhere else, naming it as the home # of a shared rule points a reader at a file that no longer holds it. @@ -1319,34 +1340,43 @@ def floor_violations(text): def always_loaded_runtime_violations(root): - """Reasons the always-loaded runtime contract has grown back (#507). - - `AGENTS.md` and `SKILL.md` are the two files a host reads before it can - answer anything; every other runtime document is soft-routed and reached - only when its task is actually invoked. #507 collapsed that pair from - 30,431 bytes to a small contract, and the budget is what keeps the - collapse from being undone one useful paragraph at a time -- the failure - it prevents is the one that motivated the slice, a host arriving at a - live decision holding a procedure manual instead of the engine's answer. - - Deliberately a byte budget over the *pair*, not a line count over each: - the tax is what a host loads before its first word, and moving prose from - one file to the other does not reduce it. + """Reasons the always-loaded runtime contract has grown back (#507, #838). + + `AGENTS.md` and `skills/fomo-kernel/SKILL.md` are the two files a host + reads before it can answer anything; every other runtime document is + soft-routed and reached only when its task is actually invoked. #507 + collapsed that pair from 30,431 bytes to a small contract. + + #838 found the two files answer two independent scenarios that a single + pair budget had bound together -- an installed host (README.md symlinks + `skills/fomo-kernel/` alone, so SKILL.md is that host's *entire* + always-loaded surface) and a repository checkout (AGENTS.md is the + always-on floor every client loads there; SKILL.md is not counted in a + checkout because it loads only when the skill fires). Binding both to one + number meant growth in one file could eat the other's headroom -- the + pair sat at exactly 16,384 bytes, zero room to spare, when #838 needed to + add boundary copy that had to land inside the installed contract. Each + file is now checked against its own budget. + + Takes the root as an argument, and keeps the missing-file arm, so the + mutation test can drive this exact logic against synthetic trees. """ - surfaces = [root / "AGENTS.md", root / "skills" / "fomo-kernel" / "SKILL.md"] - problems = [] - missing = [str(path.relative_to(root)) for path in surfaces if not path.is_file()] + budgets = [ + (root / "skills" / "fomo-kernel" / "SKILL.md", INSTALLED_CONTRACT_BUDGET_BYTES), + (root / "AGENTS.md", CHECKOUT_FLOOR_BUDGET_BYTES), + ] + missing = [str(path.relative_to(root)) for path, _ in budgets if not path.is_file()] if missing: return [f"always-loaded runtime surface is missing: {', '.join(missing)}"] - sizes = {path.relative_to(root): path.stat().st_size for path in surfaces} - total = sum(sizes.values()) - if total > ALWAYS_LOADED_RUNTIME_BUDGET_BYTES: - problems.append( - f"the always-loaded runtime contract is {total} bytes, over the " - f"{ALWAYS_LOADED_RUNTIME_BUDGET_BYTES}-byte budget #507 set: " - + ", ".join(f"{rel} {size}" for rel, size in sorted(sizes.items())) - + ". Route the new material to the document that owns its task " - "instead of adding it to the floor.") + problems = [] + for path, budget in budgets: + size = path.stat().st_size + if size > budget: + rel = path.relative_to(root) + problems.append( + f"{rel} is {size} bytes, over its own {budget}-byte budget. " + "Route the new material to the document that owns its task " + "instead of adding it to the floor.") return problems @@ -1424,41 +1454,77 @@ def test_agents_md_is_the_shared_always_on_floor(): def test_the_always_loaded_runtime_contract_stays_inside_its_budget(): - """#507: what a host loads before answering a live decision is bounded. - - This is the mechanism that replaced several deleted wording locks. Those - pinned individual sentences and could not see the actual regression -- a - floor that keeps every pinned phrase and still grows a route manual around - them. A budget over the pair sees exactly that and nothing else. + """What a host loads before answering a live decision is bounded, per host. + + #507 built the mechanism that replaced several deleted wording locks -- + those pinned individual sentences and could not see the actual + regression, a floor that keeps every pinned phrase and still grows a + route manual around them. #838 split the one pair budget into two, one + per scenario, so growth in the installed host's surface (SKILL.md) can no + longer eat the checkout floor's headroom (AGENTS.md), or the reverse. """ problems = always_loaded_runtime_violations(ROOT) assert not problems, "the always-loaded runtime contract grew back:\n " + "\n ".join(problems) def test_always_loaded_runtime_budget_mutations_are_caught(): - """Mutation proof: a floor over budget, and a missing surface, both redden. + """Mutation proof: each budget reddens on its own, by name. - Without the second half a rename would silently disable the gate -- the - check would find nothing to measure and report nothing wrong. + Without the missing-file arms a rename would silently disable the gate. + Without checking each over-budget arm *while the other file stays inside + its own budget*, a bug that always blamed one file, or blamed both files + together, would pass every other assertion in this suite unnoticed. """ - assert ALWAYS_LOADED_RUNTIME_BUDGET_BYTES == 16 * 1024, \ - "the budget #507 agreed is 16 KiB; changing it needs an owner ruling, not an edit" + assert INSTALLED_CONTRACT_BUDGET_BYTES == 12 * 1024, ( + "#838's owner ruling set the installed host's entire always-loaded " + "surface (skills/fomo-kernel/SKILL.md alone, since README.md installs " + "the product by symlinking that directory alone) at 12 KiB; changing " + "it needs an owner ruling, not an edit") + assert CHECKOUT_FLOOR_BUDGET_BYTES == 8 * 1024, ( + "#838's owner ruling split this out of #507's original 16 KiB pair " + "budget as the checkout-only floor (AGENTS.md alone) at 8 KiB; " + "changing it needs an owner ruling, not an edit") + with tempfile.TemporaryDirectory() as tmp: fake = Path(tmp) skill_dir = fake / "skills" / "fomo-kernel" + skill_path = skill_dir / "SKILL.md" + agents_path = fake / "AGENTS.md" + + assert always_loaded_runtime_violations(fake), \ + "an empty tree would stay green" + skill_dir.mkdir(parents=True) + skill_path.write_text("contract", encoding="utf-8") assert always_loaded_runtime_violations(fake), \ - "a tree with neither surface would stay green" - (fake / "AGENTS.md").write_text("floor", encoding="utf-8") + "a tree missing AGENTS.md would stay green" + + skill_path.unlink() + agents_path.write_text("floor", encoding="utf-8") assert always_loaded_runtime_violations(fake), \ "a tree missing SKILL.md would stay green" - (skill_dir / "SKILL.md").write_text("contract", encoding="utf-8") + + skill_path.write_text("contract", encoding="utf-8") assert not always_loaded_runtime_violations(fake), "fixture assumption broken" - (skill_dir / "SKILL.md").write_text( - "x" * ALWAYS_LOADED_RUNTIME_BUDGET_BYTES, encoding="utf-8") + + # SKILL.md over its own budget; AGENTS.md stays inside its own. + skill_path.write_text( + "x" * (INSTALLED_CONTRACT_BUDGET_BYTES + 1), encoding="utf-8") + problems = always_loaded_runtime_violations(fake) + assert any("skills/fomo-kernel/SKILL.md is" in problem for problem in problems), \ + "an over-budget SKILL.md left the gate green, or did not name the file" + assert not any("AGENTS.md is" in problem for problem in problems), \ + "SKILL.md alone was over budget; AGENTS.md must not be blamed too" + + # Reverse: AGENTS.md over its own budget; SKILL.md stays inside its own. + skill_path.write_text("contract", encoding="utf-8") + agents_path.write_text( + "x" * (CHECKOUT_FLOOR_BUDGET_BYTES + 1), encoding="utf-8") problems = always_loaded_runtime_violations(fake) - assert any("over the" in problem for problem in problems), \ - "an over-budget runtime contract left the gate green" + assert any("AGENTS.md is" in problem for problem in problems), \ + "an over-budget AGENTS.md left the gate green, or did not name the file" + assert not any("skills/fomo-kernel/SKILL.md is" in problem for problem in problems), \ + "AGENTS.md alone was over budget; SKILL.md must not be blamed too" def test_the_maintainer_guide_holds_the_shared_contract(): diff --git a/tests/test_installed_skill_tree.py b/tests/test_installed_skill_tree.py new file mode 100644 index 00000000..c3c51212 --- /dev/null +++ b/tests/test_installed_skill_tree.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +"""The six non-negotiable boundaries, read from the tree an install actually gets (#838). + +`README.md` installs this product by symlinking `skills/fomo-kernel/` alone. +An installed host's client therefore never receives `AGENTS.md`, `docs/`, or +anything else at the repository root -- `skills/fomo-kernel/` is the entire +world it can read before it answers a live decision. Every other test suite +in this repository runs against a full checkout, so a rule that lives only +at the repository root reads as present to all of them and is silently +absent for an installed user. That is exactly how #838 shipped unseen: the +six non-negotiable boundaries were stated once, in `AGENTS.md`, and nothing +that ran against the checkout could tell the difference between "stated" +and "stated somewhere an installed host can reach." + +This suite is the difference. It builds its entire view of the world from +`skills/fomo-kernel/` and nothing else -- it must never read `AGENTS.md`, +`docs/`, or any file outside that directory -- and it fails the moment a +boundary's statement retreats to the repository root, or a file inside the +subtree cites `AGENTS.md` for a rule (a citation an installed reader cannot +follow, because the reader has no such file). + +Deliberately independent of `tests/test_doc_language.py` and +`tests/test_repo_hygiene.py`: importing either would reintroduce a +root-reading suite as a load-bearing part of this one's logic, and the whole +point is that this suite proves nothing those two suites already prove. +""" +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SKILL_TREE = ROOT / "skills" / "fomo-kernel" + +# Six entries, each a short label plus the (relative file, required phrases) +# pairs that carry it inside the installed subtree. Every phrase below is an +# exact substring copied from the on-disk files -- verified, not retyped from +# memory -- so a wording edit that drops the substance, not just the words, +# reddens this suite rather than a paraphrase drifting silently. +BOUNDARIES = [ + { + "label": "product-state-only-through-review-cli", + "files": { + "SKILL.md": [ + "only through the `engine/review.py` CLI", + ], + "references/agent-boundaries.md": [ + "Call another `engine/*` script or import engine modules directly", + ], + }, + }, + { + "label": "engine-owns-every-portfolio-fact", + "files": { + "SKILL.md": [ + "every portfolio-derived number, the portfolio basis, every " + "identity, every `rule_effect`, and every state transition " + "is the engine's", + "never recompute", + ], + }, + }, + { + "label": "four-states-never-promoted", + "files": { + "SKILL.md": [ + "considered, user-resolved, user-reported execution, " + "transaction-proven execution", + "only a transaction record proves a trade happened", + "the user's report, not proof", + ], + }, + }, + { + # Pinned as complete sentences, terminal punctuation included, not + # fragments: an adversarial probe showed that fragment pins survive an + # exception-clause rewrite ("never reach a third party or cloud memory + # unless the user asks") with the gate green. A full-sentence pin makes + # that rewrite break the pin; a *contradicting sentence added beside* + # the pinned one is still review's to catch -- no phrase gate can + # prove the absence of a contradiction elsewhere in the file. + "label": "private-data-stays-local", + "files": { + "SKILL.md": [ + "Trades, holdings, amounts, motives, and cards never reach a " + "third party or cloud memory.", + "The review card is private to the user — local files, " + "terminal output, and private-by-default in-client rendering " + "are fine; publishing is not.", + "Anything public — a shared card, an example, a bug report — " + "carries synthetic data only.", + ], + "references/agent-boundaries.md": [ + "or let trades, holdings, amounts, motives, or cards reach a " + "third party or cloud memory.", + "Private data and durable state stay local; anything public " + "carries synthetic data only.", + ], + }, + }, + { + "label": "recommendation-first", + "files": { + "SKILL.md": [ + "then recommend what to do", + "Ask only decision-changing questions", + "machine anchors and engine narration nowhere", + "A found event never becomes the user's motive until they " + "confirm it is.", + "Never claim what the user did or will do.", + ], + }, + }, + { + "label": "persist-only-through-canonical-writer", + "files": { + "SKILL.md": [ + "Persistent `consider` records the evaluation", + "Exploration leaves no canonical evaluation row", + ], + "references/agent-boundaries.md": [ + "Assemble engine card or state artifacts by hand", + "Persist only through a canonical engine writer, and only " + "when a named later reader exists — no field written for a " + "reader nobody built.", + ], + }, + }, +] + +# The routed boundary file must be reachable, not merely present: a reference +# nothing names is text nothing loads (the `profile.md` precedent -- +# registered, tested, named by no runtime surface, and so never written to in +# the product's lifetime). `SKILL.md` is the one file an installed host is +# guaranteed to load, so it must name the file that carries the boundary +# statements `SKILL.md` itself does not restate. An adversarial probe deleted +# `SKILL.md`'s single pointer to this file and every suite stayed green; this +# constant and its checker are the answer. +ROUTED_BOUNDARY_FILE = "references/agent-boundaries.md" + +# Every relative path any boundary above reads, in one place, so the tree +# reader and the mutation fixtures agree on what "the subtree" means. +BOUNDARY_FILES = sorted({rel for boundary in BOUNDARIES for rel in boundary["files"]}) + + +def load_boundary_sources(tree): + """``{relative_path: text}`` for every file a boundary reads, under ``tree``. + + A file that does not exist under ``tree`` is simply absent from the + result -- callers treat a missing key the same as an empty string, which + is what lets a tree with no `SKILL.md` at all fail every boundary that + names it, rather than crashing. + """ + sources = {} + for rel in BOUNDARY_FILES: + path = tree / rel + if path.is_file(): + sources[rel] = path.read_text(encoding="utf-8") + return sources + + +def boundary_violations(sources): + """``(label, file, phrase)`` for every phrase missing from ``sources``. + + ``sources`` is a ``{relative_path: text}`` mapping, so a mutation arm can + hand this a doctored copy of the real text without touching disk. + """ + violations = [] + for boundary in BOUNDARIES: + for rel, phrases in boundary["files"].items(): + text = sources.get(rel, "") + for phrase in phrases: + if phrase not in text: + violations.append((boundary["label"], rel, phrase)) + return violations + + +def reachability_violations(sources): + """Reasons the routed boundary file is unreachable under ``sources``. + + Presence is not delivery: the boundaries `references/agent-boundaries.md` + carries reach an installed host only through `SKILL.md` naming it, because + `SKILL.md` is the sole file that host loads unprompted. + """ + problems = [] + if f"`{ROUTED_BOUNDARY_FILE}`" not in sources.get("SKILL.md", ""): + problems.append( + f"SKILL.md does not name `{ROUTED_BOUNDARY_FILE}` -- the " + "boundaries that file carries are text nothing loads") + if ROUTED_BOUNDARY_FILE not in sources: + problems.append(f"{ROUTED_BOUNDARY_FILE} is missing from the tree") + return problems + + +def iter_markdown_and_json_sources(tree): + """``(relative_path, text)`` for every ``.md``/``.json`` file under ``tree``. + + ``.py`` files under `engine/` and `tools/` are outside this walk + deliberately, and the maintainer guide's mirrored-surfaces row states the + same scope: boundary 1 keeps an installed agent out of engine internals, + so an `AGENTS.md` mention in a code comment there is maintainer-facing + rationale, never an instruction an installed reader follows. + """ + for path in sorted(tree.rglob("*")): + if path.is_file() and path.suffix in (".md", ".json"): + yield str(path.relative_to(tree)), path.read_text( + encoding="utf-8", errors="ignore") + + +def citation_violations(sources): + """``"file:line: text"`` for every line naming ``AGENTS.md`` in ``sources``. + + ``sources`` is an iterable of ``(relative_path, text)`` pairs, so a + mutation arm can inject a citation into one file's text without touching + disk. An installed reader has no `AGENTS.md` to open, so a rule stated + only as "see AGENTS.md" is a dead pointer for that reader, not a rule. + """ + return [ + f"{rel}:{number}: {line.strip()[:120]}" + for rel, text in sources + for number, line in enumerate(text.splitlines(), 1) + if "AGENTS.md" in line + ] + + +def test_every_boundary_phrase_is_present_in_the_installed_tree(): + """Every one of the six boundaries' phrases must be readable from + `skills/fomo-kernel/` alone -- the entire tree an installed host gets. + """ + violations = boundary_violations(load_boundary_sources(SKILL_TREE)) + assert not violations, ( + "a non-negotiable boundary is not readable from inside " + "skills/fomo-kernel/ (an installed host has nothing else to read):\n " + + "\n ".join( + f"{label}: {rel} is missing {phrase!r}" + for label, rel, phrase in violations + ) + ) + + +def test_the_routed_boundary_file_is_reachable_from_skill_md(): + """`references/agent-boundaries.md` carries boundary statements `SKILL.md` + does not restate (the CLI whitelist, the hand-assembly ban, the canonical + writer rule). It must exist *and* be named by `SKILL.md`, or those + boundaries become text nothing loads on an installed host. + """ + violations = reachability_violations(load_boundary_sources(SKILL_TREE)) + assert not violations, ( + "the routed boundary file is unreachable from SKILL.md:\n " + + "\n ".join(violations) + ) + + +def test_no_file_under_the_installed_tree_cites_agents_md(): + """An installed reader has no `AGENTS.md` -- a citation pointing at it + from inside `skills/fomo-kernel/` is dead on arrival for that reader. + """ + violations = citation_violations(iter_markdown_and_json_sources(SKILL_TREE)) + assert not violations, ( + "skills/fomo-kernel/ cites AGENTS.md, which an installed host cannot " + "read:\n " + "\n ".join(violations) + ) + + +def test_boundary_and_citation_checks_are_mutation_proof(): + """(a) the real tree is green; (b) removing any one boundary phrase + reddens the checker for that exact (label, file, phrase); (c) injecting + an AGENTS.md citation into a real file's text reddens the citation + checker for that file; (d) a tree with no SKILL.md at all is red. + + Follows the section-scoped mutation style of `test_doc_language.py`: each + mutation targets one phrase and asserts the failure names it, not merely + that some violation somewhere fired -- a checker that always reports + everything wrong would pass a looser assertion for the wrong reason. + """ + real_sources = load_boundary_sources(SKILL_TREE) + assert not boundary_violations(real_sources), ( + "fixture assumption broken: the real installed tree is not green") + + for boundary in BOUNDARIES: + for rel, phrases in boundary["files"].items(): + for phrase in phrases: + assert sources_has_phrase(real_sources, rel, phrase), ( + f"fixture assumption broken: {phrase!r} not found " + f"verbatim in {rel}") + mutated = dict(real_sources) + mutated[rel] = real_sources[rel].replace(phrase, "", 1) + violations = boundary_violations(mutated) + assert (boundary["label"], rel, phrase) in violations, ( + f"removing {phrase!r} from {rel} did not redden " + f"boundary {boundary['label']!r}") + + real_md_json = dict(iter_markdown_and_json_sources(SKILL_TREE)) + # Two injection targets: the tree's top level, and a file down inside + # `references/` -- the second pins that the walk actually descends into + # subdirectories, so narrowing `rglob` to a flat listing reddens here + # instead of silently shrinking what the citation check covers. + for target_rel in ("SKILL.md", ROUTED_BOUNDARY_FILE): + assert target_rel in real_md_json, ( + f"fixture assumption broken: the source walk did not reach " + f"{target_rel}") + mutated_pairs = [ + (rel, text if rel != target_rel + else text + "\nper AGENTS.md invariant 4\n") + for rel, text in real_md_json.items() + ] + injected_violations = citation_violations(mutated_pairs) + assert any( + v.startswith(f"{target_rel}:") for v in injected_violations), ( + f"injecting an AGENTS.md citation into {target_rel}'s text did " + "not redden the citation checker") + + # Reachability arms: the real tree is green, dropping SKILL.md's single + # pointer to the routed file is red, and losing the file itself is red. + assert not reachability_violations(real_sources), ( + "fixture assumption broken: the routed boundary file is already " + "unreachable") + unpointed = dict(real_sources) + unpointed["SKILL.md"] = real_sources["SKILL.md"].replace( + f"`{ROUTED_BOUNDARY_FILE}`", "", 1) + assert reachability_violations(unpointed), ( + "deleting SKILL.md's pointer to the routed boundary file would " + "stay green") + fileless = {rel: text for rel, text in real_sources.items() + if rel != ROUTED_BOUNDARY_FILE} + assert reachability_violations(fileless), ( + "a tree without the routed boundary file would stay green") + + with tempfile.TemporaryDirectory() as tmp: + empty_tree = Path(tmp) + assert boundary_violations(load_boundary_sources(empty_tree)), ( + "a tree with no SKILL.md at all would stay green") + + +def sources_has_phrase(sources, rel, phrase): + return phrase in sources.get(rel, "") + + +_REGISTERED_TESTS = [] # populated by main(); see the registration self-check + + +def test_every_test_in_this_module_is_registered(): + """``main()`` runs a hand-maintained list, so a ``test_*`` added to this + file but not to that list runs zero times while the module still prints + PASS and exits 0 -- proven live by an adversarial probe during review, + and the same incident `tests/test_doc_language.py` already records for + itself. Same guard here: the defined set and the registered list must be + identical. + """ + defined = {name for name, value in sorted(globals().items()) + if name.startswith("test_") and callable(value)} + registered = {test.__name__ for test in _REGISTERED_TESTS} + assert registered, "main() has not populated the registry" + assert defined == registered, ( + "test functions defined but never run (add them to main()'s list), " + "or registered but not defined:\n" + f" unregistered: {sorted(defined - registered)}\n" + f" undefined: {sorted(registered - defined)}" + ) + + +def main(): + tests = [ + test_every_boundary_phrase_is_present_in_the_installed_tree, + test_the_routed_boundary_file_is_reachable_from_skill_md, + test_no_file_under_the_installed_tree_cites_agents_md, + test_boundary_and_citation_checks_are_mutation_proof, + test_every_test_in_this_module_is_registered, + ] + global _REGISTERED_TESTS + _REGISTERED_TESTS = tests + for test in tests: + test() + print(f"PASS {test.__name__}") + print(f"PASS: {len(tests)} installed skill tree boundary tests") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 31c544ab443a25ea2f43d056e5a3a6d49feacde5 Mon Sep 17 00:00:00 2001 From: test Date: Sun, 23 Aug 2026 16:55:46 +0800 Subject: [PATCH 5/5] fix(review): close the #829 wording residue and harden the installed-tree gate (PR #840 review round) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten finder angles plus a gap sweep over the full PR diff; every fix below re-verified by the touched suites and the full product group (49/49). - references/trade-consequence.md still told the agent to "rerun only the selected or live candidate without the flag" — the pre-#829 licence to persist a canonical row for a candidate the user never selected, in the exact reference SKILL.md routes to for consider's rules. It now carries #829's sentence: a persistent rerun needs the user's explicit selection, and a standing recommendation is not one. - tests/test_installed_skill_tree.py: phrase matching is whitespace-collapsed through one shared helper (a cosmetic re-flow no longer false-reddens, and the live gate and its mutation proof share one membership definition); the citation scan widened from .md/.json to every text runtime surface (.md/.json/.html/.txt — the card templates and requirements.txt were silently outside it) and reads strictly instead of errors="ignore"; source keys are POSIX so the assertions hold on every OS; the "never recompute" pin carries the full clause. - tests/test_doc_language.py: budget-violation messages name files in POSIX form, matching the mutation test's assertions on every OS. - tests/test_research_priors.py: BOOK_BEARING is derived from ROUTE_FILES instead of hand-listed, and test_b is renamed to match what it actually covers (every route, not only the book-bearing two); the maintainer-guide row citing it follows. - docs/maintainer-guide.md: the #838 row states the widened citation-scan scope and documents that the privacy section's anything-public example list is deliberately re-cast for the installed audience. Reviewed and deliberately unchanged: no combined AGENTS.md+SKILL.md sum ceiling (the #838 owner ruling split the scenarios; the sum is bounded by the two ceilings), and SKILL.md's prose cwd statement (#821's accepted fix). Co-Authored-By: Claude Fable 5 --- docs/maintainer-guide.md | 4 +- .../references/trade-consequence.md | 2 +- tests/test_doc_language.py | 6 +- tests/test_installed_skill_tree.py | 77 +++++++++++++------ tests/test_research_priors.py | 6 +- 5 files changed, 64 insertions(+), 31 deletions(-) diff --git a/docs/maintainer-guide.md b/docs/maintainer-guide.md index cdaf37f3..32771dde 100644 --- a/docs/maintainer-guide.md +++ b/docs/maintainer-guide.md @@ -200,8 +200,8 @@ reader-question-chain) while leaving those rows' every other ruling intact. | Positive decision contract and ephemeral comparison (#825, 2026-08-19) | `AGENTS.md` boundary 5 ↔ `skills/fomo-kernel/SKILL.md` answer shape / candidate comparison ↔ `review.py` `consider --ephemeral` ↔ `answer_provenance.agent_case_claims` / `validate_agent_case` ↔ `schemas/answer-provenance.schema.json` / `schemas/evaluation-challenge.schema.json` ↔ `evaluation_challenge.CASE_REQUIRED` ↔ `references/trade-consequence.md` / `decision-framing.md` / `market-lookup.md` ↔ `evals/judge_trade_answers.py` ↔ `tests/test_answer_provenance.py` / `test_consider.py` / `test_trade_answer_judge.py`. New cases use one `recommendation` labelled `agent_judgment`, non-empty `support`, and optional `counter_case`; the legacy `for`/`against` shape remains readable for append-only history. `--ephemeral` is allowed only against the existing recorded book, computes the identical content-addressed evaluation, reports `append.status=ephemeral`, and never touches `trade_evaluations.jsonl`; rerunning the selected candidate without the flag is the canonical write. Research, counter-cases, unchecked disclosures, questions, and resolution invitations are relevance-driven rather than standing quotas. Numeric authority, provenance coverage, private/local state, execution truth, and canonical persistent writes are unchanged. | | Permission boundaries and useful-answer freedom (#827, 2026-08-19) | Supersedes the answer-shape portions of #543, #597, #629, #674, and #697 while preserving their integrity work. Reasoning, research, explicit candidate discovery, comparison, recommendation, and materially useful tools or visuals are allowed with or without a recorded book. A book gates only book-derived claims: weights, concentration, cash effects, rule collisions, and state transitions remain engine-owned. Questions, source count, lookup attempts, option count, sentence shape, and presentation form have no universal numeric ceiling; use decision value, material coverage, marginal value, cost, and latency as stopping criteria. `usable_facts_grounding` owns the frozen numeric allow-set; `single_candidate_integrity` owns supplied context, process leakage, and false action claims rather than banning all digits. Exploration stays `consider --ephemeral`; only the user-selected live candidate is rerun persistently, so rejected exploration adds zero canonical evaluation rows. Historical rows above describe why their slices shipped, but their effort ceilings, answer templates, and recommendation bans are not current authority after this row. | | Deletion-first answer obligations (#830, 2026-08-20) | Supersedes the answer-shape portions of the whitelist era — #479 Wave B cut 2's single `must_state` list, #823's placement-only D-series, and #825's "relevance is governed, placement and form are free" — while leaving every integrity gate those rows built exactly where it was. `engine/evaluation_challenge.py` emits three lists instead of one: `must_state` (the floor), `may_state` (the concentration family and cash — computed, addressed, owed by default on no call), and `machine_state` (`basis.state_version`, never rendered in any register). Its readers: `schemas/evaluation-challenge.schema.json` (additive; new keys are required because the block is computed fresh per call and never stored, and the `must_state` topic enum narrows to match `TOPICS` because `tests/test_evaluation_challenge.py` holds those two as an ordered equality) ↔ `references/trade-consequence.md` "What the answer owes" (which now states a reason per keep and per delete, and defines *stated*: the fact appears with its correct anchor — inline number, table cell, or end-block line all qualify, so fifteen obligations are not fifteen sentences) ↔ `SKILL.md`'s answer shape ↔ `references/freeform-answers.md` rule 4 ↔ `docs/expression-contract.md` D7 ↔ `tools/ux_receipt.py` ↔ `tests/test_evaluation_challenge.py` / `test_consider.py` / `test_expression_contract.py` / `test_interaction_trajectory.py` / `tests/agent/check_expression.py` / `evals/trade_answers/`. **The deletion is of obligations, never of data**: every number is still computed, still anchorable, still citable, and the user can ask for any of it — which is what makes not saying a number this decision does not turn on different from hiding it. **The two silences that could hurt stayed machine-enforced**: `rule_effects` and `required_coverage` are untouched, so a rule the *user* wrote is still named and still refused-for-dropping while the engine's own default threshold became a may-state. **Volume distribution became expression's business** (D7: a fact lives on exactly one floor — opening body, parenthetical beside its number, one end-block line, or not rendered), which §2 had disclaimed and nothing else had claimed; that gap is why fifteen owed facts became fifteen body sentences with every D rule satisfied. The reading-budget rule proposed as V10 in the issue is **not** adopted: a length cap is what #827 had just deleted. The named risk is the other direction — a deciding consequence omitted — and its backstops are the two hard mandates above plus the owner-live `comprehension` verdict, never a checker. | -| Cross-route research baseline (#716, 2026-08-23) | `skills/fomo-kernel/references/research-priors.md` is the single catalogue and the single statement of how far a prior may travel. Its readers: `references/decision-framing.md` (the route that already had it — the baseline is its top sentence) ↔ `references/trade-consequence.md` ("The research baseline, and why it may not fill a gap") ↔ `references/freeform-answers.md` ("The research baseline is available here too") ↔ `docs/expression-contract.md` §6 ↔ `tests/test_research_priors.py`. Four rules. **The catalogue is reachable from every route that answers a decision, not only from the one with no book.** Before this row it was linked by exactly one file, and the user-visible consequence was inverted evidence value: a user with nothing recorded heard that broad diversification is the baseline and that an index label alone does not establish breadth, and the same user, after handing over a book, got weights and concentration and could no longer reach that baseline at all. `test_b_the_catalogue_is_reachable_from_every_book_bearing_route` is what fails when a route's link is dropped, and it walks `SKILL.md → route file → catalogue` rather than asserting the catalogue exists, because a reference nothing names is text nothing loads (the `profile.md` precedent). **An engine fact dominates a prior, and that boundary has one home.** `research-priors.md`'s own "An engine fact dominates a prior" section owns it: a prior may interpret a deterministic result, never replace, substitute for, or fill a gap in one, and it may not invent a cap, an allocation, or a threshold the user has no rule for and the engine did not compute. The two book-bearing routes carry that sentence **verbatim, not paraphrased**, and name the section it comes from: a paraphrase is how five independent phrasings of answer-first happened (#832), and a pointer alone is a boundary the agent has to open a second file to learn. This is #834's answer applied to a rule instead of an exemplar — the same text in three places, made mechanical rather than forbidden, with `test_e_the_engine_fact_boundary_is_one_sentence_in_three_places` failing on any drift and `test_f_reachability_and_boundary_mutations_are_caught` proving it fails. `decision-framing.md`'s "A missing number is never replaced by a general rule" red line stays where it is and gains a pointer: it is the *no-book* form (no computed weight exists at all), and the new section is the form for the routes that do have one. **It is a claim authority, not a derivation.** It adds no shape parameter to any surface, so `expression-contract.md` §6's derivation column is deliberately **unchanged** and the catalogue appears in the last column instead; `freeform-answers.md`'s empty derivation (#832) stays empty and says so in the new section itself. A prior enters an existing block, is subject to §3's increment gate like any other, and a standing paragraph on the value of diversification is the `default_as_insight` ban with a citation stapled to it. **No runtime surface moved**: no engine file, schema, `.json` payload contract, new field, or new engine vocabulary — #716 §1 says the first version needs no runtime schema, and `SKILL.md` is untouched because it already routes to all three route files, which is the whole point of #507's byte budget on the always-loaded pair. Why the row exists: #716's title is "Cross-route evidence-backed decision priors" and the shipped implementation (#727) wired exactly one route, so the issue's own §4 — the boundary that makes the other routes safe — had never been written down anywhere. | -| The six non-negotiable boundaries reach the installed host (#838, 2026-08-23) | `README.md` installs the product by symlinking `skills/fomo-kernel/` alone, so `AGENTS.md` — and any rule stated only there — does not exist on an installed host. The floor's six boundaries therefore live twice: `AGENTS.md` "## Non-negotiable boundaries" (the checkout floor) ↔ `skills/fomo-kernel/SKILL.md` (each boundary stated in the section that exercises it; the privacy boundary as its own "Private data stays local" section, key phrases verbatim) ↔ `skills/fomo-kernel/references/agent-boundaries.md` (the routed may/may-not contract `SKILL.md` names as holding throughout; carries the CLI whitelist, the hand-assembly ban, and the third-party/cloud privacy sentence). No runtime surface under `skills/fomo-kernel/` (every `.md`/`.json`) may cite `AGENTS.md` for a rule — its reader may not have the file; `references/freeform-answers.md` stating the card-privacy default instead of citing "AGENTS.md invariant 4" is the pattern. `.py` comments under `engine/` and `tools/` sit outside that scan deliberately: boundary 1 keeps an installed agent out of engine internals, so those citations are maintainer-facing rationale, not instructions an installed reader follows. `tests/test_installed_skill_tree.py` reads **only** `skills/fomo-kernel/` and fails when a boundary's statement retreats to the repository root — every other suite reads the checkout, which is exactly why #838 shipped unseen. The #507 byte budget split with it: `SKILL.md` has its own ceiling (the whole always-loaded surface of an installed host) and `AGENTS.md` its own (the checkout-only floor), because one budget over a pair that exists only in a checkout let the floor's bytes squeeze the installed contract to zero headroom — the pair sat at exactly 16384 bytes when #838 was found. | +| Cross-route research baseline (#716, 2026-08-23) | `skills/fomo-kernel/references/research-priors.md` is the single catalogue and the single statement of how far a prior may travel. Its readers: `references/decision-framing.md` (the route that already had it — the baseline is its top sentence) ↔ `references/trade-consequence.md` ("The research baseline, and why it may not fill a gap") ↔ `references/freeform-answers.md` ("The research baseline is available here too") ↔ `docs/expression-contract.md` §6 ↔ `tests/test_research_priors.py`. Four rules. **The catalogue is reachable from every route that answers a decision, not only from the one with no book.** Before this row it was linked by exactly one file, and the user-visible consequence was inverted evidence value: a user with nothing recorded heard that broad diversification is the baseline and that an index label alone does not establish breadth, and the same user, after handing over a book, got weights and concentration and could no longer reach that baseline at all. `test_b_the_catalogue_is_reachable_from_every_route` is what fails when a route's link is dropped, and it walks `SKILL.md → route file → catalogue` rather than asserting the catalogue exists, because a reference nothing names is text nothing loads (the `profile.md` precedent). **An engine fact dominates a prior, and that boundary has one home.** `research-priors.md`'s own "An engine fact dominates a prior" section owns it: a prior may interpret a deterministic result, never replace, substitute for, or fill a gap in one, and it may not invent a cap, an allocation, or a threshold the user has no rule for and the engine did not compute. The two book-bearing routes carry that sentence **verbatim, not paraphrased**, and name the section it comes from: a paraphrase is how five independent phrasings of answer-first happened (#832), and a pointer alone is a boundary the agent has to open a second file to learn. This is #834's answer applied to a rule instead of an exemplar — the same text in three places, made mechanical rather than forbidden, with `test_e_the_engine_fact_boundary_is_one_sentence_in_three_places` failing on any drift and `test_f_reachability_and_boundary_mutations_are_caught` proving it fails. `decision-framing.md`'s "A missing number is never replaced by a general rule" red line stays where it is and gains a pointer: it is the *no-book* form (no computed weight exists at all), and the new section is the form for the routes that do have one. **It is a claim authority, not a derivation.** It adds no shape parameter to any surface, so `expression-contract.md` §6's derivation column is deliberately **unchanged** and the catalogue appears in the last column instead; `freeform-answers.md`'s empty derivation (#832) stays empty and says so in the new section itself. A prior enters an existing block, is subject to §3's increment gate like any other, and a standing paragraph on the value of diversification is the `default_as_insight` ban with a citation stapled to it. **No runtime surface moved**: no engine file, schema, `.json` payload contract, new field, or new engine vocabulary — #716 §1 says the first version needs no runtime schema, and `SKILL.md` is untouched because it already routes to all three route files, which is the whole point of #507's byte budget on the always-loaded pair. Why the row exists: #716's title is "Cross-route evidence-backed decision priors" and the shipped implementation (#727) wired exactly one route, so the issue's own §4 — the boundary that makes the other routes safe — had never been written down anywhere. | +| The six non-negotiable boundaries reach the installed host (#838, 2026-08-23) | `README.md` installs the product by symlinking `skills/fomo-kernel/` alone, so `AGENTS.md` — and any rule stated only there — does not exist on an installed host. The floor's six boundaries therefore live twice: `AGENTS.md` "## Non-negotiable boundaries" (the checkout floor) ↔ `skills/fomo-kernel/SKILL.md` (each boundary stated in the section that exercises it; the privacy boundary as its own "Private data stays local" section, key sentences verbatim — except the anything-public example list, deliberately re-cast for the installed audience as "a shared card, an example, a bug report" where the floor names the maintainer's issues/PRs/fixtures/receipts) ↔ `skills/fomo-kernel/references/agent-boundaries.md` (the routed may/may-not contract `SKILL.md` names as holding throughout; carries the CLI whitelist, the hand-assembly ban, and the third-party/cloud privacy sentence). No text runtime surface under `skills/fomo-kernel/` (every `.md`/`.json`/`.html`/`.txt`) may cite `AGENTS.md` for a rule — its reader may not have the file; `references/freeform-answers.md` stating the card-privacy default instead of citing "AGENTS.md invariant 4" is the pattern. `.py` comments under `engine/` and `tools/` sit outside that scan deliberately: boundary 1 keeps an installed agent out of engine internals, so those citations are maintainer-facing rationale, not instructions an installed reader follows. `tests/test_installed_skill_tree.py` reads **only** `skills/fomo-kernel/` and fails when a boundary's statement retreats to the repository root — every other suite reads the checkout, which is exactly why #838 shipped unseen. The #507 byte budget split with it: `SKILL.md` has its own ceiling (the whole always-loaded surface of an installed host) and `AGENTS.md` its own (the checkout-only floor), because one budget over a pair that exists only in a checkout let the floor's bytes squeeze the installed contract to zero headroom — the pair sat at exactly 16384 bytes when #838 was found. | Date product assumptions when using them for prioritization. Reconfirm assumptions that are several weeks old or contradicted by new evidence. diff --git a/skills/fomo-kernel/references/trade-consequence.md b/skills/fomo-kernel/references/trade-consequence.md index 610be79d..bc917352 100644 --- a/skills/fomo-kernel/references/trade-consequence.md +++ b/skills/fomo-kernel/references/trade-consequence.md @@ -543,7 +543,7 @@ A rejected case is refused before it is stored or shown: the caller gets the val ## Recording what the user did -Persistent calls are recorded in a local, append-only log. Candidate fan-out uses `--ephemeral`, which computes against the existing recorded book and writes no evaluation; rerun only the selected or live candidate without the flag. +Persistent calls are recorded in a local, append-only log. Candidate fan-out uses `--ephemeral`, which computes against the existing recorded book and writes no evaluation. A persistent rerun without the flag needs the user's explicit selection; a standing recommendation is not one. **When a persistent call happens, say once that the record is a consideration and not an execution.** This is one of the answer's keeps, and it is owed exactly when a canonical write occurred — never on an `--ephemeral` fan-out, which recorded nothing to be confused about. The user cannot see the difference between "stored what you were weighing" and "placed the order" from where they sit, and the difference is real money. diff --git a/tests/test_doc_language.py b/tests/test_doc_language.py index d83d4b3a..c6b46872 100644 --- a/tests/test_doc_language.py +++ b/tests/test_doc_language.py @@ -1365,14 +1365,16 @@ def always_loaded_runtime_violations(root): (root / "skills" / "fomo-kernel" / "SKILL.md", INSTALLED_CONTRACT_BUDGET_BYTES), (root / "AGENTS.md", CHECKOUT_FLOOR_BUDGET_BYTES), ] - missing = [str(path.relative_to(root)) for path, _ in budgets if not path.is_file()] + missing = [path.relative_to(root).as_posix() for path, _ in budgets if not path.is_file()] if missing: return [f"always-loaded runtime surface is missing: {', '.join(missing)}"] problems = [] for path, budget in budgets: size = path.stat().st_size if size > budget: - rel = path.relative_to(root) + # POSIX form, so the mutation test's file-naming assertions hold + # on every OS rather than only where the native separator is "/". + rel = path.relative_to(root).as_posix() problems.append( f"{rel} is {size} bytes, over its own {budget}-byte budget. " "Route the new material to the document that owns its task " diff --git a/tests/test_installed_skill_tree.py b/tests/test_installed_skill_tree.py index c3c51212..7907c42d 100644 --- a/tests/test_installed_skill_tree.py +++ b/tests/test_installed_skill_tree.py @@ -55,7 +55,7 @@ "every portfolio-derived number, the portfolio basis, every " "identity, every `rule_effect`, and every state transition " "is the engine's", - "never recompute", + "never recompute, adjust, or fill its gaps", ], }, }, @@ -158,6 +158,27 @@ def load_boundary_sources(tree): return sources +def collapsed(text): + """One-space form of ``text``, for phrase matching and mutation alike. + + A cosmetic hard-wrap re-flow of a pinned sentence must never read as a + lost boundary, and a mutation must redden for the rule it removed, not + for the line breaks around it -- the same reason `test_doc_language.py` + matches its floor phrases collapsed. + """ + return " ".join(text.split()) + + +def phrase_present(sources, rel, phrase): + """Whether ``phrase`` appears in ``sources[rel]``, whitespace-collapsed. + + The one membership definition every checker and mutation arm in this + module shares -- two inline copies of this check drifting apart would let + the live gate and its mutation proof disagree on what "present" means. + """ + return collapsed(phrase) in collapsed(sources.get(rel, "")) + + def boundary_violations(sources): """``(label, file, phrase)`` for every phrase missing from ``sources``. @@ -167,9 +188,8 @@ def boundary_violations(sources): violations = [] for boundary in BOUNDARIES: for rel, phrases in boundary["files"].items(): - text = sources.get(rel, "") for phrase in phrases: - if phrase not in text: + if not phrase_present(sources, rel, phrase): violations.append((boundary["label"], rel, phrase)) return violations @@ -182,7 +202,7 @@ def reachability_violations(sources): `SKILL.md` is the sole file that host loads unprompted. """ problems = [] - if f"`{ROUTED_BOUNDARY_FILE}`" not in sources.get("SKILL.md", ""): + if not phrase_present(sources, "SKILL.md", f"`{ROUTED_BOUNDARY_FILE}`"): problems.append( f"SKILL.md does not name `{ROUTED_BOUNDARY_FILE}` -- the " "boundaries that file carries are text nothing loads") @@ -191,19 +211,28 @@ def reachability_violations(sources): return problems -def iter_markdown_and_json_sources(tree): - """``(relative_path, text)`` for every ``.md``/``.json`` file under ``tree``. +# Every file type an installed reader can be routed to as instructions, +# contract, or template. ``.py`` is excluded deliberately, wherever it sits: +# boundary 1 keeps an installed agent out of engine internals, so an +# `AGENTS.md` mention in a code comment is maintainer-facing rationale, never +# an instruction an installed reader follows. The maintainer guide's +# mirrored-surfaces row states the same scope. +TEXT_RUNTIME_SUFFIXES = (".md", ".json", ".html", ".txt") + - ``.py`` files under `engine/` and `tools/` are outside this walk - deliberately, and the maintainer guide's mirrored-surfaces row states the - same scope: boundary 1 keeps an installed agent out of engine internals, - so an `AGENTS.md` mention in a code comment there is maintainer-facing - rationale, never an instruction an installed reader follows. +def iter_text_runtime_sources(tree): + """``(relative_path, text)`` for every text runtime surface under ``tree``. + + Scope is ``TEXT_RUNTIME_SUFFIXES`` -- the card templates and + `requirements.txt` are runtime surfaces too, not only the markdown. + Paths are keyed in POSIX form so assertions match on every OS, and text + is read strictly: an undecodable byte in a runtime surface is its own + defect, not something to skip past silently. """ for path in sorted(tree.rglob("*")): - if path.is_file() and path.suffix in (".md", ".json"): - yield str(path.relative_to(tree)), path.read_text( - encoding="utf-8", errors="ignore") + if path.is_file() and path.suffix in TEXT_RUNTIME_SUFFIXES: + yield path.relative_to(tree).as_posix(), path.read_text( + encoding="utf-8") def citation_violations(sources): @@ -254,7 +283,7 @@ def test_no_file_under_the_installed_tree_cites_agents_md(): """An installed reader has no `AGENTS.md` -- a citation pointing at it from inside `skills/fomo-kernel/` is dead on arrival for that reader. """ - violations = citation_violations(iter_markdown_and_json_sources(SKILL_TREE)) + violations = citation_violations(iter_text_runtime_sources(SKILL_TREE)) assert not violations, ( "skills/fomo-kernel/ cites AGENTS.md, which an installed host cannot " "read:\n " + "\n ".join(violations) @@ -279,17 +308,21 @@ def test_boundary_and_citation_checks_are_mutation_proof(): for boundary in BOUNDARIES: for rel, phrases in boundary["files"].items(): for phrase in phrases: - assert sources_has_phrase(real_sources, rel, phrase), ( + assert phrase_present(real_sources, rel, phrase), ( f"fixture assumption broken: {phrase!r} not found " - f"verbatim in {rel}") + f"in {rel}") + # Mutate the collapsed form, the same form the checker + # matches on -- removing the raw phrase would miss a pin + # that the file wraps across lines. mutated = dict(real_sources) - mutated[rel] = real_sources[rel].replace(phrase, "", 1) + mutated[rel] = collapsed(real_sources[rel]).replace( + collapsed(phrase), "", 1) violations = boundary_violations(mutated) assert (boundary["label"], rel, phrase) in violations, ( f"removing {phrase!r} from {rel} did not redden " f"boundary {boundary['label']!r}") - real_md_json = dict(iter_markdown_and_json_sources(SKILL_TREE)) + real_md_json = dict(iter_text_runtime_sources(SKILL_TREE)) # Two injection targets: the tree's top level, and a file down inside # `references/` -- the second pins that the walk actually descends into # subdirectories, so narrowing `rglob` to a flat listing reddens here @@ -315,7 +348,7 @@ def test_boundary_and_citation_checks_are_mutation_proof(): "fixture assumption broken: the routed boundary file is already " "unreachable") unpointed = dict(real_sources) - unpointed["SKILL.md"] = real_sources["SKILL.md"].replace( + unpointed["SKILL.md"] = collapsed(real_sources["SKILL.md"]).replace( f"`{ROUTED_BOUNDARY_FILE}`", "", 1) assert reachability_violations(unpointed), ( "deleting SKILL.md's pointer to the routed boundary file would " @@ -331,10 +364,6 @@ def test_boundary_and_citation_checks_are_mutation_proof(): "a tree with no SKILL.md at all would stay green") -def sources_has_phrase(sources, rel, phrase): - return phrase in sources.get(rel, "") - - _REGISTERED_TESTS = [] # populated by main(); see the registration self-check diff --git a/tests/test_research_priors.py b/tests/test_research_priors.py index 4d5ba70c..080433f2 100644 --- a/tests/test_research_priors.py +++ b/tests/test_research_priors.py @@ -24,7 +24,9 @@ "consider": CONSEQUENCE, "freeform answers": FREEFORM, } -BOOK_BEARING = ("consider", "freeform answers") +# Derived, not hand-listed: ROUTE_FILES is the authoritative enumeration, and +# a route added or renamed there must not leave a second collection stale. +BOOK_BEARING = tuple(s for s in ROUTE_FILES if s != "no recorded book") CATALOGUE_LINK = "[research-priors.md](research-priors.md)" # #716 section 4, the boundary that makes the book-bearing routes safe. It is @@ -120,7 +122,7 @@ def test_a_the_catalogue_has_only_the_audited_priors_and_the_required_fields(): assert field in section, f"{prior} is missing {field}" -def test_b_the_catalogue_is_reachable_from_every_book_bearing_route(): +def test_b_the_catalogue_is_reachable_from_every_route(): """#716's defect, stated as the thing that must now be true. The shipped implementation (#727) wired the no-book route only, and the issue's own title is *cross-route*: a user who hands over a book must not lose the