Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/maintainer-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ When multiple sessions are active:
| The price recovery kit, and the two lanes that degrade in opposite directions (#629) | `review._price_feed_status` is the single builder of the kit — `provenance`, `request`, the `recovery` block above, and the instruction. Two routes call it and neither restates it: `cmd_prepare` (through the plan's `input.price_feed`) and `review._consider_price_feed_status`, which supplies only this route's own inputs and delegates. Only two things differ between them, and both are parameters rather than a second sentence: `command` (which subcommand takes the envelope back) and `request_path` (where the caller finds the manifest in the payload it is holding). `review._declared_prices_unavailable` is likewise the single reader of `--prices-unavailable`, now accepted by both commands, so one cannot accept a declaration the other refuses. Readers: `references/price-feed.md` ("Two lanes, two opposite rules" — the single statement of the asymmetry) ↔ `references/freeform-answers.md` (the agent-facing bound, and why the lookup is not the multi-tool production rule 8 forbids) ↔ `references/trade-consequence.md` ("When the engine could not price the book") ↔ `SKILL.md` rule 8 / `AGENTS.md` boundary 8 ↔ `tests/test_consider.py` section O. **The asymmetry is the point and must stay written down**: the identical `--prices-unavailable` declaration delivers the degraded card on the review lane and *refuses* on `consider`. A retrospective card's cost weights describe what the user actually paid; a forward concentration decision computed on cost describes a book that no longer exists — on `mock/sample_momentum.csv` the largest position moves more than thirteen points and the second and third by size invert. `test_o_the_recovery_kit_is_the_prepare_builder_rather_than_a_second_one` is the anti-fork gate: it rewrites `prepare`'s own sentence with those two substitutions and requires `consider`'s to start with it, so a hand-written second manifest reddens. Deliberately **not** gated: whether a host actually performs the lookup, the same instruction-only footing `docs/development-guide.md` §4 admits for the recommendation ban. |
| The cash anchor, and when it is asked for (#357, #507 knife 1) | `review.py`'s `_cash_anchor_status` is the single statement of whether a review has an accounting anchor — every other surface reads `review_plan.input.cash_anchor` rather than re-deriving it from `state["cash"]`. Its readers: `schemas/review-plan.schema.json` (`input.cash_anchor`, `additionalProperties: false`) ↔ `review.py` `cmd_add_cash` / `_cash_recompute_drift` ↔ `tools/ux_receipt.py`'s `CASH_OUTCOMES` and the outcome-dependent ordering gate ↔ `references/data-contract.md` (the one full statement of the beat) ↔ `references/ux-receipt.md` ↔ `references/interaction-delivery.md` ↔ `flows/first-review.md` and `flows/weekly-review.md` (each one short route-specific pointer — the two verbatim-identical preflight paragraphs #358 shipped were an unregistered hand mirror and are gone) ↔ `docs/qa-runbook.md` gate 3 ↔ `SKILL.md` "Fixed lifecycle" step 4 ↔ `tests/test_review_v2.py` + `tests/test_interaction_trajectory.py`. Four rules. **The engine states the gap in both directions**: with an anchor it already demanded a disclosure (`acct_perf_basis` in `required_honesty_keys`) and without one it demanded nothing at all, so the single condition the agent had to notice unprompted was the only one the plan never mentioned — five recurrences, three on real data. `not_applicable` is a positive claim with a `reason`, never an absent key, which is what makes the light-tier promise mechanical instead of depending on a flow paragraph asking before a tier check three lines later (#358 finding 1). **The timing is the opposite of `input.price_feed.request`'s, and `ask_after` says so**: a price gap is recovered before the user sees anything because every price-dependent number is degraded; a missing anchor degrades one pillar and leaves the rest byte-identical, so it is asked at the card beat where the user can see what answering buys (owner ruling 2026-07-30 against a `prepare` hard block; #507 principle 1). **The recompute proves it was the same review rather than asserting it**, and #665 is what that costs when the proof is aimed at the wrong thing. `add-cash` re-enters `_prepare_session` — never a second implementation of run-engine-ingest-plan — and the two halves below are what make that a recompute rather than a second review. **It reuses the frame instead of re-resolving it** (`args.amending_session` → `market_data.FROZEN_ENV` → `market_data.frame_frozen` → `_from_frozen_frame`, an *exact-request* same-day reuse that never reaches a provider on any outcome). The same-day cache was carrying that claim before and could not: `MarketDataBundle.covers` deliberately refuses to serve a request naming a symbol the stored bundle failed to price, so a transient outage gets its retry — and one unpriced symbol anywhere in the universe therefore made this command re-price the whole review at a second instant. **And the gate compares source facts, not derived objects.** `CASH_RECOMPUTE_SOURCE_FACTS` names them positively — the input files, the frozen frame, the recorded book, the diagnosis, the rules offered, the question identities already answered — because the predecessor asked the opposite question (exclude a hand-maintained set of cash-derived keys from `engine_state`/`engine_card`, then compare `state_snapshot` and `question_queue` **whole**) and that shape cannot be kept true: the re-entry's own downstream effects arrived as "drift", the refusal named four surfaces a cash anchor cannot reach, and a card-beat answer became unrecordable. Two rules for adding a row: it must be an input or a frozen observation rather than a projection of one, and it must be *measured* invariant under `--cash` on the same book (across five mock books an anchor moves `engine_state.cash`, `engine_card`'s `cash`/`acct_perf`/`honesty_ledger`, and `card_plan.required_honesty_keys`, and nothing else). The refusal names which of the gate's two verdicts it reached — "the facts moved", never merely that something did — and its counterpart `recompute.outcome: "anchor_propagated"` is emitted on success so the allowed outcome is stated too. A transaction file that grew is refused **before any write**, inside `_verify_and_ingest_frozen_trades` (`amending=True`, on the engine's own `fresh` dedup): a gate that only reads the recomputed plan has already appended the rows it is about to refuse. **No receipt outcome means "the agent decided not to ask"**: `skipped` was that value, and #357's fifth recurrence recorded it correctly and in order while the user was never offered the question, so the gate passed and the experience was identical to forgetting. |
| Behavior verdict store (#446 cut 1) | `horizon.py`'s existing `horizon_contradiction()` judgment ↔ `review.py` `_horizon_markers_all` (the untruncated computation; `_horizon_markers` is only its `HORIZON_MARKER_LIMIT`-bounded slice for the card's attention budget — the limit is a *display* cap, never a storage cap) ↔ `review.py` `_horizon_verdict_rows` (assembled only from what `_build_plan` already stamped into the plan — `engine_state`, `state_snapshot.thesis_states`, `state_snapshot.recent_exits` — never re-derived from disk or from a second, differently-sourced `active_cycle_ids`) ↔ `engine/verdicts.py` (`build_horizon_verdict`, the pure `replay()` contract, `RULE_PARAMS`/`RULE_PARAM_DIGEST`) ↔ `schemas/behavior-verdict.schema.json` (no `input` sub-object — every field is engine-assigned, the same reason `condition-check.schema.json` refuses `user_response` on its own envelope) ↔ `session.py`'s projection appending to `<root>/verdicts.jsonl`, the same firewall `conditions.py:66-72` gives `conditions.jsonl` — a verdict never enters `problems.check_rules`' mechanical reconciliation ↔ `coach.py`'s `DATA_FILES` ↔ `tests/test_verdicts.py` (+ `tests/test_review_v2.py::test_all_json_schemas_parse`, `tests/test_coach_data_cli.py`). A verdict recomputes to the same `outcome` from exactly its five frozen scalar fields — `replay(rule.id, rule.version, subject.value, observed.value, observed.closed)` — forever, including after the thesis row `subject.row_id` names is revised (a revision writes a new `event_id`; the old one, and every verdict pointing at it, is never rewritten) or a future profile label `profile_label_id` references is superseded (that field is a back-reference only, load-bearing for nothing; `replay`'s signature carries no parameter for a labels store at all). The one way replay could fail silently — a threshold edited without a version bump — is blocked mechanically: `RULE_PARAM_DIGEST` pins `RULE_PARAMS`'s hash as a literal (never recomputed live, which would make the check vacuous), and a companion test cross-checks `RULE_PARAMS`'s live version against `horizon.py`'s own `EXIT_FAST`/`HELD_LONG` constants, so an edit landing in either file is caught. |
| What `planned_entry` obliges the agent to collect, and what the honesty gate demands (#667) | `question_surface._INITIAL_THESIS_REQUIREMENTS["planned_entry"]` (the declared `answer_contract.requirements_by_choice`, read by the agent before it answers) and `review.py` `_validate_thesis_completeness`'s `inferred_planned` check (the enforced rule, read after the answer arrives) describe the same fact from two hand-written literals rather than one derivation — the same shape `new_evidence`'s `_ADD_REQUIREMENTS` / `thesis.build_decision_events` already accept for this reason, so this is not a new class of drift, only a new instance of one. If the validator's rule ever changes (a new required `thesis_updates` field, a different accepted `maturity` set), the declared requirement must change with it or the contract silently promises less than the gate demands again. `tests/test_question_surfaces.py::test_planned_entry_declares_its_thesis_capture_requirement_and_others_stay_empty` pins today's value; nothing mechanically ties the two sides together. |

Date product assumptions when using them for prioritization. Reconfirm assumptions that are several weeks old or contradicted by new evidence.

Expand Down
22 changes: 20 additions & 2 deletions skills/fomo-kernel/engine/question_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,19 @@
"external_constraint": [],
"skip": [],
}
# #667: `planned_entry` asserts a real thesis existed at entry, and
# `review.py` `_validate_thesis_completeness` refuses to record that assertion
# against a silently-inferred update (#291). Before this, the declared
# contract promised nothing further for this choice, so an agent that
# collected exactly what the contract named still landed in that refusal --
# a dead end the contract never predicted. The capture itself is not a new
# object: it is the same `thesis_updates` row every `missing_thesis_positions`
# entry already carries (`flows/first-review.md` step 3), named here so the
# agent knows to make it a *real* capture -- non-placeholder `why`/
# `exit_trigger` and a `maturity` other than `inferred` -- in this same
# exchange, rather than the default inferred placeholder.
_INITIAL_THESIS_REQUIREMENTS = {
"planned_entry": [],
"planned_entry": ["thesis_updates.why", "thesis_updates.exit_trigger", "thesis_updates.maturity"],
"momentum_follow": [],
"external_call": [],
"no_clear_thesis": [],
Expand Down Expand Up @@ -111,7 +122,14 @@ def _requirement_copy(language, kind, choices):
"skip": "",
}
if kind == "initial_thesis":
return {key: "" for key in _INITIAL_THESIS_REQUIREMENTS}
return {
"planned_entry": ("Requires your real reason for entering and what would prove "
"it wrong, captured now rather than filled in as a placeholder."
if en else
"必須提供你進場時真正的理由,以及什麼情況會證明這個想法是錯的,"
"現在就講清楚,不能只是先佔位的推測。"),
"momentum_follow": "", "external_call": "", "no_clear_thesis": "", "skip": "",
}
if kind == "condition_crossing":
# One map covers both vocabularies, keyed off this question's own
# canonical choices — a numeric surface never advertises `yes`.
Expand Down
6 changes: 6 additions & 0 deletions skills/fomo-kernel/engine/review.py
Original file line number Diff line number Diff line change
Expand Up @@ -4384,6 +4384,12 @@ def _validate_thesis_completeness(plan, answers):
# it — that cycle must carry a real capture (maturity draft/testable). Every
# other answer (momentum_follow/external_call/no_clear_thesis/skip) keeps
# inferred legal, so different answers produce different downstream state.
# #667: this rule is enforced here, strictly, and unchanged by that fix — the
# defect it found was that `planned_entry`'s own declared answer contract
# (`question_surface._INITIAL_THESIS_REQUIREMENTS["planned_entry"]`) named
# none of this, so an agent could satisfy the contract and still land in the
# refusal below. Keep the two in sync (maintainer-guide.md mirrored-surfaces
# table) rather than loosening this check.
answer_choice = {row.get("question_id"): row.get("choice")
for row in (answers.get("answers") or []) if isinstance(row, dict)}
planned_entry_cycles = {q.get("cycle_id") for q in plan.get("question_queue") or []
Expand Down
2 changes: 2 additions & 0 deletions skills/fomo-kernel/flows/first-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ When the `exit_trigger` names something checkable, send it as a condition slot o

The engine prefills `ticker`, `maturity:"inferred"`, and provenance. Never present an inferred thesis as user-confirmed. Optional fields (`stop`, `target_size`, `driver`, and the inference-only accumulation fields in `authoring_contract`) follow one rule: fill one only when the conversation contains a real signal, leave it null otherwise, and never add a question for it. An invented source or emotion is worse than a missing one, and these cannot be backfilled later. Upgrade `_inferred` to false or `source_confidence` to `"confirmed"` only when the user volunteers it.

One choice does not tolerate the inferred default: `planned_entry` asserts a real thesis existed at entry, and its own `question_opportunity.answer_contract.requirements_by_choice` names this same row's `why`, `exit_trigger`, and `maturity` as required (#667). When the cycle's `initial_thesis` answer is `planned_entry`, collect the user's real reason and real falsifier in that same exchange and submit `maturity:"testable"` or `"draft"` for it — `preview` refuses a `planned_entry` cycle left at `maturity:"inferred"`. Every other answer (`momentum_follow`, `external_call`, `no_clear_thesis`, `skip`) keeps the inferred placeholder legal.

**4. Write the narrative, qualitative and digit-free.** `headline` and `mirror` are required; `counterfactual` and `strength` are optional. Cover every `card_plan.required_honesty_keys` entry with one sentence in `narrative.honesty` (wording guidance in `card-spec.md`). Frame coverage gaps the engine never asked about as neutral facts per `authoring_contract.narrative.unprompted_gaps` — they are not the central judgment of the card. Optionally add `synthesis`: a closing block after the rule that connects facts sitting in separate sections into one point of view. If it only restates a number already on the card, leave it out. Do not write `rule_rationale`; the engine derives the rule's trade-off sentence itself and the field survives only so older sessions stay readable.

**5. Run `preview`.** If validation fails, fix the artifact the error names rather than working around the gate.
Expand Down
12 changes: 7 additions & 5 deletions tests/test_preview_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,13 @@ def _choice(question):
The two routes this suite drives queue different kinds -- `initial_thesis`
on the CSV first review, `headline_motive` on a test drive -- so the answer
cannot be one literal. It is an explicit map rather than a read of the
plan's own `requirements_by_choice`, because that contract describes a
choice's own follow-up fields and not the cross-field rules layered on top
of it: `planned_entry` advertises no requirements and still needs a
captured thesis of maturity draft or testable. This suite owns the preview
precondition; the question contracts belong to the suites that own them.
plan's own `requirements_by_choice`, because a choice whose declared
requirement resolves to a `thesis_updates` capture (`planned_entry`, #667)
would still need this suite to construct one, and this suite is about
the preview/finalize precondition, not thesis-capture mechanics.
`no_clear_thesis` keeps every question here payload-free by construction;
the question contracts themselves belong to the suites that own them
(`test_question_surfaces.py`, `test_review_v2.py`).
"""
return {
"initial_thesis": "no_clear_thesis",
Expand Down
27 changes: 27 additions & 0 deletions tests/test_question_surfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,33 @@ def test_exit_consistency_opportunity_is_engine_owned_and_grounded():
assert review_engine._exit_consistency_question({"ticker_diagnosis": []}, "en") is None


def test_planned_entry_declares_its_thesis_capture_requirement_and_others_stay_empty():
"""#667: `planned_entry` asserts a real thesis existed at entry, and
`review.py` `_validate_thesis_completeness` refuses to record that
assertion against a silently-inferred update (#291). Before this fix, the
declared contract promised nothing further for this choice -- an agent
that satisfied the *declared* contract could still land in that refusal, a
dead end the contract never predicted. The fix names the requirement here,
reusing `new_evidence`'s existing shape (a list of required field paths
plus non-empty requirement text) rather than inventing a parallel capture
object -- the paths resolve into the same `thesis_updates` row every
missing-thesis position already carries (`flows/first-review.md` step 3).
"""
question = review_engine._initial_thesis_question(
"AAA", {"cycle_id": "AAA#2026-01-01#1", "currency": "USD"}, 5000,
{"ticker_diagnosis": []}, {"holdings": {"positions": {}}}, "en",
)
contract = question["question_opportunity"]["answer_contract"]
assert contract["requirements_by_choice"]["planned_entry"] == \
["thesis_updates.why", "thesis_updates.exit_trigger", "thesis_updates.maturity"]
assert contract["requirement_text_by_choice"]["planned_entry"], \
"a declared requirement needs agent-facing requirement text"
for choice in ("momentum_follow", "external_call", "no_clear_thesis", "skip"):
assert contract["requirements_by_choice"][choice] == [], \
f"{choice} keeps an inferred record legal (#291) and must stay unrequired"
assert contract["requirement_text_by_choice"][choice] == ""


def _grounded_headline(ticker, pct):
raw = {"dim": "部位 sizing", "max_ticker": ticker, "max_pct": pct,
"risk_weights": {ticker: pct}}
Expand Down
Loading
Loading