diff --git a/home-manager/modules/tools/agent-tasks.nix b/home-manager/modules/tools/agent-tasks.nix new file mode 100644 index 0000000..1bdd3ef --- /dev/null +++ b/home-manager/modules/tools/agent-tasks.nix @@ -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; + }; + }; + }; +} diff --git a/home-manager/profiles/work.nix b/home-manager/profiles/work.nix index bae83dc..5986f97 100644 --- a/home-manager/profiles/work.nix +++ b/home-manager/profiles/work.nix @@ -5,6 +5,7 @@ imports = [ ../modules/ide/vscode.nix ../modules/dev/rust.nix + ../modules/tools/agent-tasks.nix ]; home = { diff --git a/justfile b/justfile index e62709d..212ae1e 100644 --- a/justfile +++ b/justfile @@ -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" \ No newline at end of file + 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()}} diff --git a/secrets/personal/linear-api-key.age b/secrets/personal/linear-api-key.age new file mode 100644 index 0000000..f439b7e --- /dev/null +++ b/secrets/personal/linear-api-key.age @@ -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 +mg1@H#l6H)*c'2!#\S )T*S섹i \ No newline at end of file diff --git a/secrets/personal/ticket-drafts-prompt.age b/secrets/personal/ticket-drafts-prompt.age new file mode 100644 index 0000000..2cd0890 --- /dev/null +++ b/secrets/personal/ticket-drafts-prompt.age @@ -0,0 +1,8 @@ +age-encryption.org/v1 +-> X25519 OiRzKIRR9BIjzMhqjCK9EAnB6tg6kvnuxPZwhCZQSzo +2srJI+DF/x3j2Z15uOaB6JXfdzOBj0ptTNX4/uEXAEY +-> PH*51S-grease pC X9 Jc}Gc X) +R4gsT6CJdi+T3zTO+zqZBu0k1u+uP+cYIDOOFHryIKILBYtSOHzW8PK0Rfg +--- H7fsqY3QvektApjlgvruQR7d7mYcy1tDnxu+d5LEdQs +Zi9rQmpI6 m]YOUmC9͏;A~@nh0Gq/N?{}菵ZHj1 Z3(ާ' f *DGcy̌ĻlA1AH}rU=r,sڙV%0Y(!ufvAŅЯ&vdHO} +$qnWmzTTi}++^I zoh~1+D賓 "?cn+Nc/ \ No newline at end of file diff --git a/secrets/secrets.nix b/secrets/secrets.nix index 2453d1f..89624d6 100644 --- a/secrets/secrets.nix +++ b/secrets/secrets.nix @@ -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 ]; } diff --git a/tools/agents/bin/ticket-drafts-review b/tools/agents/bin/ticket-drafts-review new file mode 100755 index 0000000..e47a0c4 --- /dev/null +++ b/tools/agents/bin/ticket-drafts-review @@ -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" diff --git a/tools/agents/bin/ticket-drafts-run b/tools/agents/bin/ticket-drafts-run new file mode 100755 index 0000000..3d88db6 --- /dev/null +++ b/tools/agents/bin/ticket-drafts-run @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +# ticket-drafts-run — drafting arm of the daily-ticket-status-drafts harness. +# +# Modes: +# (default) daily batch (F1): draft for all active tickets +# --ticket single ticket (F2): skips the daily gates +# --dry-run validation: auth preflight + codex context run, records +# land in the outbox marked dry_run (review ignores them) +# --repo dotfiles checkout for the rage secret fallback +# +# Default-red: exit 0 only when the outbox gained pending drafts or an +# evidence-carrying skip record. Every failure gets a distinct reason: +# auth | connector | rate-limit | timeout | malformed-output | incomplete +# | evidence-free-skip | placeholder-secret +# (an `incomplete` run still writes its valid drafts, marked partial_run). +# +# The agent never writes files (codex runs with a read-only sandbox) and never +# posts (see hooks/never-post-linear.json). This wrapper owns all outbox writes. +set -euo pipefail + +SENTINEL="AGENTS-TASK-PLACEHOLDER-SENTINEL" +TASK="daily-ticket-status-drafts" +OUTBOX="$HOME/.agents/outbox/$TASK" +AGENIX_DIR="$HOME/.local/share/agenix" +AGE_KEY="$HOME/.age/personal-key.txt" + +TICKET="" +DRY_RUN=0 +REPO="" +while [ $# -gt 0 ]; do + case "$1" in + --ticket) TICKET="${2:?--ticket needs an ID}"; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + --repo) REPO="${2:?--repo needs a path}"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +RUN_ID="$(date +%H%M%S)-$$" +RUN_DATE="$(date +%F)" +DRY_JSON="$([ "$DRY_RUN" = 1 ] && echo true || echo false)" +mkdir -p "$OUTBOX" + +# Hook-gate provenance: the probe/install flow (see the task bundle's hooks/ +# README) records its outcome in this state file; env var overrides for tests. +HOOK_STATE_FILE="$HOME/.agents/tasks/$TASK/hook-gate-state" +TICKET_DRAFTS_HOOK_GATE="${TICKET_DRAFTS_HOOK_GATE:-$(cat "$HOOK_STATE_FILE" 2>/dev/null || echo unknown)}" +export TICKET_DRAFTS_HOOK_GATE + +red() { # reason, message + local reason="$1" msg="$2" + jq -n --arg reason "$reason" --arg msg "$msg" --arg run_id "$RUN_ID" \ + --arg date "$RUN_DATE" --argjson dry "$DRY_JSON" \ + '{kind:"run", status:"red", reason:$reason, message:$msg, run_id:$run_id, + run_date:$date, dry_run:$dry, hook_gate_active:(env.TICKET_DRAFTS_HOOK_GATE // "unknown"), + created_at:(now|todate)}' \ + > "$OUTBOX/$RUN_DATE-run-$RUN_ID.json" + echo "RED ($reason): $msg" >&2 + exit 1 +} + +# --- secret resolution: agenix runtime path first, rage fallback ------------- +# Decrypted content lives only in shell variables; never written to disk here. +resolve_secret() { # name -> stdout + local name="$1" + if [ -r "$AGENIX_DIR/$name" ]; then + cat "$AGENIX_DIR/$name" + return + fi + local repo="$REPO" + [ -n "$repo" ] || repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." 2>/dev/null && pwd)" + local cipher="$repo/secrets/personal/$name.age" + if [ -r "$AGE_KEY" ] && [ -r "$cipher" ]; then + rage -d -i "$AGE_KEY" "$cipher" + return + fi + echo "cannot resolve secret '$name': no $AGENIX_DIR/$name and no usable rage fallback (key: $AGE_KEY, cipher: $cipher)" >&2 + return 1 +} + +# --- validate ticket argument early ------------------------------------------ +if [ -n "$TICKET" ] && ! printf '%s' "$TICKET" | grep -Eq '^[A-Z][A-Z0-9]*-[0-9]+$'; then + red "malformed-output" "invalid ticket id format: $TICKET" +fi + +# --- preflight: codex present + logged in (R7 / AE5) -------------------------- +command -v codex >/dev/null 2>&1 || red "auth" "codex CLI not found on PATH" +if ! codex login status >/dev/null 2>&1; then + red "auth" "codex login status reports not logged in" +fi + +# --- resolve + sentinel-check the prompt -------------------------------------- +# Resolution order: deployed agenix path (post-activation) > agenix runtime dir +# > rage fallback against the repo ciphertext. +PROMPT_BODY="" +if [ -r "$HOME/.agents/tasks/$TASK/prompt.md" ]; then + PROMPT_BODY="$(cat "$HOME/.agents/tasks/$TASK/prompt.md")" +else + PROMPT_BODY="$(resolve_secret ticket-drafts-prompt || true)" +fi +[ -n "$PROMPT_BODY" ] || red "placeholder-secret" "prompt secret unresolvable (agenix path and rage fallback both failed)" +if printf '%s' "$PROMPT_BODY" | grep -qF "$SENTINEL"; then + red "placeholder-secret" "prompt secret is the committed placeholder — run: agenix -e secrets/personal/ticket-drafts-prompt.age" +fi + +# --- locate the output schema -------------------------------------------------- +# An explicit --repo wins (prompt and schema form one contract; a checkout run +# must not pair a fresh prompt with a stale deployed schema), then the +# home-manager-deployed copy, then checkout-relative as a last resort. +SCHEMA="" +for cand in \ + "${REPO:+$REPO/tools/agents/tasks/$TASK/output-schema.json}" \ + "$HOME/.agents/tasks/$TASK/output-schema.json" \ + "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd)/tasks/$TASK/output-schema.json"; do + [ -n "$cand" ] && [ -r "$cand" ] && SCHEMA="$cand" && break +done +[ -n "$SCHEMA" ] || red "malformed-output" "output-schema.json not found (deploy the bundle or pass --repo)" + +# --- compose the run prompt ---------------------------------------------------- +MODE_NOTE="Daily batch mode: apply every step including the calendar (PTO/holiday) gate and the already-commented-today check." +if [ -n "$TICKET" ]; then + MODE_NOTE="Single-ticket mode: draft ONLY for ticket $TICKET. Skip the calendar gate and the already-commented-today check." +fi +if [ "$DRY_RUN" = 1 ]; then + MODE_NOTE="$MODE_NOTE This is a DRY RUN for pipeline validation: perform the real context gathering (calendar + Linear reads) but emit at most one draft." +fi + +FULL_PROMPT="$PROMPT_BODY + +--- +$MODE_NOTE +Return ONLY the JSON described by the output schema. Set skip.reason=connector-error with details in skip.evidence if ANY tool/connector call fails — never report no-active-tickets when a tool failed. Set complete=false honestly if you could not consider every active ticket; your valid drafts are still kept. NEVER post, create, or modify anything in Linear or any other system: you draft text as output only." + +# --- invoke codex --------------------------------------------------------------- +# Portable mktemp (GNU coreutils rejects -t templates without X's) +LASTMSG="$(mktemp "${TMPDIR:-/tmp}/ticket-drafts-lastmsg.XXXXXX")" +SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/ticket-drafts-work.XXXXXX")" +trap 'rm -rf "$SCRATCH"' EXIT + +TIMEOUT_SECS="${TICKET_DRAFTS_TIMEOUT:-600}" +CODEX_ARGS=(exec --skip-git-repo-check -s read-only -C "$SCRATCH" + --output-schema "$SCHEMA" -o "$LASTMSG" --color never -) +CODEX_RC=0 +if command -v timeout >/dev/null 2>&1; then + printf '%s' "$FULL_PROMPT" \ + | timeout "$TIMEOUT_SECS" codex "${CODEX_ARGS[@]}" >/dev/null 2>"$SCRATCH/stderr" || CODEX_RC=$? +else + # macOS pre-activation path (no coreutils timeout): background watchdog + printf '%s' "$FULL_PROMPT" \ + | codex "${CODEX_ARGS[@]}" >/dev/null 2>"$SCRATCH/stderr" & CODEX_PID=$! + ( sleep "$TIMEOUT_SECS" && kill "$CODEX_PID" 2>/dev/null ) >/dev/null 2>&1 & WATCHDOG=$! + wait "$CODEX_PID" || CODEX_RC=$? + kill "$WATCHDOG" 2>/dev/null || true +fi +unset PROMPT_BODY FULL_PROMPT + +if [ "$CODEX_RC" = 124 ] || [ "$CODEX_RC" = 143 ]; then + red "timeout" "codex exec exceeded ${TIMEOUT_SECS}s" +fi +# Only consult stderr chatter when the run actually failed — incidental +# "usage limit" log lines on a successful run must not fabricate a red. +if [ "$CODEX_RC" != 0 ]; then + if grep -qiE 'rate.?limit|plan limit|usage limit' "$SCRATCH/stderr" 2>/dev/null; then + red "rate-limit" "$(tail -1 "$SCRATCH/stderr")" + fi + if [ ! -s "$LASTMSG" ]; then + red "malformed-output" "codex exec exited $CODEX_RC with no final message ($(tail -1 "$SCRATCH/stderr" 2>/dev/null || echo 'no stderr'))" + fi +fi + +# --- validate output against the contract --------------------------------------- +preserve_raw() { cp "$LASTMSG" "$OUTBOX/$RUN_DATE-raw-$RUN_ID.json" 2>/dev/null || true; } + +jq -e . "$LASTMSG" >/dev/null 2>&1 || { preserve_raw; red "malformed-output" "final message is not valid JSON (raw preserved)"; } +RESULT="$(jq -r '.result // empty' "$LASTMSG")" +case "$RESULT" in drafts|skip) ;; *) preserve_raw; red "malformed-output" "result must be drafts|skip (raw preserved)";; esac + +COMPLETE="$(jq -r '.complete' "$LASTMSG")" +N_DRAFTS="$(jq -r '.drafts // [] | length' "$LASTMSG")" + +WRITTEN=0 +write_draft_records() { # marker: "" or partial + local partial="$1" i ticket existing + for i in $(seq 0 $((N_DRAFTS - 1))); do + ticket="$(jq -r ".drafts[$i].ticket_id" "$LASTMSG")" + if ! printf '%s' "$ticket" | grep -Eq '^[A-Z][A-Z0-9]*-[0-9]+$'; then + echo "WARNING: dropping draft with invalid ticket id '$ticket'" >&2 + continue + fi + # supersede: transition today's existing pending record(s) for this ticket + for existing in "$OUTBOX/$RUN_DATE-$ticket-"*.json; do + [ -e "$existing" ] || continue + if [ "$(jq -r '.state' "$existing" 2>/dev/null)" = "pending" ]; then + jq '.state = "superseded" | .superseded_at = (now|todate)' "$existing" > "$existing.tmp" && mv "$existing.tmp" "$existing" + fi + done + jq -n --arg date "$RUN_DATE" --arg run_id "$RUN_ID" --slurpfile msg "$LASTMSG" --argjson i "$i" \ + --argjson partial "$([ -n "$partial" ] && echo true || echo false)" \ + --argjson dry "$DRY_JSON" \ + '{kind:"draft", schema_version:1, task:"daily-ticket-status-drafts", + ticket_id:$msg[0].drafts[$i].ticket_id, ticket_title:$msg[0].drafts[$i].ticket_title, + draft_text:$msg[0].drafts[$i].body, movement_summary:($msg[0].drafts[$i].movement_summary // null), + state:"pending", run_id:$run_id, run_date:$date, partial_run:$partial, dry_run:$dry, + calendar_checked:$msg[0].calendar_checked, + hook_gate_active:(env.TICKET_DRAFTS_HOOK_GATE // "unknown"), + created_at:(now|todate)}' \ + > "$OUTBOX/$RUN_DATE-$ticket-$RUN_ID.json" + WRITTEN=$((WRITTEN + 1)) + done +} + +if [ "$RESULT" = "skip" ]; then + REASON="$(jq -r '.skip.reason // empty' "$LASTMSG")" + EVIDENCE="$(jq -r '.skip.evidence // empty' "$LASTMSG")" + [ "$REASON" = "connector-error" ] && { preserve_raw; red "connector" "agent reported connector failure: $EVIDENCE"; } + { [ -z "$REASON" ] || [ -z "$EVIDENCE" ]; } && { preserve_raw; red "evidence-free-skip" "skip without reason+evidence (raw preserved)"; } + jq -n --arg date "$RUN_DATE" --arg run_id "$RUN_ID" --arg reason "$REASON" --arg evidence "$EVIDENCE" \ + --argjson calendar "$(jq '.calendar_checked' "$LASTMSG")" \ + --argjson dry "$DRY_JSON" \ + '{kind:"skip", status:"green", reason:$reason, evidence:$evidence, run_id:$run_id, + run_date:$date, calendar_checked:$calendar, dry_run:$dry, created_at:(now|todate)}' \ + > "$OUTBOX/$RUN_DATE-skip-$RUN_ID.json" + echo "GREEN (skip: $REASON) — $EVIDENCE" + exit 0 +fi + +# result = drafts +if [ "$N_DRAFTS" = 0 ]; then + preserve_raw + red "evidence-free-skip" "result=drafts with zero drafts and no skip record (raw preserved)" +fi +if [ "$COMPLETE" != "true" ]; then + write_draft_records partial + preserve_raw + red "incomplete" "run attested complete=false; $WRITTEN draft(s) preserved as pending (partial_run)" +fi +write_draft_records "" +if [ "$WRITTEN" = 0 ]; then + preserve_raw + red "malformed-output" "all $N_DRAFTS draft(s) had invalid ticket ids — nothing written (raw preserved)" +fi +[ "$WRITTEN" = "$N_DRAFTS" ] || echo "WARNING: wrote $WRITTEN of $N_DRAFTS drafts (invalid ticket ids dropped; raw not preserved on green)" >&2 +echo "GREEN — $WRITTEN pending draft(s) in $OUTBOX ($RUN_DATE, run $RUN_ID)$([ "$DRY_RUN" = 1 ] && echo ' [dry-run]')" diff --git a/tools/agents/tasks/daily-ticket-status-drafts/README.md b/tools/agents/tasks/daily-ticket-status-drafts/README.md new file mode 100644 index 0000000..a9b336f --- /dev/null +++ b/tools/agents/tasks/daily-ticket-status-drafts/README.md @@ -0,0 +1,82 @@ +# daily-ticket-status-drafts + +Weekday task: draft meaningful Linear status comments for Alyssa's active +tickets, for individual approve/deny before anything posts. This is the first +Cowork scheduled task migrated to a runtime-agnostic harness, run as an A/B +against the still-enabled Claude Cowork incumbent until 2026-09-01. + +Plan: `agentic-migration/docs/plans/2026-07-09-001-feat-ticket-drafts-ab-harness-plan.md` +(private corpus). Origin requirements and ideation live alongside it. + +## How it works + +- `ticket-drafts-run` invokes `codex exec` (read-only sandbox, schema-validated + output) to draft comments from Linear + Google Calendar via codex's own + connectors. The agent's only output channel is its final message; the wrapper + writes all outbox records. Runs are **default-red**: green requires drafts or + an evidence-carrying skip record. +- Drafts land as one JSON record per file in + `~/.agents/outbox/daily-ticket-status-drafts/` (`--.json`), + state machine: `pending → approved → posted`, with `denied`, `spiked` + (expired), `superseded` (re-drafted same day), and `post_failed` (retryable). +- `ticket-drafts-review` is the **only** posting path. It holds the Linear API + key (agenix, mode 0400), shows each pending draft for approve / edit / deny, + warns on cross-arm duplicates before posting, posts idempotently (frozen + approved text + attempt marker + read-before-retry), and appends every + decision to `decisions.jsonl`. + +## Outbound-comment-gate alignment + +This harness is the codex-side implementation of the outbound comment gate +(`tools/claude/rules/outbound-comment-gate.md`): nothing posts without an +explicit per-draft human approval, enforced by credential separation — the +drafting agent never holds the posting credential, and `hooks/` adds a +mechanical PreToolUse deny on Linear mutations (see below). + +## Setup (once per machine) + +1. Real prompt: `agenix -e secrets/personal/ticket-drafts-prompt.age` + (replace the sentinel placeholder; content = adapted Cowork SKILL.md steps + + comment-patterns.md style guide; scripts refuse to run on the placeholder). +2. Linear key: mint a personal API key (Linear → Settings → API), then + `agenix -e secrets/personal/linear-api-key.age` (single line, no quotes). +3. Hook gate: install and trust `hooks/never-post-linear.json` per the + comments in that file (codex requires interactive trust of the exact hook + definition once; re-trust on any change — tamper-evident by design). After + the probe step, record the outcome in + `~/.agents/tasks/daily-ticket-status-drafts/hook-gate-state` (`true` or + `false`) — the wrapper stamps it into every record as `hook_gate_active`. +4. Without a darwin-rebuild the scripts still work: they fall back to + `rage -d -i ~/.age/personal-key.txt` against this repo's ciphertexts. After + activation (home-manager module `agent-tasks.nix`), secrets resolve from + `~/.local/share/agenix/` and the scripts are on PATH. + +## Running + +```sh +just ticket-drafts # daily batch (F1) +just ticket-drafts-one SDKS-1234 # single ticket, skips daily gates (F2) +just ticket-drafts-review # review, approve/edit/deny, post (F3) +ticket-drafts-run --dry-run # validation mode: auth + context, no pending drafts +``` + +Manual invocation is deliberate for v0; the launchd/home-manager scheduler is +a later increment (see plan Scope Boundaries). + +## A/B notes + +- The Cowork task stays enabled and untouched; comparison is the daily review + plus `decisions.jsonl` (approve/edit/deny + draft-vs-posted deltas). +- Run records note whether the hook gate was active (`hook_gate_active`) so + arm provenance stays clean. +- A red run (auth, connector, rate-limit, timeout, malformed-output, + incomplete, evidence-free-skip, placeholder-secret) is an + arm-unavailability datapoint, never a draft-quality datapoint. + +## Local-plaintext doctrine + +Per `tools/agents/README.md`: prompts are local plaintext delivered via the +agenix overlay, never a URL an agent is told to fetch. The committed `.age` +files are sentinel placeholders; real content exists only encrypted here and +decrypted on machines holding the age identity. Runtime records under +`~/.agents/outbox/` contain work-internal text — they stay machine-local. diff --git a/tools/agents/tasks/daily-ticket-status-drafts/hooks/never-post-linear.json b/tools/agents/tasks/daily-ticket-status-drafts/hooks/never-post-linear.json new file mode 100644 index 0000000..9295f34 --- /dev/null +++ b/tools/agents/tasks/daily-ticket-status-drafts/hooks/never-post-linear.json @@ -0,0 +1,60 @@ +{ + "//": [ + "Codex PreToolUse hook gate: mechanical never-post enforcement for the", + "drafting arm (codex-side counterpart of tools/claude/rules/outbound-comment-gate.md).", + "", + "INSTALL (once, interactive): merge the PreToolUse entries below into the", + "codex hooks config (see `/hooks` inside codex, or hooks.json per", + "https://developers.openai.com/codex/hooks), then trust the definition", + "when codex prompts. Codex trust is hash-pinned: any edit to a hook", + "requires re-trust, which is exactly the tamper-evidence we want. Never", + "run with --dangerously-bypass-hook-trust.", + "", + "STEP 1 — PROBE (run first, once): enable only the `probe-log` entry and do", + "one real `ticket-drafts-run`. Then inspect ~/.agents/outbox/hook-probe.log:", + "if Linear/Calendar connector tool names appear, connector calls ARE", + "hook-visible -> enable the deny entry and record the outcome:", + " echo true > ~/.agents/tasks/daily-ticket-status-drafts/hook-gate-state", + "If they do NOT appear, connectors bypass hooks -> record the finding in", + "the bundle README + triage doc, promote the frozen-snapshot increment, and:", + " echo false > ~/.agents/tasks/daily-ticket-status-drafts/hook-gate-state", + "ticket-drafts-run reads that state file (env TICKET_DRAFTS_HOOK_GATE", + "overrides it) and stamps hook_gate_active into every run/draft record,", + "so A/B arm provenance stays honest without per-shell exports.", + "", + "The matcher regex is written against Linear MUTATION tool-name shapes", + "(create/update/delete/save comment|issue|attachment). Verify actual tool", + "names from the probe log and tighten the regex to what is real before", + "trusting the deny hook. Field names below follow the documented", + "hooks.json shape; confirm against `/hooks` on the installed codex version", + "(0.143.0 at authoring time) during install." + ], + "hooks": { + "PreToolUse": [ + { + "comment": "probe-log: records every tool name codex attempts; never blocks", + "matcher": ".*", + "enabled": false, + "hooks": [ + { + "type": "command", + "command": "jq -r '\"\\(now|todate) \\(.tool_name // .toolName // \"unknown\")\"' >> \"$HOME/.agents/outbox/hook-probe.log\"", + "timeout": 5 + } + ] + }, + { + "comment": "never-post-linear: deny any Linear mutation tool call", + "matcher": "(?i)linear.*(create|update|delete|save|add|post)|(create|update|delete|save|add|post).*(comment|issue|attachment).*linear", + "enabled": true, + "hooks": [ + { + "type": "command", + "command": "echo '{\"decision\":\"block\",\"reason\":\"never-post invariant: the drafting arm outputs text only; posting happens exclusively through ticket-drafts-review after human approval\"}'", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/tools/agents/tasks/daily-ticket-status-drafts/output-schema.json b/tools/agents/tasks/daily-ticket-status-drafts/output-schema.json new file mode 100644 index 0000000..eb44706 --- /dev/null +++ b/tools/agents/tasks/daily-ticket-status-drafts/output-schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "daily-ticket-status-drafts run result", + "type": "object", + "additionalProperties": false, + "required": ["result", "calendar_checked", "complete", "tickets_considered", "drafts", "skip"], + "properties": { + "result": { + "type": "string", + "enum": ["drafts", "skip"] + }, + "calendar_checked": { + "description": "Whether the PTO/holiday calendar check was actually performed. False means the calendar was unreachable; drafting proceeds fail-open and this flag is surfaced at review.", + "type": "boolean" + }, + "complete": { + "description": "Self-attestation that every active ticket was considered. Set false honestly when the run could not cover the full list; valid drafts from an incomplete run are still written (marked partial_run) so honesty is never punished.", + "type": "boolean" + }, + "tickets_considered": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["ticket_id", "disposition"], + "properties": { + "ticket_id": { "type": "string" }, + "disposition": { + "type": "string", + "enum": ["drafted", "already-commented-today", "no-movement", "error"] + }, + "note": { "type": ["string", "null"] } + } + } + }, + "drafts": { + "type": ["array", "null"], + "items": { + "type": "object", + "additionalProperties": false, + "required": ["ticket_id", "ticket_title", "body"], + "properties": { + "ticket_id": { "type": "string" }, + "ticket_title": { "type": "string" }, + "body": { "type": "string" }, + "movement_summary": { "type": ["string", "null"] } + } + } + }, + "skip": { + "type": ["object", "null"], + "additionalProperties": false, + "required": ["reason", "evidence"], + "properties": { + "reason": { + "type": "string", + "enum": ["holiday", "pto", "no-movement", "no-active-tickets", "connector-error"] + }, + "evidence": { + "description": "Concrete support for the skip: which calendar events / which tickets were seen. An evidence-free skip is treated as a failed run by the wrapper.", + "type": "string" + } + } + } + } +}