@indigoai-us/hq-cli 5.286.0 → 5.288.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 +57 -0
- package/assets/scaffold/core/scripts/clean-worktree-reconcile-handoff.sh +171 -0
- package/assets/scaffold/core/scripts/resume-thread-lock.sh +285 -0
- package/assets/scaffold/core/scripts/verify-story-deliverables.sh +148 -0
- package/assets/scaffold/core/scripts/worktree-gc.sh +362 -0
- package/dist/command-catalog.generated.d.ts +55 -0
- package/dist/command-catalog.generated.js +68 -0
- package/dist/commands/agent-enroll.js +10 -2
- package/dist/commands/agents.d.ts +1 -0
- package/dist/commands/agents.js +12 -4
- package/dist/commands/cloud-provision.js +99 -78
- package/dist/commands/core-native-handoff-flag.d.ts +4 -0
- package/dist/commands/core-native-handoff-flag.js +7 -0
- package/dist/commands/core-ontology.d.ts +4 -1
- package/dist/commands/core-ontology.js +26 -0
- package/dist/commands/core.js +45 -0
- package/dist/commands/lanes-resume.js +41 -2
- package/dist/commands/lanes-shared.d.ts +2 -0
- package/dist/commands/lanes.js +48 -29
- package/dist/commands/team-sync.js +71 -10
- package/dist/fast-lanes-list-route.js +2 -0
- package/dist/fast-lanes-list.js +1 -29
- package/dist/lanes-drain-hook.d.ts +12 -0
- package/dist/lanes-drain-hook.js +59 -55
- package/dist/lib/checkpoint-ontology/merge-copies.d.ts +10 -1
- package/dist/lib/checkpoint-ontology/merge-copies.js +17 -2
- package/dist/lib/checkpoint-ontology/plan.d.ts +0 -88
- package/dist/lib/checkpoint-ontology/plan.js +80 -107
- package/dist/lib/core-utils/sentry-report.js +5 -32
- package/dist/lib/core-utils/worktree-resume.d.ts +18 -0
- package/dist/lib/core-utils/worktree-resume.js +1182 -0
- package/dist/lib/lanes/dropbox.d.ts +1 -2
- package/dist/lib/lanes/dropbox.js +10 -111
- package/dist/lib/lanes/message-replay.d.ts +8 -0
- package/dist/lib/lanes/message-replay.js +119 -0
- package/dist/lib/lanes/outpost-snapshot-refresh.d.ts +21 -0
- package/dist/lib/lanes/outpost-snapshot-refresh.js +329 -0
- package/dist/lib/plan-limit-nag.js +11 -0
- package/dist/main.js +17 -4
- package/dist/utils/cli-telemetry.d.ts +21 -0
- package/dist/utils/cli-telemetry.js +101 -1
- package/dist/utils/cognito-refresh-sentry.d.ts +3 -0
- package/dist/utils/cognito-refresh-sentry.js +49 -0
- package/dist/utils/cognito-session.js +55 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.288.0] — 2026-09-29
|
|
6
|
+
|
|
7
|
+
## [5.287.0] — 2026-09-29
|
|
8
|
+
- Group hook-timeout events by script while preserving triage diagnostics.
|
|
9
|
+
- Preserve safe slow-child script basenames in hook-timeout diagnostics.
|
|
10
|
+
- Cloud provisioning and retirement now stop after 15 seconds when a Vault API request stalls, with a clear timeout error.
|
|
11
|
+
- Add native TypeScript implementations for four `hq core` worktree and
|
|
12
|
+
handoff scripts behind the default-off `core-native-handoff` flag. The
|
|
13
|
+
original shell assets remain bundled as the rollback path.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- `hq team-sync` keeps its successful no-team result, reports which company
|
|
18
|
+
directories are cloud-backed, and points to `hq sync pull --all` and
|
|
19
|
+
`hq sync status` for cloud-backed companies.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Confirm primary-dispatched Outpost profile recovery when it is queued for
|
|
24
|
+
reconcile, and sync the cleared recovery state to the primary lane mirror.
|
|
25
|
+
- Cache-first `hq lanes list` now allows one detached Outpost summary refresh
|
|
26
|
+
per HQ root at a time, avoiding duplicate work from overlapping list calls.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- The lanes hook reads only transcript bytes appended since its last check.
|
|
31
|
+
It still replays compaction messages and resets the offset when a transcript
|
|
32
|
+
is replaced or truncated.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- `hq core ontology merge-copies --only <file>` handles only the copy -> original pairs listed in a JSON file and reports every other found pair as skipped (`not_listed`).
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- The checkpoint ontology step always applies its ten quality rules (company
|
|
41
|
+
brief, annotation stripping on grok, grok-first backend, no link aliases,
|
|
42
|
+
first-name people, ambiguous concept links, dominant sibling link, hub
|
|
43
|
+
mention floor, concept-project link, named evidence). Their
|
|
44
|
+
`checkpoint.ontology.*` flags, on for everyone since 2026-09-29, are no
|
|
45
|
+
longer read.
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- Record company-scoped exposure for printed CLI plan-limit notices when the default-off `billing.limit-at-action-prompt` flag is enabled; count an upgrade engagement only after the user confirms opening the link.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- `hq secrets` and other noninteractive person-token callers reuse a fresh same-principal session written by another process while a stale refresh is rejected.
|
|
54
|
+
|
|
5
55
|
## [5.286.0] — 2026-09-29
|
|
6
56
|
|
|
7
57
|
### Changed
|
|
@@ -23,6 +73,13 @@
|
|
|
23
73
|
|
|
24
74
|
### Fixed
|
|
25
75
|
|
|
76
|
+
- Recover a queued Outpost lane held for `worker_profile_changed` with its
|
|
77
|
+
printed `hq lanes resume <id> --worker <profile>` command. The lane owner's
|
|
78
|
+
profile copy is authoritative: the command records an authorized re-pin in
|
|
79
|
+
history, leaves the lane queued for normal reconcile admission, and syncs the
|
|
80
|
+
cleared recovery state back to the primary mirror. Other queued lanes still
|
|
81
|
+
require `hq lanes admit`.
|
|
82
|
+
|
|
26
83
|
- A remote `hq lanes list` read no longer dispatches owner hire summaries,
|
|
27
84
|
capacity probes, or mirror snapshots to other Outposts. Two Outposts that
|
|
28
85
|
each held a lane placed on the other ran `hq lanes list --json` on each other
|
|
@@ -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
|