Commit 64b34e381415

Vincent Demeester <vincent@sbr.pm>
2026-03-26 16:20:53
feat: add daily-plan tool — Jira/GitHub to org-mode scheduling
Go CLI that bridges Jira and GitHub (source of truth) with org-mode (personal scheduling layer). Inspired by zerokspot.com's 'Avoid TODO silos when you're part of a team'. Commands: daily-plan show — org agenda + Jira + GitHub overview daily-plan inbox — new/updated items since last check (or date) daily-plan schedule — schedule Jira/GH issue as org TODO daily-plan pick — interactive pick with jayrat daily-plan weekly — weekly review Features: - --json output for Emacs integration - Bot PR filtering (konflux, dependabot, etc.) - CVE grouping by CVE ID - Dedup detection (won't schedule same issue twice) - Async Emacs rendering via daily-plan-mode (org-mode derived) - Embark integration: 's' to schedule Jira/GH refs at point - Keybindings: C-c d d/i/w/s Also: - jayrat: add daily-active/backlog/cves/team boards - nix: package daily-plan, add to kyushu system
1 parent cff37de
Changed files (15)
dots
config
pkgs
systems
tools
dots/config/emacs/site-lisp/daily-plan.el
@@ -0,0 +1,1 @@
+../../../../tools/daily-plan/daily-plan.el
\ No newline at end of file
dots/config/emacs/init.el
@@ -1507,6 +1507,25 @@ minibuffer, even without explicitly focusing it."
     (let ((url (format "https://issues.redhat.com/browse/%s" ticket)))
       (kill-new url)
       (message "Copied: %s" url)))
+
+  ;; Schedule actions (daily-plan integration)
+  (declare-function my/schedule-jira-ticket "init")
+  (declare-function my/schedule-github-issue "init")
+
+  (defun my/schedule-jira-ticket (ticket)
+    "Schedule Jira TICKET via daily-plan."
+    (let ((date (org-read-date nil nil nil (format "Schedule %s for: " ticket))))
+      (message "%s" (string-trim (shell-command-to-string
+                                  (format "daily-plan schedule %s %s" ticket date))))))
+
+  (defun my/schedule-github-issue (issue)
+    "Schedule GitHub ISSUE via daily-plan."
+    (let ((date (org-read-date nil nil nil (format "Schedule %s for: " issue))))
+      (message "%s" (string-trim (shell-command-to-string
+                                  (format "daily-plan schedule %s %s" issue date))))))
+
+  (define-key embark-jira-ticket-map "s" #'my/schedule-jira-ticket)
+  (define-key embark-github-issue-map "s" #'my/schedule-github-issue)
   )
 
 (use-package embark-consult
@@ -2217,6 +2236,21 @@ parameter), remove all other windows so the capture buffer fills the frame."
   :after org-capture
   :demand t)
 
+;; Daily Plan - Jira/GitHub → org-mode scheduling
+(use-package daily-plan
+  :commands (daily-plan-show daily-plan-inbox daily-plan-weekly
+             daily-plan-schedule daily-plan-schedule-at-point)
+  :bind-keymap ("C-c d" . daily-plan-prefix-map)
+  :config
+  (defvar daily-plan-prefix-map
+    (let ((map (make-sparse-keymap)))
+      (define-key map "d" #'daily-plan-show)
+      (define-key map "i" #'daily-plan-inbox)
+      (define-key map "w" #'daily-plan-weekly)
+      (define-key map "s" #'daily-plan-schedule)
+      map)
+    "Keymap for daily-plan commands under C-c d."))
+
 (use-package org-habit
   :after org
   :custom
dots/config/jayrat/config.yaml
@@ -87,3 +87,41 @@ boards:
     description: Team backlog
     jql: "project = SRVKP AND statusCategory != Done"
     order_by: priority
+
+  # ── Daily Planning boards ──
+  - name: daily-active
+    description: "Daily plan: my active work (In Progress + Code Review)"
+    jql: 'assignee = vdemeest AND status in ("In Progress", "Code Review") AND resolution = Unresolved'
+    order_by: priority
+    columns:
+      - key
+      - summary
+      - status
+      - priority
+  - name: daily-backlog
+    description: "Daily plan: my backlog to pick from"
+    jql: 'assignee = vdemeest AND status in ("To Do", "New") AND resolution = Unresolved'
+    order_by: priority
+    columns:
+      - key
+      - summary
+      - priority
+      - type
+  - name: daily-cves
+    description: "Daily plan: new security issues (last 7 days)"
+    jql: 'project = SRVKP AND labels = Security AND created >= -7d AND resolution = Unresolved'
+    order_by: priority
+    columns:
+      - key
+      - summary
+      - priority
+      - status
+  - name: daily-team
+    description: "Daily plan: team active work (for standup)"
+    jql: 'project = SRVKP AND status in ("In Progress", "Code Review") AND resolution = Unresolved'
+    order_by: assignee
+    columns:
+      - key
+      - summary
+      - status
+      - assignee
pkgs/default.nix
@@ -35,6 +35,7 @@ in
   slack-archive = pkgs.callPackage ../tools/slack-archive { };
   gcal-to-org = pkgs.callPackage ../tools/gcal-to-org { };
   review-tool = pkgs.callPackage ../tools/review-tool { };
+  daily-plan = pkgs.callPackage ../tools/daily-plan { };
   github-notif-manager = pkgs.callPackage ../tools/github-notif-manager { };
   beets-lidarr-fields = pkgs.python3Packages.callPackage ./beets-lidarr-fields { };
   beets-filetote = pkgs.python3Packages.callPackage ./beets-filetote { };
systems/kyushu/extra.nix
@@ -165,6 +165,7 @@ in
     roadmode
     jayrah
     jayrat
+    daily-plan
     # backup
     virt-manager
   ];
tools/daily-plan/cmd/daily-plan/main.go
@@ -0,0 +1,543 @@
+// Package main provides the daily-plan CLI.
+//
+// daily-plan pulls work items from Jira and GitHub into an org-mode
+// daily planning workflow. Jira/GitHub are the source of truth;
+// org-mode provides scheduling and daily agenda.
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+	"os/exec"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/vdemeester/home/tools/daily-plan/internal/config"
+	"github.com/vdemeester/home/tools/daily-plan/internal/display"
+	"github.com/vdemeester/home/tools/daily-plan/internal/github"
+	"github.com/vdemeester/home/tools/daily-plan/internal/jira"
+	"github.com/vdemeester/home/tools/daily-plan/internal/org"
+)
+
+var outputJSON bool
+
+func main() {
+	if err := run(os.Args[1:]); err != nil {
+		fmt.Fprintf(os.Stderr, "error: %v\n", err)
+		os.Exit(1)
+	}
+}
+
+func run(args []string) error {
+	cfg := config.DefaultConfig()
+	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+	defer cancel()
+
+	// Strip --json flag from anywhere in args
+	var filtered []string
+	for _, a := range args {
+		if a == "--json" {
+			outputJSON = true
+		} else {
+			filtered = append(filtered, a)
+		}
+	}
+	args = filtered
+
+	if len(args) == 0 {
+		return cmdShow(ctx, cfg)
+	}
+
+	switch args[0] {
+	case "show":
+		return cmdShow(ctx, cfg)
+	case "inbox":
+		sinceStr := ""
+		if len(args) > 1 {
+			sinceStr = args[1]
+		}
+		return cmdInbox(ctx, cfg, sinceStr)
+	case "schedule":
+		if len(args) < 2 {
+			return fmt.Errorf("usage: daily-plan schedule KEY [DATE]")
+		}
+		dateStr := ""
+		if len(args) > 2 {
+			dateStr = args[2]
+		}
+		return cmdSchedule(ctx, cfg, args[1], dateStr)
+	case "pick":
+		return cmdPick(ctx, cfg)
+	case "weekly":
+		return cmdWeekly(ctx, cfg)
+	case "help", "-h", "--help":
+		printHelp()
+		return nil
+	default:
+		return fmt.Errorf("unknown command: %s (try 'daily-plan help')", args[0])
+	}
+}
+
+// JSON output types for Emacs integration.
+type jsonShow struct {
+	Date           string       `json:"date"`
+	Agenda         []jsonOrg    `json:"agenda"`
+	JiraInProgress []jsonJira   `json:"jira_in_progress"`
+	JiraBacklog    []jsonJira   `json:"jira_backlog"`
+	GHIssues       []jsonGH     `json:"github_issues"`
+	GHReviews      []jsonGH     `json:"github_reviews"`
+	GHPRs          []jsonGH     `json:"github_prs"`
+}
+
+type jsonInbox struct {
+	Since      string   `json:"since"`
+	CVEs       []jsonJira `json:"cves"`
+	CVETotal   int        `json:"cve_total"`
+	Updated    []jsonJira `json:"jira_updated"`
+	NewIssues  []jsonGH   `json:"github_new_issues"`
+	NewPRs     []jsonGH   `json:"github_new_prs"`
+	Reviews    []jsonGH   `json:"github_reviews"`
+}
+
+type jsonOrg struct {
+	State   string `json:"state"`
+	Heading string `json:"heading"`
+}
+
+type jsonJira struct {
+	Key      string `json:"key"`
+	Summary  string `json:"summary"`
+	Status   string `json:"status"`
+	Priority string `json:"priority,omitempty"`
+	URL      string `json:"url"`
+}
+
+type jsonGH struct {
+	Repo      string `json:"repo"`
+	Number    int    `json:"number"`
+	Title     string `json:"title"`
+	Author    string `json:"author,omitempty"`
+	CreatedAt string `json:"created_at"`
+	URL       string `json:"url"`
+}
+
+func jiraToJSON(issues []jira.Issue, baseURL string) []jsonJira {
+	out := make([]jsonJira, 0, len(issues))
+	for _, i := range issues {
+		out = append(out, jsonJira{
+			Key:      i.Key,
+			Summary:  i.Summary,
+			Status:   i.Status,
+			Priority: i.Priority,
+			URL:      fmt.Sprintf("%s/%s", baseURL, i.Key),
+		})
+	}
+	return out
+}
+
+func ghToJSON(items []github.Item) []jsonGH {
+	out := make([]jsonGH, 0, len(items))
+	for _, i := range items {
+		out = append(out, jsonGH{
+			Repo:      i.Repo,
+			Number:    i.Number,
+			Title:     i.Title,
+			Author:    i.Author,
+			CreatedAt: i.CreatedAt.Format("2006-01-02"),
+			URL:       i.URL(),
+		})
+	}
+	return out
+}
+
+func emitJSON(v any) error {
+	enc := json.NewEncoder(os.Stdout)
+	enc.SetIndent("", "  ")
+	return enc.Encode(v)
+}
+
+func cmdShow(ctx context.Context, cfg *config.Config) error {
+	today := time.Now().Format("2006-01-02")
+
+	agenda, _ := org.TodayItems(cfg.Org.File)
+	inprog, _ := jira.FetchByStatus(ctx, cfg.Jira.User, []string{"In Progress", "Code Review", "On QA"})
+	todo, _ := jira.FetchByStatus(ctx, cfg.Jira.User, []string{"To Do", "New"})
+	ghIssues, _ := github.FetchAssignedIssues(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
+	reviews, _ := github.FetchReviewRequests(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
+	reviews = github.FilterBots(reviews, cfg.GitHub.BotFilters)
+	myPRs, _ := github.FetchMyPRs(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
+
+	if outputJSON {
+		orgItems := make([]jsonOrg, 0, len(agenda))
+		for _, a := range agenda {
+			orgItems = append(orgItems, jsonOrg{State: a.State, Heading: a.Heading})
+		}
+		return emitJSON(jsonShow{
+			Date:           today,
+			Agenda:         orgItems,
+			JiraInProgress: jiraToJSON(inprog, cfg.Jira.BaseURL),
+			JiraBacklog:    jiraToJSON(todo, cfg.Jira.BaseURL),
+			GHIssues:       ghToJSON(ghIssues),
+			GHReviews:      ghToJSON(reviews),
+			GHPRs:          ghToJSON(myPRs),
+		})
+	}
+
+	display.Header(fmt.Sprintf("Today's Org Agenda (%s)", today))
+	display.OrgItems(agenda)
+
+	display.Header("Jira — In Progress / Code Review")
+	display.JiraIssues(inprog, "active")
+
+	display.Header("Jira — To Do / Backlog")
+	display.JiraIssuesLimited(todo, 10, "dim")
+
+	display.Header("GitHub — Assigned Issues")
+	display.GitHubItems(ghIssues, "issue")
+
+	display.Header("GitHub — PRs Awaiting Your Review")
+	display.GitHubItems(reviews, "review")
+
+	display.Header("GitHub — Your Open PRs")
+	display.GitHubItems(myPRs, "pr")
+
+	display.Hint("Run 'daily-plan inbox' for new items, 'daily-plan schedule KEY' to schedule")
+	return nil
+}
+
+func cmdInbox(ctx context.Context, cfg *config.Config, sinceStr string) error {
+	since, err := parseSince(cfg, sinceStr)
+	if err != nil {
+		return fmt.Errorf("invalid date: %w", err)
+	}
+
+	cves, _ := jira.FetchSecuritySince(ctx, since)
+	grouped, total := jira.GroupByCVE(cves)
+	updated, _ := jira.FetchUpdatedSince(ctx, since)
+	newIssues, _ := github.FetchNewIssuesSince(ctx, since, cfg.GitHub.Owners)
+	newPRs, _ := github.FetchNewPRsSince(ctx, since, cfg.GitHub.Owners)
+	newPRs = github.FilterBots(newPRs, cfg.GitHub.BotFilters)
+	reviews, _ := github.FetchReviewRequests(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
+	reviews = github.FilterBots(reviews, cfg.GitHub.BotFilters)
+
+	if outputJSON {
+		updateLastCheck(cfg)
+		return emitJSON(jsonInbox{
+			Since:     since.Format("2006-01-02"),
+			CVEs:      jiraToJSON(grouped, cfg.Jira.BaseURL),
+			CVETotal:  total,
+			Updated:   jiraToJSON(updated, cfg.Jira.BaseURL),
+			NewIssues: ghToJSON(newIssues),
+			NewPRs:    ghToJSON(newPRs),
+			Reviews:   ghToJSON(reviews),
+		})
+	}
+
+	display.Header(fmt.Sprintf("New/Updated Since %s", since.Format("2006-01-02")))
+
+	display.SubHeader("Jira — New CVEs / Security Issues")
+	display.CVEIssues(grouped, total)
+
+	display.SubHeader("Jira — Updated Issues (assigned/reported by you)")
+	display.JiraIssues(updated, "active")
+
+	display.SubHeader("GitHub — New Issues (across tektoncd + openshift-pipelines)")
+	display.GitHubItems(newIssues, "issue")
+
+	display.SubHeader("GitHub — New PRs")
+	display.GitHubItems(newPRs, "pr")
+
+	display.SubHeader("GitHub — Review Requests")
+	display.GitHubItems(reviews, "review")
+
+	// Update last check
+	updateLastCheck(cfg)
+	display.Hint(fmt.Sprintf("Last check updated to %s. Run 'daily-plan schedule KEY' to schedule items.", time.Now().Format("2006-01-02")))
+	return nil
+}
+
+func cmdSchedule(ctx context.Context, cfg *config.Config, key, dateStr string) error {
+	schedDate, err := parseDate(dateStr)
+	if err != nil {
+		return err
+	}
+
+	jiraRe := regexp.MustCompile(`^[A-Z]+-\d+$`)
+	ghRe := regexp.MustCompile(`^(.+)#(\d+)$`)
+
+	if jiraRe.MatchString(key) {
+		if org.HasJiraKey(cfg.Org.File, key) {
+			return fmt.Errorf("%s already exists in %s — use org to reschedule", key, cfg.Org.File)
+		}
+		summary, err := jira.FetchSummary(ctx, key)
+		if err != nil {
+			return fmt.Errorf("could not find Jira issue %s: %w", key, err)
+		}
+		url := fmt.Sprintf("%s/%s", cfg.Jira.BaseURL, key)
+		if err := org.ScheduleJiraIssue(cfg.Org.File, cfg.Org.Section, key, summary, url, schedDate); err != nil {
+			return err
+		}
+		fmt.Printf("\033[0;32m✓\033[0m Scheduled \033[1m%s\033[0m (%s) for %s\n", key, summary, schedDate.Format("2006-01-02"))
+		return nil
+	}
+
+	if m := ghRe.FindStringSubmatch(key); m != nil {
+		repo := m[1]
+		number, _ := strconv.Atoi(m[2])
+		if org.HasGitHubIssue(cfg.Org.File, repo, number) {
+			return fmt.Errorf("%s already exists in %s — use org to reschedule", key, cfg.Org.File)
+		}
+		summary, err := github.FetchIssueSummary(ctx, repo, number)
+		if err != nil {
+			return fmt.Errorf("could not find GitHub issue %s: %w", key, err)
+		}
+		url := fmt.Sprintf("https://github.com/%s/issues/%d", repo, number)
+		if err := org.ScheduleGitHubIssue(cfg.Org.File, cfg.Org.Section, repo, number, summary, url, schedDate); err != nil {
+			return err
+		}
+		fmt.Printf("\033[0;32m✓\033[0m Scheduled \033[1m%s#%d\033[0m (%s) for %s\n", repo, number, summary, schedDate.Format("2006-01-02"))
+		return nil
+	}
+
+	return fmt.Errorf("unknown key format: %s (use PROJ-123 for Jira or owner/repo#123 for GitHub)", key)
+}
+
+func cmdPick(_ context.Context, cfg *config.Config) error {
+	tmpfile := fmt.Sprintf("/tmp/jayrat-pick-%d.json", os.Getpid())
+	defer os.Remove(tmpfile)
+
+	fmt.Println("\033[1mOpening jayrat to pick issues...\033[0m")
+	fmt.Println("\033[2mPress Enter on an issue to select it, q to quit\033[0m")
+	fmt.Println()
+
+	// jayrat needs a real TTY, use syscall exec to replace this process
+	jayratArgs := []string{"jayrat", "--board", "myissue", "--choose", tmpfile}
+
+	// Find jayrat binary
+	jayratPath, err := findBinary("jayrat")
+	if err != nil {
+		return fmt.Errorf("jayrat not found: %w", err)
+	}
+
+	// Fork-exec jayrat with the current terminal
+	proc, err := os.StartProcess(jayratPath, jayratArgs, &os.ProcAttr{
+		Files: []*os.File{os.Stdin, os.Stdout, os.Stderr},
+	})
+	if err != nil {
+		return fmt.Errorf("failed to start jayrat: %w", err)
+	}
+	state, err := proc.Wait()
+	if err != nil || !state.Success() {
+		return nil // User quit jayrat, that's fine
+	}
+
+	// Read the selected issue
+	data, err := os.ReadFile(tmpfile)
+	if err != nil || len(data) == 0 {
+		fmt.Println("\033[2mNo issue selected.\033[0m")
+		return nil
+	}
+
+	// Parse JSON and schedule
+	// For now, just extract key from the JSON
+	keyRe := regexp.MustCompile(`"key"\s*:\s*"([^"]+)"`)
+	summaryRe := regexp.MustCompile(`"summary"\s*:\s*"([^"]+)"`)
+
+	keyMatch := keyRe.FindSubmatch(data)
+	summaryMatch := summaryRe.FindSubmatch(data)
+
+	if keyMatch == nil {
+		fmt.Println("\033[2mCould not parse selected issue.\033[0m")
+		return nil
+	}
+
+	key := string(keyMatch[1])
+	summary := ""
+	if summaryMatch != nil {
+		summary = string(summaryMatch[1])
+	}
+
+	fmt.Printf("\nSelected: \033[1m%s\033[0m — %s\n", key, summary)
+	fmt.Print("Schedule for which date? [today/tomorrow/YYYY-MM-DD] (default: today): ")
+
+	var dateInput string
+	fmt.Scanln(&dateInput)
+	if dateInput == "" {
+		dateInput = "today"
+	}
+
+	schedDate, err := parseDate(dateInput)
+	if err != nil {
+		return err
+	}
+
+	url := fmt.Sprintf("%s/%s", cfg.Jira.BaseURL, key)
+	if err := org.ScheduleJiraIssue(cfg.Org.File, cfg.Org.Section, key, summary, url, schedDate); err != nil {
+		return err
+	}
+	fmt.Printf("\033[0;32m✓\033[0m Scheduled \033[1m%s\033[0m (%s) for %s\n", key, summary, schedDate.Format("2006-01-02"))
+	return nil
+}
+
+type jsonWeekly struct {
+	Week          string     `json:"week"`
+	NextMonday    string     `json:"next_monday"`
+	JiraCompleted []jsonJira `json:"jira_completed"`
+	GHMerged      []jsonGH   `json:"github_merged"`
+	JiraInProgress []jsonJira `json:"jira_in_progress"`
+	JiraBacklog   []jsonJira `json:"jira_backlog"`
+	GHIssues      []jsonGH   `json:"github_issues"`
+}
+
+func cmdWeekly(ctx context.Context, cfg *config.Config) error {
+	now := time.Now()
+	// Find last Monday
+	daysFromMonday := (int(now.Weekday()) - int(time.Monday) + 7) % 7
+	monday := now.AddDate(0, 0, -daysFromMonday)
+	nextMonday := monday.AddDate(0, 0, 7)
+
+	completed, _ := jira.FetchCompletedSince(ctx, cfg.Jira.User, monday)
+	merged, _ := github.FetchMergedPRsSince(ctx, cfg.GitHub.Username, monday, cfg.GitHub.Owners)
+	inprog, _ := jira.FetchByStatus(ctx, cfg.Jira.User, []string{"In Progress", "Code Review", "On QA"})
+	todo, _ := jira.FetchByStatus(ctx, cfg.Jira.User, []string{"To Do", "New"})
+	ghIssues, _ := github.FetchAssignedIssues(ctx, cfg.GitHub.Username, cfg.GitHub.Owners)
+
+	if outputJSON {
+		return emitJSON(jsonWeekly{
+			Week:           monday.Format("2006-01-02"),
+			NextMonday:     nextMonday.Format("2006-01-02"),
+			JiraCompleted:  jiraToJSON(completed, cfg.Jira.BaseURL),
+			GHMerged:       ghToJSON(merged),
+			JiraInProgress: jiraToJSON(inprog, cfg.Jira.BaseURL),
+			JiraBacklog:    jiraToJSON(todo, cfg.Jira.BaseURL),
+			GHIssues:       ghToJSON(ghIssues),
+		})
+	}
+
+	display.Header(fmt.Sprintf("Weekly Review (week of %s)", monday.Format("2006-01-02")))
+
+	display.SubHeader("Completed This Week (Jira)")
+	display.JiraIssues(completed, "done")
+
+	display.SubHeader("Completed This Week (GitHub — Merged PRs)")
+	display.GitHubItems(merged, "done")
+
+	display.SubHeader("Still In Progress (Jira)")
+	display.JiraIssues(inprog, "active")
+
+	display.SubHeader("Backlog — Candidates for Next Week")
+	display.Hint("  Run 'daily-plan pick' to select and schedule items")
+	display.JiraIssuesLimited(todo, 15, "dim")
+
+	fmt.Println()
+	display.SubHeader("GitHub Issues (assigned)")
+	display.GitHubItems(ghIssues, "issue")
+
+	display.Hint(fmt.Sprintf("Schedule items: daily-plan schedule SRVKP-1234 %s", nextMonday.Format("2006-01-02")))
+	return nil
+}
+
+// ── Helpers ──
+
+func parseSince(cfg *config.Config, input string) (time.Time, error) {
+	if input == "" {
+		return readLastCheck(cfg)
+	}
+
+	// Try YYYY-MM-DD
+	if t, err := time.Parse("2006-01-02", input); err == nil {
+		return t, nil
+	}
+
+	// Try natural language via date command
+	return parseNaturalDate(input)
+}
+
+func parseDate(input string) (time.Time, error) {
+	if input == "" || input == "today" {
+		return time.Now(), nil
+	}
+	if input == "tomorrow" {
+		return time.Now().AddDate(0, 0, 1), nil
+	}
+	if t, err := time.Parse("2006-01-02", input); err == nil {
+		return t, nil
+	}
+	return parseNaturalDate(input)
+}
+
+func parseNaturalDate(input string) (time.Time, error) {
+	// Use GNU date for natural language parsing
+	out, err := execCommand("date", "-d", input, "+%Y-%m-%d")
+	if err != nil {
+		return time.Time{}, fmt.Errorf("cannot parse date %q", input)
+	}
+	return time.Parse("2006-01-02", strings.TrimSpace(out))
+}
+
+func readLastCheck(cfg *config.Config) (time.Time, error) {
+	data, err := os.ReadFile(cfg.LastCheckFile())
+	if err != nil {
+		// Default to yesterday
+		return time.Now().AddDate(0, 0, -1), nil
+	}
+	return time.Parse("2006-01-02", strings.TrimSpace(string(data)))
+}
+
+func updateLastCheck(cfg *config.Config) {
+	os.MkdirAll(cfg.StateDir, 0755)
+	os.WriteFile(cfg.LastCheckFile(), []byte(time.Now().Format("2006-01-02")), 0644)
+}
+
+func findBinary(name string) (string, error) {
+	out, err := execCommand("which", name)
+	if err != nil {
+		return "", fmt.Errorf("%s not found in PATH", name)
+	}
+	return strings.TrimSpace(out), nil
+}
+
+func execCommand(name string, args ...string) (string, error) {
+	cmd := exec.Command(name, args...)
+	var stdout strings.Builder
+	cmd.Stdout = &stdout
+	if err := cmd.Run(); err != nil {
+		return "", err
+	}
+	return stdout.String(), nil
+}
+
+func printHelp() {
+	fmt.Println(`daily-plan — pull from Jira/GitHub into org-mode planning
+
+Commands:
+  show                    Show today's plan + current work (default)
+  inbox [DATE]            New/updated items since last check (or DATE)
+  pick                    Interactive pick with jayrat → schedule
+  schedule KEY [DATE]     Schedule a Jira/GH issue (PROJ-123 or org/repo#42)
+  weekly                  Weekly review: completed, in progress, backlog
+
+Flags:
+  --json                  Output as JSON (for Emacs/scripting integration)
+
+Date formats:
+  YYYY-MM-DD              Explicit date
+  today, tomorrow         Relative
+  "last monday"           Natural language (via GNU date)
+
+Examples:
+  daily-plan
+  daily-plan show --json
+  daily-plan inbox
+  daily-plan inbox 2026-03-20
+  daily-plan inbox "last monday" --json
+  daily-plan schedule SRVKP-11036
+  daily-plan schedule SRVKP-11036 2026-03-28
+  daily-plan schedule tektoncd/pipeline#9149 tomorrow
+  daily-plan pick
+  daily-plan weekly`)
+}
tools/daily-plan/internal/config/config.go
@@ -0,0 +1,76 @@
+// Package config handles daily-plan configuration.
+package config
+
+import (
+	"os"
+	"path/filepath"
+)
+
+// Config holds the daily-plan configuration.
+type Config struct {
+	// Jira configuration
+	Jira JiraConfig
+
+	// GitHub configuration
+	GitHub GitHubConfig
+
+	// Org-mode configuration
+	Org OrgConfig
+
+	// State directory for last-check tracking
+	StateDir string
+}
+
+// JiraConfig configures Jira integration.
+type JiraConfig struct {
+	User    string // Jira username (e.g. "vdemeest")
+	BaseURL string // Jira browse URL (e.g. "https://issues.redhat.com/browse")
+	Project string // Default project (e.g. "SRVKP")
+}
+
+// GitHubConfig configures GitHub integration.
+type GitHubConfig struct {
+	Username string   // GitHub username
+	Owners   []string // GitHub orgs/users to track
+	// BotFilters are author patterns to filter out from inbox
+	BotFilters []string
+}
+
+// OrgConfig configures org-mode integration.
+type OrgConfig struct {
+	File    string // Path to todos.org
+	Section string // Section to add TODOs under (e.g. "Work")
+}
+
+// DefaultConfig returns sensible defaults.
+func DefaultConfig() *Config {
+	home, _ := os.UserHomeDir()
+	return &Config{
+		Jira: JiraConfig{
+			User:    "vdemeest",
+			BaseURL: "https://issues.redhat.com/browse",
+			Project: "SRVKP",
+		},
+		GitHub: GitHubConfig{
+			Username: "vdemeester",
+			Owners:   []string{"tektoncd", "openshift-pipelines"},
+			BotFilters: []string{
+				"openshift-pipelines-bot",
+				"red-hat-konflux",
+				"dependabot[bot]",
+				"github-actions[bot]",
+				"openshift-cherrypick-robot",
+			},
+		},
+		Org: OrgConfig{
+			File:    filepath.Join(home, "desktop", "org", "todos.org"),
+			Section: "Work",
+		},
+		StateDir: filepath.Join(home, ".local", "share", "daily-plan"),
+	}
+}
+
+// LastCheckFile returns the path to the last-check timestamp file.
+func (c *Config) LastCheckFile() string {
+	return filepath.Join(c.StateDir, "last-check")
+}
tools/daily-plan/internal/display/display.go
@@ -0,0 +1,147 @@
+// Package display provides terminal output formatting.
+package display
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/vdemeester/home/tools/daily-plan/internal/github"
+	"github.com/vdemeester/home/tools/daily-plan/internal/jira"
+	"github.com/vdemeester/home/tools/daily-plan/internal/org"
+)
+
+const (
+	bold    = "\033[1m"
+	dim     = "\033[2m"
+	red     = "\033[0;31m"
+	green   = "\033[0;32m"
+	yellow  = "\033[0;33m"
+	blue    = "\033[0;34m"
+	magenta = "\033[0;35m"
+	cyan    = "\033[0;36m"
+	reset   = "\033[0m"
+)
+
+// Header prints a section header.
+func Header(title string) {
+	fmt.Printf("\n%s%s── %s ──%s\n", bold, cyan, title, reset)
+}
+
+// SubHeader prints a subsection header.
+func SubHeader(title string) {
+	fmt.Printf("  %s%s%s%s\n", bold, magenta, title, reset)
+}
+
+// OrgItems prints scheduled org items.
+func OrgItems(items []org.ScheduledItem) {
+	if len(items) == 0 {
+		fmt.Printf("  %s(nothing scheduled)%s\n", dim, reset)
+		return
+	}
+	for _, item := range items {
+		state := item.State
+		if state != "" {
+			state += " "
+		}
+		fmt.Printf("  %s%s%s\n", state, item.Heading, reset)
+	}
+}
+
+// JiraIssues prints a list of Jira issues.
+func JiraIssues(issues []jira.Issue, style string) {
+	if len(issues) == 0 {
+		fmt.Printf("  %s(none)%s\n", dim, reset)
+		return
+	}
+	for _, issue := range issues {
+		color := yellow
+		switch style {
+		case "dim":
+			color = dim
+		case "alert":
+			color = red
+		case "done":
+			color = green
+		}
+
+		summary := issue.Summary
+		if len(summary) > 70 {
+			summary = summary[:67] + "..."
+		}
+
+		if style == "alert" {
+			fmt.Printf("  %s⚠ %-14s%s %s %s[%s]%s\n", color, issue.Key, reset, summary, dim, issue.Status, reset)
+		} else {
+			fmt.Printf("  %s%-14s%s %-70s %s[%s]%s\n", color, issue.Key, reset, summary, dim, issue.Status, reset)
+		}
+	}
+}
+
+// JiraIssuesLimited prints issues with a limit and "N more" indicator.
+func JiraIssuesLimited(issues []jira.Issue, limit int, style string) {
+	if len(issues) <= limit {
+		JiraIssues(issues, style)
+		return
+	}
+	JiraIssues(issues[:limit], style)
+	fmt.Printf("  %s... and %d more%s\n", dim, len(issues)-limit, reset)
+}
+
+// CVEIssues prints CVE issues grouped by CVE ID with total count.
+func CVEIssues(grouped []jira.Issue, totalCount int) {
+	if len(grouped) == 0 {
+		fmt.Printf("  %s(none)%s\n", dim, reset)
+		return
+	}
+	JiraIssues(grouped, "alert")
+	if totalCount > len(grouped) {
+		fmt.Printf("  %s(%d total issues across images — showing first per CVE)%s\n", dim, totalCount, reset)
+	}
+}
+
+// GitHubItems prints GitHub issues or PRs.
+func GitHubItems(items []github.Item, style string) {
+	if len(items) == 0 {
+		fmt.Printf("  %s(none)%s\n", dim, reset)
+		return
+	}
+	for _, item := range items {
+		color := blue
+		switch style {
+		case "review":
+			color = red
+		case "pr":
+			color = green
+		case "done":
+			color = green
+		}
+
+		ref := item.Ref()
+		title := item.Title
+		if len(title) > 60 {
+			title = title[:57] + "..."
+		}
+
+		extra := ""
+		if item.Author != "" {
+			extra = fmt.Sprintf("@%s", item.Author)
+		}
+		dateStr := item.CreatedAt.Format("2006-01-02")
+		if item.ClosedAt != nil {
+			dateStr = item.ClosedAt.Format("2006-01-02")
+		}
+
+		parts := []string{}
+		if extra != "" {
+			parts = append(parts, extra)
+		}
+		parts = append(parts, dateStr)
+
+		fmt.Printf("  %s%-40s%s %s %s(%s)%s\n", color, ref, reset, title, dim, strings.Join(parts, ", "), reset)
+	}
+}
+
+// Hint prints a dim hint line.
+func Hint(msg string) {
+	fmt.Printf("\n%s%s%s\n", dim, msg, reset)
+}
tools/daily-plan/internal/github/github.go
@@ -0,0 +1,200 @@
+// Package github provides GitHub issue/PR fetching via the gh CLI.
+package github
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"os/exec"
+	"strings"
+	"time"
+)
+
+// Item represents a GitHub issue or PR.
+type Item struct {
+	Repo      string
+	Number    int
+	Title     string
+	Author    string
+	CreatedAt time.Time
+	ClosedAt  *time.Time
+	Kind      string // "issue", "pr"
+}
+
+// Ref returns "org/repo#123" format.
+func (i Item) Ref() string {
+	return fmt.Sprintf("%s#%d", i.Repo, i.Number)
+}
+
+// URL returns the GitHub URL.
+func (i Item) URL() string {
+	kind := "issues"
+	if i.Kind == "pr" {
+		kind = "pull"
+	}
+	return fmt.Sprintf("https://github.com/%s/%s/%d", i.Repo, kind, i.Number)
+}
+
+type ghSearchResult struct {
+	Repository struct {
+		NameWithOwner string `json:"nameWithOwner"`
+	} `json:"repository"`
+	Number    int    `json:"number"`
+	Title     string `json:"title"`
+	Author    struct {
+		Login string `json:"login"`
+	} `json:"author"`
+	CreatedAt time.Time  `json:"createdAt"`
+	ClosedAt  *time.Time `json:"closedAt"`
+}
+
+// FetchAssignedIssues returns open issues assigned to user across owners.
+func FetchAssignedIssues(ctx context.Context, username string, owners []string) ([]Item, error) {
+	args := []string{
+		"search", "issues",
+		"--assignee", username,
+		"--state", "open",
+		"--limit", "30",
+		"--json", "repository,number,title,createdAt,author",
+	}
+	args = append(args, ownerFlags(owners)...)
+	return runGHSearch(ctx, args, "issue")
+}
+
+// FetchReviewRequests returns open PRs where review is requested from user.
+func FetchReviewRequests(ctx context.Context, username string, owners []string) ([]Item, error) {
+	args := []string{
+		"search", "prs",
+		"--review-requested", username,
+		"--state", "open",
+		"--limit", "30",
+		"--json", "repository,number,title,createdAt,author",
+	}
+	args = append(args, ownerFlags(owners)...)
+	return runGHSearch(ctx, args, "pr")
+}
+
+// FetchMyPRs returns open PRs authored by user.
+func FetchMyPRs(ctx context.Context, username string, owners []string) ([]Item, error) {
+	args := []string{
+		"search", "prs",
+		"--author", username,
+		"--state", "open",
+		"--limit", "30",
+		"--json", "repository,number,title,createdAt",
+	}
+	args = append(args, ownerFlags(owners)...)
+	return runGHSearch(ctx, args, "pr")
+}
+
+// FetchNewIssuesSince returns issues created since the given date.
+func FetchNewIssuesSince(ctx context.Context, since time.Time, owners []string) ([]Item, error) {
+	args := []string{
+		"search", "issues",
+		"--state", "open",
+		"--created", fmt.Sprintf(">=%s", since.Format("2006-01-02")),
+		"--limit", "30",
+		"--json", "repository,number,title,createdAt,author",
+	}
+	args = append(args, ownerFlags(owners)...)
+	return runGHSearch(ctx, args, "issue")
+}
+
+// FetchNewPRsSince returns PRs created since the given date.
+func FetchNewPRsSince(ctx context.Context, since time.Time, owners []string) ([]Item, error) {
+	args := []string{
+		"search", "prs",
+		"--state", "open",
+		"--created", fmt.Sprintf(">=%s", since.Format("2006-01-02")),
+		"--limit", "30",
+		"--json", "repository,number,title,createdAt,author",
+	}
+	args = append(args, ownerFlags(owners)...)
+	return runGHSearch(ctx, args, "pr")
+}
+
+// FetchMergedPRsSince returns PRs by user merged since the given date.
+func FetchMergedPRsSince(ctx context.Context, username string, since time.Time, owners []string) ([]Item, error) {
+	args := []string{
+		"search", "prs",
+		"--author", username,
+		"--merged", fmt.Sprintf(">=%s", since.Format("2006-01-02")),
+		"--limit", "30",
+		"--json", "repository,number,title,closedAt",
+	}
+	args = append(args, ownerFlags(owners)...)
+	return runGHSearch(ctx, args, "pr")
+}
+
+// FetchIssueSummary fetches title for a single GitHub issue.
+func FetchIssueSummary(ctx context.Context, repo string, number int) (string, error) {
+	cmd := exec.CommandContext(ctx, "gh", "issue", "view",
+		fmt.Sprintf("%d", number),
+		"--repo", repo,
+		"--json", "title",
+		"--jq", ".title",
+	)
+	var stdout bytes.Buffer
+	cmd.Stdout = &stdout
+	if err := cmd.Run(); err != nil {
+		return "", fmt.Errorf("failed to fetch %s#%d: %w", repo, number, err)
+	}
+	return strings.TrimSpace(stdout.String()), nil
+}
+
+// FilterBots removes items from bot authors.
+func FilterBots(items []Item, botPatterns []string) []Item {
+	var filtered []Item
+	for _, item := range items {
+		isBot := false
+		for _, pattern := range botPatterns {
+			if strings.Contains(strings.ToLower(item.Author), strings.ToLower(pattern)) {
+				isBot = true
+				break
+			}
+		}
+		if !isBot {
+			filtered = append(filtered, item)
+		}
+	}
+	return filtered
+}
+
+func ownerFlags(owners []string) []string {
+	var flags []string
+	for _, owner := range owners {
+		flags = append(flags, "--owner", owner)
+	}
+	return flags
+}
+
+func runGHSearch(ctx context.Context, args []string, kind string) ([]Item, error) {
+	cmd := exec.CommandContext(ctx, "gh", args...)
+	var stdout, stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+
+	if err := cmd.Run(); err != nil {
+		return nil, fmt.Errorf("gh error: %s", stderr.String())
+	}
+
+	var results []ghSearchResult
+	if err := json.Unmarshal(stdout.Bytes(), &results); err != nil {
+		return nil, fmt.Errorf("json parse error: %w", err)
+	}
+
+	items := make([]Item, 0, len(results))
+	for _, r := range results {
+		items = append(items, Item{
+			Repo:      r.Repository.NameWithOwner,
+			Number:    r.Number,
+			Title:     r.Title,
+			Author:    r.Author.Login,
+			CreatedAt: r.CreatedAt,
+			ClosedAt:  r.ClosedAt,
+			Kind:      kind,
+		})
+	}
+	return items, nil
+}
tools/daily-plan/internal/jira/jira.go
@@ -0,0 +1,176 @@
+// Package jira provides Jira issue fetching via the jira CLI.
+package jira
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"os/exec"
+	"regexp"
+	"strings"
+	"time"
+)
+
+// Issue represents a Jira issue.
+type Issue struct {
+	Key      string
+	Summary  string
+	Status   string
+	Priority string
+	Type     string
+	Labels   string
+}
+
+// FetchByStatus fetches issues assigned to user with given statuses.
+func FetchByStatus(ctx context.Context, user string, statuses []string) ([]Issue, error) {
+	var allIssues []Issue
+	for _, status := range statuses {
+		issues, err := fetchIssueList(ctx, user, status)
+		if err != nil {
+			continue // Skip on error, don't fail entirely
+		}
+		allIssues = append(allIssues, issues...)
+	}
+	return allIssues, nil
+}
+
+// FetchUpdatedSince fetches issues assigned to the current user updated since the given date.
+func FetchUpdatedSince(ctx context.Context, since time.Time) ([]Issue, error) {
+	sinceStr := since.Format("2006-01-02")
+	args := []string{
+		"issue", "list", "--plain", "--no-truncate",
+		"-a", jiraMe(),
+		"--updated-after", sinceStr,
+	}
+	return runJiraList(ctx, args)
+}
+
+// FetchSecuritySince fetches Security-labeled issues created since the given date.
+func FetchSecuritySince(ctx context.Context, since time.Time) ([]Issue, error) {
+	sinceStr := since.Format("2006-01-02")
+	args := []string{
+		"issue", "list", "--plain", "--no-truncate",
+		"-l", "Security",
+		"--created-after", sinceStr,
+	}
+	return runJiraList(ctx, args)
+}
+
+// FetchCompletedSince fetches issues resolved by user since the given date.
+func FetchCompletedSince(ctx context.Context, user string, since time.Time) ([]Issue, error) {
+	var allIssues []Issue
+	for _, status := range []string{"Done", "Closed"} {
+		sinceStr := since.Format("2006-01-02")
+		args := []string{
+			"issue", "list", "--plain", "--no-truncate",
+			"-a", jiraMe(),
+			"-s", status,
+			"--updated-after", sinceStr,
+		}
+		issues, err := runJiraList(ctx, args)
+		if err != nil {
+			continue
+		}
+		allIssues = append(allIssues, issues...)
+	}
+	return allIssues, nil
+}
+
+// FetchSummary fetches the summary for a single issue key.
+func FetchSummary(ctx context.Context, key string) (string, error) {
+	cmd := exec.CommandContext(ctx, "jira", "issue", "view", key, "--plain")
+	var stdout bytes.Buffer
+	cmd.Stdout = &stdout
+	if err := cmd.Run(); err != nil {
+		return "", fmt.Errorf("failed to view issue %s: %w", key, err)
+	}
+	// Extract summary from "# Title" line
+	for line := range strings.SplitSeq(stdout.String(), "\n") {
+		trimmed := strings.TrimSpace(line)
+		if after, ok := strings.CutPrefix(trimmed, "# "); ok {
+			return strings.TrimSpace(after), nil
+		}
+	}
+	return "", fmt.Errorf("could not find summary for %s", key)
+}
+
+// GroupByCVE deduplicates issues by CVE ID, returning one issue per CVE.
+func GroupByCVE(issues []Issue) ([]Issue, int) {
+	cveRe := regexp.MustCompile(`CVE-\d+-\d+`)
+	seen := make(map[string]bool)
+	var grouped []Issue
+	for _, issue := range issues {
+		cve := cveRe.FindString(issue.Summary)
+		if cve != "" {
+			if seen[cve] {
+				continue
+			}
+			seen[cve] = true
+		}
+		grouped = append(grouped, issue)
+	}
+	return grouped, len(issues)
+}
+
+func fetchIssueList(ctx context.Context, user, status string) ([]Issue, error) {
+	args := []string{
+		"issue", "list", "--plain", "--no-truncate",
+		"-a", jiraMe(),
+		"-s", status,
+	}
+	return runJiraList(ctx, args)
+}
+
+func runJiraList(ctx context.Context, args []string) ([]Issue, error) {
+	cmd := exec.CommandContext(ctx, "jira", args...)
+	var stdout, stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+
+	if err := cmd.Run(); err != nil {
+		return nil, fmt.Errorf("jira CLI error: %s", stderr.String())
+	}
+
+	return parsePlainOutput(stdout.String()), nil
+}
+
+func parsePlainOutput(output string) []Issue {
+	lines := strings.Split(strings.TrimSpace(output), "\n")
+	if len(lines) < 2 {
+		return nil
+	}
+
+	var issues []Issue
+	// Skip header line, collapse consecutive tabs
+	tabCollapser := regexp.MustCompile(`\t+`)
+	for _, line := range lines[1:] {
+		collapsed := tabCollapser.ReplaceAllString(line, "\t")
+		fields := strings.Split(collapsed, "\t")
+		if len(fields) < 4 {
+			continue
+		}
+		issue := Issue{
+			Key:     strings.TrimSpace(fields[1]),
+			Summary: strings.TrimSpace(fields[2]),
+			Status:  strings.TrimSpace(fields[3]),
+		}
+		if issue.Key == "" {
+			continue
+		}
+		if len(fields) > 6 {
+			issue.Priority = strings.TrimSpace(fields[6])
+		}
+		issues = append(issues, issue)
+	}
+	return issues
+}
+
+func jiraMe() string {
+	cmd := exec.Command("jira", "me")
+	var stdout bytes.Buffer
+	cmd.Stdout = &stdout
+	if err := cmd.Run(); err != nil {
+		return ""
+	}
+	return strings.TrimSpace(stdout.String())
+}
tools/daily-plan/internal/org/org.go
@@ -0,0 +1,170 @@
+// Package org provides org-mode integration for scheduling TODOs.
+package org
+
+import (
+	"bufio"
+	"bytes"
+	"fmt"
+	"os"
+	"os/exec"
+	"strings"
+	"time"
+)
+
+// ScheduledItem represents an org TODO scheduled for a specific day.
+type ScheduledItem struct {
+	Heading string
+	State   string // TODO, DONE, STRT, etc.
+}
+
+// TodayItems returns org items scheduled for today from the given file.
+func TodayItems(orgFile string) ([]ScheduledItem, error) {
+	today := time.Now().Format("2006-01-02")
+	return itemsForDate(orgFile, today)
+}
+
+func itemsForDate(orgFile, date string) ([]ScheduledItem, error) {
+	f, err := os.Open(orgFile)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+
+	var items []ScheduledItem
+	scanner := bufio.NewScanner(f)
+	var prevLine string
+	for scanner.Scan() {
+		line := scanner.Text()
+		if strings.Contains(line, fmt.Sprintf("SCHEDULED: <%s", date)) ||
+			strings.Contains(line, fmt.Sprintf("DEADLINE: <%s", date)) {
+			// Previous line should be the heading
+			if strings.HasPrefix(strings.TrimSpace(prevLine), "**") {
+				heading := strings.TrimSpace(prevLine)
+				// Strip leading stars
+				for strings.HasPrefix(heading, "*") {
+					heading = strings.TrimPrefix(heading, "*")
+				}
+				heading = strings.TrimSpace(heading)
+
+				state := ""
+				for _, s := range []string{"TODO", "DONE", "NEXT", "STRT", "WAIT", "CANX"} {
+					if strings.HasPrefix(heading, s+" ") {
+						state = s
+						heading = strings.TrimPrefix(heading, s+" ")
+						break
+					}
+				}
+				items = append(items, ScheduledItem{
+					Heading: strings.TrimSpace(heading),
+					State:   state,
+				})
+			}
+		}
+		prevLine = line
+	}
+	return items, scanner.Err()
+}
+
+// HasJiraKey checks if a Jira issue key already exists in the org file (as a non-DONE TODO).
+func HasJiraKey(orgFile, key string) bool {
+	return hasProperty(orgFile, "JIRA_KEY", key)
+}
+
+// HasGitHubIssue checks if a GitHub issue already exists in the org file (as a non-DONE TODO).
+func HasGitHubIssue(orgFile, repo string, number int) bool {
+	return hasPropertyPair(orgFile, "GH_REPO", repo, "GH_NUMBER", fmt.Sprintf("%d", number))
+}
+
+func hasProperty(orgFile, propName, propValue string) bool {
+	f, err := os.Open(orgFile)
+	if err != nil {
+		return false
+	}
+	defer f.Close()
+
+	needle := fmt.Sprintf(":%s: %s", propName, propValue)
+	scanner := bufio.NewScanner(f)
+	for scanner.Scan() {
+		if strings.Contains(scanner.Text(), needle) {
+			return true
+		}
+	}
+	return false
+}
+
+func hasPropertyPair(orgFile, prop1, val1, prop2, val2 string) bool {
+	f, err := os.Open(orgFile)
+	if err != nil {
+		return false
+	}
+	defer f.Close()
+
+	needle1 := fmt.Sprintf(":%s: %s", prop1, val1)
+	needle2 := fmt.Sprintf(":%s: %s", prop2, val2)
+	found1 := false
+	scanner := bufio.NewScanner(f)
+	for scanner.Scan() {
+		line := scanner.Text()
+		if strings.Contains(line, needle1) {
+			found1 = true
+		}
+		if found1 && strings.Contains(line, needle2) {
+			return true
+		}
+		// Reset if we leave a :PROPERTIES: block
+		if found1 && strings.TrimSpace(line) == ":END:" {
+			found1 = false
+		}
+	}
+	return false
+}
+
+// ScheduleJiraIssue creates an org TODO for a Jira issue under the given section.
+func ScheduleJiraIssue(orgFile, section, key, summary, url string, date time.Time) error {
+	dateStr := date.Format("2006-01-02 Mon")
+	createdStr := time.Now().Format("2006-01-02 Mon 15:04")
+
+	orgContent := fmt.Sprintf(
+		"\n** TODO [[%s][%s]] %s  :jira:\nSCHEDULED: <%s>\n:PROPERTIES:\n:JIRA_KEY: %s\n:URL: %s\n:CREATED: [%s]\n:END:\n",
+		url, key, summary, dateStr, key, url, createdStr,
+	)
+
+	return appendToSection(orgFile, section, orgContent)
+}
+
+// ScheduleGitHubIssue creates an org TODO for a GitHub issue under the given section.
+func ScheduleGitHubIssue(orgFile, section, repo string, number int, summary, url string, date time.Time) error {
+	dateStr := date.Format("2006-01-02 Mon")
+	createdStr := time.Now().Format("2006-01-02 Mon 15:04")
+	ref := fmt.Sprintf("%s#%d", repo, number)
+
+	orgContent := fmt.Sprintf(
+		"\n** TODO [[%s][%s]] %s  :github:\nSCHEDULED: <%s>\n:PROPERTIES:\n:GH_REPO: %s\n:GH_NUMBER: %d\n:URL: %s\n:CREATED: [%s]\n:END:\n",
+		url, ref, summary, dateStr, repo, number, url, createdStr,
+	)
+
+	return appendToSection(orgFile, section, orgContent)
+}
+
+func appendToSection(orgFile, section, content string) error {
+	escapedSection := strings.ReplaceAll(section, `"`, `\"`)
+	escapedContent := strings.ReplaceAll(content, `"`, `\"`)
+	escapedContent = strings.ReplaceAll(escapedContent, `\`, `\\`)
+
+	elisp := fmt.Sprintf(`
+      (with-current-buffer (find-file-noselect %q)
+        (goto-char (point-min))
+        (re-search-forward "^\\* %s")
+        (org-end-of-subtree t)
+        (insert %q)
+        (save-buffer)
+        "ok")`, orgFile, escapedSection, content)
+
+	cmd := exec.Command("emacsclient", "--eval", elisp)
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("emacsclient error: %s: %w", stderr.String(), err)
+	}
+	return nil
+}
tools/daily-plan/.gitignore
@@ -0,0 +1,1 @@
+daily-plan
tools/daily-plan/daily-plan.el
@@ -0,0 +1,291 @@
+;;; daily-plan.el --- Emacs interface to daily-plan CLI -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Integrates the daily-plan Go CLI with Emacs.
+;; Uses --json output for structured data, renders in org-mode buffers.
+;;
+;; Keybindings in daily-plan buffers:
+;;   s   - schedule item at point
+;;   RET - open item URL in browser
+;;   g   - refresh buffer
+;;   q   - quit
+;;
+;; Interactive commands:
+;;   M-x daily-plan-show      - today's plan
+;;   M-x daily-plan-inbox     - new items since last check
+;;   M-x daily-plan-weekly    - weekly review
+;;   M-x daily-plan-schedule  - schedule by key
+
+;;; Code:
+
+(require 'json)
+(require 'org)
+
+(defvar daily-plan-command "daily-plan"
+  "Path or name of the daily-plan binary.")
+
+(defvar daily-plan-org-file (expand-file-name "~/desktop/org/todos.org")
+  "Path to the org file for scheduling.")
+
+;; ── JSON helpers ──
+
+(defun daily-plan--run-json (&rest args)
+  "Run daily-plan with ARGS and --json, return parsed JSON."
+  (with-temp-buffer
+    (let ((exit-code (apply #'call-process daily-plan-command nil t nil
+                            (append args '("--json")))))
+      (when (zerop exit-code)
+        (goto-char (point-min))
+        (condition-case nil
+            (json-parse-buffer :object-type 'alist :array-type 'list)
+          (error nil))))))
+
+(defun daily-plan--run-plain (&rest args)
+  "Run daily-plan with ARGS, return output string."
+  (with-temp-buffer
+    (apply #'call-process daily-plan-command nil t nil args)
+    (string-trim (buffer-string))))
+
+;; ── Major mode ──
+
+(defvar daily-plan-mode-map
+  (let ((map (make-sparse-keymap)))
+    (define-key map (kbd "s") #'daily-plan-schedule-at-point)
+    (define-key map (kbd "RET") #'daily-plan-open-at-point)
+    (define-key map (kbd "g") #'daily-plan-refresh)
+    (define-key map (kbd "q") #'quit-window)
+    map)
+  "Keymap for `daily-plan-mode'.")
+
+(define-derived-mode daily-plan-mode org-mode "DailyPlan"
+  "Major mode for daily-plan buffers.
+\\{daily-plan-mode-map}"
+  (read-only-mode 1)
+  (setq-local buffer-read-only t))
+
+;; ── Rendering ──
+
+(defun daily-plan--insert-jira-item (item)
+  "Insert a single Jira ITEM as an org list entry with text properties."
+  (let-alist item
+    (let ((start (point)))
+      (insert (format "- [[%s][%s]] %s =%s=" .url .key .summary .status))
+      (when (and .priority (not (string-empty-p .priority)))
+        (insert (format " /%s/" .priority)))
+      (insert "\n")
+      (put-text-property start (point) 'daily-plan-key .key)
+      (put-text-property start (point) 'daily-plan-url .url)
+      (put-text-property start (point) 'daily-plan-type "jira"))))
+
+(defun daily-plan--insert-gh-item (item)
+  "Insert a single GitHub ITEM as an org list entry with text properties."
+  (let-alist item
+    (let ((start (point))
+          (ref (format "%s#%d" .repo .number)))
+      (insert (format "- [[%s][%s]] %s" .url ref .title))
+      (when (and .author (not (string-empty-p .author)))
+        (insert (format " (@%s)" .author)))
+      (insert "\n")
+      (put-text-property start (point) 'daily-plan-key ref)
+      (put-text-property start (point) 'daily-plan-url .url)
+      (put-text-property start (point) 'daily-plan-type "github"))))
+
+(defun daily-plan--insert-items (items insert-fn empty-msg)
+  "Insert ITEMS using INSERT-FN, or EMPTY-MSG if empty."
+  (if items
+      (dolist (item items)
+        (funcall insert-fn item))
+    (insert (format "- %s\n" (or empty-msg "(none)")))))
+
+(defun daily-plan--render-show (data)
+  "Render show DATA into current buffer."
+  (let-alist data
+    (insert (format "* Daily Plan — %s\n\n" .date))
+
+    (insert "** Org Agenda\n")
+    (if .agenda
+        (dolist (item .agenda)
+          (let-alist item
+            (insert (format "- %s %s\n" (or .state "") .heading))))
+      (insert "- (nothing scheduled)\n"))
+
+    (insert "\n** Jira — In Progress / Code Review\n")
+    (daily-plan--insert-items .jira_in_progress #'daily-plan--insert-jira-item nil)
+
+    (insert "\n** Jira — Backlog\n")
+    (daily-plan--insert-items .jira_backlog #'daily-plan--insert-jira-item nil)
+
+    (insert "\n** GitHub — Assigned Issues\n")
+    (daily-plan--insert-items .github_issues #'daily-plan--insert-gh-item nil)
+
+    (insert "\n** GitHub — PRs Awaiting Review  :review:\n")
+    (daily-plan--insert-items .github_reviews #'daily-plan--insert-gh-item nil)
+
+    (insert "\n** GitHub — Your Open PRs\n")
+    (daily-plan--insert-items .github_prs #'daily-plan--insert-gh-item nil)))
+
+(defun daily-plan--render-inbox (data)
+  "Render inbox DATA into current buffer."
+  (let-alist data
+    (insert (format "* Inbox — Since %s\n\n" .since))
+
+    (insert "** CVEs / Security Issues  :security:\n")
+    (daily-plan--insert-items .cves #'daily-plan--insert-jira-item nil)
+    (when (and .cve_total (> .cve_total (length .cves)))
+      (insert (format "  /(%d total across images)/\n" .cve_total)))
+
+    (insert "\n** Jira — Updated\n")
+    (daily-plan--insert-items .jira_updated #'daily-plan--insert-jira-item nil)
+
+    (insert "\n** GitHub — New Issues\n")
+    (daily-plan--insert-items .github_new_issues #'daily-plan--insert-gh-item nil)
+
+    (insert "\n** GitHub — New PRs\n")
+    (daily-plan--insert-items .github_new_prs #'daily-plan--insert-gh-item nil)
+
+    (insert "\n** GitHub — Review Requests  :review:\n")
+    (daily-plan--insert-items .github_reviews #'daily-plan--insert-gh-item nil)))
+
+(defun daily-plan--render-weekly (data)
+  "Render weekly DATA into current buffer."
+  (let-alist data
+    (insert (format "* Weekly Review — %s\n\n" .week))
+
+    (insert "** Completed (Jira)  :done:\n")
+    (daily-plan--insert-items .jira_completed #'daily-plan--insert-jira-item "(nothing completed)")
+
+    (insert "\n** Completed (GitHub — Merged PRs)  :done:\n")
+    (daily-plan--insert-items .github_merged #'daily-plan--insert-gh-item "(no merged PRs)")
+
+    (insert "\n** Still In Progress\n")
+    (daily-plan--insert-items .jira_in_progress #'daily-plan--insert-jira-item nil)
+
+    (insert "\n** Backlog — Candidates for Next Week\n")
+    (daily-plan--insert-items .jira_backlog #'daily-plan--insert-jira-item nil)
+
+    (insert "\n** GitHub Issues (assigned)\n")
+    (daily-plan--insert-items .github_issues #'daily-plan--insert-gh-item nil)
+
+    (insert (format "\n/Schedule: daily-plan schedule SRVKP-1234 %s/\n" .next_monday))))
+
+;; ── Buffer management ──
+
+(defun daily-plan--create-buffer (name render-fn data)
+  "Create or reuse buffer NAME, render DATA with RENDER-FN."
+  (let ((buf (get-buffer-create name)))
+    (with-current-buffer buf
+      (let ((inhibit-read-only t)
+            (pos (point)))
+        (erase-buffer)
+        (funcall render-fn data)
+        (daily-plan-mode)
+        (goto-char (min pos (point-max)))))
+    (pop-to-buffer buf)))
+
+(defun daily-plan--run-async (buf-name args render-fn)
+  "Run daily-plan with ARGS asynchronously, render into BUF-NAME with RENDER-FN."
+  (message "Fetching %s..." buf-name)
+  (let ((buf (get-buffer-create buf-name))
+        (proc-buf (generate-new-buffer " *daily-plan-proc*")))
+    ;; Show buffer immediately with loading message
+    (with-current-buffer buf
+      (let ((inhibit-read-only t))
+        (erase-buffer)
+        (insert "Loading...")
+        (daily-plan-mode)))
+    (pop-to-buffer buf)
+    ;; Run async
+    (make-process
+     :name "daily-plan"
+     :buffer proc-buf
+     :command (append (list daily-plan-command) args (list "--json"))
+     :sentinel
+     (lambda (proc _event)
+       (when (eq (process-status proc) 'exit)
+         (if (zerop (process-exit-status proc))
+             (let ((data (with-current-buffer proc-buf
+                           (goto-char (point-min))
+                           (condition-case nil
+                               (json-parse-buffer :object-type 'alist :array-type 'list)
+                             (error nil)))))
+               (if data
+                   (progn
+                     (daily-plan--create-buffer buf-name render-fn data)
+                     (message "%s ready." buf-name))
+                 (message "Failed to parse %s output" buf-name)))
+           (message "daily-plan failed with exit code %d" (process-exit-status proc)))
+         (kill-buffer proc-buf))))))
+
+;; ── Interactive commands ──
+
+;;;###autoload
+(defun daily-plan-show ()
+  "Show today's daily plan."
+  (interactive)
+  (daily-plan--run-async "*daily-plan*" '("show") #'daily-plan--render-show))
+
+;;;###autoload
+(defun daily-plan-inbox (&optional since)
+  "Show new/updated items since last check or SINCE date."
+  (interactive "sInbox since (empty=last check, or YYYY-MM-DD/\"last monday\"): ")
+  (let ((args (if (and since (not (string-empty-p since)))
+                  (list "inbox" since)
+                (list "inbox"))))
+    (daily-plan--run-async "*daily-plan-inbox*" args #'daily-plan--render-inbox)))
+
+;;;###autoload
+(defun daily-plan-weekly ()
+  "Show weekly review."
+  (interactive)
+  (daily-plan--run-async "*daily-plan-weekly*" '("weekly") #'daily-plan--render-weekly))
+
+;;;###autoload
+(defun daily-plan-schedule (key &optional date)
+  "Schedule a Jira or GitHub issue KEY for DATE.
+KEY should be PROJ-123 for Jira or owner/repo#123 for GitHub."
+  (interactive
+   (list (read-string "Issue key (PROJ-123 or org/repo#123): ")
+         (org-read-date nil nil nil "Schedule for: ")))
+  (let ((output (daily-plan--run-plain
+                 "schedule" key (or date (format-time-string "%Y-%m-%d")))))
+    (message "%s" output)))
+
+(defun daily-plan-schedule-at-point ()
+  "Schedule the item at point for a chosen date."
+  (interactive)
+  (let ((key (get-text-property (point) 'daily-plan-key)))
+    (if key
+        (let* ((date (org-read-date nil nil nil (format "Schedule %s for: " key)))
+               (output (daily-plan--run-plain "schedule" key date)))
+          (message "%s" output))
+      ;; Fallback: try to extract from org link
+      (save-excursion
+        (beginning-of-line)
+        (if (re-search-forward "\\[\\[\\([^]]+\\)\\]\\[\\([^]]+\\)\\]\\]" (line-end-position) t)
+            (let* ((ref (match-string 2))
+                   (date (org-read-date nil nil nil (format "Schedule %s for: " ref)))
+                   (output (daily-plan--run-plain "schedule" ref date)))
+              (message "%s" output))
+          (message "No schedulable item at point"))))))
+
+(defun daily-plan-open-at-point ()
+  "Open the URL of the item at point in the browser."
+  (interactive)
+  (let ((url (get-text-property (point) 'daily-plan-url)))
+    (if url
+        (browse-url url)
+      ;; Fallback to org link
+      (org-open-at-point))))
+
+(defun daily-plan-refresh ()
+  "Refresh the current daily-plan buffer."
+  (interactive)
+  (let ((buf-name (buffer-name)))
+    (cond
+     ((string= buf-name "*daily-plan*") (daily-plan-show))
+     ((string= buf-name "*daily-plan-inbox*") (daily-plan-inbox))
+     ((string= buf-name "*daily-plan-weekly*") (daily-plan-weekly))
+     (t (message "Not a daily-plan buffer")))))
+
+(provide 'daily-plan)
+;;; daily-plan.el ends here
tools/daily-plan/default.nix
@@ -0,0 +1,37 @@
+{
+  buildGoModule,
+  lib,
+  makeWrapper,
+  gh,
+  jayrat ? null,
+}:
+
+buildGoModule {
+  pname = "daily-plan";
+  version = "0.1.0";
+  src = ./.;
+
+  vendorHash = null;
+
+  nativeBuildInputs = [ makeWrapper ];
+
+  subPackages = [ "cmd/daily-plan" ];
+
+  # jira CLI is NOT bundled — expects the host's wrapper (injects token via passage)
+  postInstall = ''
+    wrapProgram $out/bin/daily-plan \
+      --prefix PATH : ${
+        lib.makeBinPath (
+          [ gh ]
+          ++ lib.optional (jayrat != null) jayrat
+        )
+      }
+  '';
+
+  meta = {
+    description = "Pull from Jira/GitHub into org-mode daily planning";
+    license = lib.licenses.mit;
+    platforms = lib.platforms.unix;
+    mainProgram = "daily-plan";
+  };
+}
tools/daily-plan/go.mod
@@ -0,0 +1,3 @@
+module github.com/vdemeester/home/tools/daily-plan
+
+go 1.24