feat(studio): daily Today-Hero proposal pipeline (v0.31.0)

Cross-store research (Apple, Play, Steam, Docker, VS Code,
Chrome Web Store, Flathub) consistently rewards editorial
curation over algorithmic recommendations — but manual
copywriting per release does not survive a solo-dev cadence.
This commit lands a daily-build pipeline so the Today-Hero
card stays fresh without operator hand-edits per release.

Pipeline shape (full design in docs/today-pipeline.md):

1. tools/today/collect.sh aggregates "what happened in the
   last 24 hours" across the F∆I monorepos: git log per repo,
   store-index seed.yaml diffs, architecture/system-gaps doc
   changes, Studio release tags, and (opt-in) audit-log
   highlights. Outputs plain text.

2. tools/today/propose.sh feeds the signal summary plus
   prompt.template.md to the operator's already-configured
   System-AI (Ollama default; OpenAI-compatible endpoints
   work via env-var override). Drafts N candidate stories as
   YAML files under ~/.fai/today/proposals/<date>/.

3. tools/today/accept.sh validates a chosen candidate against
   the today/v1 schema and the no-marketing-speak banned-word
   list, then atomic-renames it into ~/.fai/today/active.yaml.

4. Studio reads active.yaml at store-page init via the new
   TodayStoryLoader (lib/data/today_story_loader.dart). On any
   failure (file missing, schema mismatch, banned-words hit,
   parse error) it falls back to the compiled-in
   _kFallbackTodayStory so KRITIS deployments and fresh
   installs always render something sensible.

Trust + audit:
- All proposed and accepted stories live as plain YAML on disk.
- The pipeline calls only the operator's already-configured
  System-AI; it never reaches a CMS, never phones home, works
  air-gapped if the System-AI does.
- The bash accept gate AND the Dart loader both enforce the
  banned-word list — a hand-edited active.yaml that bypassed
  the shell still won't reach the UI.
- Removing the cron entry disables the pipeline; Studio falls
  back to the const story and continues to work.

Cron / launchd / systemd recipes documented in
tools/today/README.md.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-08 12:59:33 +02:00
parent fe933a713f
commit ce97300a12
11 changed files with 681 additions and 49 deletions

View file

@ -10,6 +10,7 @@ import 'package:flutter_markdown/flutter_markdown.dart';
import '../data/hub.dart';
import '../data/system_actions.dart';
import '../data/today_story_loader.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
@ -38,6 +39,11 @@ class _StorePageState extends State<StorePage> {
/// session. Not persisted the next Studio launch shows it
/// again so a release-bumped story has a chance to be seen.
bool _todayDismissed = false;
/// Loaded once at init from `~/.fai/today/active.yaml`; falls
/// back to the compiled-in story when the pipeline hasn't
/// produced an accepted candidate yet.
late final TodayStoryData _todayStory =
TodayStoryLoader.loadOrFallback(_kFallbackTodayStory);
late Future<List<StoreItem>> _future;
/// Active UI locale, read from the app-wide MaterialApp
@ -265,11 +271,11 @@ class _StorePageState extends State<StorePage> {
children: [
if (showToday) ...[
_StoreTodayHero(
story: _kCurrentTodayStory,
story: _todayStory,
locale: _locale,
onDismiss: () =>
setState(() => _todayDismissed = true),
onCta: _kCurrentTodayStory.cta == _TodayCta.openSettings
onCta: _todayStory.cta == TodayCta.openSettings
? () => FaiSettingsDialog.show(context)
: null,
),
@ -1934,47 +1940,11 @@ class _ScreenshotPlaceholder extends StatelessWidget {
}
}
/// Editorial action attached to a `_TodayStory`. Kept as an
/// enum (not a closure) so the story list stays `const` and
/// thus shippable as a frozen, audit-friendly artefact.
enum _TodayCta { openSettings }
/// Editorial story shown in the Today-hero strip. One story
/// is curated per release in [_kCurrentTodayStory] operators
/// who launch Studio see the same narrative until the next
/// version bump. Bilingual content is shipped inline so KRITIS
/// deployments don't need to pull translations from a CMS.
class _TodayStory {
final String badgeEn;
final String badgeDe;
final String titleEn;
final String titleDe;
final String bodyEn;
final String bodyDe;
final String ctaLabelEn;
final String ctaLabelDe;
final IconData icon;
final _TodayCta cta;
const _TodayStory({
required this.badgeEn,
required this.badgeDe,
required this.titleEn,
required this.titleDe,
required this.bodyEn,
required this.bodyDe,
required this.ctaLabelEn,
required this.ctaLabelDe,
required this.icon,
required this.cta,
});
}
/// The story shown by Studio v0.30.x. Update when the
/// release-narrative changes this is the editorial role of
/// the App Store Today tab, kept lightweight: one story, one
/// CTA, refreshed per release.
const _TodayStory _kCurrentTodayStory = _TodayStory(
/// Compiled-in fallback story shown when
/// `~/.fai/today/active.yaml` is absent or fails schema
/// validation. The pipeline operator can override at any time
/// without recompiling Studio see docs/today-pipeline.md.
const TodayStoryData _kFallbackTodayStory = TodayStoryData(
badgeEn: 'CAPABILITY FEDERATION',
badgeDe: 'CAPABILITY-FEDERATION',
titleEn: 'Two MCP servers away from a richer hub',
@ -1986,7 +1956,7 @@ const _TodayStory _kCurrentTodayStory = _TodayStory(
ctaLabelEn: 'Open MCP settings',
ctaLabelDe: 'MCP-Einstellungen öffnen',
icon: Icons.hub_outlined,
cta: _TodayCta.openSettings,
cta: TodayCta.openSettings,
);
/// Editorial hero strip the first surface a browsing operator
@ -1995,7 +1965,7 @@ const _TodayStory _kCurrentTodayStory = _TodayStory(
/// bullets. Auto-hides whenever a filter is active so a
/// purposeful search isn't pushed below the fold.
class _StoreTodayHero extends StatelessWidget {
final _TodayStory story;
final TodayStoryData story;
final String locale;
final VoidCallback onDismiss;
/// CTA action null hides the button. Wired by the parent