feat(intentions): Phase 7 — if-then rules for task / time / triage triggers
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled

Small if-then automation layer. Rules persist in SQLite; the runner
lives on the frontend and binds to the Phase 6 event bus so the rule
pipeline reuses the same delivery primitives (timer events, TTS,
Tasks navigation).

Storage:
- Migration v12 adds implementation_rules (id, enabled, trigger_kind,
  trigger_value, actions_json, last_fired_key, created_at,
  updated_at) with enabled+trigger_kind index for the runner's hot
  path.
- CRUD helpers: insert / list / get / set-enabled / mark-fired /
  delete, plus a round-trip test.

Commands (all main-window-guarded via ensure_main_window):
- list_implementation_rules
- create_implementation_rule — validates HH:MM, checks the target
  task exists at save time for surface-task actions, caps
  speak-line at 240 chars, pins v1 timers to 5 minutes.
- set_implementation_rule_enabled
- mark_implementation_rule_fired — main-thread idempotency shim so
  the runner can atomically claim a fire.
- delete_implementation_rule

Runner (implementationIntentions.svelte.ts):
- Subscribes to kon:task-completed and kon:morning-triage-finished
  (MorningTriageModal now emits on all three exit paths — empty,
  skipped, picked — so skip counts as finishing).
- 30 s poll for time-of-day rules, plus an immediate check on startup
  so a rule whose time has already passed today catches up once.
- Idempotency via last_fired_key composed as YYYY-MM-DD@HH:MM for
  time rules; new time rules whose HH:MM has already passed today
  are pre-seeded so they don't fire retroactively on save.
- Rules are paused when Nudges "Mute for now" is on — a hard mute
  stops all rule delivery in addition to OS notifications.
- Stale-task safety: if a surface-task action's target has been
  deleted, the runner opens Tasks and warns clearly rather than
  pretending to surface something that's gone.

Editor (ImplementationRulesEditor.svelte):
- Lives in Settings under a new "If-then rules" accordion section.
- `If` picker: time of day (with time input), a task completes,
  morning triage finishes.
- `Then` composer: optional surface (inbox / today / all tasks /
  specific task), optional 5-min timer, optional speak-aloud line.
- Saved rules list with enable toggle + delete.

Rules table integration for Phase 10b rename sweep: add
implementation_rules to the kon.db → corbie.db migration shim when
that phase lands.

Gates: fmt, clippy -D warnings, cargo test 265/0, svelte-check
0/0, npm build green. Pre-existing Vite chunk warning on sounds.ts
is unrelated to Phase 7.
This commit is contained in:
2026-04-24 19:27:06 +01:00
parent eebea8cb9a
commit 6cd1c22c0f
12 changed files with 1180 additions and 10 deletions

View File

@@ -0,0 +1,260 @@
// Phase 7 implementation intentions. Rules are persisted in SQLite via
// Tauri commands; this frontend runner binds them to the Phase 6 event
// bus and executes their small local actions.
import { invoke } from "@tauri-apps/api/core";
import type {
ImplementationRule,
ImplementationRuleAction,
ImplementationRuleDraft,
} from "$lib/types/app";
import { hasTauriRuntime } from "$lib/utils/runtime.js";
import { page, settings, tasks } from "$lib/stores/page.svelte.js";
import { toasts } from "$lib/stores/toasts.svelte.js";
const TIME_RULE_POLL_MS = 30_000;
const RULES_CHANGED_EVENT = "kon:implementation-rules-changed";
export const implementationRules = $state<ImplementationRule[]>([]);
let loaded = false;
let started = false;
let timePollHandle: ReturnType<typeof setInterval> | null = null;
const firingRules = new Set<string>();
function todayLocalKey(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function currentHHMM(): string {
const d = new Date();
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
function hasTimeAlreadyPassed(hhmm: string): boolean {
return currentHHMM() >= hhmm;
}
export function initialLastFiredKeyForTimeRule(hhmm: string): string | null {
return hasTimeAlreadyPassed(hhmm) ? `${todayLocalKey()}@${hhmm}` : null;
}
function replaceRule(next: ImplementationRule): void {
const idx = implementationRules.findIndex((rule) => rule.id === next.id);
if (idx >= 0) implementationRules[idx] = next;
else implementationRules.unshift(next);
}
function broadcastRulesChanged(): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(RULES_CHANGED_EVENT));
}
export async function loadImplementationRules(force = false): Promise<void> {
if (!hasTauriRuntime()) {
implementationRules.length = 0;
loaded = true;
return;
}
if (loaded && !force) return;
const rows = await invoke<ImplementationRule[]>("list_implementation_rules");
implementationRules.splice(0, implementationRules.length, ...rows);
loaded = true;
}
export async function createImplementationRule(
draft: ImplementationRuleDraft,
): Promise<ImplementationRule> {
const row = await invoke<ImplementationRule>("create_implementation_rule", { request: draft });
replaceRule(row);
broadcastRulesChanged();
return row;
}
export async function setImplementationRuleEnabled(id: string, enabled: boolean): Promise<void> {
const row = await invoke<ImplementationRule>("set_implementation_rule_enabled", { id, enabled });
replaceRule(row);
broadcastRulesChanged();
}
export async function deleteImplementationRule(id: string): Promise<void> {
await invoke("delete_implementation_rule", { id });
const idx = implementationRules.findIndex((rule) => rule.id === id);
if (idx >= 0) implementationRules.splice(idx, 1);
broadcastRulesChanged();
}
async function markRuleFired(rule: ImplementationRule, firedKey: string): Promise<void> {
const row = await invoke<ImplementationRule>("mark_implementation_rule_fired", {
id: rule.id,
firedKey,
});
replaceRule(row);
}
function actionLabel(action: ImplementationRuleAction): string {
if (action.kind === "speak_line") return `Speak "${action.text}"`;
if (action.kind === "start_timer") return "Start a 5-minute timer";
if (action.kind === "surface") {
if (action.target === "task") return `Surface ${action.label || "task"}`;
if (action.target === "inbox") return "Surface inbox";
if (action.target === "today") return "Surface today";
return "Surface tasks";
}
return "Action";
}
export function ruleSummary(rule: ImplementationRule): string {
const when = rule.triggerKind === "time_of_day"
? `At ${rule.triggerValue}`
: rule.triggerKind === "task_completed"
? "After a task completes"
: "After morning triage";
return `${when} -> ${rule.actions.map(actionLabel).join(" + ")}`;
}
function surfaceAction(action: Extract<ImplementationRuleAction, { kind: "surface" }>): void {
if (action.target === "task" && action.taskId) {
const task = tasks.find((candidate) => candidate.id === action.taskId);
if (!task) {
// The rule outlived its target. Don't fake a success toast —
// surface the full list so the user can still act, and tell
// them why the specific task isn't there. Cached label used
// only for identification, not as a claim of surfacing.
page.current = "tasks";
page.taskSidebarOpen = true;
const cachedLabel = action.label?.trim();
toasts.warn(
"Rule target missing",
cachedLabel
? `The task "${cachedLabel}" is gone — edit the rule to point at something else.`
: "That rule's task is gone — edit the rule to point at something else.",
);
return;
}
page.current = "tasks";
page.taskSidebarOpen = true;
toasts.info("Task ready", task.text);
return;
}
page.current = "tasks";
page.taskSidebarOpen = true;
if (action.target === "inbox") {
toasts.info("Inbox is here", "Your task list is open.");
} else if (action.target === "today") {
toasts.info("Today's list is here", "Your task list is open.");
} else {
toasts.info("Tasks are here", "Your list is open.");
}
}
async function executeAction(action: ImplementationRuleAction): Promise<void> {
if (action.kind === "surface") {
surfaceAction(action);
return;
}
if (action.kind === "start_timer") {
window.dispatchEvent(new CustomEvent("kon:start-timer", {
detail: {
taskId: action.taskId ?? null,
seconds: 300,
label: action.label ?? "5-minute timer",
},
}));
return;
}
if (action.kind === "speak_line") {
await invoke("tts_speak", {
text: action.text,
rate: settings.ttsRate,
voice: settings.ttsVoice ?? null,
});
}
}
async function fireRule(rule: ImplementationRule, firedKey?: string): Promise<void> {
if (!rule.enabled) return;
if (settings.nudgesMuted) return;
if (firedKey && rule.lastFiredKey === firedKey) return;
if (firingRules.has(rule.id)) return;
firingRules.add(rule.id);
try {
for (const action of rule.actions) {
try {
await executeAction(action);
} catch {
// Rule actions are assistance, not critical workflow. Keep going
// so a missing TTS voice does not block surfacing a task.
}
}
if (firedKey) {
await markRuleFired(rule, firedKey);
}
} finally {
firingRules.delete(rule.id);
}
}
function timeRuleFiredKey(rule: ImplementationRule): string {
return `${todayLocalKey()}@${rule.triggerValue}`;
}
async function checkTimeRules(): Promise<void> {
if (!loaded) await loadImplementationRules();
for (const rule of implementationRules) {
if (rule.triggerKind !== "time_of_day") continue;
if (!rule.enabled) continue;
if (currentHHMM() < rule.triggerValue) continue;
const firedKey = timeRuleFiredKey(rule);
if (rule.lastFiredKey === firedKey) continue;
await fireRule(rule, firedKey);
}
}
function onTaskCompleted(): void {
for (const rule of implementationRules) {
if (rule.triggerKind === "task_completed") void fireRule(rule);
}
}
function onMorningTriageFinished(): void {
for (const rule of implementationRules) {
if (rule.triggerKind === "morning_triage_finished") void fireRule(rule);
}
}
function onRulesChanged(): void {
void checkTimeRules();
}
export function startImplementationIntentions(): void {
if (started) return;
if (typeof window === "undefined") return;
started = true;
void loadImplementationRules(true)
.then(() => checkTimeRules())
.catch(() => {});
window.addEventListener("kon:task-completed", onTaskCompleted);
window.addEventListener("kon:morning-triage-finished", onMorningTriageFinished);
window.addEventListener(RULES_CHANGED_EVENT, onRulesChanged);
timePollHandle = setInterval(() => {
void checkTimeRules().catch(() => {});
}, TIME_RULE_POLL_MS);
}
export function stopImplementationIntentions(): void {
if (!started) return;
started = false;
window.removeEventListener("kon:task-completed", onTaskCompleted);
window.removeEventListener("kon:morning-triage-finished", onMorningTriageFinished);
window.removeEventListener(RULES_CHANGED_EVENT, onRulesChanged);
if (timePollHandle !== null) {
clearInterval(timePollHandle);
timePollHandle = null;
}
}