@indigoai-us/hq-cli 5.286.0 → 5.287.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.287.0] — 2026-09-29
6
+ - Group hook-timeout events by script while preserving triage diagnostics.
7
+ - Preserve safe slow-child script basenames in hook-timeout diagnostics.
8
+ - Cloud provisioning and retirement now stop after 15 seconds when a Vault API request stalls, with a clear timeout error.
9
+ - Add native TypeScript implementations for four `hq core` worktree and
10
+ handoff scripts behind the default-off `core-native-handoff` flag. The
11
+ original shell assets remain bundled as the rollback path.
12
+
13
+ ### Fixed
14
+
15
+ - `hq team-sync` keeps its successful no-team result, reports which company
16
+ directories are cloud-backed, and points to `hq sync pull --all` and
17
+ `hq sync status` for cloud-backed companies.
18
+
5
19
  ## [5.286.0] — 2026-09-29
6
20
 
7
21
  ### Changed
@@ -13,6 +27,11 @@
13
27
  it, and output the old daemon had not read reaches the new one. Other
14
28
  platforms keep the anonymous pipe.
15
29
 
30
+ ### Fixed
31
+
32
+ - Cache-first `hq lanes list` now allows one detached Outpost summary refresh
33
+ per HQ root at a time, avoiding duplicate work from overlapping list calls.
34
+
16
35
  ## [5.285.0] — 2026-09-29
17
36
 
18
37
  - Route eligible Linux CI jobs onto ARM runners while retaining existing test and platform gates.
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env bash
2
+ # Remove only handoff mirrors that are byte-identical to the incoming branch,
3
+ # so clean-worktree's merge can recreate them from that branch.
4
+ set -euo pipefail
5
+
6
+ usage() {
7
+ echo "Usage: clean-worktree-reconcile-handoff.sh <absolute-hq-root> <local-branch>" >&2
8
+ }
9
+
10
+ if [[ $# -ne 2 ]]; then
11
+ usage
12
+ exit 64
13
+ fi
14
+
15
+ HQ_ROOT="$1"
16
+ BRANCH="$2"
17
+ if [[ "$HQ_ROOT" != /* ]]; then
18
+ echo "ERROR: HQ root must be an absolute path" >&2
19
+ usage
20
+ exit 64
21
+ fi
22
+ if [[ ! -d "$HQ_ROOT" ]]; then
23
+ echo "ERROR: HQ root is not a directory: $HQ_ROOT" >&2
24
+ exit 1
25
+ fi
26
+ HQ_ROOT="$(cd -P "$HQ_ROOT" && pwd -P)"
27
+ TOPLEVEL="$(git -C "$HQ_ROOT" rev-parse --show-toplevel 2>/dev/null)" || {
28
+ echo "ERROR: HQ root is not a Git worktree: $HQ_ROOT" >&2
29
+ exit 1
30
+ }
31
+ TOPLEVEL="$(cd -P "$TOPLEVEL" && pwd -P)"
32
+ if [[ "$TOPLEVEL" != "$HQ_ROOT" ]]; then
33
+ echo "ERROR: supplied path is not the Git worktree root: $HQ_ROOT" >&2
34
+ exit 1
35
+ fi
36
+
37
+ BRANCH_REF="refs/heads/$BRANCH"
38
+ if ! git -C "$HQ_ROOT" show-ref --verify --quiet "$BRANCH_REF"; then
39
+ echo "ERROR: incoming branch does not exist locally: $BRANCH" >&2
40
+ exit 1
41
+ fi
42
+
43
+ POINTER_PATH="workspace/threads/handoff.json"
44
+ if ! git -C "$HQ_ROOT" cat-file -e "$BRANCH_REF:$POINTER_PATH" 2>/dev/null; then
45
+ echo "No incoming handoff pointer on $BRANCH; no mirror paths to reconcile."
46
+ exit 0
47
+ fi
48
+
49
+ THREAD_PATH="$(git -C "$HQ_ROOT" show "$BRANCH_REF:$POINTER_PATH" | jq -er '.thread_path | select(type == "string")')" || {
50
+ echo "ERROR: incoming branch handoff pointer has no string thread_path" >&2
51
+ exit 1
52
+ }
53
+ case "$THREAD_PATH" in
54
+ workspace/threads/*) ;;
55
+ *) echo "ERROR: unsafe incoming handoff thread_path: $THREAD_PATH" >&2; exit 1 ;;
56
+ esac
57
+ case "/$THREAD_PATH/" in
58
+ *"//"*|*"/./"*|*"/../"*)
59
+ echo "ERROR: unsafe incoming handoff thread_path: $THREAD_PATH" >&2
60
+ exit 1
61
+ ;;
62
+ esac
63
+ if [[ "$THREAD_PATH" == "$POINTER_PATH" || "$THREAD_PATH" == *$'\n'* || "$THREAD_PATH" == *$'\r'* ]]; then
64
+ echo "ERROR: unsafe incoming handoff thread_path: $THREAD_PATH" >&2
65
+ exit 1
66
+ fi
67
+ if ! git -C "$HQ_ROOT" cat-file -e "$BRANCH_REF:$THREAD_PATH" 2>/dev/null; then
68
+ echo "ERROR: incoming handoff thread is missing from $BRANCH: $THREAD_PATH" >&2
69
+ exit 1
70
+ fi
71
+
72
+ branch_file_matches() {
73
+ local relative_path="$1" local_file="$2" comparison_status=0
74
+ if git -C "$HQ_ROOT" show "$BRANCH_REF:$relative_path" | cmp -s - "$local_file"; then
75
+ return 0
76
+ else
77
+ comparison_status=$?
78
+ fi
79
+ if [[ "$comparison_status" -eq 1 ]]; then
80
+ return 1
81
+ fi
82
+ return 2
83
+ }
84
+
85
+ declare -a mirror_paths=("$POINTER_PATH" "$THREAD_PATH")
86
+ declare -a identical_paths=()
87
+ declare -a differing_paths=()
88
+ for relative_path in "${mirror_paths[@]}"; do
89
+ absolute_path="$HQ_ROOT/$relative_path"
90
+ if [[ ! -e "$absolute_path" && ! -L "$absolute_path" ]]; then
91
+ continue
92
+ fi
93
+ if [[ -L "$absolute_path" || ! -f "$absolute_path" ]]; then
94
+ differing_paths+=("$relative_path (not a regular file)")
95
+ continue
96
+ fi
97
+ comparison_status=0
98
+ if branch_file_matches "$relative_path" "$absolute_path"; then
99
+ identical_paths+=("$relative_path")
100
+ else
101
+ comparison_status=$?
102
+ if [[ "$comparison_status" -eq 1 ]]; then
103
+ differing_paths+=("$relative_path (content differs from $BRANCH)")
104
+ else
105
+ echo "ERROR: could not compare incoming $relative_path with $absolute_path" >&2
106
+ exit 1
107
+ fi
108
+ fi
109
+ done
110
+
111
+ if [[ ${#differing_paths[@]} -gt 0 ]]; then
112
+ for relative_path in "${differing_paths[@]}"; do
113
+ echo "BLOCK: preserved main-checkout handoff path: $relative_path" >&2
114
+ done
115
+ echo "No mirror paths were removed; surface these local files before merging." >&2
116
+ exit 2
117
+ fi
118
+
119
+ reconciliation_blocked=0
120
+ for relative_path in "${identical_paths[@]}"; do
121
+ absolute_path="$HQ_ROOT/$relative_path"
122
+ if [[ ! -e "$absolute_path" && ! -L "$absolute_path" ]]; then
123
+ continue
124
+ fi
125
+ if [[ -L "$absolute_path" || ! -f "$absolute_path" ]]; then
126
+ echo "BLOCK: preserved changed main-checkout path: $relative_path" >&2
127
+ reconciliation_blocked=1
128
+ continue
129
+ fi
130
+
131
+ destination_dir="$(dirname "$absolute_path")"
132
+ moved_file="$(mktemp "$destination_dir/.clean-worktree-handoff.XXXXXX")"
133
+ if ! mv "$absolute_path" "$moved_file"; then
134
+ rm -f "$moved_file"
135
+ echo "ERROR: could not safely move mirrored handoff path: $relative_path" >&2
136
+ exit 1
137
+ fi
138
+
139
+ comparison_status=0
140
+ if [[ ! -L "$moved_file" && -f "$moved_file" ]] && branch_file_matches "$relative_path" "$moved_file"; then
141
+ if [[ -e "$absolute_path" || -L "$absolute_path" ]]; then
142
+ rm -f "$moved_file"
143
+ echo "BLOCK: a new main-checkout file appeared during reconciliation; preserved it: $relative_path" >&2
144
+ reconciliation_blocked=1
145
+ else
146
+ rm "$moved_file"
147
+ echo "Reconciled byte-identical handoff mirror: $relative_path"
148
+ fi
149
+ else
150
+ comparison_status=$?
151
+ if [[ ! -e "$absolute_path" && ! -L "$absolute_path" ]]; then
152
+ if mv "$moved_file" "$absolute_path"; then
153
+ echo "BLOCK: handoff path changed during reconciliation; restored it untouched: $relative_path" >&2
154
+ else
155
+ echo "BLOCK: handoff path changed during reconciliation; preserved it at $moved_file" >&2
156
+ fi
157
+ else
158
+ echo "BLOCK: handoff path changed during reconciliation; preserved captured file at $moved_file and current path $absolute_path" >&2
159
+ fi
160
+ if [[ "$comparison_status" -gt 1 ]]; then
161
+ echo "ERROR: could not compare incoming $relative_path with its moved file" >&2
162
+ exit 1
163
+ fi
164
+ reconciliation_blocked=1
165
+ fi
166
+ done
167
+
168
+ if [[ "$reconciliation_blocked" -ne 0 ]]; then
169
+ echo "No merge should be attempted until the changed handoff paths are surfaced." >&2
170
+ exit 2
171
+ fi
@@ -0,0 +1,285 @@
1
+ #!/usr/bin/env bash
2
+ # hq-core: public
3
+ # resume-thread-lock.sh — durable, session-attributed lock markers for
4
+ # /resumework. Locks live outside individual thread JSON files so archival can
5
+ # move a thread without orphaning or mutating its immutable handoff record.
6
+ #
7
+ # Usage:
8
+ # resume-thread-lock.sh inspect <thread-id>
9
+ # resume-thread-lock.sh acquire <thread-id> --session-id <session-id>
10
+ # resume-thread-lock.sh acquire <thread-id> --replace --expected-generation <generation> --session-id <session-id>
11
+ #
12
+ # inspect always exits zero and returns one JSON object with status unlocked,
13
+ # locked, or stale. acquire exits 3 with that same locked/stale JSON when an
14
+ # existing marker needs an explicit user confirmation; --replace is reserved
15
+ # for the confirmed re-resume path.
16
+
17
+ set -euo pipefail
18
+
19
+ HQ_ROOT="${HQ_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
20
+ THREADS_DIR="$HQ_ROOT/workspace/threads"
21
+ LOCKS_DIR="$THREADS_DIR/resume-locks"
22
+ STALE_SECONDS="${HQ_RESUME_LOCK_STALE_SECONDS:-86400}"
23
+ REPLACE_CLAIM_STALE_SECONDS="${HQ_RESUME_REPLACE_CLAIM_STALE_SECONDS:-60}"
24
+
25
+ usage() {
26
+ cat <<'USAGE'
27
+ usage:
28
+ resume-thread-lock.sh inspect <thread-id>
29
+ resume-thread-lock.sh acquire <thread-id> --session-id <session-id>
30
+ resume-thread-lock.sh acquire <thread-id> --replace --expected-generation <generation> --session-id <session-id>
31
+ USAGE
32
+ }
33
+
34
+ fail_usage() {
35
+ echo "resume-thread-lock: $*" >&2
36
+ usage >&2
37
+ exit 2
38
+ }
39
+
40
+ command -v jq >/dev/null 2>&1 || {
41
+ echo "resume-thread-lock: jq is required" >&2
42
+ exit 1
43
+ }
44
+
45
+ [[ "$STALE_SECONDS" =~ ^[0-9]+$ ]] || fail_usage "HQ_RESUME_LOCK_STALE_SECONDS must be a non-negative integer"
46
+ [[ "$REPLACE_CLAIM_STALE_SECONDS" =~ ^[0-9]+$ ]] || fail_usage "HQ_RESUME_REPLACE_CLAIM_STALE_SECONDS must be a non-negative integer"
47
+
48
+ subcommand="${1:-}"
49
+ shift || true
50
+
51
+ thread_id="${1:-}"
52
+ shift || true
53
+ [[ "$thread_id" =~ ^T-[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || fail_usage "thread id must be a canonical T-* id"
54
+
55
+ replace="false"
56
+ session_id=""
57
+ expected_generation=""
58
+ expected_generation_set="false"
59
+ while [[ $# -gt 0 ]]; do
60
+ case "$1" in
61
+ --replace)
62
+ replace="true"
63
+ shift
64
+ ;;
65
+ --session-id)
66
+ [[ $# -ge 2 ]] || fail_usage "--session-id requires a value"
67
+ session_id="$2"
68
+ shift 2
69
+ ;;
70
+ --expected-generation)
71
+ [[ $# -ge 2 ]] || fail_usage "--expected-generation requires a value"
72
+ expected_generation="$2"
73
+ expected_generation_set="true"
74
+ shift 2
75
+ ;;
76
+ *)
77
+ fail_usage "unknown argument: $1"
78
+ ;;
79
+ esac
80
+ done
81
+
82
+ case "$subcommand" in
83
+ inspect)
84
+ [[ "$replace" = "false" && -z "$session_id" ]] \
85
+ || fail_usage "inspect takes only a thread id"
86
+ ;;
87
+ acquire)
88
+ [[ -n "$session_id" ]] || fail_usage "acquire requires --session-id"
89
+ if [[ "$replace" = "true" ]]; then
90
+ [[ "$expected_generation_set" = "true" ]] || fail_usage "--replace requires --expected-generation from the inspected lock"
91
+ else
92
+ [[ "$expected_generation_set" = "false" ]] || fail_usage "--expected-generation is only valid with --replace"
93
+ fi
94
+ ;;
95
+ *)
96
+ fail_usage "expected inspect or acquire"
97
+ ;;
98
+ esac
99
+
100
+ lock_dir="$LOCKS_DIR/$thread_id.lock"
101
+ record_path="$lock_dir/current.json"
102
+ replace_claim_dir="$lock_dir/.replace-claim"
103
+ relative_lock_path="workspace/threads/resume-locks/$thread_id.lock"
104
+
105
+ now_epoch() {
106
+ date -u +%s
107
+ }
108
+
109
+ now_iso() {
110
+ date -u +%Y-%m-%dT%H:%M:%SZ
111
+ }
112
+
113
+ claim_is_stale() {
114
+ local claim_mtime current_epoch
115
+ claim_mtime="$(stat -f %m "$replace_claim_dir" 2>/dev/null || stat -c %Y "$replace_claim_dir" 2>/dev/null || echo 0)"
116
+ [[ "$claim_mtime" =~ ^[0-9]+$ ]] || return 1
117
+ current_epoch="$(now_epoch)"
118
+ (( current_epoch - claim_mtime > REPLACE_CLAIM_STALE_SECONDS ))
119
+ }
120
+
121
+ acquire_replace_claim() {
122
+ if mkdir "$replace_claim_dir" 2>/dev/null; then
123
+ return 0
124
+ fi
125
+
126
+ # A killed process can leave an empty claim directory behind. Recover only a
127
+ # demonstrably stale claim, and only with rmdir so an unexpected payload is
128
+ # never removed.
129
+ if [[ -d "$replace_claim_dir" ]] && claim_is_stale; then
130
+ rmdir "$replace_claim_dir" 2>/dev/null || true
131
+ mkdir "$replace_claim_dir" 2>/dev/null && return 0
132
+ fi
133
+
134
+ return 1
135
+ }
136
+
137
+ emit_unlocked() {
138
+ jq -cn \
139
+ --arg thread_id "$thread_id" \
140
+ --arg lock_path "$relative_lock_path" \
141
+ '{status:"unlocked", thread_id:$thread_id, lock_path:$lock_path}'
142
+ }
143
+
144
+ emit_stale() {
145
+ local reason="$1" owner_session="$2" resumed_at="$3" generation="$4"
146
+ local prompt="This thread was already resumed by ${owner_session} at ${resumed_at}. Its lock is stale (${reason}). Re-resume anyway?"
147
+ jq -cn \
148
+ --arg thread_id "$thread_id" \
149
+ --arg lock_path "$relative_lock_path" \
150
+ --arg stale_reason "$reason" \
151
+ --arg session_id "$owner_session" \
152
+ --arg resumed_at "$resumed_at" \
153
+ --arg lock_generation "$generation" \
154
+ --arg prompt "$prompt" \
155
+ '{status:"stale", thread_id:$thread_id, lock_path:$lock_path, stale_reason:$stale_reason, session_id:$session_id, resumed_at:$resumed_at, lock_generation:$lock_generation, prompt:$prompt}'
156
+ }
157
+
158
+ emit_locked() {
159
+ local owner_session="$1" resumed_at="$2" generation="$3"
160
+ local prompt="This thread was already resumed by ${owner_session} at ${resumed_at}. Re-resume anyway?"
161
+ jq -cn \
162
+ --arg thread_id "$thread_id" \
163
+ --arg lock_path "$relative_lock_path" \
164
+ --arg session_id "$owner_session" \
165
+ --arg resumed_at "$resumed_at" \
166
+ --arg lock_generation "$generation" \
167
+ --arg prompt "$prompt" \
168
+ '{status:"locked", thread_id:$thread_id, lock_path:$lock_path, session_id:$session_id, resumed_at:$resumed_at, lock_generation:$lock_generation, prompt:$prompt}'
169
+ }
170
+
171
+ # Print the existing lock's state. A damaged or expired marker is deliberately
172
+ # not removed: it is still evidence that this handoff was resumed and must pass
173
+ # through the same explicit re-resume confirmation as a fresh marker.
174
+ existing_state() {
175
+ if [[ ! -d "$lock_dir" ]]; then
176
+ if [[ -e "$lock_dir" ]]; then
177
+ emit_stale "invalid-lock-path" "unknown session" "unknown time" ""
178
+ else
179
+ emit_unlocked
180
+ fi
181
+ return
182
+ fi
183
+
184
+ if [[ ! -f "$record_path" ]]; then
185
+ emit_stale "missing-record" "unknown session" "unknown time" ""
186
+ return
187
+ fi
188
+
189
+ if ! jq -e '
190
+ .version == 1
191
+ and (.thread_id | type == "string" and length > 0)
192
+ and (.session_id | type == "string" and length > 0)
193
+ and (.resumed_at | type == "string" and length > 0)
194
+ and (.resumed_epoch | type == "number")
195
+ and (.generation | type == "string" and length > 0)
196
+ ' "$record_path" >/dev/null 2>&1; then
197
+ emit_stale "invalid-record" "unknown session" "unknown time" ""
198
+ return
199
+ fi
200
+
201
+ local record_thread record_session record_at record_epoch record_generation current_epoch
202
+ record_thread="$(jq -r '.thread_id' "$record_path")"
203
+ record_session="$(jq -r '.session_id' "$record_path")"
204
+ record_at="$(jq -r '.resumed_at' "$record_path")"
205
+ record_epoch="$(jq -r '.resumed_epoch' "$record_path")"
206
+ record_generation="$(jq -r '.generation' "$record_path")"
207
+
208
+ if [[ "$record_thread" != "$thread_id" || ! "$record_epoch" =~ ^[0-9]+$ ]]; then
209
+ emit_stale "invalid-record" "$record_session" "$record_at" "$record_generation"
210
+ return
211
+ fi
212
+
213
+ current_epoch="$(now_epoch)"
214
+ if (( current_epoch - record_epoch > STALE_SECONDS )); then
215
+ emit_stale "expired" "$record_session" "$record_at" "$record_generation"
216
+ return
217
+ fi
218
+
219
+ emit_locked "$record_session" "$record_at" "$record_generation"
220
+ }
221
+
222
+ write_record() {
223
+ local record_dir="$1" epoch timestamp tmp generation
224
+ epoch="$(now_epoch)"
225
+ timestamp="$(now_iso)"
226
+ tmp="$(mktemp "$record_dir/.current.XXXXXX")"
227
+ generation="$(basename "$tmp")"
228
+ jq -cn \
229
+ --arg thread_id "$thread_id" \
230
+ --arg session_id "$session_id" \
231
+ --arg resumed_at "$timestamp" \
232
+ --arg generation "$generation" \
233
+ --argjson resumed_epoch "$epoch" \
234
+ '{version:1, thread_id:$thread_id, session_id:$session_id, resumed_at:$resumed_at, resumed_epoch:$resumed_epoch, generation:$generation}' \
235
+ > "$tmp"
236
+ mv -f "$tmp" "$record_path"
237
+ WRITTEN_GENERATION="$generation"
238
+ }
239
+
240
+ emit_acquired() {
241
+ local generation="$1"
242
+ jq -cn \
243
+ --arg thread_id "$thread_id" \
244
+ --arg lock_path "$relative_lock_path" \
245
+ --arg session_id "$session_id" \
246
+ --arg lock_generation "$generation" \
247
+ '{status:"acquired", thread_id:$thread_id, lock_path:$lock_path, session_id:$session_id, lock_generation:$lock_generation}'
248
+ }
249
+
250
+ case "$subcommand" in
251
+ inspect)
252
+ existing_state
253
+ ;;
254
+ acquire)
255
+ mkdir -p "$LOCKS_DIR"
256
+ if mkdir "$lock_dir" 2>/dev/null; then
257
+ write_record "$lock_dir"
258
+ emit_acquired "$WRITTEN_GENERATION"
259
+ exit 0
260
+ fi
261
+
262
+ if [[ "$replace" != "true" ]]; then
263
+ existing_state
264
+ exit 3
265
+ fi
266
+
267
+ [[ -d "$lock_dir" ]] || {
268
+ echo "resume-thread-lock: cannot refresh non-directory lock path: $relative_lock_path" >&2
269
+ exit 1
270
+ }
271
+ if ! acquire_replace_claim; then
272
+ existing_state
273
+ exit 4
274
+ fi
275
+ trap 'rmdir "$replace_claim_dir" 2>/dev/null || true' EXIT
276
+ current_state="$(existing_state)"
277
+ current_generation="$(jq -r '.lock_generation // ""' <<<"$current_state")"
278
+ if [[ "$current_generation" != "$expected_generation" ]]; then
279
+ printf '%s\n' "$current_state"
280
+ exit 4
281
+ fi
282
+ write_record "$lock_dir"
283
+ emit_acquired "$WRITTEN_GENERATION"
284
+ ;;
285
+ esac
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env bash
2
+ # hq-core: public
3
+ # verify-story-deliverables.sh — make `passes: true` checkable: verify the
4
+ # evidence a story worker returns, and any deliverables the PRD declared
5
+ # (2026-09-07).
6
+ #
7
+ # Why: a PRD "passes" flag recorded that a step RAN, not that work EXISTS. An
8
+ # audit found a project marked 16/17 complete whose named deliverables mostly
9
+ # did not exist anywhere. Nothing anywhere checked disk or git.
10
+ #
11
+ # Two inputs, both optional, deliberately NOT strict by default:
12
+ # * EVIDENCE — what the worker says it produced, returned at completion in
13
+ # the run-project / execute-task return contract (`evidence: [...]`, plus
14
+ # `commits: [...]` which are checked as `commit:` evidence). This is the
15
+ # primary signal: it is written at the moment of completion, so it is
16
+ # accurate to what was built, and --write records it on the story as an
17
+ # audit trail. A claimed-but-missing item FAILS (a fabricated claim is
18
+ # worse than no claim). No evidence at all is allowed (legacy workers).
19
+ # * DELIVERABLES — what the PRD declared up front (optional). Checked when
20
+ # present; a planned-but-missing item fails, because that is exactly the
21
+ # "16/17 complete, nothing on disk" case.
22
+ #
23
+ # Usage:
24
+ # verify-story-deliverables.sh --prd <prd.json> --story <id> [--repo <path>]
25
+ # [--evidence-json '<array>'] [--commits-json '<array>'] [--write] [--json] [--strict]
26
+ #
27
+ # Reference forms (evidence and deliverables share them):
28
+ # "deliverables": [
29
+ # "path:core/scripts/foo.sh", # file or dir, relative to HQ root
30
+ # "repo-path:src/lib/bar.ts", # relative to --repo (or metadata.repoPath)
31
+ # "branch:feature/x", # branch exists in --repo (local or origin)
32
+ # "commit:<sha-prefix>", # commit reachable in --repo
33
+ # "url:https://…" # HTTP 2xx/3xx (skipped with --offline)
34
+ # ]
35
+ # A bare string without a prefix is treated as "path:".
36
+ #
37
+ # Exit 0: every checked reference present. With nothing to check (no evidence,
38
+ # no declared deliverables) this is reported as "unverified" and still
39
+ # exits 0 — pass --strict to make that a failure.
40
+ # Exit 3: one or more references missing (each is named on stderr).
41
+ # Exit 2: usage / unreadable PRD / unknown story.
42
+ # --write: on exit 0, record the verified references on the story in prd.json as
43
+ # `evidence: [{ref, verifiedAt}]` (only that field is touched).
44
+ set -uo pipefail
45
+
46
+ HQ_ROOT="${HQ_ROOT:-${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}}"
47
+ PRD=""; STORY=""; REPO=""; JSON=0; STRICT=0; WRITE=0; EVIDENCE_JSON="[]"; COMMITS_JSON="[]"; OFFLINE="${HQ_DELIVERABLES_OFFLINE:-0}"
48
+ while [ $# -gt 0 ]; do
49
+ case "$1" in
50
+ --prd) PRD="$2"; shift 2 ;;
51
+ --story) STORY="$2"; shift 2 ;;
52
+ --repo) REPO="$2"; shift 2 ;;
53
+ --evidence-json) EVIDENCE_JSON="$2"; shift 2 ;;
54
+ --commits-json) COMMITS_JSON="$2"; shift 2 ;;
55
+ --write) WRITE=1; shift ;;
56
+ --json) JSON=1; shift ;;
57
+ --strict) STRICT=1; shift ;;
58
+ --offline) OFFLINE=1; shift ;;
59
+ -h|--help) sed -n 2,30p "$0"; exit 0 ;;
60
+ *) echo "verify-story-deliverables: unknown arg $1" >&2; exit 2 ;;
61
+ esac
62
+ done
63
+ [ -n "$PRD" ] && [ -n "$STORY" ] || { echo "usage: --prd <prd.json> --story <id> [--repo <path>]" >&2; exit 2; }
64
+ [ -r "$PRD" ] || { echo "verify-story-deliverables: cannot read $PRD" >&2; exit 2; }
65
+ command -v jq >/dev/null 2>&1 || { echo "verify-story-deliverables: jq required" >&2; exit 2; }
66
+
67
+ jq_capture() {
68
+ # jq.exe emits CRLF from Windows Bash; strip CR from every captured line.
69
+ jq "$@" | sed 's/\r$//'
70
+ }
71
+
72
+ story_json="$(jq_capture -c --arg id "$STORY" '.userStories[]? | select(.id == $id)' "$PRD" 2>/dev/null | head -1)"
73
+ [ -n "$story_json" ] || { echo "verify-story-deliverables: story $STORY not in $PRD" >&2; exit 2; }
74
+ [ -n "$REPO" ] || REPO="$(jq_capture -r '.metadata.repoPath // empty' "$PRD" 2>/dev/null)"
75
+ case "$REPO" in ""|/*) ;; *) REPO="$HQ_ROOT/$REPO" ;; esac
76
+
77
+ jq -e 'type == "array"' <<<"$EVIDENCE_JSON" >/dev/null 2>&1 || { echo "verify-story-deliverables: --evidence-json must be a JSON array" >&2; exit 2; }
78
+ jq -e 'type == "array"' <<<"$COMMITS_JSON" >/dev/null 2>&1 || { echo "verify-story-deliverables: --commits-json must be a JSON array" >&2; exit 2; }
79
+ # Everything to check: declared deliverables + worker evidence + worker commits
80
+ # (as commit: refs), de-duplicated, in that order.
81
+ REFS_JSON="$(jq_capture -cn --argjson d "$(printf '%s' "$story_json" | jq_capture -c '(.deliverables // [])')" --argjson e "$EVIDENCE_JSON" --argjson c "$COMMITS_JSON" \
82
+ '($d + $e + ($c | map("commit:" + tostring))) | map(select(type == "string" and length > 0)) | unique')"
83
+ count="$(jq_capture -r 'length' <<<"$REFS_JSON")"
84
+ declared="$(printf '%s' "$story_json" | jq_capture -r '(.deliverables // []) | length')"
85
+ if [ "$count" = "0" ]; then
86
+ if [ "$STRICT" = "1" ]; then
87
+ echo "verify-story-deliverables: $STORY has no evidence and declares no deliverables (--strict)" >&2
88
+ [ "$JSON" = 1 ] && printf '{"story":"%s","status":"unverified","missing":[],"present":[]}\n' "$STORY"
89
+ exit 3
90
+ fi
91
+ [ "$JSON" = 1 ] && printf '{"story":"%s","status":"unverified","missing":[],"present":[]}\n' "$STORY" \
92
+ || echo "verify-story-deliverables: $STORY — nothing to verify (worker returned no evidence; PRD declares no deliverables). Allowed, but this done-mark is unverified."
93
+ exit 0
94
+ fi
95
+
96
+ present=(); missing=()
97
+ while IFS= read -r d; do
98
+ [ -n "$d" ] || continue
99
+ kind="${d%%:*}"; val="${d#*:}"
100
+ case "$d" in *:*) ;; *) kind="path"; val="$d" ;; esac
101
+ ok=0
102
+ case "$kind" in
103
+ path)
104
+ case "$val" in /*) t="$val" ;; *) t="$HQ_ROOT/$val" ;; esac
105
+ [ -e "$t" ] && ok=1 ;;
106
+ repo-path)
107
+ [ -n "$REPO" ] && [ -e "$REPO/$val" ] && ok=1 ;;
108
+ branch)
109
+ [ -n "$REPO" ] && [ -d "$REPO" ] && {
110
+ git -C "$REPO" show-ref --verify --quiet "refs/heads/$val" \
111
+ || git -C "$REPO" show-ref --verify --quiet "refs/remotes/origin/$val"; } && ok=1 ;;
112
+ commit)
113
+ [ -n "$REPO" ] && [ -d "$REPO" ] && git -C "$REPO" cat-file -e "${val}^{commit}" 2>/dev/null && ok=1 ;;
114
+ url)
115
+ if [ "$OFFLINE" = "1" ]; then ok=1
116
+ else
117
+ code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 -L "$val" 2>/dev/null || echo 000)"
118
+ case "$code" in 2*|3*) ok=1 ;; esac
119
+ fi ;;
120
+ *) ok=0 ;;
121
+ esac
122
+ if [ "$ok" = 1 ]; then present+=("$d"); else missing+=("$d"); fi
123
+ done < <(jq_capture -r '.[]' <<<"$REFS_JSON")
124
+
125
+ if [ "$JSON" = 1 ]; then
126
+ jq -cn --arg s "$STORY" \
127
+ --argjson m "$(printf '%s\n' "${missing[@]:-}" | jq_capture -R . | jq_capture -s 'map(select(. != ""))')" \
128
+ --argjson p "$(printf '%s\n' "${present[@]:-}" | jq_capture -R . | jq_capture -s 'map(select(. != ""))')" \
129
+ '{story:$s, status:(if ($m|length)==0 then "present" else "missing" end), missing:$m, present:$p}'
130
+ fi
131
+ if [ "${#missing[@]}" -gt 0 ]; then
132
+ echo "verify-story-deliverables: $STORY is NOT verifiably complete — ${#missing[@]} of $count reference(s) do not exist:" >&2
133
+ for d in "${missing[@]}"; do echo " - $d" >&2; done
134
+ echo "Do not write passes:true. A claimed path/branch/commit/URL must exist; a declared deliverable must be produced (or the declaration corrected)." >&2
135
+ exit 3
136
+ fi
137
+ if [ "$WRITE" = 1 ]; then
138
+ now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
139
+ jq_capture --arg id "$STORY" --arg now "$now" --argjson refs "$(printf '%s\n' "${present[@]}" | jq_capture -R . | jq_capture -s 'map(select(. != ""))')" \
140
+ '.userStories = (.userStories | map(if .id == $id then .evidence = ($refs | map({ref: ., verifiedAt: $now})) else . end))' \
141
+ "$PRD" > "$PRD.tmp" && mv "$PRD.tmp" "$PRD"
142
+ fi
143
+ recorded_suffix=""
144
+ if [ "$WRITE" = 1 ]; then
145
+ recorded_suffix="; recorded on the story"
146
+ fi
147
+ [ "$JSON" = 1 ] || echo "verify-story-deliverables: $STORY — all $count reference(s) present ($declared declared, $((count - declared)) from worker evidence)$recorded_suffix"
148
+ exit 0