feat(observer): surface segment delivery freshness in list/status
Status and Last Seen reflect any beacon, including an empty status beacon that refreshes last_seen without a delivery because record_status_beacon sets last_seen before its all-None early return. Neither signal showed whether an observer was actually delivering segments. Persist last_segment_received_at and last_segment_day as a denormalization of the existing observer history records so list remains O(1) per row across the fleet.
All age rendering derives from last_segment_received_at. last_segment_day is contextual only and never drives an age calculation.
The compact age formatter floors units, so 19.5h renders 19h. Unknown segment freshness renders —, deliberately distinct from _fmt_time's never for unknown last_seen. Duplicate ingest remains unchanged because a duplicate is not a new delivery. reconcile_observers remains untouched because the new keys are top-level siblings of last_segment, not stats counters, so _aggregate_stats never sees them. There is no backfill: absent means unknown, and records self-populate on the next receipt.
The observer client contract manifest pins routes.py's sha256, so editing the receipt path required regeneration. Bump the bundle patch version from 2.1.8 to 2.1.9 because no wire surface changed: _serialize_observer is untouched, so the new keys are not exposed to clients.
Gates run:
- make format — exit 0
- make test-only TEST=tests/test_observer_cli.py — exit 0, 40 passed
- make test-app APP=observer — exit 0, 266 passed
- make check-layer-hygiene — exit 0
- make check-openapi — exit 0
- make ci — exit 0, 14661 passed, 16 skipped
This lode touches no Rust, so unit CI not building the Rust tree is expected and fine.
feat(convey): make the notification icon a Lucide name
The notification icon contract is now a Lucide icon name, not an emoji. Emitters in JS, Python, and sol notify help send names; the renderer resolves names to vouched SVG and never renders the caller's value as markup. A legacy glyph-to-name table keeps already-persisted localStorage['solstone:notification_history'] entries rendering correctly; that is a migration path for external data, not a backwards-compatibility shim.
The honest price is that this route deliberately manufactures window.ConveyIcons.svg('name') literals inside the notifications object whose only consumer is the guard test's allow_list <= used direction. It trades a one-line regex change for a hand-maintained duplicate list that must stay in sync with CONVEY_ICON_NAMES, and it narrows the notification namespace from 36 available names to 13. It is the right call here because the safety property - a caller can never inject markup through icon - is worth it. It is not parity with L3's genuinely-free zero-mechanism route, and a later session must not read it as precedent for always manufacturing literals.
D5 and D6 were fixed, not deferred. ERROR_ICON in SurfaceState was a hand-rolled inline triangle-alert SVG; it now uses window.ConveyIcons.svg('triangle-alert'). _updateCard never refreshed the icon even though show() updates existing.icon for keyed notifications - invisible while both were the same emoji, but a stale icon under resolved SVG. It now refreshes from the resolver.
The browser Notification icon member was dropped because it is specified as a URL; the old emoji was parsed as a relative URL and fetched, which always failed. Title, body, and tag still deliver.
feat(convey): make the notification icon a Lucide name
The notification icon contract is now a Lucide icon name, not an emoji. Emitters in JS, Python, and sol notify help send names; the renderer resolves names to vouched SVG and never renders the caller's value as markup. A legacy glyph-to-name table keeps already-persisted localStorage['solstone:notification_history'] entries rendering correctly; that is a migration path for external data, not a backwards-compatibility shim.
The honest price is that this route deliberately manufactures window.ConveyIcons.svg('name') literals inside the notifications object whose only consumer is the guard test's allow_list <= used direction. It trades a one-line regex change for a hand-maintained duplicate list that must stay in sync with CONVEY_ICON_NAMES, and it narrows the notification namespace from 36 available names to 13. It is the right call here because the safety property - a caller can never inject markup through icon - is worth it. It is not parity with L3's genuinely-free zero-mechanism route, and a later session must not read it as precedent for always manufacturing literals.
D5 and D6 were fixed, not deferred. ERROR_ICON in SurfaceState was a hand-rolled inline triangle-alert SVG; it now uses window.ConveyIcons.svg('triangle-alert'). _updateCard never refreshed the icon even though show() updates existing.icon for keyed notifications - invisible while both were the same emoji, but a stale icon under resolved SVG. It now refreshes from the resolver.
The browser Notification icon member was dropped because it is specified as a URL; the old emoji was parsed as a relative URL and fetched, which always failed. Title, body, and tag still deliver.
feat(think): synthesis access-tier — sol-surface-only cogitate (no raw-fs reads)
Add a fourth locked cogitate access tier `synthesis`
(sol=True, reads=False, submit=False): the journal is reached only through
`sol` domain commands, with no raw-filesystem read tier (read_file /
list_directory / glob / grep_search) and no outbound submit. It fills the last
meaningful corner of the (reads, submit) capability space — `outbound` was the
only reads=False tier but it drags submit=True along, which is wrong for a pure
synthesis talent.
This is the mechanism half of the weekly_reflection fs-fallback fix; 23d6498a
shipped the prose ("gather only through sol; do not walk the raw tree"), and a
2026-06-17 weekly run still logged 12 fs-fallback + 4 repeat-flail of 29 actions
because the read tools were merely discouraged, not withheld. The tier removes
them at registration, so the constraint the prose asks for is now enforced.
Set weekly_reflection + partner to `synthesis`. weekly_reflection needed no
body change (prose already forbids raw reads). partner had a live Step-1
`read_file identity/partner.md` dependency, swapped to `journal identity
partner` (the settled sol-surface read form, policy-allowed at reads=False).
The locked-contract RuntimeError guards keep COGITATE_ACCESS_TIERS in sync with
the capability map; all caps/display/validation consumers are contract-driven
and auto-adapt (openhands tool registration, cogitate_policy, talent_cli
inventory + tier audit). Updated the contract vocabulary-lock test, added the
capability row, made the finalization-harness tool-surface assertion tier-aware
(weekly_reflection is now sol-only), and documented the tier in docs/COGITATE.md.
refactor(convey): fold shared chrome copy casing and ellipses
Fold manifest-touched shared Convey action, report, diagnostic, status, date-nav, day-grid, and shell fallback copy. Keep the Python copy constants and browser mirrors aligned for the approved CONVEY_ACTION_* and CONVEY_REPORT_* rows.
Jer override: the absent 'What the journal said:' row is a no-op, so Server reason: deliberately remains unchanged in convey/copy.py, convey_copy.js, app.js, and diagnostic-console.js.
feat(convey): tear down the Jinja shell; guard against its return
The SPA conversion (L1–L6) flipped all 22 owner apps to the static
shell (convey/static/shell.html). This capstone removes the now-dead
Jinja container and makes shell-serving unconditional:
- Delete the unrendered Jinja shell: templates/{app.html, menu_bar,
date_nav, status_pane, diagnostic_console, chat_bar}.html. Only the
static, construct-free init.html remains under convey/templates/.
- Retire the transitional `spa` app flag end-to-end: drop the App
dataclass field, its parse, all 22 app.json entries, and the
/api/shell payload key. The injected index route now always serves
shell.html and the workspace fragment route serves unconditionally;
workspace_url is non-null for every app.
- Remove the orphaned App.get_workspace_template (get_background_template
is retained and unchanged).
- Add an AST-based CI guard to scripts/check_api_conventions.py: flag any
flask render_template(...) call under solstone/apps + solstone/convey
(excluding tests), resolved by import binding so the local
provider_readiness._render_template helper is not matched. The two PDF
routes (news, reflections) are the only allowlisted call sites. A
construct-free guard asserts convey/templates/ carries no Jinja markers.
Executable negative controls prove both guards fire.
No app's rendered output changes — this is dead-code removal plus an
anti-regression guard. Docs (CONVEY-FRONTEND.md, APPS.md, CONVEY.md)
updated to retire the flag and note the guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(sol): remove Python sol skills implementation
Relocate _discover_project_sources and _ROUTER_SKILL_NAMES into solstone/think/doctor.py rather than sharing them, because doctor is still Python and must not shell out to the native binary. They are knowingly duplicated with core/crates/solstone-core-sol/src/skills.rs, and that duplication ends when doctor itself is ported.
Exactly one assertion is knowingly dropped for a reason other than the build verb removal: the duplicate-router-name ValueError branch from the original skills_cli.discover_project_sources. It is unreachable now that the required payload names are a declared constant of two distinct literals.
The build verb is removed from the CLI surface as a deliberate, operator-approved change. Its sys.path.insert plus from scripts import build_skill_references scaffolding is deleted outright, not ported. make skills invokes scripts/build_skill_references.py directly as of the preceding lode.
Delete scripts/capture_skills_parity_oracle.py in this same commit. The harness drives solstone.think.skills_cli directly: it subprocesses python -m solstone.think.skills_cli and monkeypatches skills_cli.get_project_root and skills_cli.resources.files. Once that module is gone, the script cannot run at all; it would be dead code, not merely stale. Its provenance role is fully preserved: the harness and the 30 vectors it produced are committed together at 5240cfaaf, strictly before this deletion, so git log over core/fixtures/native-sol/skills-parity-v1/ still proves the vectors were captured from the live Python oracle. The frozen vectors themselves remain in the tree and continue to gate the native implementation.
Leave solstone/think/setup.py deliberately untouched. Its skills_user_command() and skills_journal_command() build python -m solstone.think.sol_cli skills install ..., but skills is in JOURNAL_ACCESS_ONLY_COMMANDS, so sol_cli rejects it with exit 2 and journal setup reports both skills_user and skills_journal as failures. That defect predates this lode and is left for a follow-up; tests/test_setup.py and tests/test_setup_jsonl.py pin the current argv shape and pass unmodified.
Co-Authored-By: OpenAI Codex <codex@openai.com>
feat(observe): bounded JSON extractors for messaging + calendar frames
Messaging and calendar screen categories now emit strict, bounded JSON with
category formatter modules that render dict outputs back into the familiar
transcript shape. Category discovery also carries a per-category output-token
cap; categories without a frontmatter override still use 4096.
D3 token derivation:
- Real-data basis (n=1304 content.messaging frames, real journal): total chars
median 1055 / p90 2522 / p99 4389 / max 7303; messages per frame max 41;
longest single message text 2859 chars; sender max 64; timestamp max 65.
- Bounds sit ABOVE observed maxima on purpose: messages maxItems 60 (>41), text
maxLength 4000 (>2859), sender 128 (>64), timestamp 96 (>65). schema_prep.py
strips maxItems/maxLength from Anthropic requests, but models.py still
validates the response against the canonical bounded schema, so a bound sized
at the median is a production SchemaValidationError, not a lint failure.
- Output cap: a faithful worst-case extraction re-encoded as JSON is ~7303 chars
of content plus key/quote/escape overhead (~1.45x), about 10.6k chars, or
about 3.0k tokens at 3.5 chars/token. The previous global cap of 4096 output
tokens (~14.3k chars) left about 26% headroom over the observed p100, and JSON
truncation is fatal (JSONDecodeError -> retry -> error) where markdown
truncation degraded gracefully. Messaging and calendar therefore get
max_output_tokens: 8192 via new category frontmatter while the default stays
4096: about 2.7x the observed p100 and about 6x p99.
- Explicitly rejected: proving the schema's theoretical maximum serialization
fits 4096 tokens. For an array-of-free-text schema that ceiling is maxItems x
maxLength (60 x 4000 = 240k chars) and is not a useful bound; forcing it under
14.3k chars would cap text near 200 chars and truncate real 2859-char
messages. Hence the per-category frontmatter key rather than a tighter schema.
- Side effect, intended: both categories now take the JSON thinking_budget of
6144 (was 4096) via the existing 6144 if is_json else 4096 path in describe.py.
D4 no migration:
- 1304 on-disk frames keep content.messaging as a markdown str. Both formatters
return "" for non-dict input, routing legacy frames through the existing str
fallback in screen.py::_format_category_content (**Messaging:** plus the raw
body), byte-identical to main. This is the meeting.py contract, not a compat
shim. No migration script, no data rewrite.
Coverage honesty:
- calendar has ZERO real frames in the journal; calendar.md was added in
97a97a7b and has never produced an extraction. Its coverage is synthetic
fixtures only.
- make eval-schemas was NOT run: no local provider is configured, so
tests/eval_schemas.py raises LOCAL_NOT_READY. No qwen results are claimed.
scripts/check_schema_bounds.py::ALLOWLIST is unchanged. The four talent schemas
labelled messaging/calendar follow-on lode (chat, participation,
participation_entry, schedule) are talent schemas, out of scope for this lode,
and remain allowlisted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(sol): remove Python sol skills implementation
Relocate _discover_project_sources and _ROUTER_SKILL_NAMES into solstone/think/doctor.py rather than sharing them, because doctor is still Python and must not shell out to the native binary. They are knowingly duplicated with core/crates/solstone-core-sol/src/skills.rs, and that duplication ends when doctor itself is ported.
Exactly one assertion is knowingly dropped for a reason other than the build verb removal: the duplicate-router-name ValueError branch from the original skills_cli.discover_project_sources. It is unreachable now that the required payload names are a declared constant of two distinct literals.
The build verb is removed from the CLI surface as a deliberate, operator-approved change. Its sys.path.insert plus from scripts import build_skill_references scaffolding is deleted outright, not ported. make skills invokes scripts/build_skill_references.py directly as of the preceding lode.
Delete scripts/capture_skills_parity_oracle.py in this same commit. The harness drives solstone.think.skills_cli directly: it subprocesses python -m solstone.think.skills_cli and monkeypatches skills_cli.get_project_root and skills_cli.resources.files. Once that module is gone, the script cannot run at all; it would be dead code, not merely stale. Its provenance role is fully preserved: the harness and the 30 vectors it produced are committed together at 5240cfaaf, strictly before this deletion, so git log over core/fixtures/native-sol/skills-parity-v1/ still proves the vectors were captured from the live Python oracle. The frozen vectors themselves remain in the tree and continue to gate the native implementation.
Leave solstone/think/setup.py deliberately untouched. Its skills_user_command() and skills_journal_command() build python -m solstone.think.sol_cli skills install ..., but skills is in JOURNAL_ACCESS_ONLY_COMMANDS, so sol_cli rejects it with exit 2 and journal setup reports both skills_user and skills_journal as failures. That defect predates this lode and is left for a follow-up; tests/test_setup.py and tests/test_setup_jsonl.py pin the current argv shape and pass unmodified.
Co-Authored-By: OpenAI Codex <codex@openai.com>
refactor: collapse top-level packages into solstone namespace
This is phase 1 of the namespace refactor required before distributing the project on PyPI as solstone. The five previous top-level packages (think, apps, observe, convey, talent) collide with unrelated PyPI projects, so a pip install solstone layout would clobber those packages instead of installing a single coherent distribution.
Move the Python package tree under solstone/, rewrite imports and runtime module strings, and update packaging, Makefile targets, scripts, tests, docs, and package data declarations to match the new canonical module paths. Audit and fix Path(__file__) walks for the new layout, with get_project_root() now correctly anchored at the repository root.
Preserve callosum event taxonomy, provider routing keys, journal config keys, journal data layout, and on-disk historical content. This is a clean break: no back-compat shims, aliases, fallbacks, or re-exports.
Phase 2 will handle the PyPI publish work; phase 3 will handle the INSTALL.md distribution rewrite.
Co-Authored-By: Codex <codex@openai.com>
refactor(indexer): retire the Python command-write path
Every write-bearing journal indexer invocation now runs solstone-core indexer; query, count, display, and interactive search stay Python.
Delete the core.indexer / core.indexer_on_decline selector, the 69 decline-fallback, and the Python CLI write blocks rather than deprecating them; the command no longer reads either key.
Remove reset_journal_index and its export.
Write-bearing commands on hosts with no compatible solstone-core wheel, including musl Linux and Intel macOS, exit 78 before native execution, Python writes, or query.
Make --rescan-file with --rescan/--rescan-full exit 64 before any side effect, replacing the old partial-side-effect ordering.
Rename native_seam.py to native.py.
Teach the indexer differential harness the native edge-extraction-skip stderr wording, mirroring the existing native markdown-sanitize rule, and point the fixture-corpus commands at the retired-selector-free journal indexer invocation.