diff --git a/src/faff_cli/config.py b/src/faff_cli/config.py
new file mode 100644
index 0000000..35c5dfb
--- /dev/null
+++ b/src/faff_cli/config.py
@@ -0,0 +1,71 @@
+"""
+faff-cli configuration.
+
+Read from the [faff-cli] section of the ledger's config.toml.
+All settings default to True if the section is absent.
+"""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import List
+
+import toml
+
+
+ALL_FIELDS = ["title", "role", "subject", "impact", "mode", "trackers", "note"]
+
+
+@dataclass
+class SessionConfig:
+ title: bool = True
+ role: bool = True
+ impact: bool = True
+ mode: bool = True
+ subject: bool = True
+ trackers: bool = True
+ note: bool = True
+ max_trackers: int = 0 # 0 = unlimited, 1 = at most one, etc.
+
+ def enabled_fields(self) -> List[str]:
+ """Return field names in canonical order, filtered to enabled ones."""
+ return [f for f in ALL_FIELDS if getattr(self, f)]
+
+
+@dataclass
+class CliConfig:
+ session: SessionConfig = field(default_factory=SessionConfig)
+
+
+def _ledger_config_path() -> Path:
+ faff_dir = os.environ.get("FAFF_DIR")
+ root = Path(faff_dir) if faff_dir else Path.home() / ".faff"
+ return root / "config.toml"
+
+
+def load_config() -> CliConfig:
+ path = _ledger_config_path()
+ if not path.exists():
+ return CliConfig()
+
+ try:
+ data = toml.load(path)
+ except Exception:
+ return CliConfig()
+
+ cli_data = data.get("faff-cli", {})
+ session_data = cli_data.get("session", {})
+ session = SessionConfig(
+ title=session_data.get("title", True),
+ role=session_data.get("role", True),
+ impact=session_data.get("impact", True),
+ mode=session_data.get("mode", True),
+ subject=session_data.get("subject", True),
+ trackers=session_data.get("trackers", True),
+ note=session_data.get("note", True),
+ max_trackers=session_data.get("max_trackers", 0),
+ )
+
+ return CliConfig(session=session)
diff --git a/src/faff_cli/field.py b/src/faff_cli/field.py
index b35db70..0f42bfb 100644
--- a/src/faff_cli/field.py
+++ b/src/faff_cli/field.py
@@ -6,13 +6,13 @@
from faff_cli.output import create_formatter
from faff_cli.filtering import parse_simple_filters, apply_filters
-app = typer.Typer(help="Manage ASTRO fields (actions, subjects, trackers, roles, objectives)")
+app = typer.Typer(help="Manage ASTRO fields (modes, subjects, trackers, roles, impacts)")
-VALID_FIELDS = ["role", "objective", "action", "subject", "tracker"]
+VALID_FIELDS = ["role", "impact", "mode", "subject", "tracker"]
PLURAL_MAP = {
"role": "roles",
- "objective": "objectives",
- "action": "actions",
+ "impact": "impacts",
+ "mode": "modes",
"subject": "subjects",
"tracker": "trackers",
}
@@ -21,7 +21,7 @@
@app.command()
def list(
ctx: typer.Context,
- field: str = typer.Argument(..., help="Field to list (role, objective, action, subject, tracker)"),
+ field: str = typer.Argument(..., help="Field to list (role, impact, mode, subject, tracker)"),
filter_strings: List[str] = typer.Argument(
None,
help="Filters: field=value (exact), field~value (contains), field!=value (not equal)",
@@ -45,14 +45,14 @@ def list(
"""
List unique values for a ASTRO field.
- Shows field values from both plan-level collections and intents, with usage counts.
+ Shows field values from both plan-level collections and sessions, with usage counts.
Results are sorted by usage (most used first).
Examples:
faff field list role
- faff field list action value~meeting
- faff field list role intents>10
- faff field list objective --json
+ faff field list mode value~meeting
+ faff field list role sessions>10
+ faff field list impact --json
"""
if field not in VALID_FIELDS:
typer.echo(f"Error: field must be one of: {', '.join(VALID_FIELDS)}", err=True)
@@ -76,10 +76,10 @@ def list(
today = ws.today()
if field == "role":
all_defined = set(ws.plans.get_roles(today))
- elif field == "objective":
- all_defined = set(ws.plans.get_objectives(today))
- elif field == "action":
- all_defined = set(ws.plans.get_actions(today))
+ elif field == "impact":
+ all_defined = set(ws.plans.get_impacts(today))
+ elif field == "mode":
+ all_defined = set(ws.plans.get_modes(today))
elif field == "subject":
all_defined = set(ws.plans.get_subjects(today))
elif field == "tracker":
@@ -87,14 +87,17 @@ def list(
else:
all_defined = set()
- # Get intent counts from plans via Rust
- intent_count = ws.plans.get_field_usage_stats(field)
-
# Get session counts and log dates from logs via Rust
session_count, log_dates_dict = ws.logs.get_field_usage_stats(field)
- # Combine all values: defined in plans + used in intents/sessions
- values = all_defined | set(intent_count.keys()) | set(session_count.keys())
+ # Combine all values: defined in plans + used in sessions
+ values = all_defined | set(session_count.keys())
+
+ # Build plan membership: value → source name (from value prefix, if in current plan)
+ plan_membership = {}
+ for value in all_defined:
+ source = value.split(":")[0] if ":" in value else "local"
+ plan_membership[value] = source
# Convert log_dates_dict values (lists of PyDate) to count of unique logs
log_count = {}
@@ -109,13 +112,12 @@ def list(
# Build field data list
field_data = []
for value in values:
- intents = intent_count.get(value, 0)
sessions = session_count.get(value, 0)
logs = log_count.get(value, 0)
row = {
"value": value,
- "intents": intents,
+ "plan": plan_membership.get(value, ""),
"sessions": sessions,
"logs": logs,
}
@@ -131,7 +133,7 @@ def list(
field_data = apply_filters(field_data, filters)
# Sort by usage (most used first = most sessions)
- field_data.sort(key=lambda x: (x["sessions"], x["intents"], x["logs"]), reverse=True)
+ field_data.sort(key=lambda x: (x["sessions"], x["logs"]), reverse=True)
# Apply limit
if limit:
@@ -146,14 +148,14 @@ def list(
columns = [
("value", "Value", "cyan"),
("name", "Name", "yellow"),
- ("intents", "Intents", "green"),
+ ("plan", "In Plan", "yellow"),
("sessions", "Sessions", "green"),
("logs", "Logs", "green"),
]
else:
columns = [
("value", "Value", "cyan"),
- ("intents", "Intents", "green"),
+ ("plan", "In Plan", "yellow"),
("sessions", "Sessions", "green"),
("logs", "Logs", "green"),
]
@@ -182,7 +184,7 @@ def list(
@app.command()
def replace(
ctx: typer.Context,
- field: str = typer.Argument(..., help="Field to replace (role, objective, action, subject)"),
+ field: str = typer.Argument(..., help="Field to replace (role, impact, mode, subject)"),
old_value: str = typer.Argument(..., help="Old value to replace"),
new_value: str = typer.Argument(..., help="New value"),
):
@@ -191,8 +193,7 @@ def replace(
This will:
- Update the field in plan-level ASTRO collections
- - Update all intents that use the old value
- - Update all log sessions that reference those intents
+ - Update all log sessions that use the old value
"""
if field not in VALID_FIELDS:
typer.echo(f"Error: field must be one of: {', '.join(VALID_FIELDS)}", err=True)
@@ -207,10 +208,10 @@ def replace(
console = Console()
# Update plans via Rust layer
- plans_updated, intents_updated = ws.plans.replace_field_in_all_plans(
+ plans_updated = ws.plans.replace_field_in_all_plans(
field, old_value, new_value
)
- console.print(f"[green]Updated {intents_updated} intent(s) across {plans_updated} plan(s)[/green]")
+ console.print(f"[green]Updated {plans_updated} plan(s)[/green]")
# Update logs via Rust layer
import datetime
diff --git a/src/faff_cli/log.py b/src/faff_cli/log.py
index afd8c03..578ae55 100644
--- a/src/faff_cli/log.py
+++ b/src/faff_cli/log.py
@@ -280,8 +280,8 @@ def summary(ctx: typer.Context, date: str = typer.Argument(None)):
if stats['mean_reflection_score'] is not None:
output += f"Mean reflection score: {stats['mean_reflection_score']:.2f}/5\n"
- output += "\nIntent Totals:\n"
- for alias, minutes in stats['by_intent'].items():
+ output += "\nAlias Totals:\n"
+ for alias, minutes in stats['by_alias'].items():
output += f"- {alias}: {humanize.precisedelta(datetime.timedelta(minutes=minutes), minimum_unit='minutes')}\n"
output += "\nTracker Totals:\n"
diff --git a/src/faff_cli/main.py b/src/faff_cli/main.py
index b95dfa8..7da689c 100644
--- a/src/faff_cli/main.py
+++ b/src/faff_cli/main.py
@@ -1,6 +1,7 @@
import typer
+from typing import Optional
-from faff_cli import log, id, plan, start, timesheet, intent, field, remote, plugin, reflect, session, sql, __version__
+from faff_cli import log, id, plan, start, timesheet, field, remote, plugin, reflect, session, sql, timeline, __version__
from faff_cli.utils import edit_file
import faff_core
@@ -13,14 +14,15 @@
cli.add_typer(start.app, name="start", rich_help_panel="Track your Time")
cli.add_typer(session.app, name="session", rich_help_panel="Track your Time")
cli.add_typer(reflect.app, name="reflect", rich_help_panel="Track your Time")
+cli.add_typer(timeline.app, name="timeline", rich_help_panel="Track your Time")
+cli.add_typer(timeline.app, name="tl", hidden=True)
# Compile and Submit Timesheets
cli.add_typer(timesheet.app, name="timesheet", rich_help_panel="Compile and Submit Timesheets")
-# Maintain Plans and Intents
-cli.add_typer(plan.app, name="plan", rich_help_panel="Maintain Plans and Intents")
-cli.add_typer(intent.app, name="intent", rich_help_panel="Maintain Plans and Intents")
-cli.add_typer(field.app, name="field", rich_help_panel="Maintain Plans and Intents")
+# Maintain Plans and Fields
+cli.add_typer(plan.app, name="plan", rich_help_panel="Maintain Plans and Fields")
+cli.add_typer(field.app, name="field", rich_help_panel="Maintain Plans and Fields")
# Ledger Setup
cli.add_typer(remote.app, name="remote", rich_help_panel="Ledger Setup")
@@ -192,7 +194,7 @@ def compile(
# FIXME: This check should be in faff-core, not faff-cli
# The compile_time_sheet method should refuse to compile logs with active sessions
# Check for unclosed session
- if log.active_session():
+ if log.active_sessions():
typer.echo(f"Cannot compile {resolved_date}: log has an unclosed session. Run 'faff stop' first.", err=True)
raise typer.Exit(1)
@@ -237,7 +239,7 @@ def compile(
# FIXME: This check should be in faff-core, not faff-cli
# The compile_time_sheet method should refuse to compile logs with active sessions
# Check for unclosed session
- if log.active_session():
+ if log.active_sessions():
skipped_unclosed.append(log_date)
continue
@@ -335,9 +337,13 @@ def push(
else:
typer.echo(f"No timesheet found for {aud.id} on {resolved_date}. Did you run 'faff compile' first?", err=True)
else:
- # No date provided - push all unsubmitted timesheets
+ # No date provided - push all unsubmitted timesheets for active audiences
+ active_audience_ids = {a.id for a in audiences}
all_timesheets = ws.timesheets.list_timesheets()
- unsubmitted = [ts for ts in all_timesheets if ts.meta.submitted_at is None]
+ unsubmitted = [
+ ts for ts in all_timesheets
+ if ts.meta.submitted_at is None and ts.meta.audience_id in active_audience_ids
+ ]
if audience:
unsubmitted = [ts for ts in unsubmitted if ts.meta.audience_id == audience]
@@ -353,7 +359,10 @@ def push(
raise typer.Exit(1)
@cli.command(rich_help_panel="Track your Time")
-def status(ctx: typer.Context):
+def status(
+ ctx: typer.Context,
+ show_stale: bool = typer.Option(False, "--show-stale", help="List stale timesheets in detail"),
+):
"""
Show ledger status.
@@ -365,6 +374,7 @@ def status(ctx: typer.Context):
Examples:
faff status
+ faff status --show-stale
"""
from rich.console import Console
@@ -394,8 +404,7 @@ def status(ctx: typer.Context):
else:
freshness = f"[red]{age_days}d ago[/red]"
- intent_count = len(plan.intents)
- console.print(f" [cyan]{source}[/cyan] · {intent_count} intent(s) · pulled {freshness}")
+ console.print(f" [cyan]{source}[/cyan] · pulled {freshness}")
else:
console.print("[yellow] No plans available[/yellow]")
@@ -415,13 +424,15 @@ def status(ctx: typer.Context):
else:
console.print(f" {minutes}m tracked")
- active_session = log.active_session()
- if active_session:
- duration_minutes = int(active_session.elapsed(ws.now()).total_seconds() / 60)
- if active_session.note:
- console.print(f" [green]●[/green] {active_session.intent.alias} [dim]({active_session.note})[/dim] · {duration_minutes}m")
- else:
- console.print(f" [green]●[/green] {active_session.intent.alias} · {duration_minutes}m")
+ active_sessions = log.active_sessions()
+ if active_sessions:
+ for active_session in active_sessions:
+ duration_minutes = int(active_session.elapsed(ws.now()).total_seconds() / 60)
+ short_id = active_session.id[:8] if active_session.id else ""
+ if active_session.note:
+ console.print(f" [green]●[/green] {active_session.title} [dim]({active_session.note})[/dim] · {duration_minutes}m [dim]{short_id}[/dim]")
+ else:
+ console.print(f" [green]●[/green] {active_session.title} · {duration_minutes}m [dim]{short_id}[/dim]")
else:
console.print(" [dim]○ Not tracking[/dim]")
@@ -452,7 +463,7 @@ def status(ctx: typer.Context):
if needs_compile_for_audiences:
total_hours = log.total_recorded_time().total_seconds() / 3600
- if log.active_session():
+ if log.active_sessions():
has_unclosed.append((log_date, total_hours, needs_compile_for_audiences))
else:
needs_compiling.append((log_date, total_hours, needs_compile_for_audiences))
@@ -469,11 +480,14 @@ def status(ctx: typer.Context):
console.print(f" {log_date} · [cyan]{hours:>4.1f}h[/cyan] → {', '.join(audience_ids)}")
if stale_unsubmitted:
- for ts in sorted(stale_unsubmitted, key=lambda t: t.date):
- hours = sum(s.duration.total_seconds() for s in ts.timeline) / 3600
- console.print(f" {ts.date} · [cyan]{hours:>4.1f}h[/cyan] ({ts.meta.audience_id}) [yellow]stale[/yellow]")
+ if show_stale:
+ for ts in sorted(stale_unsubmitted, key=lambda t: t.date):
+ hours = sum(s.duration.total_seconds() for s in ts.timeline if s.end is not None) / 3600
+ console.print(f" {ts.date} · [cyan]{hours:>4.1f}h[/cyan] ({ts.meta.audience_id}) [yellow]stale[/yellow]")
+ else:
+ console.print(f" [yellow]{len(stale_unsubmitted)} stale[/yellow] [dim](run[/dim] [cyan]faff status --show-stale[/cyan] [dim]to list)[/dim]")
- total_hours = sum(h for _, h, _ in needs_compiling) + sum(sum(s.duration.total_seconds() for s in ts.timeline) / 3600 for ts in stale_unsubmitted)
+ total_hours = sum(h for _, h, _ in needs_compiling) + sum(sum(s.duration.total_seconds() for s in ts.timeline if s.end is not None) / 3600 for ts in stale_unsubmitted)
console.print(f" [dim]{total_needing_compile} log(s),[/dim] [cyan]{total_hours:.1f}h[/cyan] [dim]total[/dim]")
# Show blockers after (unclosed sessions)
@@ -492,8 +506,12 @@ def status(ctx: typer.Context):
# 4. PUSH - Check what needs submission
console.print("[bold]Timesheets to Push:[/bold]")
+ active_audience_ids = {aud.id for aud in audiences}
failed = ws.timesheets.find_failed_submissions()
- unsubmitted = [ts for ts in existing_timesheets if ts.meta.submitted_at is None]
+ unsubmitted = [
+ ts for ts in existing_timesheets
+ if ts.meta.submitted_at is None and ts.meta.audience_id in active_audience_ids
+ ]
# Exclude stale unsubmitted from the push list (they need recompiling first)
stale_dates = {(ts.meta.audience_id, ts.date) for ts in stale_unsubmitted}
@@ -505,7 +523,7 @@ def status(ctx: typer.Context):
if unsubmitted_ready:
console.print(f" [dim]Ready to Push. Run[/dim] [cyan]faff push[/cyan]:")
for ts in sorted(unsubmitted_ready, key=lambda t: t.date):
- hours = sum(s.duration.total_seconds() for s in ts.timeline) / 3600
+ hours = sum(s.duration.total_seconds() for s in ts.timeline if s.end is not None) / 3600
console.print(f" {ts.date} · [cyan]{hours:>4.1f}h[/cyan] → {ts.meta.audience_id}")
# Show failed submissions
@@ -514,7 +532,7 @@ def status(ctx: typer.Context):
console.print()
console.print(" [red]Failed.[/red] [dim]Fix errors and run[/dim] [cyan]faff push[/cyan]:")
for ts in sorted(failed, key=lambda t: t.date):
- hours = sum(s.duration.total_seconds() for s in ts.timeline) / 3600
+ hours = sum(s.duration.total_seconds() for s in ts.timeline if s.end is not None) / 3600
error = ts.meta.submission_error
if len(error) > 50:
error = error[:47] + "..."
@@ -524,11 +542,14 @@ def status(ctx: typer.Context):
if stale_submitted:
if unsubmitted_ready or failed:
console.print()
- console.print(" [yellow]Stale (already submitted).[/yellow] [dim]Manual review needed:[/dim]")
- for ts in sorted(stale_submitted, key=lambda t: t.date):
- hours = sum(s.duration.total_seconds() for s in ts.timeline) / 3600
- submitted = ts.meta.submitted_at.strftime("%Y-%m-%d")
- console.print(f" {ts.date} · [cyan]{hours:>4.1f}h[/cyan] ({ts.meta.audience_id}) [dim]submitted {submitted}[/dim]")
+ if show_stale:
+ console.print(" [yellow]Stale (already submitted).[/yellow] [dim]Manual review needed:[/dim]")
+ for ts in sorted(stale_submitted, key=lambda t: t.date):
+ hours = sum(s.duration.total_seconds() for s in ts.timeline if s.end is not None) / 3600
+ submitted = ts.meta.submitted_at.strftime("%Y-%m-%d")
+ console.print(f" {ts.date} · [cyan]{hours:>4.1f}h[/cyan] ({ts.meta.audience_id}) [dim]submitted {submitted}[/dim]")
+ else:
+ console.print(f" [yellow]{len(stale_submitted)} stale (already submitted)[/yellow] [dim](run[/dim] [cyan]faff status --show-stale[/cyan] [dim]to list)[/dim]")
else:
console.print(" [dim]✓ All timesheets submitted[/dim]")
@@ -540,45 +561,112 @@ def status(ctx: typer.Context):
cli.add_typer(log.app, name="log", rich_help_panel="Track your Time")
cli.add_typer(sql.app, name="sql", rich_help_panel="Track your Time")
+@cli.command(name="continue", rich_help_panel="Track your Time")
+def continue_session(
+ ctx: typer.Context,
+ session_id: str = typer.Argument(..., help="ID prefix of the session to continue"),
+ concurrent: bool = typer.Option(False, "--concurrent", help="Start alongside any active sessions without stopping them"),
+ since: Optional[str] = typer.Option(None, "--since", help="Start time for the new session (e.g. '12:50', '14:30')"),
+):
+ """
+ Continue a previous session with the same properties.
+
+ Looks up a session by ID prefix and starts a new session with the same
+ title, role, impact, mode, subject, and trackers, starting now (or at --since).
+
+ Examples:
+ faff continue a3f9c12b
+ faff continue a3f9c12b --concurrent
+ faff continue a3f9c12b --since 12:50
+ """
+ try:
+ ws: Workspace = ctx.obj
+ log = ws.logs.get_log(ws.today())
+
+ matches = [s for s in log.timeline if s.id.startswith(session_id)]
+ if not matches:
+ typer.echo(f"No session found with id starting '{session_id}'", err=True)
+ raise typer.Exit(1)
+ if len(matches) > 1:
+ typer.echo(f"Ambiguous id '{session_id}' matches {len(matches)} sessions", err=True)
+ raise typer.Exit(1)
+
+ source = matches[0]
+ start_time = ws.parse_natural_datetime(since) if since else ws.now()
+
+ if not concurrent and log.active_sessions():
+ if since:
+ updated_log = log.stop_all_active_sessions(start_time)
+ trackers = ws.plans.get_trackers(ws.today())
+ ws.logs.write_log(updated_log, trackers)
+ else:
+ ws.logs.stop_all_active_sessions()
+
+ ws.logs.start_session(
+ title=source.title,
+ role=source.role,
+ impact=source.impact,
+ mode=source.mode,
+ subject=source.subject,
+ trackers=source.trackers,
+ start_time=start_time,
+ note=None,
+ )
+ typer.echo(f"Continuing '{source.title}' at {start_time.strftime('%H:%M')}")
+ except typer.Exit:
+ raise
+ except Exception as e:
+ typer.echo(f"Error continuing session: {e}", err=True)
+ raise typer.Exit(1)
+
+
@cli.command(rich_help_panel="Track your Time")
-def stop(ctx: typer.Context):
+def stop(
+ ctx: typer.Context,
+ session_id: str = typer.Argument(None, help="ID prefix of the session to stop (omit to stop all active sessions)"),
+):
"""
- Stop the current session.
+ Stop active session(s).
- Ends the currently active time tracking session.
+ With no argument, stops all active sessions. Pass a session ID prefix
+ (as shown in 'faff status' or 'faff session list') to stop a specific one.
Examples:
faff stop
+ faff stop a3f9c12b
"""
try:
- import humanize
ws: Workspace = ctx.obj
- # Get the current log to see what we're stopping
log = ws.logs.get_log(ws.today())
- active = log.active_session()
+ active = log.active_sessions()
if not active:
typer.echo("No active session to stop", err=True)
raise typer.Exit(1)
- # Capture the details before stopping
- intent_alias = active.intent.alias
- start_time = active.start
-
- # Stop the session
- ws.logs.stop_current_session()
-
- # Calculate duration
end_time = ws.now()
- duration = end_time - start_time
- duration_minutes = int(duration.total_seconds() / 60)
-
- # Show feedback
- typer.echo(f"Stopped '{intent_alias}'")
- typer.echo(f" Started: {start_time.strftime('%H:%M')}")
- typer.echo(f" Ended: {end_time.strftime('%H:%M')}")
- typer.echo(f" Duration: {duration_minutes} minutes")
+
+ if session_id:
+ matches = [s for s in active if s.id.startswith(session_id)]
+ if not matches:
+ typer.echo(f"No active session with id starting '{session_id}'", err=True)
+ raise typer.Exit(1)
+ ws.logs.stop_session(session_id)
+ for session in matches:
+ duration_minutes = int((end_time - session.start).total_seconds() / 60)
+ typer.echo(f"Stopped '{session.title}'")
+ typer.echo(f" Started: {session.start.strftime('%H:%M')}")
+ typer.echo(f" Ended: {end_time.strftime('%H:%M')}")
+ typer.echo(f" Duration: {duration_minutes} minutes")
+ else:
+ ws.logs.stop_all_active_sessions()
+ for session in active:
+ duration_minutes = int((end_time - session.start).total_seconds() / 60)
+ typer.echo(f"Stopped '{session.title}'")
+ typer.echo(f" Started: {session.start.strftime('%H:%M')}")
+ typer.echo(f" Ended: {end_time.strftime('%H:%M')}")
+ typer.echo(f" Duration: {duration_minutes} minutes")
except Exception as e:
typer.echo(f"Error stopping session: {e}", err=True)
raise typer.Exit(1)
diff --git a/src/faff_cli/plan.py b/src/faff_cli/plan.py
index 57c05b9..937b92f 100644
--- a/src/faff_cli/plan.py
+++ b/src/faff_cli/plan.py
@@ -35,13 +35,11 @@ def list_plans(
plan_data = []
for plan in plans:
valid_until_str = str(plan.valid_until) if plan.valid_until else "∞"
- intent_count = len(plan.intents)
plan_data.append({
"source": plan.source,
"valid_from": str(plan.valid_from),
"valid_until": valid_until_str,
- "intent_count": intent_count,
})
# Create formatter and output
@@ -50,7 +48,6 @@ def list_plans(
("source", "Source", "cyan"),
("valid_from", "Valid From", None),
("valid_until", "Valid Until", None),
- ("intent_count", "Intents", "green"),
]
formatter.print_table(
@@ -119,18 +116,6 @@ def show(
"source": plan.source,
"valid_from": str(plan.valid_from),
"valid_until": str(plan.valid_until) if plan.valid_until else None,
- "intents": [
- {
- "intent_id": intent.intent_id,
- "alias": intent.alias,
- "role": intent.role,
- "objective": intent.objective,
- "action": intent.action,
- "subject": intent.subject,
- "trackers": list(intent.trackers) if intent.trackers else [],
- }
- for intent in plan.intents
- ],
}
typer.echo(json.dumps(plan_dict, indent=2))
elif plain_output:
@@ -143,8 +128,7 @@ def show(
console = Console()
console.print(f"\n[bold cyan]Plan: {plan.source}[/bold cyan]")
- console.print(f"[dim]Valid from {plan.valid_from}{' to ' + str(plan.valid_until) if plan.valid_until else ' onwards'}[/dim]")
- console.print(f"[dim]Intents: {len(plan.intents)}[/dim]\n")
+ console.print(f"[dim]Valid from {plan.valid_from}{' to ' + str(plan.valid_until) if plan.valid_until else ' onwards'}[/dim]\n")
# Show as TOML with syntax highlighting
toml_content = plan.to_toml()
diff --git a/src/faff_cli/reflect.py b/src/faff_cli/reflect.py
index aaaece0..2fc4dda 100644
--- a/src/faff_cli/reflect.py
+++ b/src/faff_cli/reflect.py
@@ -49,7 +49,7 @@ def reflect(ctx: typer.Context, date: str = typer.Argument(None)):
end_time = session.end.strftime("%H:%M") if session.end else "ongoing"
typer.echo(f"Session {session_idx + 1}/{len(log.timeline)}")
typer.echo(f" Time: {start_time} - {end_time}")
- typer.echo(f" Intent: {session.intent.alias}")
+ typer.echo(f" Task: {session.title}")
if session.note:
typer.echo(f" Note: {session.note}")
typer.echo()
diff --git a/src/faff_cli/session.py b/src/faff_cli/session.py
index 7401fa8..f800ee8 100644
--- a/src/faff_cli/session.py
+++ b/src/faff_cli/session.py
@@ -73,7 +73,7 @@ def list_sessions(
Examples:
faff session list
faff session list --from 2025-01-01 --to 2025-01-31
- faff session list alias~meeting
+ faff session list title~meeting
faff session list role=consultant --since last-monday
faff session list --json
"""
@@ -112,14 +112,15 @@ def list_sessions(
session_data.append({
"date": str(log.date),
"date_obj": log.date,
+ "id": session.id[:8] if session.id else "",
"start": session.start.strftime("%H:%M:%S"),
"end": session.end.strftime("%H:%M:%S") if session.end else "active",
- "alias": session.intent.alias,
- "role": session.intent.role or "",
- "objective": session.intent.objective or "",
- "action": session.intent.action or "",
- "subject": session.intent.subject or "",
- "trackers": ",".join(session.intent.trackers) if session.intent.trackers else "",
+ "title": session.title,
+ "role": session.role or "",
+ "impact": session.impact or "",
+ "mode": session.mode or "",
+ "subject": session.subject or "",
+ "trackers": ",".join(session.trackers) if session.trackers else "",
"duration": humanize.precisedelta(duration, minimum_unit="minutes"),
"duration_seconds": duration.total_seconds(),
"reflection": f"{session.reflection_score:.1f}" if session.reflection_score is not None else "",
@@ -143,9 +144,10 @@ def list_sessions(
# Define columns for table output
columns = [
("date", "Date", "cyan"),
+ ("id", "ID", "dim"),
("start", "Start", None),
("end", "End", None),
- ("alias", "Intent", "yellow"),
+ ("title", "Title", "yellow"),
("duration", "Duration", "green"),
("reflection", "Reflection", "blue"),
]
@@ -179,7 +181,7 @@ def report(
group: Optional[str] = typer.Option(
None,
"--group", "-g",
- help="Field to group by (e.g. date, role, objective, subject, alias).",
+ help="Field to group by (e.g. date, role, impact, subject, title).",
),
from_date: Optional[str] = typer.Option(
None,
@@ -220,9 +222,9 @@ def report(
Examples:
faff session report role=consultant
- faff session report alias~meeting --from 2025-01-01
+ faff session report title~meeting --from 2025-01-01
faff session report --since last-monday
- faff session report objective=alignment --sum
+ faff session report impact=alignment --sum
"""
try:
ws = ctx.obj
diff --git a/src/faff_cli/sql.py b/src/faff_cli/sql.py
index 248340d..a4a6442 100644
--- a/src/faff_cli/sql.py
+++ b/src/faff_cli/sql.py
@@ -27,11 +27,10 @@ def load_ledger_to_db(ws, db_path: Path):
cursor.execute('''
CREATE TABLE sessions (
date TEXT,
- intent_id TEXT,
- alias TEXT,
+ title TEXT,
role TEXT,
- objective TEXT,
- action TEXT,
+ impact TEXT,
+ mode TEXT,
subject TEXT,
start TEXT,
end TEXT,
@@ -49,8 +48,6 @@ def load_ledger_to_db(ws, db_path: Path):
date_str = log.date.isoformat()
for session in log.timeline:
- intent = session.intent
-
# Calculate duration in minutes
duration = None
if session.end:
@@ -58,15 +55,14 @@ def load_ledger_to_db(ws, db_path: Path):
# Insert session
cursor.execute('''
- INSERT INTO sessions VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO sessions VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
date_str,
- intent.intent_id,
- intent.alias,
- intent.role,
- intent.objective,
- intent.action,
- intent.subject,
+ session.title,
+ session.role,
+ session.impact,
+ session.mode,
+ session.subject,
session.start.isoformat(),
session.end.isoformat() if session.end else None,
duration,
@@ -78,7 +74,7 @@ def load_ledger_to_db(ws, db_path: Path):
# Create useful indexes
cursor.execute('CREATE INDEX idx_sessions_date ON sessions(date)')
cursor.execute('CREATE INDEX idx_sessions_role ON sessions(role)')
- cursor.execute('CREATE INDEX idx_sessions_intent ON sessions(intent_id)')
+ cursor.execute('CREATE INDEX idx_sessions_title ON sessions(title)')
conn.commit()
conn.close()
diff --git a/src/faff_cli/start.py b/src/faff_cli/start.py
index 7a170ee..bf74b1b 100644
--- a/src/faff_cli/start.py
+++ b/src/faff_cli/start.py
@@ -1,12 +1,13 @@
import typer
from collections import defaultdict
+from types import SimpleNamespace
from titlecase import titlecase
from faff_cli.ui import FuzzyItem, fuzzy_select
+from faff_cli.config import load_config
from faff_core import Workspace
-from faff_core.models import Intent
app = typer.Typer(help="Start a new task or activity.")
@@ -33,56 +34,95 @@ def nicer(strings) -> list[FuzzyItem]:
]
-def _filter_intents(all_intents, alias, context=None):
- """Filter intents by alias, then by context fields, falling back to alias-only if nothing matches."""
- alias_matching = [i for i in all_intents if i.alias == alias]
+def _filter_sessions(all_sessions, title, context=None):
+ """Filter sessions by title, then by context fields, falling back to title-only if nothing matches."""
+ title_matching = [s for s in all_sessions if getattr(s, 'title', None) == title]
if not context:
- return alias_matching
+ return title_matching
matching = [
- i for i in alias_matching
- if all(getattr(i, k, None) == v for k, v in context.items() if v is not None)
+ s for s in title_matching
+ if all(getattr(s, k, None) == v for k, v in context.items() if v is not None)
]
- return matching if matching else alias_matching
+ return matching if matching else title_matching
-def get_alias_choices(intents, session_counts_by_id):
- """Deduplicate intents by alias and sum session counts per alias."""
- alias_totals = defaultdict(int)
- for intent in intents:
- alias_totals[intent.alias] += session_counts_by_id.get(intent.intent_id, 0)
+def get_title_choices(all_sessions):
+ """Build title choices from historical sessions, sorted by frequency.
- sorted_aliases = sorted(alias_totals.items(), key=lambda x: x[1], reverse=True)
+ Hint sessions (synthetic, from plans) are shown with a '→' marker.
+ Historical sessions are shown with a count like (3).
+ """
+ title_counts = defaultdict(int)
+ title_is_hint = {}
+ for session in all_sessions:
+ title = getattr(session, 'title', None)
+ if not title:
+ continue
+ if getattr(session, '_is_hint', False):
+ title_is_hint.setdefault(title, True)
+ else:
+ title_counts[title] += 1
+ title_is_hint[title] = False
+
+ # Separate historical (count > 0) from hints-only
+ historical = [(t, c) for t, c in title_counts.items() if c > 0]
+ historical.sort(key=lambda x: x[1], reverse=True)
+ hints_only = [t for t, is_hint in title_is_hint.items() if is_hint and t not in title_counts]
choices = []
- for alias, count in sorted_aliases:
- decoration = f"({count})" if count > 0 else None
- choices.append(FuzzyItem(name=alias, value=alias, decoration=decoration))
+ for title, count in historical:
+ choices.append(FuzzyItem(name=title, value=title, decoration=f"({count})"))
+ for title in hints_only:
+ choices.append(FuzzyItem(name=title, value=title, decoration="↑"))
return choices
-def get_weighted_field_choices(field_name, alias, all_intents, all_values, session_counts_by_id, context=None):
- """Build weighted FuzzyItem list for a field, conditioned on alias and any previously chosen fields."""
- matching = _filter_intents(all_intents, alias, context)
+def get_weighted_field_choices(field_name, alias, all_sessions, all_values, context=None):
+ """Build weighted FuzzyItem list for a field, conditioned on alias and any previously chosen fields.
+ Real historical sessions drive frequency counts and decoration.
+ Hint sessions (synthetic) contribute ordering only — they appear with '→' if not in history.
+ """
+ matching = _filter_sessions(all_sessions, alias, context)
+
+ # Count only real sessions for frequency display
field_freq = defaultdict(int)
- for intent in matching:
- val = getattr(intent, field_name, None)
- if val:
- field_freq[val] += session_counts_by_id.get(intent.intent_id, 0)
+ hint_only_vals = set()
+ for session in matching:
+ val = getattr(session, field_name, None)
+ if not val:
+ continue
+ if getattr(session, '_is_hint', False):
+ hint_only_vals.add(val)
+ else:
+ field_freq[val] += 1
+
+ # Values in hint_only_vals that also appear in real history are not hint-only
+ hint_only_vals -= set(field_freq.keys())
correlated = sorted(field_freq.items(), key=lambda x: x[1], reverse=True)
correlated_set = {v for v, _ in correlated}
- others = [v for v in all_values if v not in correlated_set]
+
+ all_historical = {getattr(s, field_name, None) for s in all_sessions if not getattr(s, '_is_hint', False)}
+ all_historical.discard(None)
+ all_known = dict.fromkeys(list(all_values) + sorted(all_historical))
+ others = [v for v in all_known if v not in correlated_set and v not in hint_only_vals]
choices = []
for val, count in correlated:
name = prettify_path_label(val)
if not name:
continue
- decoration = f"{val} ({count})" if count > 0 else val
+ decoration = f"{val} ({count})"
choices.append(FuzzyItem(name=name, value=val, decoration=decoration))
+ for val in hint_only_vals:
+ name = prettify_path_label(val)
+ if not name:
+ continue
+ choices.append(FuzzyItem(name=name, value=val, decoration=f"{val} ↑"))
+
for val in others:
name = prettify_path_label(val)
if not name:
@@ -92,18 +132,18 @@ def get_weighted_field_choices(field_name, alias, all_intents, all_values, sessi
return choices
-def get_weighted_tracker_choices(alias, all_intents, all_tracker_ids, tracker_names, session_counts_by_id, context=None, chosen_trackers=None):
+def get_weighted_tracker_choices(alias, all_sessions, all_tracker_ids, tracker_names, context=None, chosen_trackers=None):
"""Build weighted tracker choices conditioned on alias and previously chosen fields.
Returns (choices, has_correlated) where has_correlated indicates whether any
trackers were historically associated with this context.
"""
- matching = _filter_intents(all_intents, alias, context)
+ matching = _filter_sessions(all_sessions, alias, context)
tracker_freq = defaultdict(int)
- for intent in matching:
- for tracker in (intent.trackers or []):
- tracker_freq[tracker] += session_counts_by_id.get(intent.intent_id, 0)
+ for session in matching:
+ for tracker in (getattr(session, 'trackers', None) or []):
+ tracker_freq[tracker] += 1
chosen_set = set(chosen_trackers or [])
remaining = [t for t in all_tracker_ids if t not in chosen_set]
@@ -127,129 +167,210 @@ def get_weighted_tracker_choices(alias, all_intents, all_tracker_ids, tracker_na
return choices, bool(correlated)
-def match_or_create_intent(ws, alias, role, objective, action, subject, trackers):
- """Find an existing intent matching all fields, or create and persist a new one."""
- date = ws.today()
- existing_intents = ws.plans.get_intents(date)
-
- for intent in existing_intents:
- if (intent.alias == alias and
- intent.role == role and
- intent.objective == objective and
- intent.action == action and
- intent.subject == subject and
- sorted(intent.trackers or []) == sorted(trackers or [])):
- return intent
-
- local_plan = ws.plans.get_local_plan_or_create(date)
- new_intent = Intent(
- alias=alias,
- role=role,
- objective=objective,
- action=action,
- subject=subject,
- trackers=trackers
- )
- new_plan = local_plan.add_intent(new_intent)
- ws.plans.write_plan(new_plan)
+def _hints_to_sessions(hints):
+ """Convert hint dicts to session-like objects for the heuristic weighting pool."""
+ return [
+ SimpleNamespace(
+ title=h["title"],
+ role=h.get("role"),
+ subject=h.get("subject"),
+ impact=h.get("impact"),
+ mode=h.get("mode"),
+ trackers=h.get("trackers") or [],
+ _is_hint=True,
+ )
+ for h in hints
+ ]
- # Get the intent back with its generated ID
- return [i for i in new_plan.intents if i.alias == alias][-1]
+def _resolve_tracker(hints, role, subject, impact, mode, exclude=None):
+ """Find hints whose field constraints all match the given session values."""
+ exclude = set(exclude or [])
+ matches = []
+ for h in hints:
+ tracker_ids = h.get("trackers") or []
+ if not tracker_ids:
+ continue
+ if all(tid in exclude for tid in tracker_ids):
+ continue
+ if (h.get("role") is None or h["role"] == role) and \
+ (h.get("subject") is None or h["subject"] == subject) and \
+ (h.get("impact") is None or h["impact"] == impact) and \
+ (h.get("mode") is None or h["mode"] == mode):
+ if any(h.get(f) is not None for f in ("role", "subject", "impact", "mode")):
+ matches.append(h)
+ return matches
-def _prompt_for_intent(ws, existing_intents, session_counts):
- """Interactively prompt for all intent fields. Returns the matched/created Intent, or None if aborted."""
+
+def _prompt_for_session_fields(ws, all_sessions):
+ """Interactively prompt for session fields per CLI config. Returns field dict, or None if aborted."""
from rich.console import Console
console = Console()
date = ws.today()
-
- # Step 1: Alias
- console.print()
- console.print("[bold]I am doing/making/attending:[/bold]")
- console.print("[dim]e.g. 1:1 with Yan, Monthly Strategy Meeting[/dim]")
- chosen_alias, _ = fuzzy_select(
- prompt="Title:",
- choices=get_alias_choices(existing_intents, session_counts),
- escapable=False,
- slugify_new=False,
- )
- if not chosen_alias:
- return None
- alias = chosen_alias.value
-
- # Step 2: Role
- console.print()
- console.print("[bold]I am performing the role of:[/bold]")
- console.print("[dim]e.g. Line Manager, Pre-Sales Engineer, Parent[/dim]")
- role, _ = fuzzy_select("Role:", get_weighted_field_choices(
- "role", alias, existing_intents,
- list(ws.plans.get_roles(date)), session_counts,
- ), escapable=True)
- role_val = role.value if role else None
-
- # Step 3: Impact (objective in data model)
- console.print()
- console.print("[bold]I hope the impact is:[/bold]")
- console.print("[dim]e.g. Career Development, New Revenue[/dim]")
- impact, _ = fuzzy_select("Impact:", get_weighted_field_choices(
- "objective", alias, existing_intents,
- list(ws.plans.get_objectives(date)), session_counts,
- context={"role": role_val},
- ), escapable=True)
- impact_val = impact.value if impact else None
-
- # Step 4: Subject
- console.print()
- console.print("[bold]I am mostly focused on:[/bold]")
- console.print("[dim]e.g. John Smith, ACME Corporation[/dim]")
- subject, _ = fuzzy_select("Subject:", get_weighted_field_choices(
- "subject", alias, existing_intents,
- list(ws.plans.get_subjects(date)), session_counts,
- context={"role": role_val, "objective": impact_val},
- ), escapable=True)
- subject_val = subject.value if subject else None
-
- # Step 5: Mode (action in data model)
- console.print()
- console.print("[bold]My primary mode is:[/bold]")
- console.print("[dim]e.g. Planning, Meeting, Testing[/dim]")
- mode, _ = fuzzy_select("Mode:", get_weighted_field_choices(
- "action", alias, existing_intents,
- list(ws.plans.get_actions(date)), session_counts,
- context={"role": role_val, "objective": impact_val, "subject": subject_val},
- ), escapable=True)
- mode_val = mode.value if mode else None
-
- # Step 6: Trackers
+ cfg = load_config().session
+ enabled = cfg.enabled_fields()
+
+ # Load all plan data in a single call (one disk read instead of 7+)
+ plan_data = ws.plans.get_start_data(date)
+ plan_hints = plan_data["hints"]
+ plan_hint_tracker_ids = {tid for h in plan_hints for tid in (h.get("trackers") or [])}
+ tracker_mapping_hints = [
+ {"title": m.get("hint_title") or m["tracker_name"], "role": m["role"], "subject": m["subject"],
+ "impact": m["impact"], "mode": m["mode"], "trackers": [m["tracker_id"]]}
+ for m in plan_data["tracker_mappings"]
+ if m["tracker_id"] not in plan_hint_tracker_ids
+ ]
+ all_hints = plan_hints + tracker_mapping_hints
+ sessions_with_hints = list(all_sessions) + _hints_to_sessions(all_hints)
+
+ title = None
+ role_val = None
+ impact_val = None
+ mode_val = None
+ subject_val = None
trackers: list[str] = []
- tracker_names = ws.plans.get_trackers(date)
- all_tracker_ids = list(tracker_names.keys())
- if all_tracker_ids:
+ if "title" in enabled:
console.print()
- console.print("[bold]Time spent in this session should be logged to:[/bold]")
- console.print("[dim]e.g. PROJ-123, Billable: ACME[/dim]")
-
- full_context = {"role": role_val, "objective": impact_val, "subject": subject_val, "action": mode_val}
- while True:
- if all(t in trackers for t in all_tracker_ids):
- break
-
- weighted, has_correlated = get_weighted_tracker_choices(
- alias, existing_intents, all_tracker_ids, tracker_names,
- session_counts, context=full_context, chosen_trackers=trackers,
+ console.print("[bold]I am doing/making/attending:[/bold]")
+ console.print("[dim]e.g. 1:1 with Yan, Monthly Strategy Meeting[/dim]")
+ chosen_title, _ = fuzzy_select(
+ prompt=None,
+ choices=get_title_choices(sessions_with_hints),
+ escapable=True,
+ slugify_new=False,
+ )
+ if chosen_title:
+ title = chosen_title.value
+
+ if "role" in enabled:
+ console.print()
+ console.print("[bold]I am performing the role of:[/bold]")
+ console.print("[dim]e.g. Line Manager, Pre-Sales Engineer, Parent[/dim]")
+ role, _ = fuzzy_select(None, get_weighted_field_choices(
+ "role", title, sessions_with_hints,
+ plan_data["roles"],
+ ), escapable=True)
+ role_val = role.value if role else None
+
+ if "subject" in enabled:
+ console.print()
+ console.print("[bold]I am mostly focused on:[/bold]")
+ console.print("[dim]e.g. John Smith, ACME Corporation, Sales Team[/dim]")
+ subject, _ = fuzzy_select(None, get_weighted_field_choices(
+ "subject", title, sessions_with_hints,
+ plan_data["subjects"],
+ context={"role": role_val},
+ ), escapable=True)
+ subject_val = subject.value if subject else None
+
+ if "impact" in enabled:
+ console.print()
+ console.print("[bold]I hope the impact is:[/bold]")
+ console.print("[dim]e.g. Career Development, New Revenue[/dim]")
+ impact, _ = fuzzy_select(None, get_weighted_field_choices(
+ "impact", title, sessions_with_hints,
+ plan_data["impacts"],
+ context={"role": role_val, "subject": subject_val},
+ ), escapable=True)
+ impact_val = impact.value if impact else None
+
+ if "mode" in enabled:
+ console.print()
+ console.print("[bold]My primary mode is:[/bold]")
+ console.print("[dim]e.g. Planning, Meeting, Testing[/dim]")
+ mode, _ = fuzzy_select(None, get_weighted_field_choices(
+ "mode", title, sessions_with_hints,
+ plan_data["modes"],
+ context={"role": role_val, "subject": subject_val, "impact": impact_val},
+ ), escapable=True)
+ mode_val = mode.value if mode else None
+
+ if "trackers" in enabled:
+ tracker_names = plan_data["trackers"]
+ all_tracker_ids = list(tracker_names.keys())
+ max_trackers = cfg.max_trackers # 0 = unlimited
+
+ def tracker_limit_reached():
+ return max_trackers > 0 and len(trackers) >= max_trackers
+
+ if all_tracker_ids:
+ full_context = {"role": role_val, "impact": impact_val, "subject": subject_val, "mode": mode_val}
+
+ auto_matches = _resolve_tracker(
+ all_hints, role_val, subject_val, impact_val, mode_val,
+ exclude=trackers,
)
- done_label = "[No trackers]" if not trackers else "[Done]"
- done_item = FuzzyItem(name=done_label, value=None, decoration=None)
- choices = weighted + [done_item] if has_correlated else [done_item] + weighted
-
- picked, _ = fuzzy_select(prompt="Tracker:", choices=choices, escapable=True, create_new=False)
- if picked and picked.value:
- trackers.append(picked.value)
- else:
- break
-
- return match_or_create_intent(ws, alias, role_val, impact_val, mode_val, subject_val, trackers)
+ # Flatten to a deduplicated list of suggested tracker IDs
+ auto_tracker_ids = list(dict.fromkeys(
+ tid for m in auto_matches for tid in (m.get("trackers") or [])
+ if tid not in trackers
+ ))
+
+ console.print()
+ console.print("[bold]Time spent in this session should be logged to:[/bold]")
+ console.print("[dim]e.g. PROJ-123, Billable: ACME[/dim]")
+
+ if len(auto_tracker_ids) == 1:
+ # Single match: show as top suggestion, user confirms or overrides
+ tid = auto_tracker_ids[0]
+ name = tracker_names.get(tid, tid)
+ suggested = FuzzyItem(name=name, value=tid, decoration=f"{tid} (suggested)")
+ done_item = FuzzyItem(name="[No trackers]", value=None, decoration=None)
+ other_ids = [] if tracker_limit_reached() else [t for t in all_tracker_ids if t != tid]
+ other_choices, _ = get_weighted_tracker_choices(
+ title, sessions_with_hints, other_ids, tracker_names,
+ context=full_context, chosen_trackers=trackers,
+ )
+ picked, _ = fuzzy_select(
+ prompt=None,
+ choices=[suggested, done_item] + other_choices,
+ escapable=True,
+ create_new=False,
+ )
+ if picked and picked.value:
+ trackers.append(picked.value)
+
+ elif len(auto_tracker_ids) > 1 and not tracker_limit_reached():
+ # Multiple matches: prompt from the matching subset first
+ auto_subset = [tid for tid in all_tracker_ids if tid in auto_tracker_ids]
+ while not tracker_limit_reached():
+ remaining_subset = [t for t in auto_subset if t not in trackers]
+ if not remaining_subset:
+ break
+ weighted, has_correlated = get_weighted_tracker_choices(
+ title, sessions_with_hints, remaining_subset, tracker_names,
+ context=full_context, chosen_trackers=trackers,
+ )
+ done_label = "[No trackers]" if not trackers else "[Done]"
+ done_item = FuzzyItem(name=done_label, value=None, decoration=None)
+ choices = weighted + [done_item] if has_correlated else [done_item] + weighted
+ picked, _ = fuzzy_select(prompt=None, choices=choices, escapable=True, create_new=False)
+ if picked and picked.value:
+ trackers.append(picked.value)
+ else:
+ break
+
+ # Allow adding more (or full manual selection if no auto-match)
+ while not tracker_limit_reached():
+ if all(t in trackers for t in all_tracker_ids):
+ break
+
+ weighted, has_correlated = get_weighted_tracker_choices(
+ title, sessions_with_hints, all_tracker_ids, tracker_names,
+ context=full_context, chosen_trackers=trackers,
+ )
+ done_label = "[No trackers]" if not trackers else "[Done]"
+ done_item = FuzzyItem(name=done_label, value=None, decoration=None)
+ choices = weighted + [done_item] if has_correlated else [done_item] + weighted
+
+ picked, _ = fuzzy_select(prompt=None, choices=choices, escapable=True, create_new=False)
+ if picked and picked.value:
+ trackers.append(picked.value)
+ else:
+ break
+
+ return title, role_val, impact_val, mode_val, subject_val, trackers
@app.callback(invoke_without_command=True)
@@ -257,6 +378,7 @@ def start(
ctx: typer.Context,
since: str = typer.Option(None, "--since", help="Start time (e.g., '14:30', 'now')"),
continue_from_last: bool = typer.Option(False, "--continue", "-c", help="Start at the end of the previous session"),
+ concurrent: bool = typer.Option(False, "--concurrent", help="Start alongside any active sessions without stopping them"),
):
"""Start a new task or activity."""
try:
@@ -278,22 +400,38 @@ def start(
else:
start_time = ws.now()
- session_counts = defaultdict(int)
- for log in ws.logs.list_logs():
- for session in log.timeline:
- session_counts[session.intent.intent_id] += 1
+ # Gather recent historical sessions for weighted choices (last 90 days is plenty)
+ all_sessions = []
+ for log in ws.logs.list_logs_recent(90):
+ all_sessions.extend(log.timeline)
- existing_intents = ws.plans.get_intents(date)
+ title, role, impact, mode, subject, trackers = _prompt_for_session_fields(ws, all_sessions)
- intent = _prompt_for_intent(ws, existing_intents, session_counts)
- if not intent:
- typer.echo("aborting")
- return
+ cfg = load_config().session
+ note = input("? Note for this session (optional): ") if cfg.note else None
- note = input("? Note for this session (optional): ")
-
- ws.logs.start_intent(intent, start_time, note if note else None)
- typer.echo(f"Started '{intent.alias}' at {start_time.strftime('%H:%M')}")
+ # For sequential start (default), stop any active sessions first
+ if not concurrent:
+ log = ws.logs.get_log(date)
+ if log.active_sessions():
+ if since:
+ updated_log = log.stop_all_active_sessions(start_time)
+ trackers = ws.plans.get_trackers(date)
+ ws.logs.write_log(updated_log, trackers)
+ else:
+ ws.logs.stop_all_active_sessions()
+
+ ws.logs.start_session(
+ title=title,
+ role=role,
+ impact=impact,
+ mode=mode,
+ subject=subject,
+ trackers=trackers,
+ start_time=start_time,
+ note=note if note else None,
+ )
+ typer.echo(f"Started '{title}' at {start_time.strftime('%H:%M')}")
except Exception as e:
typer.echo(f"Error starting session: {e}", err=True)
raise typer.Exit(1)
diff --git a/src/faff_cli/timeline.py b/src/faff_cli/timeline.py
new file mode 100644
index 0000000..90d8ed7
--- /dev/null
+++ b/src/faff_cli/timeline.py
@@ -0,0 +1,366 @@
+import math
+import typer
+from rich.console import Console
+
+from faff_core import Workspace
+
+app = typer.Typer(help="Show a visual timeline of the day's sessions.")
+
+MAX_HEIGHT = 5 # max connector/gap lines between events
+
+
+@app.callback(invoke_without_command=True)
+def timeline(
+ ctx: typer.Context,
+ date: str = typer.Argument(None),
+):
+ """
+ Show a visual timeline of the day's sessions.
+
+ Concurrent sessions are shown in parallel columns. Session and gap heights
+ are scaled proportionally (sqrt) so longer durations appear taller.
+
+ Examples:
+ faff timeline
+ faff timeline yesterday
+ faff timeline 2026-03-30
+ """
+ ws: Workspace = ctx.obj
+ console = Console()
+ resolved_date = ws.parse_natural_date(date)
+ log = ws.logs.get_log(resolved_date)
+ now = ws.now()
+
+ sessions = sorted(log.timeline, key=lambda s: s.start)
+
+ if not sessions:
+ console.print("[dim]No sessions recorded.[/dim]")
+ return
+
+ # --- Lane assignment ---
+ lane_ends = []
+ lane_assignments = {} # session.id -> lane index
+
+ for s in sessions:
+ s_end = s.end or now
+ lane = next((i for i, e in enumerate(lane_ends) if e <= s.start), len(lane_ends))
+ if lane == len(lane_ends):
+ lane_ends.append(s_end)
+ else:
+ lane_ends[lane] = s_end
+ lane_assignments[s.id] = lane
+
+ num_lanes = len(lane_ends)
+
+ # --- Session colours ---
+ palette = ['green', 'cyan', 'magenta', 'yellow', 'blue']
+ session_colors = {s.id: palette[i % len(palette)] for i, s in enumerate(sessions)}
+
+ # --- Build event list ---
+ # 'active' events represent still-running sessions pinned to now.
+ # Sort order at equal times: ends first, then actives, then starts.
+ events = []
+ for s in sessions:
+ events.append((s.start, 'start', s))
+ if s.end:
+ events.append((s.end, 'end', s))
+ else:
+ events.append((now, 'active', s))
+
+ def event_sort_key(e):
+ time, type_, session = e
+ # Zero-duration sessions (end == start): sort start before end so the
+ # session is in active_ids when its end event fires.
+ # All other cases: ends before starts so handoffs remove the outgoing
+ # session before adding the incoming one.
+ is_zero_dur = type_ in ('start', 'end') and session.end and session.end == session.start
+ if is_zero_dur:
+ return (time, {'start': 0, 'end': 1}[type_])
+ return (time, {'end': 0, 'active': 1, 'start': 2}[type_])
+
+ events.sort(key=event_sort_key)
+
+ # --- Helpers ---
+ def fmt_dur(total_seconds):
+ minutes = int(total_seconds // 60)
+ h, m = divmod(minutes, 60)
+ if h and m:
+ return f"{h}h {m}m"
+ if h:
+ return f"{h}h"
+ return f"{m}m"
+
+ def lane_color(lane_idx, active_sids):
+ for sid in active_sids:
+ if lane_assignments.get(sid) == lane_idx:
+ return session_colors.get(sid)
+ return None
+
+ def make_lane_str(active, event_lane=None, event_char=None):
+ parts = []
+ for i in range(num_lanes):
+ if i == event_lane:
+ parts.append(event_char)
+ elif any(lane_assignments[sid] == i for sid in active):
+ c = lane_color(i, active)
+ parts.append(f'[{c}]│[/{c}]' if c else '│')
+ else:
+ parts.append(' ')
+ return ''.join(parts)
+
+ # --- Build render plan (two-pass for sqrt scaling) ---
+ # Pass 1: collect all inter-event segments with type and duration
+ plan = []
+ active_ids = set()
+ last_time = None
+
+ for event_time, event_type, session in events:
+ sid = session.id
+
+ if last_time is not None and event_time != last_time:
+ seg_secs = (event_time - last_time).total_seconds()
+ if seg_secs >= 60: # suppress sub-minute segments (e.g. now vs session end)
+ if active_ids:
+ plan.append(('connector', seg_secs, frozenset(active_ids)))
+ else:
+ plan.append(('gap', seg_secs, None))
+
+ plan.append(('event', event_time, event_type, session))
+
+ if event_type == 'start':
+ active_ids.add(sid)
+ elif event_type == 'end':
+ active_ids.discard(sid)
+
+ last_time = event_time
+
+ # Pass 2: find max segment duration for sqrt scaling
+ seg_durations = [item[1] for item in plan if item[0] in ('connector', 'gap')]
+ max_secs = max(seg_durations) if seg_durations else 1.0
+
+ def scaled_height(secs):
+ return max(1, round(math.sqrt(secs / max_secs) * MAX_HEIGHT))
+
+ # Pre-process: annotate end events that lead into a gap with the gap duration,
+ # and mark those gap items as consumed so they aren't rendered separately.
+ consumed = set()
+ for i, item in enumerate(plan):
+ if item[0] == 'event' and item[2] == 'end':
+ j = i + 1
+ # Skip past connectors and simultaneous end events at the same time
+ while j < len(plan) and (
+ plan[j][0] == 'connector' or
+ (plan[j][0] == 'event' and plan[j][2] == 'end' and plan[j][1] == item[1])
+ ):
+ j += 1
+ if j < len(plan) and plan[j][0] == 'gap':
+ plan[i] = item + (plan[j][1],) # append break_secs
+ consumed.add(j)
+
+ # Pass 3: render
+ active_ids = set()
+ prev_shown_time = None
+ sep = " [dim]·[/dim] "
+
+ for idx, item in enumerate(plan):
+ if idx in consumed:
+ if item[0] == 'gap':
+ # Gap label moved to the preceding ● line; still render ┊ height lines
+ _, gap_secs, _ = item
+ height = scaled_height(gap_secs)
+ for _ in range(height):
+ console.print(f" [dim]┊[/dim]")
+ # active/start events consumed by lookahead: skip entirely
+ continue
+
+ if item[0] == 'connector':
+ _, seg_secs, frozen_active = item
+ height = scaled_height(seg_secs)
+ lane_str = make_lane_str(frozen_active)
+ for _ in range(height):
+ console.print(f" {lane_str}")
+
+ elif item[0] == 'gap':
+ _, gap_secs, _ = item
+ height = scaled_height(gap_secs)
+ for _ in range(height):
+ console.print(f" [dim]┊[/dim]")
+
+ else: # event
+ _, event_time, event_type, session = item[:4]
+ sid = session.id
+ color = session_colors[sid]
+ time_fmt = event_time.strftime('%H:%M')
+ time_str = time_fmt if time_fmt != prev_shown_time else ' '
+
+ if event_type == 'start':
+ # Collect all simultaneous start events
+ starts_now = [session]
+ j = idx + 1
+ while j < len(plan) and plan[j][0] == 'event' and plan[j][2] == 'start' and plan[j][1] == event_time:
+ starts_now.append(plan[j][3])
+ consumed.add(j)
+ j += 1
+ # Build lane string with ● for each starting lane
+ parts = []
+ for i in range(num_lanes):
+ s_in_lane = next((s for s in starts_now if lane_assignments.get(s.id) == i), None)
+ if s_in_lane:
+ c = session_colors[s_in_lane.id]
+ parts.append(f'[{c}]●[/{c}]')
+ elif any(lane_assignments[s2] == i for s2 in active_ids):
+ c = lane_color(i, active_ids)
+ parts.append(f'[{c}]│[/{c}]' if c else '│')
+ else:
+ parts.append(' ')
+ lane_str = ''.join(parts)
+ for s in starts_now:
+ active_ids.add(s.id)
+ # First label inline with the dots; subsequent labels on connector lines below
+ for i, s in enumerate(starts_now):
+ c = session_colors[s.id]
+ if s.end:
+ elapsed = (s.end - s.start).total_seconds()
+ dur_str = f"[white]{fmt_dur(elapsed)}[/white]"
+ else:
+ elapsed = (now - s.start).total_seconds()
+ dur_str = f"[white]{fmt_dur(elapsed)}…[/white]"
+ label = f"[{c}]{s.title or '(untitled)'}[/{c}]{sep}{dur_str}{sep}[dim]{s.id[:8]}[/dim]"
+ if i == 0:
+ console.print(f" {time_str} {lane_str} {label}")
+ else:
+ connector = make_lane_str(active_ids)
+ console.print(f" {connector} {label}")
+ prev_shown_time = time_fmt
+
+ elif event_type == 'end':
+ # Collect all simultaneous end events
+ ends_now = [session]
+ j = idx + 1
+ while j < len(plan) and plan[j][0] == 'event' and plan[j][2] == 'end' and plan[j][1] == event_time:
+ ends_now.append(plan[j][3])
+ consumed.add(j)
+ j += 1
+ # Build lane string with ● for each ending lane
+ parts = []
+ for i in range(num_lanes):
+ s_in_lane = next((s for s in ends_now if lane_assignments.get(s.id) == i), None)
+ if s_in_lane:
+ c = session_colors[s_in_lane.id]
+ parts.append(f'[{c}]●[/{c}]')
+ elif any(lane_assignments[s2] == i for s2 in active_ids if s2 not in {s.id for s in ends_now}):
+ c = lane_color(i, active_ids)
+ parts.append(f'[{c}]│[/{c}]' if c else '│')
+ else:
+ parts.append(' ')
+ lane_str = ''.join(parts)
+ for s in ends_now:
+ active_ids.discard(s.id)
+ if active_ids:
+ console.print(f" {time_str} {lane_str}")
+ else:
+ break_secs = item[4] if len(item) > 4 else None
+ # Build a full-width lane string: colored ● for ending lanes,
+ # plain white ● at lane 0 if nothing ends there (anchors ┊ alignment).
+ be_parts = []
+ for i in range(num_lanes):
+ s_in_ends = next((s for s in ends_now if lane_assignments.get(s.id) == i), None)
+ if s_in_ends:
+ c = session_colors[s_in_ends.id]
+ be_parts.append(f'[{c}]●[/{c}]')
+ elif i == 0:
+ be_parts.append('●')
+ else:
+ be_parts.append(' ')
+ be_lane_str = ''.join(be_parts)
+ if break_secs is not None:
+ console.print(f" {time_str} {be_lane_str} [white]Break[/white]{sep}[white]{fmt_dur(break_secs)}[/white]")
+ else:
+ # Check for sequential handoff (start at same time)
+ next_starts = []
+ k = idx + 1
+ while k < len(plan) and plan[k][0] == 'event' and plan[k][2] == 'start' and plan[k][1] == event_time:
+ next_starts.append(plan[k][3])
+ consumed.add(k)
+ k += 1
+ if next_starts:
+ for s in next_starts:
+ active_ids.add(s.id)
+ for i, s in enumerate(next_starts):
+ c = session_colors[s.id]
+ if s.end:
+ elapsed = (s.end - s.start).total_seconds()
+ dur_str = f"[white]{fmt_dur(elapsed)}[/white]"
+ else:
+ elapsed = (now - s.start).total_seconds()
+ dur_str = f"[white]{fmt_dur(elapsed)}…[/white]"
+ label = f"[{c}]{s.title or '(untitled)'}[/{c}]{sep}{dur_str}{sep}[dim]{s.id[:8]}[/dim]"
+ parts = []
+ for li in range(num_lanes):
+ s_in_ends = next((e for e in ends_now if lane_assignments.get(e.id) == li), None)
+ s_in_starts = next((st for st in next_starts if lane_assignments.get(st.id) == li), None)
+ if s_in_starts:
+ lc = session_colors[s_in_starts.id]
+ parts.append(f'[{lc}]●[/{lc}]')
+ elif s_in_ends:
+ lc = session_colors[s_in_ends.id]
+ parts.append(f'[{lc}]●[/{lc}]')
+ elif any(lane_assignments[s2] == li for s2 in active_ids if s2 not in {s.id for s in next_starts}):
+ lc = lane_color(li, active_ids)
+ parts.append(f'[{lc}]│[/{lc}]' if lc else '│')
+ else:
+ parts.append(' ')
+ merged_lane = ''.join(parts)
+ if i == 0:
+ console.print(f" {time_str} {merged_lane} {label}")
+ else:
+ connector = make_lane_str(active_ids)
+ console.print(f" {connector} {label}")
+ else:
+ console.print(f" {time_str} {be_lane_str} [white]End[/white]")
+ prev_shown_time = time_fmt
+
+ else: # active — collect all simultaneous active events and render on one line
+ active_now = [session]
+ j = idx + 1
+ while j < len(plan) and plan[j][0] == 'event' and plan[j][2] == 'active' and plan[j][1] == event_time:
+ active_now.append(plan[j][3])
+ consumed.add(j)
+ j += 1
+ parts = []
+ for i in range(num_lanes):
+ s_in_lane = next((s for s in active_now if lane_assignments.get(s.id) == i), None)
+ if s_in_lane:
+ c = session_colors[s_in_lane.id]
+ parts.append(f'[{c}]○[/{c}]')
+ elif any(lane_assignments[s2] == i for s2 in active_ids if s2 not in {s.id for s in active_now}):
+ c = lane_color(i, active_ids)
+ parts.append(f'[{c}]│[/{c}]' if c else '│')
+ else:
+ parts.append(' ')
+ lane_str = ''.join(parts)
+ for s in active_now:
+ active_ids.discard(s.id)
+ console.print(f" {time_str} {lane_str} [dim]Now[/dim]")
+ prev_shown_time = time_fmt
+
+ # Summary line
+ summary = log.summary(now)
+ total_secs = sum(((s.end or now) - s.start).total_seconds() for s in sessions)
+ total = fmt_dur(total_secs)
+
+ # Elapsed = union of session intervals (excludes breaks, overlaps counted once)
+ merged = []
+ for s in sorted(sessions, key=lambda s: s.start):
+ s_end = s.end or now
+ if merged and s.start <= merged[-1][1]:
+ merged[-1] = (merged[-1][0], max(merged[-1][1], s_end))
+ else:
+ merged.append((s.start, s_end))
+ elapsed_secs = sum((e - s).total_seconds() for s, e in merged)
+ wallclock = fmt_dur(elapsed_secs)
+ console.print()
+ if summary['has_concurrent_sessions']:
+ console.print(f" [dim]{total} tracked{sep}{wallclock} elapsed (concurrent sessions)[/dim]")
+ else:
+ console.print(f" [dim]{total} tracked{sep}{wallclock} elapsed[/dim]")
diff --git a/src/faff_cli/ui/fuzzy_select.py b/src/faff_cli/ui/fuzzy_select.py
index 554edf3..26a9bfb 100644
--- a/src/faff_cli/ui/fuzzy_select.py
+++ b/src/faff_cli/ui/fuzzy_select.py
@@ -1,29 +1,13 @@
-from slugify import slugify
-
-import html
-
-from prompt_toolkit import Application
-from prompt_toolkit.buffer import Buffer
-from prompt_toolkit.layout import Layout, HSplit, Window, VSplit
-from prompt_toolkit.layout.dimension import Dimension
-from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
-from prompt_toolkit.key_binding import KeyBindings
-from prompt_toolkit.styles import Style
-from prompt_toolkit.shortcuts import print_formatted_text
-from prompt_toolkit.formatted_text import HTML
-
from typing import Union, Any, Optional, List, Sequence
from dataclasses import dataclass
-from pfzy import fzy_scorer
-
-style = Style.from_dict({
+_STYLE_DICT = {
"selector": "fg:ansigray",
"match": "fg:ansimagenta bold",
"select": "fg:ansiblue",
"decoration": "fg:ansigreen",
"count": "fg:ansigreen",
-})
+}
@dataclass
class FuzzyItem:
@@ -52,10 +36,11 @@ def normalize_to_fuzzyitems(items: Sequence[Any]) -> List[FuzzyItem]:
raise TypeError("Expected list of str, dicts with name/value, or FuzzyItem instances")
def slugify_preserving_slashes(path: str, **kwargs) -> str:
+ from slugify import slugify
segments = path.split("/")
return "/".join(slugify(seg, **kwargs) for seg in segments)
-def fuzzy_select(prompt: str,
+def fuzzy_select(prompt: str | None,
choices: Sequence[Union[str, FuzzyItem]],
create_new: bool = True,
max_fraction: float = 0.5,
@@ -63,7 +48,21 @@ def fuzzy_select(prompt: str,
slugify_new: bool = True,
multi_select_key: bool = False) -> tuple[FuzzyItem | None, bool]:
+ import html
import shutil
+ from slugify import slugify
+ from pfzy import fzy_scorer
+ from prompt_toolkit import Application
+ from prompt_toolkit.buffer import Buffer
+ from prompt_toolkit.layout import Layout, HSplit, Window, VSplit
+ from prompt_toolkit.layout.dimension import Dimension
+ from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
+ from prompt_toolkit.key_binding import KeyBindings
+ from prompt_toolkit.styles import Style
+ from prompt_toolkit.shortcuts import print_formatted_text
+ from prompt_toolkit.formatted_text import HTML
+
+ style = Style.from_dict(_STYLE_DICT)
total = shutil.get_terminal_size().lines
max_rows = max(3, int(total * max_fraction))
@@ -141,8 +140,9 @@ def on_change(_):
match_indices = {}
else:
scored = []
+ query_lower = user_input.lower()
for cand in normalised_choices:
- score, idxs = fzy_scorer(user_input, cand.name)
+ score, idxs = fzy_scorer(query_lower, cand.name.lower())
if score > 0:
scored.append((cand, score, idxs))
scored.sort(key=lambda x: x[1], reverse=True)
@@ -188,7 +188,9 @@ def get_menu_tokens():
return tokens
- prompt_win = Window(height=1, content=FormattedTextControl('? ' + prompt))
+ layout_children = []
+ if prompt:
+ layout_children.append(Window(height=1, content=FormattedTextControl('? ' + prompt)))
def get_input_width():
return len(buf.text) + 1 # +1 for caret space
@@ -212,7 +214,7 @@ def get_input_width():
wrap_lines=False,
height=Dimension(max=max_rows))
- root = HSplit([prompt_win, input_row, menu_win])
+ root = HSplit(layout_children + [input_row, menu_win])
app: Application = Application(layout=Layout(root),
key_bindings=kb,
style=style,
@@ -227,8 +229,9 @@ def get_input_width():
if selection:
continue_marker = " (+)" if continue_selecting else ""
+ prompt_prefix = f"{prompt} " if prompt else ""
print_formatted_text(
- HTML(f"? {prompt} {html.escape(selection.name)}{' *NEW*' if selection and selection.is_new else ''}{continue_marker}")
+ HTML(f"? {prompt_prefix}{html.escape(selection.name)}{' *NEW*' if selection and selection.is_new else ''}{continue_marker}")
)
return selection, continue_selecting
\ No newline at end of file
diff --git a/tests/conftest.py b/tests/conftest.py
index c3dbf2b..43b6ca9 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -9,7 +9,7 @@
import zoneinfo
from faff_core import Workspace
-from faff_core.models import Intent, Plan
+from faff_core.models import Plan
@pytest.fixture
@@ -54,36 +54,6 @@ def workspace(temp_faff_dir, monkeypatch):
return ws
-@pytest.fixture
-def sample_intent():
- """
- Create a sample Intent for testing.
- """
- return Intent(
- alias="test-task",
- role="developer",
- objective="testing",
- action="writing",
- subject="tests",
- trackers=["project:test"]
- )
-
-
-@pytest.fixture
-def sample_intent_alt():
- """
- Create an alternative Intent for testing.
- """
- return Intent(
- alias="other-task",
- role="developer",
- objective="development",
- action="implementing",
- subject="feature",
- trackers=["project:main"]
- )
-
-
@pytest.fixture
def sample_plan_toml():
"""
@@ -139,17 +109,16 @@ def workspace_with_log(workspace, temp_faff_dir):
Create a workspace with a log entry for today.
"""
today = workspace.today()
- intent = Intent(
+
+ # Start and stop a session
+ workspace.logs.start_session(
alias="existing-task",
role="developer",
objective="testing",
action="coding",
subject="tests",
- trackers=[]
+ trackers=[],
)
-
- # Start and stop a session
- workspace.logs.start_intent(intent)
workspace.logs.stop_current_session()
# Write the log file so it persists
diff --git a/tests/test_cli_log.py b/tests/test_cli_log.py
index e76e92e..d580058 100644
--- a/tests/test_cli_log.py
+++ b/tests/test_cli_log.py
@@ -112,7 +112,7 @@ def test_log_summary_with_data(self, workspace_with_log, temp_faff_dir, monkeypa
result = runner.invoke(cli, ["log", "summary"])
assert result.exit_code == 0
- assert "Intent Totals" in result.stdout
+ assert "Alias Totals" in result.stdout
assert "Tracker Totals" in result.stdout
def test_log_summary_specific_date(self, temp_faff_dir, monkeypatch):
diff --git a/tests/test_models.py b/tests/test_models.py
index d1248f1..1842921 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -5,50 +5,7 @@
from pathlib import Path
from datetime import date
-from faff_core.models import Intent, Plan
-
-
-class TestIntent:
- """Test Intent model."""
-
- def test_create_intent_with_all_fields(self):
- """Should create intent with all fields populated."""
- intent = Intent(
- alias="test-task",
- role="developer",
- objective="testing",
- action="writing",
- subject="tests",
- trackers=["project:test"]
- )
-
- assert intent.alias == "test-task"
- assert intent.role == "developer"
- assert intent.objective == "testing"
- assert intent.action == "writing"
- assert intent.subject == "tests"
- assert intent.trackers == ["project:test"]
- # intent_id is empty until added to a plan
- assert intent.intent_id == ""
-
- def test_create_intent_minimal(self):
- """Should create intent with minimal fields."""
- intent = Intent(alias="simple-task")
-
- assert intent.alias == "simple-task"
- assert intent.role is None
- assert intent.objective is None
- assert intent.intent_id is not None
-
- def test_intent_with_multiple_trackers(self):
- """Should handle multiple trackers."""
- intent = Intent(
- alias="multi-tracker-task",
- trackers=["project:a", "project:b", "client:x"]
- )
-
- assert len(intent.trackers) == 3
- assert "project:a" in intent.trackers
+from faff_core.models import Plan
class TestPlanFromFile: