@indigoai-us/hq-cli 5.291.0 → 5.293.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +53 -8
  2. package/assets/scaffold/core/scripts/handoff-finalize.sh +18 -0
  3. package/assets/scaffold/core/scripts/handoff-post.sh +71 -6
  4. package/assets/scaffold/core/scripts/hq-delegate-bundle.sh +307 -0
  5. package/assets/scaffold/core/scripts/hq-delegate-grant.sh +226 -0
  6. package/assets/scaffold/core/scripts/hq-delegate-publish.sh +91 -0
  7. package/assets/scaffold/core/scripts/hq-delegate-send.sh +259 -0
  8. package/assets/scaffold/core/scripts/hq-delegate-verify.sh +213 -0
  9. package/assets/scaffold/core/scripts/hq-job-notify.sh +389 -0
  10. package/assets/scaffold/core/scripts/hq-job-probe.sh +835 -0
  11. package/assets/scaffold/core/scripts/hq-job-remediate.sh +359 -0
  12. package/assets/scaffold/core/scripts/hq-job-run.sh +1025 -0
  13. package/assets/scaffold/core/scripts/outpost-jobs-reconcile.sh +830 -0
  14. package/dist/command-catalog.generated.d.ts +109 -1
  15. package/dist/command-catalog.generated.js +136 -1
  16. package/dist/commands/core-delegate-w1b.d.ts +8 -0
  17. package/dist/commands/core-delegate-w1b.js +1049 -0
  18. package/dist/commands/core-delegate.d.ts +28 -0
  19. package/dist/commands/core-delegate.js +15 -13
  20. package/dist/commands/core-handoff.js +152 -23
  21. package/dist/commands/core-jobs-native.d.ts +15 -0
  22. package/dist/commands/core-jobs-native.js +709 -2
  23. package/dist/commands/core-ontology.d.ts +22 -4
  24. package/dist/commands/core-ontology.js +90 -8
  25. package/dist/commands/core.js +10 -0
  26. package/dist/commands/lanes-move.js +54 -1
  27. package/dist/commands/lanes-questions.js +4 -0
  28. package/dist/commands/lanes.d.ts +2 -0
  29. package/dist/commands/lanes.js +119 -5
  30. package/dist/lib/checkpoint-ontology/drop-aliases.d.ts +41 -0
  31. package/dist/lib/checkpoint-ontology/drop-aliases.js +76 -0
  32. package/dist/lib/checkpoint-ontology/entity-file.d.ts +6 -3
  33. package/dist/lib/checkpoint-ontology/entity-file.js +12 -6
  34. package/dist/lib/checkpoint-ontology/merge-copies.d.ts +27 -8
  35. package/dist/lib/checkpoint-ontology/merge-copies.js +64 -97
  36. package/dist/lib/core-utils/sentry-report.js +2 -2
  37. package/dist/lib/daemon/flag.d.ts +2 -0
  38. package/dist/lib/daemon/flag.js +2 -0
  39. package/dist/lib/daemon/run.d.ts +11 -0
  40. package/dist/lib/daemon/run.js +17 -1
  41. package/dist/lib/lanes/autopilot.d.ts +13 -1
  42. package/dist/lib/lanes/autopilot.js +271 -28
  43. package/dist/lib/lanes/grants.d.ts +1 -1
  44. package/dist/lib/lanes/grants.js +7 -5
  45. package/dist/lib/lanes/index.d.ts +1 -1
  46. package/dist/lib/lanes/keep-alive.d.ts +5 -0
  47. package/dist/lib/lanes/keep-alive.js +9 -4
  48. package/dist/lib/lanes/questions.d.ts +2 -1
  49. package/dist/lib/lanes/questions.js +2 -0
  50. package/dist/lib/lanes/reconcile.js +186 -9
  51. package/dist/lib/lanes/spawn.js +4 -0
  52. package/dist/lib/lanes/store.js +21 -1
  53. package/dist/lib/lanes/types.d.ts +13 -1
  54. package/dist/lib/lanes/types.js +2 -2
  55. package/dist/lib/lanes/watcher.js +3 -1
  56. package/dist/utils/scaffold-sibling-references.js +4 -4
  57. package/dist/utils/self-update.d.ts +13 -0
  58. package/dist/utils/self-update.js +33 -7
  59. package/dist/utils/staged-install.d.ts +34 -0
  60. package/dist/utils/staged-install.js +296 -0
  61. package/dist/utils/version-gate.d.ts +15 -0
  62. package/dist/utils/version-gate.js +46 -2
  63. package/package.json +1 -1
  64. package/scripts/mesh-daemon-if-stale.mjs +4 -0
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env bash
2
+ # hq-core: public
3
+ # hq-delegate-verify.sh — prove a delegation can actually be picked up
4
+ # before anyone is told it is theirs, or print the full plan without
5
+ # touching anything.
6
+ #
7
+ # Usage (probe mode — after grants, before the DM):
8
+ # core/scripts/hq-delegate-verify.sh --manifest <path>
9
+ #
10
+ # Probes every manifest vaultPrefix with `hq files browse` and requires it
11
+ # reachable AND non-empty. All pass -> manifest advances granted -> verified
12
+ # (send is gated on this). Any failure -> non-zero, the failing prefix and
13
+ # likely cause named, status left at its last successful value so a re-run
14
+ # resumes without re-granting.
15
+ #
16
+ # Usage (dry-run mode — full plan, zero mutation):
17
+ # core/scripts/hq-delegate-verify.sh --dry-run \
18
+ # --company <slug> --project <name> --to <principal> [--mode transfer|share]
19
+ #
20
+ # Prints recipient, mode, every prefix with its permission, the secret
21
+ # names an .env.schema would grant, repo + branch, and the DM headline that
22
+ # would be sent. Exits 0. Creates nothing under workspace/delegations/ and
23
+ # invokes no mutating command.
24
+ #
25
+ # Env: HQ_ROOT — HQ root override (default: resolved from script location)
26
+
27
+ set -euo pipefail
28
+
29
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
30
+ HQ_ROOT="${HQ_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
31
+
32
+ usage() { sed -n '3,25p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
33
+ die() { echo "hq-delegate-verify: $*" >&2; exit 1; }
34
+
35
+ command -v jq >/dev/null 2>&1 || die "jq is required but not installed"
36
+
37
+ MANIFEST="" DRY_RUN=0 COMPANY="" PROJECT="" TO="" MODE="transfer"
38
+ while [ $# -gt 0 ]; do
39
+ case "$1" in
40
+ --manifest) MANIFEST="${2:-}"; shift 2 ;;
41
+ --dry-run) DRY_RUN=1; shift ;;
42
+ --company) COMPANY="${2:-}"; shift 2 ;;
43
+ --project) PROJECT="${2:-}"; shift 2 ;;
44
+ --to) TO="${2:-}"; shift 2 ;;
45
+ --mode) MODE="${2:-}"; shift 2 ;;
46
+ -h|--help) usage; exit 0 ;;
47
+ *) die "unknown argument: $1" ;;
48
+ esac
49
+ done
50
+
51
+ # =============================================================================
52
+ # Dry-run mode: full plan from the builder's own derivation, zero mutation
53
+ # =============================================================================
54
+
55
+ if [ "$DRY_RUN" -eq 1 ]; then
56
+ [ -n "$COMPANY" ] || die "--dry-run requires --company"
57
+ [ -n "$PROJECT" ] || die "--dry-run requires --project"
58
+ [ -n "$TO" ] || die "--dry-run requires --to"
59
+
60
+ # The builder's --dry-run prints the manifest it WOULD write — single
61
+ # derivation source, nothing lands on disk.
62
+ PLAN_JSON="$(HQ_ROOT="$HQ_ROOT" bash "$SCRIPT_DIR/hq-delegate-bundle.sh" build \
63
+ --company "$COMPANY" --project "$PROJECT" --to "$TO" --mode "$MODE" --dry-run)" \
64
+ || die "could not derive the delegation plan (see builder error above)"
65
+
66
+ # Secret names the US-005 step would grant (same .env.schema derivation).
67
+ REPO_REL="$(printf '%s' "$PLAN_JSON" | jq -r '.repo.path // empty')"
68
+ SCHEMA=""
69
+ for candidate in \
70
+ "$HQ_ROOT/companies/$COMPANY/projects/$PROJECT/.env.schema" \
71
+ "${REPO_REL:+$HQ_ROOT/$REPO_REL/.env.schema}"; do
72
+ [ -n "$candidate" ] && [ -f "$candidate" ] && { SCHEMA="$candidate"; break; }
73
+ done
74
+ SECRET_NAMES="(none — no .env.schema found)"
75
+ if [ -n "$SCHEMA" ]; then
76
+ SECRET_NAMES="$(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$SCHEMA" | cut -d= -f1 | sort -u | tr '\n' ' ')"
77
+ [ -n "$SECRET_NAMES" ] || SECRET_NAMES="(none declared)"
78
+ fi
79
+
80
+ echo "Delegation dry run — nothing will be pushed, granted, transferred, or sent."
81
+ echo
82
+ echo " Recipient: $TO"
83
+ echo " Mode: $MODE"
84
+ echo " Company: $COMPANY"
85
+ echo " Project: $PROJECT"
86
+ echo
87
+ echo " Vault grants that would be written:"
88
+ printf '%s' "$PLAN_JSON" | jq -r '.vaultPrefixes[] | " \(.permission)\ton \(.prefix)\t(\(.reason // ""))"'
89
+ echo
90
+ echo " Secret names that would be granted (read): $SECRET_NAMES"
91
+ echo
92
+ if printf '%s' "$PLAN_JSON" | jq -e '.repo != null' >/dev/null; then
93
+ printf '%s' "$PLAN_JSON" | jq -r '" Repo handover: \(.repo.path) — branch \(.repo.branch // "(none)") (base \(.repo.baseBranch))"'
94
+ else
95
+ echo " Repo handover: none (project has no repoPath)"
96
+ fi
97
+ echo
98
+ echo " DM that would be sent to $TO:"
99
+ echo " \"Project handoff: $PROJECT is yours now — open details for the brief, paste the prompt into your agent to pick it up\""
100
+ echo " (prompt: generated pickup prompt; details: the delegation brief)"
101
+ exit 0
102
+ fi
103
+
104
+ # =============================================================================
105
+ # Probe mode: reachability of every granted prefix
106
+ # =============================================================================
107
+
108
+ [ -n "$MANIFEST" ] || die "--manifest is required (or --dry-run with --company/--project/--to)"
109
+ [ -f "$MANIFEST" ] || die "manifest not found: $MANIFEST"
110
+ jq -e . "$MANIFEST" >/dev/null 2>&1 || die "manifest is not valid JSON: $MANIFEST"
111
+
112
+ COMPANY="$(jq -r '.company // empty' "$MANIFEST")"
113
+ LOCK="$MANIFEST.send-lock"
114
+ mkdir "$LOCK" 2>/dev/null || die "verification or delivery is active; retry after it finishes"
115
+ REF_BROWSE_CACHE=""
116
+ trap '[[ -z "$REF_BROWSE_CACHE" ]] || rm -rf "$REF_BROWSE_CACHE"; rmdir "$LOCK"' EXIT
117
+ STATUS="$(jq -r '.status // empty' "$MANIFEST")"
118
+ [ -n "$COMPANY" ] || die "manifest has no company"
119
+
120
+ case "$STATUS" in
121
+ granted|verified) ;;
122
+ building) die "manifest status is 'building' — run hq-delegate-grant.sh first (probe verifies grants, it cannot create them)" ;;
123
+ *) die "manifest status is '$STATUS' — probe runs from 'granted' (or re-runs from 'verified')" ;;
124
+ esac
125
+
126
+ PREFIX_COUNT="$(jq '.vaultPrefixes | length' "$MANIFEST")"
127
+ [ "$PREFIX_COUNT" -gt 0 ] || die "manifest has no vaultPrefixes to probe"
128
+
129
+ echo "hq-delegate-verify: probing $PREFIX_COUNT prefix(es) in company '$COMPANY'"
130
+ FAILED=0
131
+ i=0
132
+ while [ "$i" -lt "$PREFIX_COUNT" ]; do
133
+ PFX="$(jq -r ".vaultPrefixes[$i].prefix" "$MANIFEST")"
134
+ BROWSE_OUT="$(hq files browse "$PFX" --company "$COMPANY" 2>&1)" && BROWSE_RC=0 || BROWSE_RC=$?
135
+ if [ "$BROWSE_RC" -ne 0 ]; then
136
+ echo " FAIL $PFX — browse errored (likely cause: the grant did not land, or the caller's session expired — re-run /hq-login and hq-delegate-grant.sh)"
137
+ FAILED=1
138
+ elif [ -z "$(printf '%s' "$BROWSE_OUT" | tr -d '[:space:]')" ]; then
139
+ echo " FAIL $PFX — reachable but EMPTY (likely cause: the dossier was never pushed to the vault — hq sync push companies/$COMPANY/... and re-run)"
140
+ FAILED=1
141
+ else
142
+ echo " pass $PFX"
143
+ fi
144
+ i=$((i + 1))
145
+ done
146
+
147
+ # --- referenced files: prefix reachability is not enough ---------------------
148
+ # (Live finding: a knowledge prefix can be reachable and non-empty while the
149
+ # SPECIFIC referenced file is absent — the recipient then pulls a folder that
150
+ # silently misses the note the brief points at. Probe each referenced file.)
151
+
152
+ # Memoized, throttle-tolerant browse for the referenced-file loop. Multiple
153
+ # referenced files can share a parent prefix (e.g. two policies under
154
+ # policies/), and back-to-back vault browses of the same prefix can be throttled
155
+ # to an EMPTY page — which the grep below would misread as "file not in vault".
156
+ # Browse each distinct parent at most once, and retry an empty result a few
157
+ # times before trusting it. (Cache dir cleaned on exit.)
158
+ REF_BROWSE_CACHE="$(mktemp -d)"
159
+ browse_parent() { # parent -> listing (memoized per run, retried on empty)
160
+ local parent="$1" key out attempt
161
+ key="$REF_BROWSE_CACHE/$(printf '%s' "$parent" | tr '/@:' '___')"
162
+ if [ -f "$key" ]; then cat "$key"; return 0; fi
163
+ attempt=1
164
+ out=""
165
+ while :; do
166
+ out="$(hq files browse "$parent" --company "$COMPANY" 2>/dev/null || true)"
167
+ [ -n "$(printf '%s' "$out" | tr -d '[:space:]')" ] && break
168
+ [ "$attempt" -ge "${HQ_DELEGATE_BROWSE_RETRIES:-3}" ] && break
169
+ attempt=$((attempt + 1))
170
+ sleep "${HQ_DELEGATE_BROWSE_RETRY_SLEEP:-2}"
171
+ done
172
+ printf '%s' "$out" > "$key"
173
+ printf '%s' "$out"
174
+ }
175
+
176
+ REF_COUNT="$(jq -r --arg co "$COMPANY" \
177
+ '[((.knowledge // []) + (.policies // []))[] | select(startswith("companies/" + $co + "/"))] | length' "$MANIFEST")"
178
+ if [ "$REF_COUNT" -gt 0 ]; then
179
+ echo "hq-delegate-verify: probing $REF_COUNT referenced knowledge/policy file(s)"
180
+ while IFS= read -r kpath; do
181
+ [ -n "$kpath" ] || continue
182
+ REL="${kpath#companies/$COMPANY/}"
183
+ PARENT="$(dirname "$REL")/"
184
+ BASE="$(basename "$REL")"
185
+ LISTING="$(browse_parent "$PARENT")"
186
+ # Pipe-free substring test: `printf | grep -q` lets grep close the pipe on an
187
+ # early match, which under `set -o pipefail` surfaces printf's SIGPIPE as a
188
+ # non-zero pipeline and FALSE-FAILS a file that is genuinely present (it bit
189
+ # exactly the referenced files that sort early in a large parent listing).
190
+ if [[ "$LISTING" == *"$BASE"* ]]; then
191
+ echo " pass $REL"
192
+ else
193
+ echo " FAIL $REL — granted prefix is reachable but this referenced file is NOT in the vault (likely cause: it was never pushed — hq sync push $kpath --company $COMPANY and re-run)"
194
+ FAILED=1
195
+ fi
196
+ done <<EOF
197
+ $(jq -r --arg co "$COMPANY" '((.knowledge // []) + (.policies // []))[] | select(startswith("companies/" + $co + "/"))' "$MANIFEST")
198
+ EOF
199
+ fi
200
+
201
+ if [ "$FAILED" -ne 0 ]; then
202
+ echo "hq-delegate-verify: probe FAILED — the DM must not be sent; fix the failing prefix(es) above and re-run (manifest status left at '$STATUS')" >&2
203
+ exit 1
204
+ fi
205
+
206
+ HQ_ROOT="$HQ_ROOT" bash "$SCRIPT_DIR/hq-delegate-publish.sh" --manifest "$MANIFEST" --lock-held \
207
+ || die "final dossier publication failed; notification blocked"
208
+ NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
209
+ TMP_MANIFEST="$(mktemp)"
210
+ jq --arg now "$NOW" '.status = "verified" | .verifiedAt = $now' \
211
+ "$MANIFEST" > "$TMP_MANIFEST" && mv "$TMP_MANIFEST" "$MANIFEST"
212
+
213
+ echo "hq-delegate-verify: all $PREFIX_COUNT prefix(es) reachable — manifest status advanced to 'verified'"
@@ -0,0 +1,389 @@
1
+ #!/usr/bin/env bash
2
+ # hq-core: public
3
+ # hq-job-notify.sh — dm-only remediation alerts for Outpost scheduled jobs (US-006).
4
+ #
5
+ # Delivers via schedule-alerts profile with v1 channel=dm only (`hq dm` to owner).
6
+ # If profile channel is slack|email|outpost-session, still deliver dm and log
7
+ # channel_unimplemented. Never targets the original /schedule create session.
8
+ #
9
+ # Escalation collapse:
10
+ # - first failure → DM immediately
11
+ # - consecutive failures → at most one failure DM per 24h per job
12
+ # - recovery after failures → recovered DM
13
+ # - success after success → ok DM (every successful run)
14
+ #
15
+ # Notify delivery failure is logged and never fails the caller (exit 0).
16
+ #
17
+ # Usage:
18
+ # core/scripts/hq-job-notify.sh \
19
+ # --job-id <id> --job-name <name> --outcome <ok|failed|blocked> \
20
+ # --summary <one-line> --duration <sec> --log-path <path> \
21
+ # [--failure-class <class>] [--next-action <text>] \
22
+ # [--owner <email|prs_*>] [--notify <dm|none|profile>] \
23
+ # [--hq-root <dir>]
24
+ #
25
+ # Env / overrides (tests):
26
+ # HQ_ROOT, HOME, HQ_JOB_ALERT_DIR, HQ_JOB_NOTIFY_DM_BIN, HQ_JOB_NOTIFY_NOW_EPOCH
27
+ # HQ_JOB_NOTIFY_COLLAPSE_SEC (default 86400)
28
+ #
29
+ # Exit: 0 always (delivery errors logged only); 2 usage.
30
+
31
+ set -euo pipefail
32
+
33
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
34
+ HQ_ROOT="${HQ_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
35
+
36
+ JOB_ID=""
37
+ JOB_NAME=""
38
+ OUTCOME=""
39
+ SUMMARY=""
40
+ DURATION=""
41
+ LOG_PATH=""
42
+ FAILURE_CLASS=""
43
+ NEXT_ACTION=""
44
+ OWNER=""
45
+ NOTIFY_MODE=""
46
+
47
+ usage() {
48
+ sed -n '3,32p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
49
+ }
50
+
51
+ die() {
52
+ echo "hq-job-notify: $*" >&2
53
+ exit 2
54
+ }
55
+
56
+ log() {
57
+ echo "hq-job-notify: $*" >&2
58
+ }
59
+
60
+ while [ $# -gt 0 ]; do
61
+ case "$1" in
62
+ --hq-root)
63
+ HQ_ROOT="${2:-}"
64
+ [ -n "$HQ_ROOT" ] || die "--hq-root requires a directory"
65
+ shift 2
66
+ ;;
67
+ --job-id)
68
+ JOB_ID="${2:-}"
69
+ [ -n "$JOB_ID" ] || die "--job-id requires a value"
70
+ shift 2
71
+ ;;
72
+ --job-name)
73
+ JOB_NAME="${2:-}"
74
+ shift 2
75
+ ;;
76
+ --outcome)
77
+ OUTCOME="${2:-}"
78
+ [ -n "$OUTCOME" ] || die "--outcome requires a value"
79
+ shift 2
80
+ ;;
81
+ --summary)
82
+ SUMMARY="${2:-}"
83
+ shift 2
84
+ ;;
85
+ --duration)
86
+ DURATION="${2:-}"
87
+ shift 2
88
+ ;;
89
+ --log-path)
90
+ LOG_PATH="${2:-}"
91
+ shift 2
92
+ ;;
93
+ --failure-class)
94
+ FAILURE_CLASS="${2:-}"
95
+ shift 2
96
+ ;;
97
+ --next-action)
98
+ NEXT_ACTION="${2:-}"
99
+ shift 2
100
+ ;;
101
+ --owner)
102
+ OWNER="${2:-}"
103
+ shift 2
104
+ ;;
105
+ --notify)
106
+ NOTIFY_MODE="${2:-}"
107
+ shift 2
108
+ ;;
109
+ -h|--help)
110
+ usage
111
+ exit 0
112
+ ;;
113
+ --)
114
+ shift
115
+ break
116
+ ;;
117
+ -*)
118
+ die "unknown option: $1"
119
+ ;;
120
+ *)
121
+ die "unexpected argument: $1"
122
+ ;;
123
+ esac
124
+ done
125
+
126
+ [ -n "$JOB_ID" ] || die "usage: hq-job-notify.sh --job-id <id> --outcome <ok|failed|blocked> ..."
127
+ case "$OUTCOME" in
128
+ ok|failed|blocked) ;;
129
+ *) die "--outcome must be ok|failed|blocked" ;;
130
+ esac
131
+
132
+ [ -n "$JOB_NAME" ] || JOB_NAME="$JOB_ID"
133
+ [ -n "$SUMMARY" ] || SUMMARY="$OUTCOME"
134
+ [ -n "$DURATION" ] || DURATION="0"
135
+ [ -n "$LOG_PATH" ] || LOG_PATH=""
136
+
137
+ COLLAPSE_SEC="${HQ_JOB_NOTIFY_COLLAPSE_SEC:-86400}"
138
+ ALERT_BASE="${HQ_JOB_ALERT_DIR:-${HOME}/.hq/jobs/alerts}"
139
+ STATE_DIR="$ALERT_BASE/$JOB_ID"
140
+ mkdir -p "$STATE_DIR"
141
+ STATE_FILE="$STATE_DIR/state.json"
142
+ DELIVERY_LOG="$STATE_DIR/delivery.log"
143
+
144
+ epoch_now() {
145
+ if [ -n "${HQ_JOB_NOTIFY_NOW_EPOCH:-}" ]; then
146
+ printf '%s' "$HQ_JOB_NOTIFY_NOW_EPOCH"
147
+ return
148
+ fi
149
+ date -u +%s 2>/dev/null || date +%s
150
+ }
151
+
152
+ iso_now() {
153
+ date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null \
154
+ || date -u +"%Y-%m-%dT%H:%M:%S+00:00"
155
+ }
156
+
157
+ read_state() {
158
+ if [ -f "$STATE_FILE" ]; then
159
+ cat "$STATE_FILE"
160
+ else
161
+ echo '{}'
162
+ fi
163
+ }
164
+
165
+ write_state() {
166
+ local json="$1"
167
+ printf '%s\n' "$json" >"$STATE_FILE"
168
+ chmod 600 "$STATE_FILE" 2>/dev/null || true
169
+ }
170
+
171
+ # Resolve notify mode + profile channel.
172
+ PROFILE_CHANNEL="dm"
173
+ PROFILE_PATH="$HQ_ROOT/personal/settings/schedule-alerts.yaml"
174
+ if [ -z "$NOTIFY_MODE" ] || [ "$NOTIFY_MODE" = "null" ]; then
175
+ NOTIFY_MODE="profile"
176
+ fi
177
+
178
+ case "$NOTIFY_MODE" in
179
+ none)
180
+ log "notify=none — skipping delivery for job $JOB_ID"
181
+ echo '{"delivered":false,"reason":"notify_none"}'
182
+ exit 0
183
+ ;;
184
+ dm)
185
+ PROFILE_CHANNEL="dm"
186
+ ;;
187
+ profile)
188
+ if [ -f "$PROFILE_PATH" ] && command -v yq >/dev/null 2>&1; then
189
+ PROFILE_CHANNEL="$(yq -r '.channel // "dm"' "$PROFILE_PATH" 2>/dev/null || echo dm)"
190
+ else
191
+ PROFILE_CHANNEL="dm"
192
+ fi
193
+ ;;
194
+ *)
195
+ log "unknown notify mode '$NOTIFY_MODE' — falling back to dm"
196
+ PROFILE_CHANNEL="dm"
197
+ ;;
198
+ esac
199
+
200
+ CHANNEL_UNIMPLEMENTED=0
201
+ case "$PROFILE_CHANNEL" in
202
+ dm|"")
203
+ PROFILE_CHANNEL="dm"
204
+ ;;
205
+ slack|email|outpost-session)
206
+ CHANNEL_UNIMPLEMENTED=1
207
+ log "channel_unimplemented=$PROFILE_CHANNEL — delivering dm fallback"
208
+ PROFILE_CHANNEL="dm"
209
+ ;;
210
+ *)
211
+ log "channel_unimplemented=$PROFILE_CHANNEL — delivering dm fallback"
212
+ CHANNEL_UNIMPLEMENTED=1
213
+ PROFILE_CHANNEL="dm"
214
+ ;;
215
+ esac
216
+
217
+ # Resolve owner (recipient). Prefer explicit --owner, else job owner left to caller.
218
+ if [ -z "$OWNER" ] || [ "$OWNER" = "null" ]; then
219
+ if command -v hq >/dev/null 2>&1; then
220
+ OWNER="$(hq whoami 2>/dev/null | awk -F': ' '/^email:/{print $2; exit}' || true)"
221
+ fi
222
+ fi
223
+ # hq dm accepts personUid recipients; keep an explicit owner UID unchanged.
224
+ if [ -z "$OWNER" ]; then
225
+ log "no owner recipient — cannot dm (logged only); never fails the job"
226
+ echo '{"delivered":false,"reason":"missing_owner"}' | tee -a "$DELIVERY_LOG" >/dev/null
227
+ # Still update local state so collapse logic stays coherent.
228
+ :
229
+ fi
230
+
231
+ NOW_EPOCH="$(epoch_now)"
232
+ PREV="$(read_state)"
233
+ PREV_OUTCOME="$(printf '%s' "$PREV" | jq -r '.last_outcome // empty')"
234
+ PREV_FAIL_DM="$(printf '%s' "$PREV" | jq -r '.last_failure_dm_epoch // 0')"
235
+ case "$PREV_FAIL_DM" in
236
+ ''|*[!0-9]*) PREV_FAIL_DM=0 ;;
237
+ esac
238
+
239
+ SEND_KIND="" # ok | failed | recovered | suppressed
240
+ case "$OUTCOME" in
241
+ ok)
242
+ if [ "$PREV_OUTCOME" = "failed" ] || [ "$PREV_OUTCOME" = "blocked" ]; then
243
+ SEND_KIND="recovered"
244
+ else
245
+ SEND_KIND="ok"
246
+ fi
247
+ ;;
248
+ failed|blocked)
249
+ if [ "$PREV_OUTCOME" = "failed" ] || [ "$PREV_OUTCOME" = "blocked" ]; then
250
+ elapsed=$((NOW_EPOCH - PREV_FAIL_DM))
251
+ if [ "$PREV_FAIL_DM" -gt 0 ] && [ "$elapsed" -lt "$COLLAPSE_SEC" ]; then
252
+ SEND_KIND="suppressed"
253
+ else
254
+ SEND_KIND="failed"
255
+ fi
256
+ else
257
+ SEND_KIND="failed"
258
+ fi
259
+ ;;
260
+ esac
261
+
262
+ STATUS_WORD="$OUTCOME"
263
+ case "$SEND_KIND" in
264
+ recovered) STATUS_WORD="recovered" ;;
265
+ suppressed)
266
+ log "collapse: suppressing failure dm for job $JOB_ID (within ${COLLAPSE_SEC}s window)"
267
+ NEW_STATE="$(jq -nc \
268
+ --arg outcome "$OUTCOME" \
269
+ --argjson now "$NOW_EPOCH" \
270
+ --argjson last_dm "$PREV_FAIL_DM" \
271
+ --arg updated "$(iso_now)" \
272
+ --arg fc "$FAILURE_CLASS" \
273
+ '{
274
+ last_outcome: $outcome,
275
+ last_failure_dm_epoch: $last_dm,
276
+ last_updated_at: $updated,
277
+ last_failure_class: $fc,
278
+ last_delivery: "suppressed"
279
+ }')"
280
+ write_state "$NEW_STATE"
281
+ jq -nc \
282
+ --arg reason "collapsed_24h" \
283
+ --argjson channel_unimplemented "$CHANNEL_UNIMPLEMENTED" \
284
+ '{delivered:false, reason:$reason, channel_unimplemented:($channel_unimplemented==1)}'
285
+ exit 0
286
+ ;;
287
+ esac
288
+
289
+ # Build message body (no create-thread targeting — hq dm only).
290
+ CLASS_PART=""
291
+ if [ -n "$FAILURE_CLASS" ] && [ "$FAILURE_CLASS" != "null" ]; then
292
+ CLASS_PART=" class=$FAILURE_CLASS"
293
+ fi
294
+ ACTION_PART=""
295
+ if [ -n "$NEXT_ACTION" ]; then
296
+ ACTION_PART=" next: $NEXT_ACTION"
297
+ fi
298
+
299
+ case "$SEND_KIND" in
300
+ ok)
301
+ HEADLINE="Scheduled job '${JOB_NAME}' ok (${DURATION}s)"
302
+ ;;
303
+ recovered)
304
+ HEADLINE="Scheduled job '${JOB_NAME}' recovered (${DURATION}s)"
305
+ ;;
306
+ failed)
307
+ HEADLINE="Scheduled job '${JOB_NAME}' ${STATUS_WORD}${CLASS_PART} (${DURATION}s)"
308
+ ;;
309
+ *)
310
+ HEADLINE="Scheduled job '${JOB_NAME}' ${STATUS_WORD} (${DURATION}s)"
311
+ ;;
312
+ esac
313
+
314
+ DETAILS="$(printf 'job=%s\nstatus=%s\nclassification=%s\nsummary=%s\nduration_seconds=%s\nlog=%s%s\n' \
315
+ "$JOB_ID" "$STATUS_WORD" "${FAILURE_CLASS:-none}" "$SUMMARY" "$DURATION" "${LOG_PATH:-none}" "$ACTION_PART")"
316
+
317
+ DELIVERED=0
318
+ DELIVER_RC=0
319
+ DM_BIN="${HQ_JOB_NOTIFY_DM_BIN:-}"
320
+ if [ -z "$DM_BIN" ]; then
321
+ if command -v hq >/dev/null 2>&1; then
322
+ DM_BIN="hq"
323
+ fi
324
+ fi
325
+
326
+ if [ -z "$OWNER" ]; then
327
+ log "skip dm send — missing owner"
328
+ DELIVER_RC=1
329
+ elif [ -z "$DM_BIN" ]; then
330
+ log "skip dm send — hq CLI not found"
331
+ DELIVER_RC=1
332
+ else
333
+ set +e
334
+ if [ "$DM_BIN" = "hq" ]; then
335
+ hq dm "$OWNER" "$HEADLINE" --details "$DETAILS" >>"$DELIVERY_LOG" 2>&1
336
+ DELIVER_RC=$?
337
+ else
338
+ # Custom binary: invoked as <bin> <owner> <headline> <details>
339
+ "$DM_BIN" "$OWNER" "$HEADLINE" "$DETAILS" >>"$DELIVERY_LOG" 2>&1
340
+ DELIVER_RC=$?
341
+ fi
342
+ set -e
343
+ if [ "$DELIVER_RC" -eq 0 ]; then
344
+ DELIVERED=1
345
+ log "dm delivered to $OWNER kind=$SEND_KIND job=$JOB_ID"
346
+ else
347
+ log "dm delivery failed rc=$DELIVER_RC job=$JOB_ID (logged; not failing run)"
348
+ fi
349
+ fi
350
+
351
+ FAIL_DM_EPOCH="$PREV_FAIL_DM"
352
+ if [ "$SEND_KIND" = "failed" ] && [ "$DELIVERED" -eq 1 ]; then
353
+ FAIL_DM_EPOCH="$NOW_EPOCH"
354
+ elif [ "$SEND_KIND" = "ok" ] || [ "$SEND_KIND" = "recovered" ]; then
355
+ FAIL_DM_EPOCH=0
356
+ fi
357
+
358
+ NEW_STATE="$(jq -nc \
359
+ --arg outcome "$OUTCOME" \
360
+ --argjson now "$NOW_EPOCH" \
361
+ --argjson last_dm "$FAIL_DM_EPOCH" \
362
+ --arg updated "$(iso_now)" \
363
+ --arg fc "$FAILURE_CLASS" \
364
+ --arg kind "$SEND_KIND" \
365
+ --argjson delivered "$DELIVERED" \
366
+ '{
367
+ last_outcome: $outcome,
368
+ last_failure_dm_epoch: $last_dm,
369
+ last_updated_at: $updated,
370
+ last_failure_class: $fc,
371
+ last_delivery: $kind,
372
+ last_delivered: ($delivered == 1)
373
+ }')"
374
+ write_state "$NEW_STATE"
375
+
376
+ jq -nc \
377
+ --argjson delivered "$DELIVERED" \
378
+ --arg kind "$SEND_KIND" \
379
+ --arg owner "$OWNER" \
380
+ --argjson channel_unimplemented "$CHANNEL_UNIMPLEMENTED" \
381
+ --argjson deliver_rc "$DELIVER_RC" \
382
+ '{
383
+ delivered: ($delivered == 1),
384
+ kind: $kind,
385
+ owner: $owner,
386
+ channel_unimplemented: ($channel_unimplemented == 1),
387
+ deliver_rc: $deliver_rc
388
+ }'
389
+ exit 0