Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions home-manager/modules/tools/agent-tasks.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Scheduled-agent task harness (daily-ticket-status-drafts A/B, v0).
#
# Work-scoped: imported from profiles/work.nix, NOT common.nix — the Linear
# write key and Ditto ticket workflow don't belong in devcontainer/home
# profiles. work.nix also backs the alyssa@work-dev aarch64-linux devcontainer,
# so everything here is additionally guarded to darwin.
#
# Deploys: the two harness scripts (nix-managed deps, shellcheck at build),
# the task bundle to ~/.agents/tasks/, and the agenix secrets — the encrypted
# task prompt exposed at a stable home path, and the Linear API key at the
# agenix runtime dir (mode 0400 default; only ticket-drafts-review reads it).
# Scripts also run from a plain repo checkout pre-activation via just recipes
# (rage-direct fallback) — see tools/agents/tasks/daily-ticket-status-drafts/README.md.
{ config, lib, pkgs, ... }:
let
taskName = "daily-ticket-status-drafts";
taskSrc = ../../../tools/agents/tasks/daily-ticket-status-drafts;
mkTaskScript = name:
pkgs.writeShellApplication {
inherit name;
runtimeInputs = [ pkgs.jq pkgs.curl pkgs.rage pkgs.coreutils ];
# Included verbatim: the source's own shebang/strict-mode lines are
# redundant under writeShellApplication but harmless, and keeping the
# files byte-identical preserves the pre-activation checkout path.
text = builtins.readFile (../../../tools/agents/bin + "/${name}");
};
in
{
config = lib.mkIf pkgs.stdenv.isDarwin {
home.packages = [
(mkTaskScript "ticket-drafts-run")
(mkTaskScript "ticket-drafts-review")
];

home.file = {
".agents/tasks/${taskName}/README.md".source = taskSrc + "/README.md";
".agents/tasks/${taskName}/output-schema.json".source = taskSrc + "/output-schema.json";
".agents/tasks/${taskName}/hooks/never-post-linear.json".source =
taskSrc + "/hooks/never-post-linear.json";
};

age.secrets = {
# Decrypted prompt at a stable home path (same per-secret `path` pattern
# as agents.nix); plaintext stays in the agenix runtime dir, not the store.
ticket-drafts-prompt = {
file = ../../../secrets/personal/ticket-drafts-prompt.age;
path = "${config.home.homeDirectory}/.agents/tasks/${taskName}/prompt.md";
};
# No `path`, no `mode`: lands at ~/.local/share/agenix/linear-api-key,
# 0400 — deliberately not under a browsable location.
linear-api-key = {
file = ../../../secrets/personal/linear-api-key.age;
};
};
};
}
1 change: 1 addition & 0 deletions home-manager/profiles/work.nix
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
imports = [
../modules/ide/vscode.nix
../modules/dev/rust.nix
../modules/tools/agent-tasks.nix
];

home = {
Expand Down
29 changes: 28 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,31 @@ agents-capsule:
# Copy the compiled capsule to the clipboard (macOS pbcopy)
[group('agents')]
agents-copy:
just agents-capsule | pbcopy && echo "Copied agent capsule to clipboard"
just agents-capsule | pbcopy && echo "Copied agent capsule to clipboard"

# Draft status comments for all active Linear tickets (daily-ticket-status-drafts, F1)
[group('agents')]
ticket-drafts:
#!/usr/bin/env bash
set -euo pipefail
bin="{{justfile_directory()}}/tools/agents/bin/ticket-drafts-run"
[ -x "$bin" ] || bin="$(command -v ticket-drafts-run)"
"$bin" --repo {{justfile_directory()}}

# Draft a status comment for one ticket, skipping the daily gates (F2)
[group('agents')]
ticket-drafts-one TICKET:
#!/usr/bin/env bash
set -euo pipefail
bin="{{justfile_directory()}}/tools/agents/bin/ticket-drafts-run"
[ -x "$bin" ] || bin="$(command -v ticket-drafts-run)"
"$bin" --ticket {{quote(TICKET)}} --repo {{justfile_directory()}}

# Review pending drafts: approve/edit/deny each; the only path that posts to Linear (F3)
[group('agents')]
ticket-drafts-review:
#!/usr/bin/env bash
set -euo pipefail
bin="{{justfile_directory()}}/tools/agents/bin/ticket-drafts-review"
[ -x "$bin" ] || bin="$(command -v ticket-drafts-review)"
"$bin" --repo {{justfile_directory()}}
8 changes: 8 additions & 0 deletions secrets/personal/linear-api-key.age
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
age-encryption.org/v1
-> X25519 BQ7VvYD3d9xb1dhul9Yn2rWQ/EqMTxHwE2Ylg934oFk
PjOM/0G5tbDjJGVh4RjW6KmChsE2fdujc124kIT+woo
-> Z-grease W+eB] [1N1!Me
25J/0lzUC6KfmZYWwKz3jFesmnUhOyFLsVoZGjiUkXZEyeIyIc5h8aImoFq6zhmx
a0Zjh4t9BVWJuxblYZvoLbht03ls
--- 1A0Z7xiHT8GR/8EUZHU1alBEi89TqQZIwhTQ7Ord8jQ
…mg£1ò@Hµ“áþ#¨l‡®ý6H )*c'2”õžÂé×Ä!¤#ôê\ýSŠ Í)ðT*¬ÌSæì„¹¥îi
8 changes: 8 additions & 0 deletions secrets/personal/ticket-drafts-prompt.age
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
age-encryption.org/v1
-> X25519 OiRzKIRR9BIjzMhqjCK9EAnB6tg6kvnuxPZwhCZQSzo
2srJI+DF/x3j2Z15uOaB6JXfdzOBj0ptTNX4/uEXAEY
-> PH*51S-grease pC <kxQ>X9 Jc}Gc X)
R4gsT6CJdi+T3zTO+zqZBu0k1u+uP+cYIDOOFHryIKILBYtSOHzW8PK0Rfg
--- H7fsqY3QvektApjlgvruQR7d7mYcy1tDnxu+d5LEdQs
ûÇZ­ìiì¸9ÔrQmÿpI6 m™Áø]YOU¯‚˜m´ëC9͏;A~@þÇn‚Âh0†„´ªˆ’ÿíGŠÇìq/šNüÿÆ?ý{Å}菵ZÍHj1“ ©Z‚3(Þ§'ŸÊ fÐ *DÃGcyÌŒÉÄ»l¸‹×AÚéÁ¥1¨·ž ¥AH}ÛÞårU=š•r,ÂsÚ™çV%…0®ÂY(!À¹ŽåÞufÓvA€°ÊŅЯû&vdºHÁO˜}
ˆ$•q nêWm­ÕÆzT•Tiñ®ª}³+Æ+¿Œïä^Iæ ázo§h‚÷ß~õ1+±…ÛD›賓§ "Ž?ºcnÈ+žÍNÁc„à/å
5 changes: 5 additions & 0 deletions secrets/secrets.nix
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,9 @@ in
# Private agent-instruction overlay (issue #40). Decrypted only on local
# machines to ~/.agents/instructions.private.md; never committed as plaintext.
"personal/agent-instructions.age".publicKeys = [ alyssa ];

# daily-ticket-status-drafts task (agent-tasks harness). Committed content is
# a sentinel placeholder; scripts refuse to run until replaced via agenix -e.
"personal/ticket-drafts-prompt.age".publicKeys = [ alyssa ];
"personal/linear-api-key.age".publicKeys = [ alyssa ];
}
257 changes: 257 additions & 0 deletions tools/agents/bin/ticket-drafts-review
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
#!/usr/bin/env bash
# ticket-drafts-review — the human approval gate and ONLY posting path of the
# daily-ticket-status-drafts harness (outbound-comment-gate, codex side).
#
# Flow per pending draft: show ticket + text -> approve / edit-then-approve /
# deny / skip-for-now. Approval FREEZES the final text; posting is idempotent:
# post_attempted_at is written before the HTTP call, an ambiguous failure
# leaves state=post_failed, and a retry posts the exact frozen body only after
# checking today's own comments for a frozen-body match. Editing a post_failed
# record revokes the approval (fresh approve required) so a landed-but-
# reported-failed post can never be followed by a differently-worded duplicate.
#
# Expiry (lazy): pending drafts not created today are spiked at list time and
# re-checked at decision time. post_failed records are exempt and surface
# first, requiring re-confirmation of the frozen text.
#
# The Linear API key is read into a shell variable (agenix path or rage
# fallback) and passed to curl via process substitution — never argv, never a
# temp file. Every decision appends to decisions.jsonl.
set -euo pipefail

SENTINEL="AGENTS-TASK-PLACEHOLDER-SENTINEL"
TASK="daily-ticket-status-drafts"
OUTBOX="$HOME/.agents/outbox/$TASK"
DECISIONS="$OUTBOX/decisions.jsonl"
AGENIX_DIR="$HOME/.local/share/agenix"
AGE_KEY="$HOME/.age/personal-key.txt"
LINEAR_API="https://api.linear.app/graphql"

REPO=""
LIST_ONLY=0
while [ $# -gt 0 ]; do
case "$1" in
--repo) REPO="${2:?--repo needs a path}"; shift 2 ;;
--list) LIST_ONLY=1; shift ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done

TODAY="$(date +%F)"
mkdir -p "$OUTBOX"

log_decision() { # ticket, record, action, extra-json
jq -n --arg ticket "$1" --arg record "$2" --arg action "$3" --argjson extra "${4:-null}" \
'{ts:(now|todate), ticket:$ticket, record:$record, action:$action, extra:$extra}' >> "$DECISIONS"
}

update_record() { # file, jq-program, [extra jq args...]
local f="$1" prog="$2"
shift 2
jq "$@" "$prog" "$f" > "$f.tmp" && mv "$f.tmp" "$f"
}

state_of() { jq -r '.state // empty' "$1" 2>/dev/null; }

# --- lazy expiry pass (R10): spike stale pendings; post_failed exempt ---------
# dry_run records are validation noise: spiked silently, never logged as
# decisions (decisions.jsonl is the A/B dataset).
for f in "$OUTBOX"/*.json; do
[ -e "$f" ] || continue
[ "$(jq -r '.kind // empty' "$f")" = "draft" ] || continue
if [ "$(state_of "$f")" = "pending" ] && [ "$(jq -r '.run_date' "$f")" != "$TODAY" ]; then
update_record "$f" '.state = "spiked" | .spiked_at = (now|todate)'
if [ "$(jq -r '.dry_run // false' "$f")" != "true" ]; then
log_decision "$(jq -r '.ticket_id' "$f")" "$(basename "$f")" "spike" null
fi
fi
done

# --- collect work: post_failed first, then today's pendings -------------------
collect() { # state, date-filter(optional "today")
local f
for f in "$OUTBOX"/*.json; do
[ -e "$f" ] || continue
[ "$(jq -r '.kind // empty' "$f")" = "draft" ] || continue
[ "$(jq -r '.dry_run // false' "$f")" = "true" ] && continue
[ "$(state_of "$f")" = "$1" ] || continue
if [ "${2:-}" = "today" ] && [ "$(jq -r '.run_date' "$f")" != "$TODAY" ]; then continue; fi
printf '%s\n' "$f"
done
}
# Retry surface: post_failed AND approved — an interrupt/decline between
# approval and post completion leaves state=approved, which must resurface
# (frozen text exists) rather than becoming invisible.
FAILED=()
while IFS= read -r line; do [ -n "$line" ] && FAILED+=("$line"); done < <(collect post_failed; collect approved)
PENDING=()
while IFS= read -r line; do [ -n "$line" ] && PENDING+=("$line"); done < <(collect pending today)

# --- context banners -----------------------------------------------------------
for f in "$OUTBOX/$TODAY"-skip-*.json; do
[ -e "$f" ] || continue
echo "NOTE: today's run was a skip — $(jq -r '"\(.reason): \(.evidence)"' "$f")"
done
for f in "${PENDING[@]:-}"; do
[ -n "$f" ] || continue
[ "$(jq -r '.partial_run // false' "$f")" = "true" ] \
&& { echo "NOTE: some drafts came from an INCOMPLETE run (partial_run) — coverage was not full."; break; }
done

TOTAL=$(( ${#FAILED[@]} + ${#PENDING[@]} ))
if [ "$TOTAL" = 0 ]; then
echo "Nothing pending. ($OUTBOX)"
exit 0
fi
echo "${#FAILED[@]} failed-post retry(ies), ${#PENDING[@]} pending draft(s)."
if [ "$LIST_ONLY" = 1 ] || [ ! -t 0 ]; then
for f in "${FAILED[@]:-}" "${PENDING[@]:-}"; do
[ -n "$f" ] || continue
jq -r '"[\(.state)] \(.ticket_id) — \(.ticket_title) (\(.run_date))"' "$f"
done
exit 0
fi

# --- Linear access ---------------------------------------------------------------
resolve_key() {
local key=""
if [ -r "$AGENIX_DIR/linear-api-key" ]; then
key="$(cat "$AGENIX_DIR/linear-api-key")"
else
local repo="$REPO"
[ -n "$repo" ] || repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." 2>/dev/null && pwd)"
local cipher="$repo/secrets/personal/linear-api-key.age"
[ -r "$AGE_KEY" ] && [ -r "$cipher" ] && key="$(rage -d -i "$AGE_KEY" "$cipher")"
fi
[ -n "$key" ] || { echo "cannot resolve Linear API key (agenix or rage fallback)" >&2; return 1; }
printf '%s' "$key" | grep -qF "$SENTINEL" \
&& { echo "Linear key is the committed placeholder — run: agenix -e secrets/personal/linear-api-key.age" >&2; return 1; }
printf '%s' "$key"
}
LINEAR_KEY="$(resolve_key)" || exit 1

gql() { # query, variables-json -> response body (key via fd, never argv)
local vars="${2:-}"
[ -n "$vars" ] || vars='{}'
curl -sS --fail-with-body --max-time 30 \
-H "Content-Type: application/json" \
-H @<(printf 'Authorization: %s\n' "$LINEAR_KEY") \
-d "$(jq -n --arg q "$1" --argjson v "$vars" '{query:$q, variables:$v}')" \
"$LINEAR_API"
}

# NOTE: field shapes (issue(id:) accepting identifiers, user.isMe, comment.url)
# verified against the live API on first run with a real key; adjust here if
# the API differs — this is the single integration point.
# shellcheck disable=SC2016 # GraphQL $vars are literal, not shell expansions
Q_ISSUE='query($id: String!){ issue(id:$id){ id identifier title state { name type }
comments(last: 50){ nodes { body createdAt user { isMe } } } } }'
# shellcheck disable=SC2016
M_COMMENT='mutation($issueId: String!, $body: String!){
commentCreate(input:{issueId:$issueId, body:$body}){ success comment { url } } }'

post_record() { # record-file -> 0 posted, 1 failed-ambiguous, 2 rejected
local f="$1" ticket frozen resp uuid statename commented_today match url
ticket="$(jq -r '.ticket_id' "$f")"
frozen="$(jq -r '.final_text' "$f")"

# pre-post check (R14) + current state + duplicate/frozen-body scan
if ! resp="$(gql "$Q_ISSUE" "$(jq -n --arg id "$ticket" '{id:$id}')" 2>&1)"; then
echo " Linear query failed: $(printf '%s' "$resp" | tail -1)"; return 1
fi
uuid="$(printf '%s' "$resp" | jq -r '.data.issue.id // empty')"
[ -n "$uuid" ] || { echo " ticket $ticket not found on Linear"; return 2; }
statename="$(printf '%s' "$resp" | jq -r '.data.issue.state.name')"
# "today" = within the last 24h — createdAt is UTC and a local-date prefix
# match misses evening comments that cross the UTC day boundary.
commented_today="$(printf '%s' "$resp" | jq -r \
'[.data.issue.comments.nodes[] | select(.user.isMe == true and ((now - (.createdAt | fromdateiso8601)) < 86400))] | length')"
match="$(printf '%s' "$resp" | jq -r --arg body "$frozen" \
'[.data.issue.comments.nodes[] | select(.user.isMe == true and .body == $body)] | length')"

if [ "$match" != "0" ]; then
echo " frozen body already present on $ticket — marking posted (idempotent retry)."
update_record "$f" '.state = "posted" | .posted_at = (now|todate) | .posted_via = "retry-match"'
log_decision "$ticket" "$(basename "$f")" "post" '{"via":"retry-match"}'
return 0
fi
echo " ticket state: $statename; my comments today: $commented_today"
if [ "$commented_today" != "0" ] || printf '%s' "$statename" | grep -qiE 'done|closed|canceled|completed'; then
read -r -p " WARNING: duplicate-risk or closed ticket. Post anyway? [y/N] " yn
[ "$yn" = "y" ] || { echo " not posted."; return 2; }
fi

update_record "$f" '.post_attempted_at = (now|todate)'
if resp="$(gql "$M_COMMENT" "$(jq -n --arg issueId "$uuid" --arg body "$frozen" '{issueId:$issueId, body:$body}')" 2>&1)" \
&& [ "$(printf '%s' "$resp" | jq -r '.data.commentCreate.success // false')" = "true" ]; then
url="$(printf '%s' "$resp" | jq -r '.data.commentCreate.comment.url // ""')"
update_record "$f" '.state = "posted" | .posted_at = (now|todate)'
log_decision "$ticket" "$(basename "$f")" "post" "$(jq -n --arg url "$url" '{url:$url}')"
echo " POSTED: $url"
return 0
fi
update_record "$f" '.state = "post_failed"'
log_decision "$ticket" "$(basename "$f")" "post_failed" null
echo " POST FAILED (state=post_failed; retry via this CLI — frozen text only)."
return 1
}

review_one() { # record-file, is_retry
local f="$1" retry="${2:-0}" ticket title text action edited
# concurrent-run protection: re-read state before acting
local st; st="$(state_of "$f")"
if [ "$retry" = 1 ] && [ "$st" != "post_failed" ] && [ "$st" != "approved" ]; then return; fi
if [ "$retry" = 0 ] && [ "$st" != "pending" ]; then return; fi

ticket="$(jq -r '.ticket_id' "$f")"; title="$(jq -r '.ticket_title' "$f")"
echo; echo "=== $ticket — $title $([ "$retry" = 1 ] && echo "[RETRY: $st — frozen text]")"
if [ "$retry" = 1 ]; then
text="$(jq -r '.final_text' "$f")"
else
text="$(jq -r '.draft_text' "$f")"
fi
printf '%s\n' "$text" | sed 's/^/ | /'

if [ "$retry" = 1 ]; then
read -r -p "[r]etry frozen post / [e]dit (revokes approval) / [d]eny / [s]kip: " action
case "$action" in
r) post_record "$f" || true ;;
e)
update_record "$f" '.state = "pending" | .final_text = null | .post_attempted_at = null'
log_decision "$ticket" "$(basename "$f")" "revoke-approval" null
review_one "$f" 0 ;;
d) update_record "$f" '.state = "denied" | .decided_at = (now|todate)'
log_decision "$ticket" "$(basename "$f")" "deny" null ;;
*) echo " skipped." ;;
esac
return
fi

read -r -p "[a]pprove / [e]dit-then-approve / [d]eny / [s]kip: " action
case "$action" in
a)
# shellcheck disable=SC2016 # $t is a jq variable, not shell
update_record "$f" '.state = "approved" | .final_text = $t | .decided_at = (now|todate)' --arg t "$text"
log_decision "$ticket" "$(basename "$f")" "approve" null
post_record "$f" || true ;;
e)
edited="$(mktemp "${TMPDIR:-/tmp}/ticket-draft-edit.XXXXXX")"
printf '%s\n' "$text" > "$edited"
"${EDITOR:-vi}" "$edited"
text="$(cat "$edited")"; rm -f "$edited"
[ -n "$text" ] || { echo " empty edit — skipped."; return; }
# shellcheck disable=SC2016 # $t is a jq variable, not shell
update_record "$f" '.state = "approved" | .final_text = $t | .edited = true | .decided_at = (now|todate)' --arg t "$text"
log_decision "$ticket" "$(basename "$f")" "edit-approve" null
post_record "$f" || true ;;
d)
update_record "$f" '.state = "denied" | .decided_at = (now|todate)'
log_decision "$ticket" "$(basename "$f")" "deny" null ;;
*) echo " skipped." ;;
esac
}

for f in "${FAILED[@]:-}"; do [ -n "$f" ] && review_one "$f" 1; done
for f in "${PENDING[@]:-}"; do [ -n "$f" ] && review_one "$f" 0; done
unset LINEAR_KEY
echo; echo "Done. Decisions logged to $DECISIONS"
Loading
Loading