Compare commits
9
Commits
9b763743bd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e824417b5
|
||
|
|
7d74b01bb5
|
||
|
|
7e015ec9ba
|
||
|
|
29477da849
|
||
|
|
d77523921e
|
||
|
|
3a5c7de68d
|
||
|
|
83646ef471
|
||
|
|
b64b4b472d
|
||
|
|
3629b9465f
|
Generated
+33
-33
@@ -29,11 +29,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772408722,
|
"lastModified": 1782949081,
|
||||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -70,11 +70,11 @@
|
|||||||
"uv2nix": "uv2nix"
|
"uv2nix": "uv2nix"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785270744,
|
"lastModified": 1785872157,
|
||||||
"narHash": "sha256-8F3atV7WA32b2d2WvljENtW14XxUtJ7fOduU8HQyOAQ=",
|
"narHash": "sha256-pO67XFTtQ7Y18u9k4UnMmiGwDcmVEZREUDt6IQt1rmQ=",
|
||||||
"owner": "NousResearch",
|
"owner": "NousResearch",
|
||||||
"repo": "hermes-agent",
|
"repo": "hermes-agent",
|
||||||
"rev": "28d11ab38c4cbee47f14a1064ada363f7ea93d3f",
|
"rev": "43717123ca1566a073270c5a61431e2e0e4a0211",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -111,11 +111,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785269816,
|
"lastModified": 1785531816,
|
||||||
"narHash": "sha256-6JkNDlJ18NY1iAuoSYCPDO7O+yYN/vPwpFmHOHNpVvY=",
|
"narHash": "sha256-vkMnV0JIyw+g/NmcfoajlGaAO+9a0ezia+FZohQJrik=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "home-manager",
|
"repo": "home-manager",
|
||||||
"rev": "3d25fa94cf355cf7da64c77bb9c3c15806486329",
|
"rev": "bf9ce9fec78f95f374e8dd3b503863a3ec128ebe",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -132,11 +132,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1783963347,
|
"lastModified": 1785531816,
|
||||||
"narHash": "sha256-r376E2XpakiXwModDHIxlvB6qLq4iFVEq730vxOO4JY=",
|
"narHash": "sha256-vkMnV0JIyw+g/NmcfoajlGaAO+9a0ezia+FZohQJrik=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "home-manager",
|
"repo": "home-manager",
|
||||||
"rev": "a45a7c451455a51ae740ec3bce4024b312809c29",
|
"rev": "bf9ce9fec78f95f374e8dd3b503863a3ec128ebe",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -147,11 +147,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1775036866,
|
"lastModified": 1785318670,
|
||||||
"narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
|
"narHash": "sha256-dN6Ou5x/+23FZLEpYP3IffO+NyJFzUlGumt1uu3MMaY=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
|
"rev": "0954f7ee2f6bb3dc7d4e3d0d8bcb8fd4bde4cfc5",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -163,11 +163,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-deck": {
|
"nixpkgs-deck": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785090369,
|
"lastModified": 1785828668,
|
||||||
"narHash": "sha256-m0pDuRJG7EDo9ri+4Ksu83VsI+PlxNC9lNBfydejce4=",
|
"narHash": "sha256-8fsyqeO+mJqvIzeO4xIpgJe/f7MTbbVTEC6RT6WSXNs=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "624af665418d3c65d544145b4d34ad696439570e",
|
"rev": "e72e4f299401a3689d4b3d5fc6496b11db7064eb",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -179,11 +179,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785133411,
|
"lastModified": 1785734586,
|
||||||
"narHash": "sha256-Yjv0WEg39KRYS0rBdTbu6Fc/or/ihAKk13W9sQ6VWd0=",
|
"narHash": "sha256-ODZkEK9Gy50yg6h98u7KkitZ3oc/uuTFK00bh1CRdNA=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "2f5a153c270b70cb0f8c11f46d96d6d3bc39f4e3",
|
"rev": "531670d871c0e29724a02f3cbcac170adc65b58c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -230,11 +230,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772555609,
|
"lastModified": 1785115949,
|
||||||
"narHash": "sha256-3BA3HnUvJSbHJAlJj6XSy0Jmu7RyP2gyB/0fL7XuEDo=",
|
"narHash": "sha256-8AM37BfyGaL2v/SZyg4PupRxJ01Y4htvM+WrTjWrPpo=",
|
||||||
"owner": "pyproject-nix",
|
"owner": "pyproject-nix",
|
||||||
"repo": "build-system-pkgs",
|
"repo": "build-system-pkgs",
|
||||||
"rev": "c37f66a953535c394244888598947679af231863",
|
"rev": "62c0d86027edb1c4f39a5facc09876348144f7c9",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -251,11 +251,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772865871,
|
"lastModified": 1784591072,
|
||||||
"narHash": "sha256-/ZTSg97aouL0SlPHaokA4r3iuH9QzHVuWPACD2CUCFY=",
|
"narHash": "sha256-zP/WaDxrRu8GANZM61+V2LT/7ycEEdoyLWn7M6WzU7M=",
|
||||||
"owner": "pyproject-nix",
|
"owner": "pyproject-nix",
|
||||||
"repo": "pyproject.nix",
|
"repo": "pyproject.nix",
|
||||||
"rev": "e537db02e72d553cea470976b9733581bcf5b3ed",
|
"rev": "e3b599ca2e7fcf93d4edf65d7f19bbf6491724f3",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -302,11 +302,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773039484,
|
"lastModified": 1785277507,
|
||||||
"narHash": "sha256-+boo33KYkJDw9KItpeEXXv8+65f7hHv/earxpcyzQ0I=",
|
"narHash": "sha256-9Tq3UDX2hD/aveW/HvkBlAmEwJTOlY5HQXJM+L5BGmE=",
|
||||||
"owner": "pyproject-nix",
|
"owner": "pyproject-nix",
|
||||||
"repo": "uv2nix",
|
"repo": "uv2nix",
|
||||||
"rev": "b68be7cfeacbed9a3fa38a2b5adc0cfb81d9bb1f",
|
"rev": "5a836d395cbf5fc22670eb98dd4aa4fc4d406977",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -323,11 +323,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785224333,
|
"lastModified": 1785830592,
|
||||||
"narHash": "sha256-8DNJUVM10ktK02V9ZIV064CH+fuPF272VCAZBn6arig=",
|
"narHash": "sha256-4lqvnaiXkjbMCYUkgDqkkPaD40ln0r0bCIz5pGu17EU=",
|
||||||
"owner": "0xc000022070",
|
"owner": "0xc000022070",
|
||||||
"repo": "zen-browser-flake",
|
"repo": "zen-browser-flake",
|
||||||
"rev": "5f1a4be4d280e1ae06d61a75775a234b1714da3f",
|
"rev": "3eb64fada36de0780703158ef9b1063ba697c24a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -32,11 +32,13 @@
|
|||||||
../../modules/home-manager/pi
|
../../modules/home-manager/pi
|
||||||
../../modules/home-manager/tmux
|
../../modules/home-manager/tmux
|
||||||
../../modules/home-manager/openspec
|
../../modules/home-manager/openspec
|
||||||
|
../../modules/home-manager/dev-orchestrator
|
||||||
../../modules/home-manager/power-profiles
|
../../modules/home-manager/power-profiles
|
||||||
../../modules/home-manager/ntfy-notify
|
../../modules/home-manager/ntfy-notify
|
||||||
../../modules/home-manager/pinentry
|
../../modules/home-manager/pinentry
|
||||||
../../modules/home-manager/swaync
|
../../modules/home-manager/swaync
|
||||||
../../modules/home-manager/bitwarden-cli
|
../../modules/home-manager/bitwarden-cli
|
||||||
|
../../modules/home-manager/alarms-timers
|
||||||
|
|
||||||
inputs.zen-browser.homeModules.beta
|
inputs.zen-browser.homeModules.beta
|
||||||
];
|
];
|
||||||
@@ -96,9 +98,27 @@
|
|||||||
enable = true;
|
enable = true;
|
||||||
};
|
};
|
||||||
openspec.enable = true;
|
openspec.enable = true;
|
||||||
|
dev-orchestrator.enable = true;
|
||||||
power-profiles.enable = true;
|
power-profiles.enable = true;
|
||||||
ntfy-notify.enable = true;
|
ntfy-notify = {
|
||||||
|
enable = true;
|
||||||
|
server = "https://ntfy.joaoporta.com";
|
||||||
|
subscriptions = [
|
||||||
|
{
|
||||||
|
priority = "normal";
|
||||||
|
topic = "music-download";
|
||||||
|
title = "Music Downloaded";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
priority = "critical";
|
||||||
|
topic = "joao-kanban";
|
||||||
|
title = "Kanban Update";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
};
|
||||||
bitwarden.enable = true;
|
bitwarden.enable = true;
|
||||||
|
alarms-timers.enable = true;
|
||||||
};
|
};
|
||||||
home.packages =
|
home.packages =
|
||||||
let
|
let
|
||||||
@@ -119,6 +139,7 @@
|
|||||||
telegram-desktop
|
telegram-desktop
|
||||||
koreader
|
koreader
|
||||||
xorg.xauth
|
xorg.xauth
|
||||||
|
easyeffects
|
||||||
];
|
];
|
||||||
|
|
||||||
programs = {
|
programs = {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
enable = true;
|
enable = true;
|
||||||
settings = {
|
settings = {
|
||||||
font = {
|
font = {
|
||||||
size = 12;
|
size = 11;
|
||||||
normal = {
|
normal = {
|
||||||
family = "BerkeleyMono Nerd Font Mono";
|
family = "BerkeleyMono Nerd Font Mono";
|
||||||
style = "Regular";
|
style = "Regular";
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
cfg = config.custom.alarms-timers;
|
||||||
|
runtime = pkgs.writeShellScriptBin "alarm-runtime" ''
|
||||||
|
set -efu
|
||||||
|
|
||||||
|
state_dir="''${XDG_STATE_HOME:-$HOME/.local/state}/alarm"
|
||||||
|
state_file="$state_dir/events.json"
|
||||||
|
lock_file="$state_dir/events.lock"
|
||||||
|
mkdir -p "$state_dir"
|
||||||
|
|
||||||
|
init_state() {
|
||||||
|
if [ ! -s "$state_file" ] || ! jq -e '(.nextId | numbers) and (.events | arrays)' "$state_file" >/dev/null 2>&1; then
|
||||||
|
printf '%s\n' '{"nextId":1,"events":[]}' > "$state_file"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_state() {
|
||||||
|
tmp=$(mktemp "$state_dir/events.XXXXXX")
|
||||||
|
jq -c . > "$tmp"
|
||||||
|
mv -f "$tmp" "$state_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
printf '%s\n' "usage: timer DURATION|list|cancel ID" " alarm [YYYY-MM-DD] HH:MM [DESCRIPTION...]" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf 'alarm: %s\n' "$1" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_duration() {
|
||||||
|
[ -n "$1" ] && [[ "$1" != *[!0-9smh]* ]] || return 1
|
||||||
|
value=''${1%[smh]}
|
||||||
|
unit=''${1#''${value}}
|
||||||
|
[ -n "$value" ] && [ "$value" -gt 0 ] || return 1
|
||||||
|
case "$unit" in s) printf '%s\n' "$value" ;; m) printf '%s\n' "$((value * 60))" ;; h) printf '%s\n' "$((value * 3600))" ;; *) return 1 ;; esac
|
||||||
|
}
|
||||||
|
|
||||||
|
valid_time() { [[ "$1" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]]; }
|
||||||
|
valid_date() { [[ "$1" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; }
|
||||||
|
|
||||||
|
create_timer() {
|
||||||
|
seconds=$(parse_duration "$1") || die "duration must be a positive number followed by s, m, or h"
|
||||||
|
now=$(date +%s)
|
||||||
|
(
|
||||||
|
flock -x 9
|
||||||
|
init_state
|
||||||
|
id=$(jq -r '.nextId' "$state_file")
|
||||||
|
jq --argjson id "$id" --argjson due "$((now + seconds))" \
|
||||||
|
'.nextId = ($id + 1) | .events += [{id:$id,kind:"timer",due:$due,description:null}]' \
|
||||||
|
"$state_file" | write_state
|
||||||
|
printf '%s\n' "$id"
|
||||||
|
) 9>"$lock_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
create_alarm() {
|
||||||
|
if valid_date "$1"; then
|
||||||
|
[ "$#" -ge 2 ] || usage
|
||||||
|
alarm_date=$1; alarm_time=$2; shift 2
|
||||||
|
valid_time "$alarm_time" || die "time must use HH:MM"
|
||||||
|
else
|
||||||
|
alarm_date=$(date +%F); alarm_time=$1; shift
|
||||||
|
valid_time "$alarm_time" || die "time must use HH:MM"
|
||||||
|
candidate=$(date -d "$alarm_date $alarm_time" +%s) || die "invalid local time"
|
||||||
|
[ "$candidate" -gt "$(date +%s)" ] || alarm_date=$(date -d tomorrow +%F)
|
||||||
|
fi
|
||||||
|
candidate=$(date -d "$alarm_date $alarm_time" +%s) || die "invalid date or time"
|
||||||
|
[ "$(date -d "@$candidate" +%F\ %H:%M)" = "$alarm_date $alarm_time" ] || die "invalid date or time"
|
||||||
|
[ "$candidate" -gt "$(date +%s)" ] || die "explicit alarm date is in the past"
|
||||||
|
description="$*"
|
||||||
|
(
|
||||||
|
flock -x 9
|
||||||
|
init_state
|
||||||
|
if jq -e --argjson due "$candidate" '.events[] | select(.kind == "alarm" and .due == $due)' "$state_file" >/dev/null; then
|
||||||
|
die "an alarm already exists at that time"
|
||||||
|
fi
|
||||||
|
id=$(jq -r '.nextId' "$state_file")
|
||||||
|
jq --argjson id "$id" --argjson due "$candidate" --arg description "$description" \
|
||||||
|
'.nextId = ($id + 1) | .events += [{id:$id,kind:"alarm",due:$due,description:(if $description == "" then null else $description end)}]' \
|
||||||
|
"$state_file" | write_state
|
||||||
|
printf '%s\n' "$id"
|
||||||
|
) 9>"$lock_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
list_events() {
|
||||||
|
kind=$1
|
||||||
|
(
|
||||||
|
flock -s 9
|
||||||
|
init_state
|
||||||
|
if ! jq -e --arg kind "$kind" '.events | map(select(.kind == $kind)) | length > 0' "$state_file" >/dev/null; then
|
||||||
|
printf 'No %s events scheduled.\n' "$kind"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
jq -r --arg kind "$kind" '
|
||||||
|
.events | map(select(.kind == $kind)) | sort_by(.due)[] |
|
||||||
|
"\(.id) \(if .kind == "timer" then ("due " + ( .due | todateiso8601)) else ("at " + (.due | strftime("%Y-%m-%d %H:%M"))) end)\(if .description then " - " + .description else "" end)"
|
||||||
|
' "$state_file"
|
||||||
|
) 9>"$lock_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel_event() {
|
||||||
|
kind=$1; id=$2
|
||||||
|
[[ "$id" =~ ^[0-9]+$ ]] || die "ID must be numeric"
|
||||||
|
(
|
||||||
|
flock -x 9
|
||||||
|
init_state
|
||||||
|
if ! jq -e --arg kind "$kind" --argjson id "$id" '.events[] | select(.id == $id and .kind == $kind)' "$state_file" >/dev/null; then
|
||||||
|
die "no $kind with ID $id"
|
||||||
|
fi
|
||||||
|
jq --arg kind "$kind" --argjson id "$id" '.events |= map(select(.id != $id or .kind != $kind))' "$state_file" | write_state
|
||||||
|
printf 'Cancelled %s %s.\n' "$kind" "$id"
|
||||||
|
) 9>"$lock_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
notify_event() {
|
||||||
|
kind=$1; due=$2; description=$3
|
||||||
|
title=$(printf '%s' "$kind" | tr '[:lower:]' '[:upper:]')
|
||||||
|
body="Due $(date -d "@$due" '+%Y-%m-%d %H:%M')"
|
||||||
|
[ -n "$description" ] && body="$body - $description"
|
||||||
|
notify-send -u critical "$title" "$body" || true
|
||||||
|
timeout 1 pw-play "${pkgs.sound-theme-freedesktop}/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduler() {
|
||||||
|
started=$(date +%s)
|
||||||
|
while :; do
|
||||||
|
now=$(date +%s)
|
||||||
|
event=$(mktemp)
|
||||||
|
(
|
||||||
|
flock -x 9
|
||||||
|
init_state
|
||||||
|
jq --argjson started "$started" --argjson now "$now" \
|
||||||
|
' .events |= map(select(.due >= $started or .due > $now))' "$state_file" | write_state
|
||||||
|
jq -c --argjson started "$started" --argjson now "$now" \
|
||||||
|
'[.events[] | select(.due >= $started and .due <= $now)] | sort_by(.due) | .[0] // empty' "$state_file" > "$event"
|
||||||
|
if [ -s "$event" ]; then
|
||||||
|
jq --argjson id "$(jq -r .id "$event")" '.events |= map(select(.id != $id))' "$state_file" | write_state
|
||||||
|
fi
|
||||||
|
) 9>"$lock_file"
|
||||||
|
if [ -s "$event" ]; then
|
||||||
|
kind=$(jq -r .kind "$event"); due=$(jq -r .due "$event"); description=$(jq -r '.description // empty' "$event")
|
||||||
|
rm -f "$event"
|
||||||
|
notify_event "$kind" "$due" "$description"
|
||||||
|
else
|
||||||
|
rm -f "$event"
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
waybar() {
|
||||||
|
now=$(date +%s)
|
||||||
|
init_state
|
||||||
|
jq -cn --argjson now "$now" --slurpfile state "$state_file" '
|
||||||
|
($state[0].events | map(select((.id|numbers) and (.kind == "timer" or .kind == "alarm") and (.due|numbers) and .due >= $now)) | sort_by(.due) | .[0:3]) as $events |
|
||||||
|
{text: ($events | map(if .kind == "timer" then " " + (if (.due - $now) < 60 then ((.due - $now)|tostring) else (((.due - $now) / 3600)|floor|tostring|if length < 2 then "0" + . else . end) + ":" + ((((.due - $now) % 3600) / 60)|floor|tostring|if length < 2 then "0" + . else . end) end) else " " + (.due | strftime("%H:%M")) end) | join(" ")), tooltip: ($events | map((.kind + " " + (.due | strftime("%Y-%m-%d %H:%M")) + (if .description then " - " + .description else "" end))) | join("\n"))}
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
command=''${1:-}
|
||||||
|
case "$command" in
|
||||||
|
timer) [ "$#" -ge 2 ] || usage; case "$2" in list) list_events timer ;; cancel) [ "$#" = 3 ] || usage; cancel_event timer "$3" ;; *) [ "$#" = 2 ] || usage; create_timer "$2" ;; esac ;;
|
||||||
|
alarm) [ "$#" -ge 2 ] || usage; case "$2" in list) list_events alarm ;; cancel) [ "$#" = 3 ] || usage; cancel_event alarm "$3" ;; *) create_alarm ''${@:2} ;; esac ;;
|
||||||
|
scheduler) scheduler ;;
|
||||||
|
waybar) waybar ;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
|
'';
|
||||||
|
timer = pkgs.writeShellScriptBin "timer" ''exec ${runtime}/bin/alarm-runtime timer "$@"'';
|
||||||
|
alarm = pkgs.writeShellScriptBin "alarm" ''exec ${runtime}/bin/alarm-runtime alarm "$@"'';
|
||||||
|
scheduler = pkgs.writeShellScriptBin "alarm-scheduler" ''exec ${runtime}/bin/alarm-runtime scheduler'';
|
||||||
|
waybar = pkgs.writeShellScriptBin "alarm-waybar" ''exec ${runtime}/bin/alarm-runtime waybar'';
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.custom.alarms-timers.enable = lib.mkEnableOption "terminal alarms and timers";
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
home.packages = [ timer alarm scheduler waybar pkgs.jq pkgs.libnotify pkgs.pipewire pkgs.sound-theme-freedesktop ];
|
||||||
|
|
||||||
|
home.activation.alarmsTimersState = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
|
||||||
|
run mkdir -p "$HOME/.local/state/alarm"
|
||||||
|
if [ ! -s "$HOME/.local/state/alarm/events.json" ]; then
|
||||||
|
run printf '%s\n' '{"nextId":1,"events":[]}' > "$HOME/.local/state/alarm/events.json"
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
|
||||||
|
systemd.user.services.alarm-scheduler = {
|
||||||
|
Unit = {
|
||||||
|
Description = "Terminal alarm and timer scheduler";
|
||||||
|
After = [ "graphical-session.target" ];
|
||||||
|
PartOf = [ "graphical-session.target" ];
|
||||||
|
};
|
||||||
|
Service = {
|
||||||
|
Type = "simple";
|
||||||
|
ExecStart = "${scheduler}/bin/alarm-scheduler";
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 2;
|
||||||
|
};
|
||||||
|
Install.WantedBy = [ "graphical-session.target" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{ lib, config, pkgs, ... }:
|
||||||
|
{
|
||||||
|
options.custom = {
|
||||||
|
dev-orchestrator.enable = lib.mkEnableOption "enable dev-orchestrator — OpenSpec + OpenCode dev→QA loop with git worktrees";
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf config.custom.dev-orchestrator.enable {
|
||||||
|
home.packages = [
|
||||||
|
(pkgs.writeShellApplication {
|
||||||
|
name = "dev-orchestrator";
|
||||||
|
runtimeInputs = with pkgs; [ git ];
|
||||||
|
text = builtins.readFile ../../../tooling/dev-orchestrator;
|
||||||
|
})
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,16 @@
|
|||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
cfg = config.custom.ntfy-notify;
|
cfg = config.custom.ntfy-notify;
|
||||||
|
ntfy-notify = pkgs.writeShellApplication {
|
||||||
|
name = "ntfy-notify-authenticated";
|
||||||
|
runtimeInputs = with pkgs; [ ntfy-sh rbw ];
|
||||||
|
text = ''
|
||||||
|
username="$(rbw get --field username ntfy.joaoporta.com)"
|
||||||
|
password="$(rbw get ntfy.joaoporta.com)"
|
||||||
|
export NTFY_USER="$username:$password"
|
||||||
|
exec ntfy subscribe --from-config
|
||||||
|
'';
|
||||||
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.custom.ntfy-notify = {
|
options.custom.ntfy-notify = {
|
||||||
@@ -24,6 +34,10 @@ in
|
|||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
description = "ntfy topic to subscribe to.";
|
description = "ntfy topic to subscribe to.";
|
||||||
};
|
};
|
||||||
|
title = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "Notification title for this topic";
|
||||||
|
};
|
||||||
priority = lib.mkOption {
|
priority = lib.mkOption {
|
||||||
type = lib.types.enum [ "low" "normal" "critical" ];
|
type = lib.types.enum [ "low" "normal" "critical" ];
|
||||||
default = "normal";
|
default = "normal";
|
||||||
@@ -35,6 +49,7 @@ in
|
|||||||
{
|
{
|
||||||
topic = "joao-kanban";
|
topic = "joao-kanban";
|
||||||
priority = "critical";
|
priority = "critical";
|
||||||
|
title = "Kanban Update";
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
description = ''
|
description = ''
|
||||||
@@ -56,7 +71,7 @@ in
|
|||||||
subscribe:
|
subscribe:
|
||||||
'' + lib.concatMapStringsSep "\n" (sub:
|
'' + lib.concatMapStringsSep "\n" (sub:
|
||||||
let
|
let
|
||||||
cmd = "notify-send -a Kanban -u ${sub.priority} \"Kanban Update\" \"$m\"";
|
cmd = "${lib.getExe pkgs.libnotify} -a ntfy -u ${sub.priority} \"${sub.title}\" \"$m\"";
|
||||||
in
|
in
|
||||||
" - topic: ${cfg.server}/${sub.topic}\n"
|
" - topic: ${cfg.server}/${sub.topic}\n"
|
||||||
+ " command: ${cmd}"
|
+ " command: ${cmd}"
|
||||||
@@ -71,7 +86,7 @@ in
|
|||||||
};
|
};
|
||||||
Service = {
|
Service = {
|
||||||
Type = "simple";
|
Type = "simple";
|
||||||
ExecStart = "${pkgs.ntfy-sh}/bin/ntfy subscribe";
|
ExecStart = "${ntfy-notify}/bin/ntfy-notify-authenticated";
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = 10;
|
RestartSec = 10;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -124,13 +124,9 @@ return {
|
|||||||
notes_subdir = "",
|
notes_subdir = "",
|
||||||
new_notes_location = "notes_subdir",
|
new_notes_location = "notes_subdir",
|
||||||
workspaces = {
|
workspaces = {
|
||||||
{
|
|
||||||
name = "personal",
|
|
||||||
path = "~/docs/personal",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name = "hermes",
|
name = "hermes",
|
||||||
path = "/mnt/documents/journal",
|
path = "/mnt/obsidian",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
daily_notes = {
|
daily_notes = {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
services.swaync = {
|
services.swaync = {
|
||||||
enable = true;
|
enable = true;
|
||||||
settings = {
|
settings = {
|
||||||
positionX = "left";
|
positionX = "center";
|
||||||
positionY = "top";
|
positionY = "top";
|
||||||
cssPriority = "user";
|
cssPriority = "user";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-31
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The desktop runs NixOS with Home Manager, Hyprland, Waybar, SwayNC, and PipeWire. Existing Home Manager modules install shell applications with `writeShellApplication`, define user services, and expose state-backed Waybar scripts. The Waybar layout is maintained separately in `~/dotfiles/waybar/.config/waybar/`; it already has JSON custom modules, a one-second-compatible refresh pattern, and a left-side sequence containing workspaces.
|
||||||
|
|
||||||
|
The feature needs one persistent source of truth shared by interactive commands, a scheduler, and Waybar. It must remain local, tolerate concurrent command/scheduler access, and avoid replaying events missed while the machine was unavailable.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Provide `timer` and `alarm` commands with creation, listing, and ID-based cancellation.
|
||||||
|
- Persist unlimited events and calculate absolute local wall-clock deadlines.
|
||||||
|
- Run scheduling as a user service independent of the terminal that created an event.
|
||||||
|
- Deliver one critical SwayNC notification and one-second PipeWire sound per completed event.
|
||||||
|
- Provide a reusable Waybar JSON helper showing the three nearest active events.
|
||||||
|
- Enable the Home Manager module only on `jpporta-nixos` initially.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Recurring alarms, snooze, pause/resume, labels, graphical management, or click actions.
|
||||||
|
- A database, network service, system-wide daemon, or cross-host synchronization.
|
||||||
|
- Managing the complete external dotfiles repository from this Nix repository.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Use one JSON state file
|
||||||
|
|
||||||
|
Store active events and the next numeric ID in `~/.local/state/alarm/events.json`. Each event contains an ID, kind, absolute due timestamp, and optional description. JSON keeps the state inspectable and lets the existing `jq` package handle parsing without adding a runtime dependency.
|
||||||
|
|
||||||
|
Writes use a temporary file followed by an atomic rename. Command and scheduler mutations use a user-level file lock so a cancellation cannot race with a firing event or another creation.
|
||||||
|
|
||||||
|
**Alternative considered:** SQLite. Rejected because this is a single-user local state file with no query volume or relational data; SQLite would add setup and migration overhead.
|
||||||
|
|
||||||
|
### 2. Use one CLI executable with `timer` and `alarm` entry points
|
||||||
|
|
||||||
|
Install a shared implementation and expose the requested commands. `timer DURATION`, `timer list`, and `timer cancel ID` manage timers. `alarm [DATE] TIME [DESCRIPTION...]`, `alarm list`, and `alarm cancel ID` manage alarms. Numeric IDs make cancellation unambiguous when unlimited events exist.
|
||||||
|
|
||||||
|
Durations support a single positive numeric value with a unit such as `s`, `m`, or `h`. Time-only alarms resolve to the next occurrence in local time. Explicit dated alarms use the supplied date and are rejected when already in the past. Alarm creation rejects an existing alarm with the same normalized timestamp.
|
||||||
|
|
||||||
|
**Alternative considered:** A single `alarm set` command. Rejected because it would not preserve the requested ergonomic `timer 10m` and `alarm 15:10` commands.
|
||||||
|
|
||||||
|
### 3. Use a polling user service for scheduling
|
||||||
|
|
||||||
|
The scheduler runs under `systemd --user`, checks state once per second, and removes events whose deadlines were missed while the service or computer was unavailable. It fires an event only when the scheduler observes the event at or after its deadline without having previously missed it. After firing, it removes the event before sending completion effects, preventing duplicate notifications if the effect command fails.
|
||||||
|
|
||||||
|
**Alternative considered:** One systemd timer unit per event. Rejected because unlimited dynamic units complicate creation, cancellation, cleanup, and missed-event semantics. A single small scheduler is easier to reason about and sufficient for a personal desktop.
|
||||||
|
|
||||||
|
### 4. Reuse the existing desktop notification and audio stack
|
||||||
|
|
||||||
|
Completion uses `notify-send` with critical urgency so SwayNC applies its existing critical styling. It plays a one-second sound through `pw-play`, using a packaged freedesktop sound-theme file. Notification and sound failures do not restore or duplicate the already-completed event.
|
||||||
|
|
||||||
|
**Alternative considered:** Terminal bell only. Rejected because terminal bells depend on the terminal and may be disabled or inaudible outside the originating shell.
|
||||||
|
|
||||||
|
### 5. Make Waybar a read-only state consumer
|
||||||
|
|
||||||
|
`alarm-waybar` reads active state, removes invalid/stale entries from its output without mutating the scheduler state, sorts timers and alarms by due timestamp, and emits JSON for the three closest entries. It emits an empty text value when there are no active events, allowing Waybar to hide the module. Timer text uses `HH:MM` above one minute and seconds below one minute; alarms use fixed local `HH:MM` time. Descriptions appear in escaped tooltips.
|
||||||
|
|
||||||
|
The external Waybar config adds `custom/alarm` directly after `hyprland/workspaces` in `modules-left`, with `interval: 1`, JSON return type, and no click handlers. The external stylesheet adds the module to the existing shared module selector.
|
||||||
|
|
||||||
|
### 6. Package the feature as a reusable Home Manager module
|
||||||
|
|
||||||
|
The module owns the CLI, scheduler, Waybar helper, state directory setup, sound theme dependency, and user service. `hosts/jpporta-nixos/home.nix` imports and enables it. Other hosts remain unaffected until they explicitly enable the option.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **[One-second polling consumes a small amount of CPU]** → Keep the scheduler as a single process with a one-second sleep and use the same interval only for the Waybar helper; avoid one process per event.
|
||||||
|
- **[System time changes can move deadlines]** → Store absolute local timestamps and compare against the current wall clock on every loop; document that manual clock changes affect pending events.
|
||||||
|
- **[Concurrent state updates can lose events]** → Serialize mutations with a lock and write state atomically through a temporary file.
|
||||||
|
- **[A sound file may be unavailable on a different host]** → Package the freedesktop sound theme with the module and make audio playback best-effort while still sending the notification.
|
||||||
|
- **[Waybar lives in a separate repository]** → Keep the generated helper in ndots, make the external config edit explicit in the task list, and verify both repositories independently.
|
||||||
|
- **[Descriptions may contain shell-sensitive or JSON-sensitive characters]** → Treat descriptions as data, avoid shell interpolation of user text, and JSON-escape all Waybar and notification fields.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Enable the new Home Manager module on `jpporta-nixos` and rebuild the user environment.
|
||||||
|
2. Add the `custom/alarm` module and CSS selector to `~/dotfiles/waybar/.config/waybar/`.
|
||||||
|
3. Start or reload Waybar and the user scheduler through the normal Hyprland session.
|
||||||
|
4. Verify creation, listing, cancellation, Waybar refresh, completion notification, sound, duplicate rejection, and missed-event cleanup.
|
||||||
|
5. Roll back by disabling the module, removing the Waybar entry, and deleting the optional state directory if the user wants to discard pending events.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- None blocking implementation. Explicit dated alarms are defined to reject past timestamps; only time-only alarms roll forward to the next day.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The desktop currently has no quick, reliable way to schedule short-lived timers or wall-clock alarms from a terminal. A local command-line scheduler would make these events available from any shell while keeping their status visible in the existing Waybar and using the existing SwayNC/PipeWire desktop stack for completion alerts.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add reusable Home Manager support for `timer` and `alarm` commands.
|
||||||
|
- Persist unlimited active timers and alarms with numeric IDs.
|
||||||
|
- Support timer durations, time-only alarms, dated alarms, optional descriptions, listing, cancellation, and duplicate alarm prevention.
|
||||||
|
- Add a user-level scheduler that fires due events once, skips events missed while the computer is unavailable, and survives the terminal closing.
|
||||||
|
- Send one critical desktop notification and play a one-second PipeWire sound when an event fires.
|
||||||
|
- Add a Waybar JSON module that refreshes every second and displays the three closest active events across both event types.
|
||||||
|
- Integrate the module into the external Waybar configuration at `~/dotfiles/waybar/.config/waybar/`, immediately after workspaces in `modules-left`.
|
||||||
|
- Exclude recurring alarms, snoozing, pause/resume, graphical management, and Waybar click actions.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `terminal-alarms-and-timers`: Create, persist, inspect, cancel, schedule, and complete terminal timers and wall-clock alarms, including their Waybar status display.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
<!-- No existing capability requirements change. -->
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Adds a reusable Home Manager module under `modules/home-manager/` and enables it for `jpporta-nixos` only.
|
||||||
|
- Adds user-facing commands, persisted state under `~/.local/state`, and a systemd user service.
|
||||||
|
- Uses existing `jq`, `notify-send`/SwayNC, PipeWire, and Waybar conventions; adds the freedesktop sound theme if needed for the completion sound.
|
||||||
|
- Updates the separately managed Waybar config and stylesheet under `~/dotfiles/waybar/.config/waybar/`.
|
||||||
|
- No network services, database, external API, or system-wide daemon is introduced.
|
||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Create timers
|
||||||
|
|
||||||
|
The system SHALL provide a `timer` command that accepts a positive duration and creates a new timer with a unique numeric ID and an absolute local wall-clock deadline.
|
||||||
|
|
||||||
|
#### Scenario: Create a minute timer
|
||||||
|
|
||||||
|
- **WHEN** the user runs `timer 10m`
|
||||||
|
- **THEN** the system creates one timer due ten minutes after the command is accepted and prints its numeric ID
|
||||||
|
|
||||||
|
#### Scenario: Reject an invalid duration
|
||||||
|
|
||||||
|
- **WHEN** the user runs `timer 0m`, `timer -1h`, or an unsupported duration format
|
||||||
|
- **THEN** the system rejects the command without creating an event
|
||||||
|
|
||||||
|
### Requirement: Create alarms
|
||||||
|
|
||||||
|
The system SHALL provide an `alarm` command that accepts a local time, an optional date, and an optional free-form description.
|
||||||
|
|
||||||
|
#### Scenario: Create a future time-only alarm
|
||||||
|
|
||||||
|
- **WHEN** the user runs `alarm 15:10` before 15:10 local time
|
||||||
|
- **THEN** the system creates an alarm for 15:10 on the current local date
|
||||||
|
|
||||||
|
#### Scenario: Roll a passed time-only alarm to tomorrow
|
||||||
|
|
||||||
|
- **WHEN** the user runs `alarm 15:10` at or after 15:10 local time
|
||||||
|
- **THEN** the system creates an alarm for 15:10 on the next local date
|
||||||
|
|
||||||
|
#### Scenario: Create a dated alarm with a description
|
||||||
|
|
||||||
|
- **WHEN** the user runs `alarm 2026-08-01 10:20 Important Meeting`
|
||||||
|
- **THEN** the system creates an alarm for that local date and time with `Important Meeting` as its description
|
||||||
|
|
||||||
|
#### Scenario: Reject a past explicit date
|
||||||
|
|
||||||
|
- **WHEN** the user supplies an explicit date and the resulting local timestamp is in the past
|
||||||
|
- **THEN** the system rejects the command without creating an event
|
||||||
|
|
||||||
|
#### Scenario: Reject a duplicate alarm timestamp
|
||||||
|
|
||||||
|
- **WHEN** an alarm already exists at the requested normalized timestamp
|
||||||
|
- **THEN** the system rejects the new alarm without creating a second event
|
||||||
|
|
||||||
|
### Requirement: List active events
|
||||||
|
|
||||||
|
The system SHALL provide `timer list` and `alarm list` commands that display active events with their numeric IDs, type-appropriate due information, and optional descriptions.
|
||||||
|
|
||||||
|
#### Scenario: List timers and alarms
|
||||||
|
|
||||||
|
- **WHEN** the user runs either list command
|
||||||
|
- **THEN** the system displays only active events of that type in deadline order with IDs suitable for cancellation
|
||||||
|
|
||||||
|
#### Scenario: List with no events
|
||||||
|
|
||||||
|
- **WHEN** the selected event type has no active events
|
||||||
|
- **THEN** the system exits successfully and reports that no events are scheduled
|
||||||
|
|
||||||
|
### Requirement: Cancel events by ID
|
||||||
|
|
||||||
|
The system SHALL provide `timer cancel ID` and `alarm cancel ID` commands that remove the matching active event.
|
||||||
|
|
||||||
|
#### Scenario: Cancel an existing event
|
||||||
|
|
||||||
|
- **WHEN** the user cancels an existing event ID with the matching command
|
||||||
|
- **THEN** the system removes that event and confirms the cancellation
|
||||||
|
|
||||||
|
#### Scenario: Cancel an unknown or mismatched ID
|
||||||
|
|
||||||
|
- **WHEN** the requested ID does not exist for the selected command
|
||||||
|
- **THEN** the system reports an error and leaves all events unchanged
|
||||||
|
|
||||||
|
### Requirement: Persist and serialize event state
|
||||||
|
|
||||||
|
The system SHALL persist active events under the user's XDG state directory and SHALL serialize state mutations so concurrent creation, cancellation, scheduling, and firing cannot overwrite each other's changes.
|
||||||
|
|
||||||
|
#### Scenario: Terminal exits after creation
|
||||||
|
|
||||||
|
- **WHEN** the shell that created an event exits
|
||||||
|
- **THEN** the event remains available to the scheduler and list commands
|
||||||
|
|
||||||
|
#### Scenario: Concurrent state mutation
|
||||||
|
|
||||||
|
- **WHEN** a command and the scheduler update state at the same time
|
||||||
|
- **THEN** one complete update is applied without corrupting or losing unrelated events
|
||||||
|
|
||||||
|
### Requirement: Schedule due events
|
||||||
|
|
||||||
|
The system SHALL run a user-level scheduler that checks active events against the local wall clock and completes each observed due event once.
|
||||||
|
|
||||||
|
#### Scenario: Timer reaches its deadline
|
||||||
|
|
||||||
|
- **WHEN** the scheduler observes an active timer at or after its deadline before it has been missed
|
||||||
|
- **THEN** the system removes the timer and emits its completion effects exactly once
|
||||||
|
|
||||||
|
#### Scenario: Event is missed while unavailable
|
||||||
|
|
||||||
|
- **WHEN** an event deadline passes while the computer or scheduler is unavailable
|
||||||
|
- **THEN** the system removes the event without emitting a notification or sound
|
||||||
|
|
||||||
|
#### Scenario: Completion effect fails
|
||||||
|
|
||||||
|
- **WHEN** notification or sound playback fails after an event is marked complete
|
||||||
|
- **THEN** the event remains removed and is not retried as a duplicate event
|
||||||
|
|
||||||
|
### Requirement: Notify and play sound on completion
|
||||||
|
|
||||||
|
The system SHALL send one critical desktop notification and SHALL attempt to play a one-second PipeWire sound when an observed timer or alarm completes.
|
||||||
|
|
||||||
|
#### Scenario: Complete an event with a description
|
||||||
|
|
||||||
|
- **WHEN** an event with a description completes
|
||||||
|
- **THEN** the critical notification identifies the event type and includes the description, and the system attempts one sound playback
|
||||||
|
|
||||||
|
#### Scenario: Complete an event without a description
|
||||||
|
|
||||||
|
- **WHEN** an event without a description completes
|
||||||
|
- **THEN** the critical notification identifies the event type and deadline without an empty description artifact, and the system attempts one sound playback
|
||||||
|
|
||||||
|
### Requirement: Display the nearest events in Waybar
|
||||||
|
|
||||||
|
The system SHALL provide a JSON-producing Waybar helper that refreshes every second and represents at most the three closest active events across timers and alarms.
|
||||||
|
|
||||||
|
#### Scenario: Display mixed event types
|
||||||
|
|
||||||
|
- **WHEN** active timers and alarms exist
|
||||||
|
- **THEN** the helper sorts all events by deadline and emits only the three closest with distinct timer and alarm icons
|
||||||
|
|
||||||
|
#### Scenario: Format a timer above one minute
|
||||||
|
|
||||||
|
- **WHEN** a displayed timer has at least one minute remaining
|
||||||
|
- **THEN** its text uses `HH:MM` remaining time
|
||||||
|
|
||||||
|
#### Scenario: Format a timer below one minute
|
||||||
|
|
||||||
|
- **WHEN** a displayed timer has less than one minute remaining
|
||||||
|
- **THEN** its text contains only the remaining seconds
|
||||||
|
|
||||||
|
#### Scenario: Format an alarm
|
||||||
|
|
||||||
|
- **WHEN** a displayed alarm is active
|
||||||
|
- **THEN** its text contains its fixed local due time in `HH:MM` format
|
||||||
|
|
||||||
|
#### Scenario: Hide the empty module
|
||||||
|
|
||||||
|
- **WHEN** no active events exist
|
||||||
|
- **THEN** the helper emits an empty Waybar text value
|
||||||
|
|
||||||
|
#### Scenario: Show descriptions safely
|
||||||
|
|
||||||
|
- **WHEN** a displayed event has a description containing spaces or JSON-sensitive characters
|
||||||
|
- **THEN** the helper emits valid JSON and exposes the description through the tooltip without shell or JSON injection
|
||||||
|
|
||||||
|
### Requirement: Integrate the Waybar module
|
||||||
|
|
||||||
|
The Waybar configuration SHALL place the event module immediately after workspaces in `modules-left`, refresh it every second, use JSON return type, and define no click actions.
|
||||||
|
|
||||||
|
#### Scenario: Load the module in the left bar
|
||||||
|
|
||||||
|
- **WHEN** Waybar starts with the updated external configuration
|
||||||
|
- **THEN** the event module appears after the workspace module and disappears when its text is empty
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
## 1. Module and State Foundation
|
||||||
|
|
||||||
|
- [x] 1.1 Create a reusable Home Manager module with a disabled-by-default `custom.alarms-timers.enable` option.
|
||||||
|
- [x] 1.2 Define the XDG state path, JSON event schema, numeric ID allocation, atomic writes, and file-locking helpers.
|
||||||
|
- [x] 1.3 Add strict parsing and normalization for timer durations, local times, explicit dates, and optional descriptions.
|
||||||
|
- [x] 1.4 Add `timer` and `alarm` command entry points for creation, validation errors, and ID output.
|
||||||
|
|
||||||
|
## 2. Listing and Cancellation
|
||||||
|
|
||||||
|
- [x] 2.1 Implement `timer list` and `alarm list` with deadline ordering, IDs, descriptions, and empty-state output.
|
||||||
|
- [x] 2.2 Implement `timer cancel ID` and `alarm cancel ID` with type checking and unknown-ID errors.
|
||||||
|
- [x] 2.3 Add focused shell-level checks for creation, time-only rollover, explicit-date rejection, duplicate alarms, listing, cancellation, and malformed input.
|
||||||
|
|
||||||
|
## 3. Scheduler and Completion Effects
|
||||||
|
|
||||||
|
- [x] 3.1 Implement the user scheduler loop with one-second wall-clock checks and serialized state access.
|
||||||
|
- [x] 3.2 Remove events missed while the scheduler or computer was unavailable without notifying.
|
||||||
|
- [x] 3.3 Remove an observed due event before running completion effects so failures cannot duplicate notifications.
|
||||||
|
- [x] 3.4 Send one critical `notify-send` notification with type, deadline, and optional description.
|
||||||
|
- [x] 3.5 Package or reference a freedesktop sound-theme file and play it once for approximately one second through `pw-play`.
|
||||||
|
- [x] 3.6 Define and enable the systemd user service on the graphical session, with restart behavior that does not replay missed events.
|
||||||
|
- [x] 3.7 Add scheduler checks for due events, missed events, duplicate prevention, notification failure, and concurrent state mutation.
|
||||||
|
|
||||||
|
## 4. Home Manager Integration
|
||||||
|
|
||||||
|
- [x] 4.1 Install the CLI, scheduler, Waybar helper, `jq`, PipeWire playback dependency, and sound theme through the reusable module.
|
||||||
|
- [x] 4.2 Enable the module in `hosts/jpporta-nixos/home.nix` without enabling it for other hosts.
|
||||||
|
- [x] 4.3 Add state-directory initialization and verify the generated user service and executable paths.
|
||||||
|
- [x] 4.4 Evaluate and build the `jpporta-nixos` configuration to catch Nix/module errors.
|
||||||
|
|
||||||
|
## 5. Waybar Integration
|
||||||
|
|
||||||
|
- [x] 5.1 Implement `alarm-waybar` JSON output with global deadline ordering and a maximum of three entries.
|
||||||
|
- [x] 5.2 Format timers as `HH:MM` above one minute and seconds below one minute; format alarms as fixed local `HH:MM` times.
|
||||||
|
- [x] 5.3 Add safely escaped descriptions to Waybar tooltips and emit empty text when no events exist.
|
||||||
|
- [x] 5.4 Update `~/dotfiles/waybar/.config/waybar/config.jsonc` with `custom/alarm` immediately after workspaces, one-second refresh, JSON return type, and no click actions.
|
||||||
|
- [x] 5.5 Update the Waybar stylesheet selector for the new module and verify it matches the existing visual treatment.
|
||||||
|
- [x] 5.6 Reload Waybar and manually verify mixed timer/alarm ordering, three-item truncation, countdown updates, tooltip content, and hidden empty state.
|
||||||
|
|
||||||
|
## 6. End-to-End Verification
|
||||||
|
|
||||||
|
- [x] 6.1 Create representative timers and alarms from a terminal and verify IDs, list output, duplicate rejection, and cancellation.
|
||||||
|
- [x] 6.2 Verify a due event produces exactly one critical SwayNC notification and one short PipeWire sound.
|
||||||
|
- [x] 6.3 Verify scheduler restart or system resume removes missed events without producing late notifications.
|
||||||
|
- [x] 6.4 Verify the final Nix build and Waybar configuration together on `jpporta-nixos`.
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Provide terminal timers and wall-clock alarms that persist independently of the creating shell, notify on completion, and expose the nearest active events in Waybar.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Create timers
|
||||||
|
|
||||||
|
The system SHALL provide a `timer` command that accepts a positive duration and creates a new timer with a unique numeric ID and an absolute local wall-clock deadline.
|
||||||
|
|
||||||
|
#### Scenario: Create a minute timer
|
||||||
|
|
||||||
|
- **WHEN** the user runs `timer 10m`
|
||||||
|
- **THEN** the system creates one timer due ten minutes after the command is accepted and prints its numeric ID
|
||||||
|
|
||||||
|
#### Scenario: Reject an invalid duration
|
||||||
|
|
||||||
|
- **WHEN** the user runs `timer 0m`, `timer -1h`, or an unsupported duration format
|
||||||
|
- **THEN** the system rejects the command without creating an event
|
||||||
|
|
||||||
|
### Requirement: Create alarms
|
||||||
|
|
||||||
|
The system SHALL provide an `alarm` command that accepts a local time, an optional date, and an optional free-form description.
|
||||||
|
|
||||||
|
#### Scenario: Create a future time-only alarm
|
||||||
|
|
||||||
|
- **WHEN** the user runs `alarm 15:10` before 15:10 local time
|
||||||
|
- **THEN** the system creates an alarm for 15:10 on the current local date
|
||||||
|
|
||||||
|
#### Scenario: Roll a passed time-only alarm to tomorrow
|
||||||
|
|
||||||
|
- **WHEN** the user runs `alarm 15:10` at or after 15:10 local time
|
||||||
|
- **THEN** the system creates an alarm for 15:10 on the next local date
|
||||||
|
|
||||||
|
#### Scenario: Create a dated alarm with a description
|
||||||
|
|
||||||
|
- **WHEN** the user runs `alarm 2026-08-01 10:20 Important Meeting`
|
||||||
|
- **THEN** the system creates an alarm for that local date and time with `Important Meeting` as its description
|
||||||
|
|
||||||
|
#### Scenario: Reject a past explicit date
|
||||||
|
|
||||||
|
- **WHEN** the user supplies an explicit date and the resulting local timestamp is in the past
|
||||||
|
- **THEN** the system rejects the command without creating an event
|
||||||
|
|
||||||
|
#### Scenario: Reject a duplicate alarm timestamp
|
||||||
|
|
||||||
|
- **WHEN** an alarm already exists at the requested normalized timestamp
|
||||||
|
- **THEN** the system rejects the new alarm without creating a second event
|
||||||
|
|
||||||
|
### Requirement: List active events
|
||||||
|
|
||||||
|
The system SHALL provide `timer list` and `alarm list` commands that display active events with their numeric IDs, type-appropriate due information, and optional descriptions.
|
||||||
|
|
||||||
|
#### Scenario: List timers and alarms
|
||||||
|
|
||||||
|
- **WHEN** the user runs either list command
|
||||||
|
- **THEN** the system displays only active events of that type in deadline order with IDs suitable for cancellation
|
||||||
|
|
||||||
|
#### Scenario: List with no events
|
||||||
|
|
||||||
|
- **WHEN** the selected event type has no active events
|
||||||
|
- **THEN** the system exits successfully and reports that no events are scheduled
|
||||||
|
|
||||||
|
### Requirement: Cancel events by ID
|
||||||
|
|
||||||
|
The system SHALL provide `timer cancel ID` and `alarm cancel ID` commands that remove the matching active event.
|
||||||
|
|
||||||
|
#### Scenario: Cancel an existing event
|
||||||
|
|
||||||
|
- **WHEN** the user cancels an existing event ID with the matching command
|
||||||
|
- **THEN** the system removes that event and confirms the cancellation
|
||||||
|
|
||||||
|
#### Scenario: Cancel an unknown or mismatched ID
|
||||||
|
|
||||||
|
- **WHEN** the requested ID does not exist for the selected command
|
||||||
|
- **THEN** the system reports an error and leaves all events unchanged
|
||||||
|
|
||||||
|
### Requirement: Persist and serialize event state
|
||||||
|
|
||||||
|
The system SHALL persist active events under the user's XDG state directory and SHALL serialize state mutations so concurrent creation, cancellation, scheduling, and firing cannot overwrite each other's changes.
|
||||||
|
|
||||||
|
#### Scenario: Terminal exits after creation
|
||||||
|
|
||||||
|
- **WHEN** the shell that created an event exits
|
||||||
|
- **THEN** the event remains available to the scheduler and list commands
|
||||||
|
|
||||||
|
#### Scenario: Concurrent state mutation
|
||||||
|
|
||||||
|
- **WHEN** a command and the scheduler update state at the same time
|
||||||
|
- **THEN** one complete update is applied without corrupting or losing unrelated events
|
||||||
|
|
||||||
|
### Requirement: Schedule due events
|
||||||
|
|
||||||
|
The system SHALL run a user-level scheduler that checks active events against the local wall clock and completes each observed due event once.
|
||||||
|
|
||||||
|
#### Scenario: Timer reaches its deadline
|
||||||
|
|
||||||
|
- **WHEN** the scheduler observes an active timer at or after its deadline before it has been missed
|
||||||
|
- **THEN** the system removes the timer and emits its completion effects exactly once
|
||||||
|
|
||||||
|
#### Scenario: Event is missed while unavailable
|
||||||
|
|
||||||
|
- **WHEN** an event deadline passes while the computer or scheduler is unavailable
|
||||||
|
- **THEN** the system removes the event without emitting a notification or sound
|
||||||
|
|
||||||
|
#### Scenario: Completion effect fails
|
||||||
|
|
||||||
|
- **WHEN** notification or sound playback fails after an event is marked complete
|
||||||
|
- **THEN** the event remains removed and is not retried as a duplicate event
|
||||||
|
|
||||||
|
### Requirement: Notify and play sound on completion
|
||||||
|
|
||||||
|
The system SHALL send one critical desktop notification and SHALL attempt to play a one-second PipeWire sound when an observed timer or alarm completes.
|
||||||
|
|
||||||
|
#### Scenario: Complete an event with a description
|
||||||
|
|
||||||
|
- **WHEN** an event with a description completes
|
||||||
|
- **THEN** the critical notification identifies the event type and includes the description, and the system attempts one sound playback
|
||||||
|
|
||||||
|
#### Scenario: Complete an event without a description
|
||||||
|
|
||||||
|
- **WHEN** an event without a description completes
|
||||||
|
- **THEN** the critical notification identifies the event type and deadline without an empty description artifact, and the system attempts one sound playback
|
||||||
|
|
||||||
|
### Requirement: Display the nearest events in Waybar
|
||||||
|
|
||||||
|
The system SHALL provide a JSON-producing Waybar helper that refreshes every second and represents at most the three closest active events across timers and alarms.
|
||||||
|
|
||||||
|
#### Scenario: Display mixed event types
|
||||||
|
|
||||||
|
- **WHEN** active timers and alarms exist
|
||||||
|
- **THEN** the helper sorts all events by deadline and emits only the three closest with distinct timer and alarm icons
|
||||||
|
|
||||||
|
#### Scenario: Format a timer above one minute
|
||||||
|
|
||||||
|
- **WHEN** a displayed timer has at least one minute remaining
|
||||||
|
- **THEN** its text uses `HH:MM` remaining time
|
||||||
|
|
||||||
|
#### Scenario: Format a timer below one minute
|
||||||
|
|
||||||
|
- **WHEN** a displayed timer has less than one minute remaining
|
||||||
|
- **THEN** its text contains only the remaining seconds
|
||||||
|
|
||||||
|
#### Scenario: Format an alarm
|
||||||
|
|
||||||
|
- **WHEN** a displayed alarm is active
|
||||||
|
- **THEN** its text contains its fixed local due time in `HH:MM` format
|
||||||
|
|
||||||
|
#### Scenario: Hide the empty module
|
||||||
|
|
||||||
|
- **WHEN** no active events exist
|
||||||
|
- **THEN** the helper emits an empty Waybar text value
|
||||||
|
|
||||||
|
#### Scenario: Show descriptions safely
|
||||||
|
|
||||||
|
- **WHEN** a displayed event has a description containing spaces or JSON-sensitive characters
|
||||||
|
- **THEN** the helper emits valid JSON and exposes the description through the tooltip without shell or JSON injection
|
||||||
|
|
||||||
|
### Requirement: Integrate the Waybar module
|
||||||
|
|
||||||
|
The Waybar configuration SHALL place the event module immediately after workspaces in `modules-left`, refresh it every second, use JSON return type, and define no click actions.
|
||||||
|
|
||||||
|
#### Scenario: Load the module in the left bar
|
||||||
|
|
||||||
|
- **WHEN** Waybar starts with the updated external configuration
|
||||||
|
- **THEN** the event module appears after the workspace module and disappears when its text is empty
|
||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
export XDG_STATE_HOME="$tmp/state"
|
||||||
|
runtime=${1:?pass path to alarm-runtime}
|
||||||
|
|
||||||
|
test "$("$runtime" timer 2s)" = 1
|
||||||
|
test "$("$runtime" alarm "$(date +%H:%M)" Rolled Over)" = 2
|
||||||
|
test "$("$runtime" alarm "$(date -d tomorrow +%F)" 10:20 Important Meeting)" = 3
|
||||||
|
! "$runtime" timer 0m >/dev/null 2>&1
|
||||||
|
! "$runtime" alarm "$(date -d yesterday +%F)" 10:20 >/dev/null 2>&1
|
||||||
|
! "$runtime" alarm "$(date -d tomorrow +%F)" 10:20 >/dev/null 2>&1
|
||||||
|
test "$("$runtime" timer list | wc -l)" = 1
|
||||||
|
"$runtime" timer cancel 1 >/dev/null
|
||||||
|
! "$runtime" timer cancel 1 >/dev/null 2>&1
|
||||||
|
test "$("$runtime" timer list)" = "No timer events scheduled."
|
||||||
|
|
||||||
|
mkdir -p "$tmp/bin"
|
||||||
|
printf '#!/bin/sh\nprintf notify >> "$LOG"\n' > "$tmp/bin/notify-send"
|
||||||
|
printf '#!/bin/sh\nprintf sound >> "$LOG"\n' > "$tmp/bin/pw-play"
|
||||||
|
chmod +x "$tmp/bin/notify-send" "$tmp/bin/pw-play"
|
||||||
|
export PATH="$tmp/bin:$PATH" LOG="$tmp/effects"
|
||||||
|
test "$("$runtime" timer 1s)" = 4
|
||||||
|
sleep 2
|
||||||
|
set +e
|
||||||
|
timeout 2 "$runtime" scheduler
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
test "$status" = 124
|
||||||
|
test ! -s "$LOG"
|
||||||
|
test "$("$runtime" timer 1s)" = 5
|
||||||
|
set +e
|
||||||
|
timeout 3 "$runtime" scheduler
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
test "$status" = 124
|
||||||
|
test "$(wc -c < "$LOG")" = 11
|
||||||
|
test "$(jq '.events | length' "$XDG_STATE_HOME/alarm/events.json")" = 2
|
||||||
|
printf 'alarm/timer checks passed\n'
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# Dev Orchestrator — AI Workflow Guide
|
||||||
|
|
||||||
|
Toolchain de desenvolvimento com IA: **OpenCode** (agente) + **OpenSpec** (spec-driven dev) + **Dev Orchestrator** (loop dev→QA automático).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ferramentas — Quando Usar Cada Uma
|
||||||
|
|
||||||
|
### OpenCode — O Agente
|
||||||
|
|
||||||
|
Executa tarefas de código. Use **diretamente** quando:
|
||||||
|
|
||||||
|
- Explorar uma codebase nova: `opencode` (TUI interativo)
|
||||||
|
- Fazer uma mudança pontual: `opencode run "corrige typo no header"`
|
||||||
|
- Debugar um erro específico: `opencode run "por que esse teste falha?" --thinking`
|
||||||
|
- Revisar um PR manualmente: `opencode pr 42`
|
||||||
|
|
||||||
|
Use via **orquestrador** quando:
|
||||||
|
- Implementar uma feature completa com spec (`dev-orchestrator build`)
|
||||||
|
- Precisar de QA automático depois do dev
|
||||||
|
- Quiser loop dev→QA sem babysitting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Modos do OpenCode
|
||||||
|
opencode # TUI interativo (exploração)
|
||||||
|
opencode run "tarefa" # one-shot (automação)
|
||||||
|
opencode run "..." --thinking # vê o raciocínio do modelo
|
||||||
|
opencode run "..." -f arquivo.ts # anexa contexto
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenSpec — O Spec Engine
|
||||||
|
|
||||||
|
Gerencia specs como source of truth. Use **diretamente** quando:
|
||||||
|
|
||||||
|
- Criar spec manualmente: `openspec new change minha-feature`
|
||||||
|
- Validar um spec existente: `openspec validate minha-feature --strict`
|
||||||
|
- Ver status de todos os changes: `openspec list --json`
|
||||||
|
- Arquivar spec concluído: `openspec archive minha-feature --yes`
|
||||||
|
|
||||||
|
Use via **orquestrador** quando:
|
||||||
|
- Quiser que a IA preencha o spec automaticamente (`dev-orchestrator spec`)
|
||||||
|
- O archive deve acontecer automático pós-QA-pass (`dev-orchestrator build`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Comandos OpenSpec que você mais usa
|
||||||
|
openspec list # o que está ativo?
|
||||||
|
openspec show minha-feature # ler um spec
|
||||||
|
openspec validate minha-feature # check pré-implementação
|
||||||
|
openspec status --change minha-feature # progresso dos artefatos
|
||||||
|
openspec instructions --change minha-feature # o que o agente deve fazer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dev Orchestrator — O Script
|
||||||
|
|
||||||
|
Automatiza o loop completo. **Substitui** os comandos manuais acima no fluxo principal.
|
||||||
|
|
||||||
|
```
|
||||||
|
você tem ideia → spec → review → build → PR pronto
|
||||||
|
↑ ↑ ↑
|
||||||
|
orquestrador você automático
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pré-requisitos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g opencode-ai@latest
|
||||||
|
npm install -g @fission-ai/openspec@latest
|
||||||
|
opencode auth login # configura provider (OpenRouter, Anthropic, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comandos do Orquestrador
|
||||||
|
|
||||||
|
| Comando | O que faz |
|
||||||
|
|---|---|
|
||||||
|
| `dev-orchestrator init` | Configura repo (OpenSpec + .gitignore) — 1x por projeto |
|
||||||
|
| `dev-orchestrator spec <nome>` | Cria spec + worktree isolado + preenche spec via IA |
|
||||||
|
| `dev-orchestrator build <nome>` | Loop dev→QA (3 tentativas), merge automático se passar |
|
||||||
|
| `dev-orchestrator status` | Dashboard de features em andamento |
|
||||||
|
| `dev-orchestrator clean <nome>` | Remove worktree + branch (abortar feature) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fluxo Completo
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐
|
||||||
|
│ init │───→│ spec <nome> │───→│ [review] │───→│ build <nome> │
|
||||||
|
│ (1x/proj)│ │ (orquestrador│ │ (você aprova) │ │ (dev→QA loop)│
|
||||||
|
└──────────┘ │ preenche) │ └──────────────┘ └──────┬──────┘
|
||||||
|
└──────────────┘ │
|
||||||
|
┌────────────────────┘
|
||||||
|
│ QA PASS → merge + archive
|
||||||
|
│ QA FAIL → retry (3x max)
|
||||||
|
└────────────────────────
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Inicializar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/projetos/meu-app
|
||||||
|
dev-orchestrator init
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Especificar feature
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dev-orchestrator spec add-oauth
|
||||||
|
```
|
||||||
|
|
||||||
|
O que acontece internamente:
|
||||||
|
1. `openspec new change add-oauth --json` → cria templates
|
||||||
|
2. `git worktree add -b dev-flow/add-oauth ../.worktrees/add-oauth/ HEAD`
|
||||||
|
3. `npm install` dentro do worktree
|
||||||
|
4. `opencode run "preenche os specs lendo a codebase"` → orquestrador preenche spec.md, design.md, tasks.md
|
||||||
|
|
||||||
|
### 3. Revisar os specs (MOMENTO CRÍTICO)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ler o spec gerado
|
||||||
|
cat openspec/changes/add-oauth/spec.md
|
||||||
|
cat openspec/changes/add-oauth/design.md
|
||||||
|
|
||||||
|
# Validar
|
||||||
|
openspec validate add-oauth --strict
|
||||||
|
|
||||||
|
# Ajustar manualmente se quiser — edite os arquivos
|
||||||
|
vim openspec/changes/add-oauth/spec.md
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Você é o gatekeeper aqui.** Se o spec não está certo, o build vai sair errado.
|
||||||
|
> Gaste tempo revisando os specs — é o investimento mais rentável do fluxo.
|
||||||
|
|
||||||
|
### 4. Build (dev→QA loop)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dev-orchestrator build add-oauth
|
||||||
|
```
|
||||||
|
|
||||||
|
Loop:
|
||||||
|
```
|
||||||
|
Dev Phase ──→ OpenCode implementa + testa + commita
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
QA Phase ──→ OpenCode revisa: spec compliance, testes, regressões
|
||||||
|
│
|
||||||
|
├── PASS → merge no main, archive spec, remove worktree ✅
|
||||||
|
│
|
||||||
|
└── FAIL → volta pro Dev (até 3 tentativas)
|
||||||
|
falhou 3x → worktree mantido pra correção manual
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Status dashboard
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dev-orchestrator status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quando Sair do Orquestrador
|
||||||
|
|
||||||
|
O script cobre 90% dos casos. Saia dele quando:
|
||||||
|
|
||||||
|
| Situação | O que fazer |
|
||||||
|
|---|---|
|
||||||
|
| Spec ficou ruim e quero reescrever do zero | `openspec new change X` manual, preenche na mão |
|
||||||
|
| QA rejeitou e quero corrigir eu mesmo | `cd ../.worktrees/feature/` → edita → commita → `dev-orchestrator build feature` |
|
||||||
|
| Feature complexa demais pra um spec só | `openspec new change feature-pt1`, `openspec new change feature-pt2` |
|
||||||
|
| Quero iterar rápido sem spec | `opencode` TUI direto (pula o orquestrador) |
|
||||||
|
| Bug fix trivial (1-2 linhas) | `opencode run "fix: ..."` direto, nem cria spec |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Paralelismo
|
||||||
|
|
||||||
|
Rode múltiplos features ao mesmo tempo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1
|
||||||
|
dev-orchestrator build feature-a
|
||||||
|
|
||||||
|
# Terminal 2
|
||||||
|
dev-orchestrator build feature-b
|
||||||
|
```
|
||||||
|
|
||||||
|
Cada um em seu worktree isolado. O merge no main serializa no final — se houver conflito, o script para e avisa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estrutura de Diretórios
|
||||||
|
|
||||||
|
```
|
||||||
|
~/projetos/meu-app/
|
||||||
|
├── src/
|
||||||
|
├── openspec/
|
||||||
|
│ ├── specs/ # specs arquivados
|
||||||
|
│ └── changes/ # specs ativos
|
||||||
|
│ └── add-oauth/
|
||||||
|
│ ├── spec.md
|
||||||
|
│ ├── design.md
|
||||||
|
│ └── tasks.md
|
||||||
|
├── .gitignore # inclui .worktrees/
|
||||||
|
└── ...
|
||||||
|
|
||||||
|
../.worktrees/
|
||||||
|
└── add-oauth/ # worktree isolado
|
||||||
|
├── .git # branch: dev-flow/add-oauth
|
||||||
|
├── src/
|
||||||
|
├── node_modules/
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opcional: Swarm Tools + .hive
|
||||||
|
|
||||||
|
Kanban mais visual, sem servidor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g opencode-swarm-plugin
|
||||||
|
swarm setup
|
||||||
|
```
|
||||||
|
|
||||||
|
Dentro do OpenCode:
|
||||||
|
- `/swarm "tarefa"` — decompõe e spawna workers paralelos
|
||||||
|
- `/hive` — quadro kanban das tasks
|
||||||
|
- `/inbox` — mensagens entre agentes
|
||||||
|
|
||||||
|
O `.hive/` é uma pasta git-tracked. Independe do `dev-orchestrator`.
|
||||||
Executable
+306
@@ -0,0 +1,306 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# dev-orchestrator — OpenSpec + OpenCode dev→QA loop with git worktrees
|
||||||
|
# Zero dependencies beyond git, opencode, openspec. No heavy binaries.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="${DEV_FLOW_REPO:-$(pwd)}"
|
||||||
|
WORKTREE_ROOT="$REPO/../.worktrees"
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_wt_path() { echo "$WORKTREE_ROOT/$1"; }
|
||||||
|
_wt_branch(){ echo "dev-flow/$1"; }
|
||||||
|
|
||||||
|
_die() { echo "✗ $*" >&2; exit 1; }
|
||||||
|
_ok() { echo "✓ $*"; }
|
||||||
|
|
||||||
|
# Ensure we're inside a git repo with openspec
|
||||||
|
_guard() {
|
||||||
|
cd "$REPO"
|
||||||
|
git rev-parse --show-toplevel >/dev/null 2>&1 || _die "not a git repo: $REPO"
|
||||||
|
[ -d openspec ] || _die "openspec not initialized. Run: dev-orchestrator init"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── commands ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cmd_init() {
|
||||||
|
cd "$REPO"
|
||||||
|
git rev-parse --show-toplevel >/dev/null 2>&1 || _die "not a git repo"
|
||||||
|
|
||||||
|
mkdir -p "$WORKTREE_ROOT"
|
||||||
|
if [ -d openspec ]; then
|
||||||
|
_ok "openspec already initialized"
|
||||||
|
else
|
||||||
|
openspec init --tools opencode --force
|
||||||
|
_ok "openspec initialized"
|
||||||
|
fi
|
||||||
|
|
||||||
|
grep -qxF '.worktrees/' .gitignore 2>/dev/null || {
|
||||||
|
echo '.worktrees/' >> .gitignore
|
||||||
|
_ok "added .worktrees/ to .gitignore"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Repo ready. Next: dev-orchestrator spec <feature-name>"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_spec() {
|
||||||
|
local name="$1"
|
||||||
|
_guard
|
||||||
|
|
||||||
|
# ── validate name (kebab-case) ──
|
||||||
|
[[ "$name" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] || _die "name must be kebab-case: add-oauth, fix-login"
|
||||||
|
|
||||||
|
# ── create OpenSpec change ──
|
||||||
|
echo "── Creating OpenSpec change: $name ──"
|
||||||
|
openspec new change "$name" --json 2>/dev/null || {
|
||||||
|
_die "openspec new change failed. Already exists? Run: dev-orchestrator status"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── create worktree ──
|
||||||
|
local branch; branch="$(_wt_branch "$name")"
|
||||||
|
local wt; wt="$(_wt_path "$name")"
|
||||||
|
echo "── Creating worktree: $wt [$branch] ──"
|
||||||
|
git worktree add -b "$branch" "$wt" HEAD
|
||||||
|
|
||||||
|
# ── install deps in worktree (fully isolated, no symlinks) ──
|
||||||
|
if [ -f "$wt/package.json" ]; then
|
||||||
|
echo "── Installing dependencies ──"
|
||||||
|
(cd "$wt" && npm install --silent 2>&1 | tail -1) || true
|
||||||
|
fi
|
||||||
|
if [ -f "$wt/pyproject.toml" ] || [ -f "$wt/setup.py" ]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
(cd "$wt" && [ -d .venv ] || python3 -m venv .venv && . .venv/bin/activate && pip install -e . -q 2>&1 | tail -1) || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
_ok "Spec '$name' scaffolded at openspec/changes/$name/"
|
||||||
|
echo " Worktree: $wt"
|
||||||
|
echo ""
|
||||||
|
echo " ── Next: explore in OpenCode ──"
|
||||||
|
echo " opencode"
|
||||||
|
echo " Then use OpenSpec native commands:"
|
||||||
|
echo " /opsx:explore — discuss the feature, refine requirements"
|
||||||
|
echo " /opsx:continue — fill spec artifacts one by one"
|
||||||
|
echo ""
|
||||||
|
echo " After specs are filled → dev-orchestrator build $name"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_fill() {
|
||||||
|
local name="$1"
|
||||||
|
_guard
|
||||||
|
|
||||||
|
local wt; wt="$(_wt_path "$name")"
|
||||||
|
[ -d "openspec/changes/$name" ] || _die "spec not found: openspec/changes/$name. Run: dev-orchestrator spec $name"
|
||||||
|
|
||||||
|
echo "── AI filling spec from conversation context ──"
|
||||||
|
opencode run \
|
||||||
|
"You are filling an OpenSpec change for feature: $name.
|
||||||
|
|
||||||
|
STEPS:
|
||||||
|
1. Read the codebase structure first (scan src/, lib/, or equivalent).
|
||||||
|
2. Read openspec/changes/$name/ — note the template files created.
|
||||||
|
3. Read openspec/specs/ for existing specs to avoid conflicts.
|
||||||
|
4. For each template file in the change directory, fill it with:
|
||||||
|
- Clear requirements and acceptance criteria
|
||||||
|
- Files that need to change
|
||||||
|
- Dependencies on other modules
|
||||||
|
- Test plan
|
||||||
|
5. Run: openspec validate $name --strict
|
||||||
|
6. Fix any validation errors, re-run until clean.
|
||||||
|
7. Output a summary: spec files created, key decisions, estimated scope." \
|
||||||
|
--workdir "$REPO"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
_ok "Spec '$name' filled at openspec/changes/$name/"
|
||||||
|
echo " Review the spec, then: dev-orchestrator build $name"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_build() {
|
||||||
|
local name="$1"
|
||||||
|
_guard
|
||||||
|
|
||||||
|
local wt; wt="$(_wt_path "$name")"
|
||||||
|
[ -d "$wt" ] || _die "worktree not found: $wt. Run: dev-orchestrator spec $name"
|
||||||
|
|
||||||
|
local branch; branch="$(_wt_branch "$name")"
|
||||||
|
local max_retries=3
|
||||||
|
|
||||||
|
for attempt in $(seq 1 $max_retries); do
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════════════════"
|
||||||
|
echo " $name — Dev Phase (attempt $attempt/$max_retries)"
|
||||||
|
echo "══════════════════════════════════════════════════"
|
||||||
|
|
||||||
|
opencode run \
|
||||||
|
"IMPLEMENT the spec at openspec/changes/$name/.
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Read openspec instructions --change $name first
|
||||||
|
- Implement ALL requirements from the spec
|
||||||
|
- Write tests for every new code path
|
||||||
|
- Run the test suite and ensure it passes
|
||||||
|
- If tests fail, fix them before considering work done
|
||||||
|
- Commit with message: '$name: implement feature (attempt $attempt)'
|
||||||
|
- Do NOT modify openspec/ files — only source code and tests" \
|
||||||
|
--workdir "$wt" || {
|
||||||
|
echo "⚠ Dev phase had errors, proceeding to QA anyway..."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── QA phase ──
|
||||||
|
echo ""
|
||||||
|
echo "──────────────────────────────────────────────────"
|
||||||
|
echo " $name — QA Review"
|
||||||
|
echo "──────────────────────────────────────────────────"
|
||||||
|
|
||||||
|
local qa_file="/tmp/dev-orchestrator-qa-$$.txt"
|
||||||
|
opencode run \
|
||||||
|
"QA REVIEW for $name.
|
||||||
|
|
||||||
|
CHECKLIST (answer each with PASS or FAIL):
|
||||||
|
1. SPEC COMPLIANCE — Does the code implement everything in openspec/changes/$name/?
|
||||||
|
2. TESTS — Do all tests pass? Run them now.
|
||||||
|
3. REGRESSIONS — Does any existing test break? Check git diff vs main.
|
||||||
|
4. EDGE CASES — Are errors handled? Null/empty inputs? Timeouts?
|
||||||
|
5. CODE QUALITY — Clear naming? No debug leftovers? No commented-out code?
|
||||||
|
|
||||||
|
OUTPUT FORMAT (exactly these 2 lines, nothing else):
|
||||||
|
VERDICT: PASS|FAIL
|
||||||
|
REASON: <one-sentence summary>" \
|
||||||
|
--workdir "$wt" > "$qa_file" 2>&1
|
||||||
|
|
||||||
|
local verdict
|
||||||
|
verdict=$(grep '^VERDICT:' "$qa_file" | head -1 | awk -F': ' '{print $2}')
|
||||||
|
local reason
|
||||||
|
reason=$(grep '^REASON:' "$qa_file" | head -1 | cut -d' ' -f2-)
|
||||||
|
|
||||||
|
if [ "$verdict" = "PASS" ]; then
|
||||||
|
echo ""
|
||||||
|
_ok "QA PASSED — $reason"
|
||||||
|
|
||||||
|
# ── archive + merge ──
|
||||||
|
cd "$REPO"
|
||||||
|
echo "── Archiving spec ──"
|
||||||
|
openspec archive "$name" --yes 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "── Merging to main ──"
|
||||||
|
git merge "$branch" -m "dev-flow: merge $name" 2>/dev/null || {
|
||||||
|
echo "⚠ Merge conflict. Resolve manually in $wt then run:"
|
||||||
|
echo " cd $wt && git checkout main && git merge $branch"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "── Cleaning up worktree ──"
|
||||||
|
git worktree remove "$wt" 2>/dev/null || true
|
||||||
|
git branch -d "$branch" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
_ok "$name — BUILD COMPLETE ✓"
|
||||||
|
rm "$qa_file"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✗ QA FAILED — ${reason:-see $qa_file}"
|
||||||
|
rm "$qa_file"
|
||||||
|
|
||||||
|
if [ "$attempt" -eq "$max_retries" ]; then
|
||||||
|
_die "$name FAILED after $max_retries attempts. Worktree kept at $wt for manual fix."
|
||||||
|
fi
|
||||||
|
echo "↻ Sending back to dev with QA feedback..."
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_status() {
|
||||||
|
_guard 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
printf "%-4s %-30s %-10s %-10s %-15s\n" "#" "FEATURE" "COMMITS" "SPEC" "WORKTREE"
|
||||||
|
printf "%-4s %-30s %-10s %-10s %-15s\n" "───" "──────────────────────────────" "──────────" "──────────" "──────────────"
|
||||||
|
|
||||||
|
local n=0
|
||||||
|
for wt in "$WORKTREE_ROOT"/*/; do
|
||||||
|
[ -d "$wt" ] || continue
|
||||||
|
local name
|
||||||
|
name=$(basename "$wt")
|
||||||
|
local branch; branch="$(_wt_branch "$name")"
|
||||||
|
local commits="?"
|
||||||
|
[ -d "$wt/.git" ] && commits=$(cd "$wt" && git rev-list --count "$branch" -- 2>/dev/null || echo "?")
|
||||||
|
commits="${commits:-0}"
|
||||||
|
|
||||||
|
# spec status
|
||||||
|
local spec="?"
|
||||||
|
[ -d "$REPO/openspec/changes/$name" ] && {
|
||||||
|
spec=$(cd "$REPO" && openspec status --change "$name" --json 2>/dev/null | \
|
||||||
|
python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('artifacts_completed','?'),'/',d.get('artifacts_total','?'),sep='')" 2>/dev/null || echo "active")
|
||||||
|
}
|
||||||
|
|
||||||
|
n=$((n+1))
|
||||||
|
printf "%-4s %-30s %-10s %-10s %-15s\n" \
|
||||||
|
"$n." "$name" "$commits" "${spec:-active}" "$wt"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$n" -eq 0 ]; then
|
||||||
|
echo " (no active features)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_clean() {
|
||||||
|
local name="$1"
|
||||||
|
cd "$REPO"
|
||||||
|
local wt; wt="$(_wt_path "$name")"
|
||||||
|
local branch; branch="$(_wt_branch "$name")"
|
||||||
|
|
||||||
|
git worktree remove "$wt" 2>/dev/null || true
|
||||||
|
git branch -D "$branch" 2>/dev/null || true
|
||||||
|
if [ ! -d "$wt" ]; then
|
||||||
|
_ok "cleaned $name"
|
||||||
|
else
|
||||||
|
_die "could not remove $wt"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── dispatch ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
init)
|
||||||
|
cmd_init
|
||||||
|
;;
|
||||||
|
spec)
|
||||||
|
[ $# -ge 2 ] || _die "usage: dev-orchestrator spec <feature-name>"
|
||||||
|
cmd_spec "$2"
|
||||||
|
;;
|
||||||
|
fill)
|
||||||
|
[ $# -ge 2 ] || _die "usage: dev-orchestrator fill <feature-name>"
|
||||||
|
cmd_fill "$2"
|
||||||
|
;;
|
||||||
|
build)
|
||||||
|
[ $# -ge 2 ] || _die "usage: dev-orchestrator build <feature-name>"
|
||||||
|
cmd_build "$2"
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
cmd_status
|
||||||
|
;;
|
||||||
|
clean)
|
||||||
|
[ $# -ge 2 ] || _die "usage: dev-orchestrator clean <feature-name>"
|
||||||
|
cmd_clean "$2"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "dev-orchestrator — OpenSpec + OpenCode dev→QA loop"
|
||||||
|
echo ""
|
||||||
|
echo "commands:"
|
||||||
|
echo " init Set up repo (run once per project)"
|
||||||
|
echo " spec <feature-name> Scaffold OpenSpec + worktree (no fill)"
|
||||||
|
echo " fill <feature-name> AI fills spec after exploration"
|
||||||
|
echo " build <feature-name> Dev→QA loop in isolated worktree"
|
||||||
|
echo " status Kanban view of features in flight"
|
||||||
|
echo " clean <feature-name> Remove worktree + branch"
|
||||||
|
echo ""
|
||||||
|
echo "flow: init → spec → [explore in OpenCode] → fill → build → [QA loop]"
|
||||||
|
echo ""
|
||||||
|
echo "worktrees live at: ../.worktrees/<feature>/"
|
||||||
|
echo "branches named: dev-flow/<feature>"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Reference in New Issue
Block a user