forge-orkes 0.63.0 → 0.64.2
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/package.json +1 -1
- package/template/.claude/hooks/forge-release-fold.sh +9 -1
- package/template/.claude/skills/jarvis/SKILL.md +204 -0
- package/template/.forge/checks/forge-board-render.sh +11 -5
- package/template/.forge/checks/forge-jarvis-answer.sh +85 -0
- package/template/.forge/checks/forge-jarvis-awareness.sh +133 -0
- package/template/.forge/checks/forge-jarvis-bridge-clean.sh +133 -0
- package/template/.forge/checks/forge-jarvis-dispatch.sh +198 -0
- package/template/.forge/checks/forge-jarvis-instruments.sh +149 -0
- package/template/.forge/checks/forge-jarvis-notify-logonly.sh +46 -0
- package/template/.forge/checks/forge-jarvis-promote.sh +116 -0
- package/template/.forge/checks/forge-jarvis-prwatch.sh +109 -0
- package/template/.forge/checks/forge-jarvis-relay.sh +242 -0
- package/template/.forge/checks/forge-jarvis.sh +161 -0
- package/template/.forge/checks/tests/forge-jarvis-answer.test.sh +75 -0
- package/template/.forge/checks/tests/forge-jarvis-awareness.test.sh +118 -0
- package/template/.forge/checks/tests/forge-jarvis-bridge-clean.test.sh +94 -0
- package/template/.forge/checks/tests/forge-jarvis-dispatch.test.sh +162 -0
- package/template/.forge/checks/tests/forge-jarvis-instruments.test.sh +64 -0
- package/template/.forge/checks/tests/forge-jarvis-notify-logonly.test.sh +63 -0
- package/template/.forge/checks/tests/forge-jarvis-promote.test.sh +102 -0
- package/template/.forge/checks/tests/forge-jarvis-prwatch.test.sh +66 -0
- package/template/.forge/checks/tests/forge-jarvis-relay.test.sh +135 -0
- package/template/.forge/checks/tests/forge-jarvis.test.sh +122 -0
- package/template/.forge/templates/project.yml +11 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# forge-jarvis-bridge-clean.sh — reap orphaned RC bridge worktrees, never touch a live one
|
|
3
|
+
# (m-37 B5; FR-206; B1 probe ii finding).
|
|
4
|
+
#
|
|
5
|
+
# RC on-demand `claude remote-control --spawn worktree` sessions create
|
|
6
|
+
# `.claude/worktrees/bridge-{cse-id}` worktrees that DO NOT auto-remove when the session dies
|
|
7
|
+
# (B1 probe ii). Left alone they accumulate. This reaps the orphans — a bridge worktree with no
|
|
8
|
+
# live session — and NEVER a live one.
|
|
9
|
+
#
|
|
10
|
+
# LIVENESS (best-effort, injectable): a bridge worktree is treated as LIVE (kept) if it has
|
|
11
|
+
# uncommitted changes, OR its git LOCK names a pid whose process is still alive. RC locks every
|
|
12
|
+
# `--spawn worktree` bridge under the persistent server's pid ("locked claude agent bridge-<id>
|
|
13
|
+
# (pid N ...)") — that lock, not filesystem mtime, is the authoritative signal: mtime goes stale
|
|
14
|
+
# while the server is very much alive. A lock whose pid is DEAD is a stale lock → ORPHAN. Only an
|
|
15
|
+
# unlocked / no-pid worktree falls back to mtime freshness (--stale-hours, default 6). Because RC
|
|
16
|
+
# locks every bridge, a confirmed orphan is `git worktree unlock`ed before removal; `git worktree
|
|
17
|
+
# remove` (no --force) still refuses a DIRTY worktree, so unsaved work is protected twice over.
|
|
18
|
+
# Overridable via FORGE_JARVIS_BRIDGE_LIVECHECK (a command given the worktree path; exit 0 = live)
|
|
19
|
+
# for a deterministic test or a site-specific session probe.
|
|
20
|
+
#
|
|
21
|
+
# SCOPE GUARD: only paths whose basename matches bridge-* are ever considered; a non-bridge
|
|
22
|
+
# worktree can never be selected, dry-run or not.
|
|
23
|
+
#
|
|
24
|
+
# forge-jarvis-bridge-clean.sh [--dry-run] [--stale-hours N] [--worktrees-root <dir>]
|
|
25
|
+
# --dry-run list orphan candidates; remove NOTHING (default is to remove).
|
|
26
|
+
# --stale-hours N freshness window; a bridge modified within N hours is LIVE (default 6).
|
|
27
|
+
# --worktree-root override the scan root (tests); default: <repo>/.claude/worktrees.
|
|
28
|
+
# (--worktrees-root accepted as an alias — historical spelling.)
|
|
29
|
+
set -u
|
|
30
|
+
|
|
31
|
+
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
32
|
+
REPO_ROOT="$(cd "$SELF_DIR/../.." && pwd)"
|
|
33
|
+
|
|
34
|
+
DRY_RUN=0
|
|
35
|
+
STALE_HOURS=6
|
|
36
|
+
WT_ROOT=""
|
|
37
|
+
while [ $# -gt 0 ]; do
|
|
38
|
+
case "$1" in
|
|
39
|
+
--dry-run) DRY_RUN=1; shift ;;
|
|
40
|
+
--stale-hours) STALE_HOURS="${2:-6}"; shift 2 ;;
|
|
41
|
+
# both spellings accepted — the family standard is --worktree-root (relay/awareness/
|
|
42
|
+
# instruments); the plural was this script's original flag and stays as an alias.
|
|
43
|
+
--worktree-root|--worktrees-root) WT_ROOT="${2:-}"; shift 2 ;;
|
|
44
|
+
*) printf 'forge-jarvis-bridge-clean: unknown argument: %s\n' "$1" >&2; exit 2 ;;
|
|
45
|
+
esac
|
|
46
|
+
done
|
|
47
|
+
[ -n "$WT_ROOT" ] || WT_ROOT="$REPO_ROOT/.claude/worktrees"
|
|
48
|
+
|
|
49
|
+
# portable dir mtime epoch (BSD then GNU); 0 on failure.
|
|
50
|
+
_mtime() { # _mtime <path>
|
|
51
|
+
stat -f %m "$1" 2>/dev/null || stat -c %Y "$1" 2>/dev/null || echo 0
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# The pid embedded in a worktree's git LOCK reason, if any. RC locks each bridge with a reason
|
|
55
|
+
# like "claude agent bridge-<id> (pid N start ...)"; that pid is the persistent server that owns
|
|
56
|
+
# the worktree. Empty when the worktree is unlocked or its lock reason carries no pid.
|
|
57
|
+
worktree_lock_pid() { # worktree_lock_pid <worktree-path> → prints the pid or nothing
|
|
58
|
+
_wt="$1"; _b="$(basename "$_wt")"
|
|
59
|
+
git -C "$_wt" worktree list --porcelain 2>/dev/null | awk -v tgt="$_b" '
|
|
60
|
+
$1=="worktree" { cur=$2; sub(/.*\//,"",cur) }
|
|
61
|
+
/^locked/ && cur==tgt && match($0,/pid [0-9]+/) {
|
|
62
|
+
s=substr($0,RSTART,RLENGTH); sub(/pid /,"",s); print s; exit
|
|
63
|
+
}'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# default liveness (best-effort): uncommitted work OR a LIVE lock pid → live; a DEAD lock pid
|
|
67
|
+
# (the owning RC server is gone) → orphan; only an unlocked / no-pid worktree falls back to mtime.
|
|
68
|
+
default_livecheck() { # default_livecheck <worktree-path> → exit 0 if LIVE
|
|
69
|
+
_wt="$1"
|
|
70
|
+
[ -n "$(git -C "$_wt" status --porcelain 2>/dev/null)" ] && return 0 # dirty → live
|
|
71
|
+
_pid="$(worktree_lock_pid "$_wt")"
|
|
72
|
+
if [ -n "$_pid" ]; then
|
|
73
|
+
ps -p "$_pid" >/dev/null 2>&1 && return 0 # lock's server alive → live
|
|
74
|
+
return 1 # lock's server dead → stale lock → orphan
|
|
75
|
+
fi
|
|
76
|
+
_now="$(date +%s 2>/dev/null || echo 0)"
|
|
77
|
+
_mt="$(_mtime "$_wt")"
|
|
78
|
+
[ "$_mt" -gt 0 ] && [ "$_now" -gt 0 ] && [ $(( (_now - _mt) / 3600 )) -lt "$STALE_HOURS" ] && return 0
|
|
79
|
+
return 1
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
is_live() { # is_live <worktree-path>
|
|
83
|
+
if [ -n "${FORGE_JARVIS_BRIDGE_LIVECHECK:-}" ]; then
|
|
84
|
+
$FORGE_JARVIS_BRIDGE_LIVECHECK "$1" >/dev/null 2>&1
|
|
85
|
+
else
|
|
86
|
+
default_livecheck "$1"
|
|
87
|
+
fi
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# the repo's MAIN worktree — the safe place to run `git worktree remove` from (first list entry).
|
|
91
|
+
main_wt() { # main_wt <any-worktree-path>
|
|
92
|
+
git -C "$1" worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if [ ! -d "$WT_ROOT" ]; then
|
|
96
|
+
printf 'forge-jarvis-bridge-clean: no worktrees root (%s) — nothing to reap.\n' "$WT_ROOT"
|
|
97
|
+
exit 0
|
|
98
|
+
fi
|
|
99
|
+
|
|
100
|
+
_kept=0; _cand=0; _reaped=0
|
|
101
|
+
for _wt in "$WT_ROOT"/bridge-*/; do
|
|
102
|
+
[ -d "$_wt" ] || continue # unmatched glob → skip
|
|
103
|
+
_wt="${_wt%/}"
|
|
104
|
+
_base="$(basename "$_wt")"
|
|
105
|
+
case "$_base" in bridge-*) : ;; *) continue ;; esac # scope guard (belt-and-braces)
|
|
106
|
+
|
|
107
|
+
if is_live "$_wt"; then
|
|
108
|
+
_kept=$((_kept + 1))
|
|
109
|
+
printf ' keep %s (live)\n' "$_base"
|
|
110
|
+
continue
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
_cand=$((_cand + 1))
|
|
114
|
+
if [ "$DRY_RUN" -eq 1 ]; then
|
|
115
|
+
printf ' orphan %s (candidate — dry-run, not removed)\n' "$_base"
|
|
116
|
+
continue
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
_main="$(main_wt "$_wt")"
|
|
120
|
+
# RC locks every bridge; a confirmed orphan's lock is stale, so unlock before removing.
|
|
121
|
+
# `remove` (no --force) still refuses a DIRTY worktree, so unsaved work stays protected.
|
|
122
|
+
git -C "$_main" worktree unlock "$_wt" 2>/dev/null || true
|
|
123
|
+
if [ -n "$_main" ] && git -C "$_main" worktree remove "$_wt" 2>/dev/null; then
|
|
124
|
+
_reaped=$((_reaped + 1))
|
|
125
|
+
printf ' reaped %s\n' "$_base"
|
|
126
|
+
else
|
|
127
|
+
printf ' SKIP %s (git worktree remove refused — left intact)\n' "$_base"
|
|
128
|
+
fi
|
|
129
|
+
done
|
|
130
|
+
|
|
131
|
+
printf 'bridge-clean: %d kept, %d candidate(s), %d reaped%s.\n' \
|
|
132
|
+
"$_kept" "$_cand" "$_reaped" "$([ "$DRY_RUN" -eq 1 ] && printf ' (dry-run)')"
|
|
133
|
+
exit 0
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# forge-jarvis-dispatch.sh --slice <worktree> [runner args...] [--dry-run]
|
|
3
|
+
# (m-37, Brief-R2 step 4 B3; contract handoff-step4-jarvis.md REV2)
|
|
4
|
+
#
|
|
5
|
+
# On the operator's "go", Jarvis dispatches the slice runner DETACHED so the build
|
|
6
|
+
# survives Jarvis's death (B1 probe viii: nohup-&). This helper owns ONLY the caller-side
|
|
7
|
+
# environment — the runner loop is UNTOUCHED (the "no runner changes" boundary; the runner's
|
|
8
|
+
# swappable seams are used exactly as designed).
|
|
9
|
+
#
|
|
10
|
+
# WHAT IT SETS (and nothing else):
|
|
11
|
+
# FORGE_SLICE_NOTIFY_LOG → INTO the slice worktree (<worktree>/.forge/runner-work/notify.log),
|
|
12
|
+
# never the invoker's cwd. The default would land the log in the
|
|
13
|
+
# MAIN checkout (one shared file, invisible to the worktree glob,
|
|
14
|
+
# dirtying step 0's clean-main invariant). This is the path the B4
|
|
15
|
+
# relay derives per slice from its worktree glob. (acceptance #5)
|
|
16
|
+
# FORGE_SLICE_NOTIFY → the LOG-ONLY channel (forge-jarvis-notify-logonly.sh), so the
|
|
17
|
+
# default channel's per-ping headless `claude -p` push stays quiet
|
|
18
|
+
# — under a live relay, delivery is the relay's job; a live default
|
|
19
|
+
# channel would risk a double-buzz (B1 probe x). Explicit + portable
|
|
20
|
+
# (FR-197): true regardless of the ambient auth shape.
|
|
21
|
+
#
|
|
22
|
+
# MODEL MAP — PASSED THROUGH UNTOUCHED (FR-198, boundary "No routing"): the runner reads
|
|
23
|
+
# config.model_by_phase_type from its CONFIG (the step-5 seam — read, not routed). This helper
|
|
24
|
+
# forwards every runner arg (incl. --config) VERBATIM and carries NO model-selection logic. If
|
|
25
|
+
# this dispatch ever starts choosing models, STOP — routing is step 5.
|
|
26
|
+
#
|
|
27
|
+
# All non-flag args are forwarded to the runner unchanged (--slice <worktree>, and optionally
|
|
28
|
+
# --work-dir / --config / --schema / --report-template — whatever the runner accepts). The only
|
|
29
|
+
# arg this helper consumes for itself is --dry-run.
|
|
30
|
+
#
|
|
31
|
+
# --dry-run print the exact `nohup env ... runner ...` command + env on stdout and exit 0;
|
|
32
|
+
# never dispatches. Use it to show the operator what "go" will run.
|
|
33
|
+
#
|
|
34
|
+
# WORK-ORDER MATERIALIZATION (FR-197; walk run 4, 2026-07-22). The runner's contract
|
|
35
|
+
# (consumed, never edited) is <worktree>/slice.yml listing phase names, each resolving to
|
|
36
|
+
# <worktree>/phases/<name>/plan.md — but the Forge intake act writes plans under
|
|
37
|
+
# .forge/phases/milestone-{id}/{phase}-{name}/plan-NN.md, and nothing bridged the two: the
|
|
38
|
+
# observed failure was a clean dispatch whose runner refused instantly ('no slice.yml'), the
|
|
39
|
+
# refusal visible only in dispatch.out — the operator's phone stayed silent. So before the
|
|
40
|
+
# detach this helper MATERIALIZES the work order from the milestone's locked plans (slice.yml
|
|
41
|
+
# in phase order + phases/<name>/plan.md concatenated from that phase's plan-NN.md files), and
|
|
42
|
+
# REFUSES LOUDLY AND SYNCHRONOUSLY when it cannot — the failure surface is this exit code, not
|
|
43
|
+
# a silent phone. An existing slice.yml is honored untouched (a hand-authored work order wins).
|
|
44
|
+
# --materialize-only materialize (or refuse) and exit — no dispatch. Test hook + operator tool.
|
|
45
|
+
set -u
|
|
46
|
+
|
|
47
|
+
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
48
|
+
REPO_ROOT="$(cd "$SELF_DIR/../.." && pwd)" # .forge/checks → repo root
|
|
49
|
+
RUNNER="$REPO_ROOT/.claude/hooks/forge-slice-runner.sh" # the slice runner (consumed, never edited)
|
|
50
|
+
CHANNEL="$SELF_DIR/forge-jarvis-notify-logonly.sh" # the log-only channel (sibling)
|
|
51
|
+
|
|
52
|
+
# --- parse args: consume --dry-run; extract --slice; forward everything else verbatim -------
|
|
53
|
+
# POSIX count-guard idiom: shift originals off the front, re-append forwarded args to the back;
|
|
54
|
+
# when the original count hits 0, "$@" holds exactly the forwarded runner args (spaces safe).
|
|
55
|
+
DRY_RUN=0
|
|
56
|
+
MAT_ONLY=0
|
|
57
|
+
WORKTREE=""
|
|
58
|
+
argc=$#
|
|
59
|
+
while [ "$argc" -gt 0 ]; do
|
|
60
|
+
arg="$1"; shift; argc=$((argc - 1))
|
|
61
|
+
case "$arg" in
|
|
62
|
+
--dry-run)
|
|
63
|
+
DRY_RUN=1 ;; # ours — not forwarded
|
|
64
|
+
--materialize-only)
|
|
65
|
+
MAT_ONLY=1 ;; # ours — not forwarded
|
|
66
|
+
--slice)
|
|
67
|
+
WORKTREE="${1:-}"; shift; argc=$((argc - 1))
|
|
68
|
+
set -- "$@" --slice "$WORKTREE" ;; # forward it too (runner needs it)
|
|
69
|
+
*)
|
|
70
|
+
set -- "$@" "$arg" ;; # forward verbatim
|
|
71
|
+
esac
|
|
72
|
+
done
|
|
73
|
+
|
|
74
|
+
[ -n "$WORKTREE" ] || { printf 'forge-jarvis-dispatch: --slice <worktree> is required\n' >&2; exit 2; }
|
|
75
|
+
[ -d "$WORKTREE" ] || { printf 'forge-jarvis-dispatch: slice worktree not found: %s\n' "$WORKTREE" >&2; exit 2; }
|
|
76
|
+
WORKTREE="$(cd "$WORKTREE" && pwd)" # absolutize — the log path must be stable
|
|
77
|
+
|
|
78
|
+
[ -f "$RUNNER" ] || { printf 'forge-jarvis-dispatch: runner not found: %s\n' "$RUNNER" >&2; exit 2; }
|
|
79
|
+
[ -f "$CHANNEL" ] || { printf 'forge-jarvis-dispatch: log-only channel not found: %s\n' "$CHANNEL" >&2; exit 2; }
|
|
80
|
+
|
|
81
|
+
# --- materialize the work order from the milestone's locked plans (FR-197) -------------------
|
|
82
|
+
# slice.yml present → honored untouched. Absent → build it from the single
|
|
83
|
+
# .forge/phases/milestone-*/ dir: one phases/<name>/plan.md per phase subdir (plan-NN.md
|
|
84
|
+
# concatenated in order), slice.yml listing the names numerically. Cannot → REFUSE, exit 2,
|
|
85
|
+
# BEFORE the detach — the caller sees it synchronously and must relay it to the operator.
|
|
86
|
+
materialize_work_order() {
|
|
87
|
+
if [ -f "$WORKTREE/slice.yml" ]; then
|
|
88
|
+
printf 'forge-jarvis-dispatch: slice.yml present — using it as-is\n' >&2
|
|
89
|
+
return 0
|
|
90
|
+
fi
|
|
91
|
+
# Prefer the worktree's OWN milestone — .forge/phases/ carries the repo's whole milestone
|
|
92
|
+
# history (32 dirs on the forge repo), so "the only milestone dir" is a fixture-world
|
|
93
|
+
# assumption. The Worktree Convention names the branch forge/{anchor} with anchor =
|
|
94
|
+
# {milestone-id}[-{session}]; derive the id and pick that milestone's dir.
|
|
95
|
+
_mdir=""
|
|
96
|
+
_branch="$(git -C "$WORKTREE" branch --show-current 2>/dev/null)"
|
|
97
|
+
case "$_branch" in
|
|
98
|
+
forge/m-*)
|
|
99
|
+
_anchor="${_branch#forge/m-}" # 40 | 40-a1b2c3d4 | R61-002
|
|
100
|
+
[ -d "$WORKTREE/.forge/phases/milestone-$_anchor" ] && _mdir="$WORKTREE/.forge/phases/milestone-$_anchor"
|
|
101
|
+
if [ -z "$_mdir" ]; then
|
|
102
|
+
_short="${_anchor%-*}" # strip one trailing -token (session suffix)
|
|
103
|
+
[ "$_short" != "$_anchor" ] && [ -d "$WORKTREE/.forge/phases/milestone-$_short" ] && _mdir="$WORKTREE/.forge/phases/milestone-$_short"
|
|
104
|
+
fi ;;
|
|
105
|
+
esac
|
|
106
|
+
# Fallback (non-git fixture dirs / unconventional branches): a SINGLE milestone dir is
|
|
107
|
+
# unambiguous; several without a branch-derived pick is a refusal, never a guess.
|
|
108
|
+
if [ -z "$_mdir" ]; then
|
|
109
|
+
_mdirs=0
|
|
110
|
+
for _d in "$WORKTREE"/.forge/phases/milestone-*; do
|
|
111
|
+
[ -d "$_d" ] || continue
|
|
112
|
+
_mdirs=$((_mdirs + 1)); _mdir="$_d"
|
|
113
|
+
done
|
|
114
|
+
if [ "$_mdirs" -eq 0 ]; then
|
|
115
|
+
printf 'forge-jarvis-dispatch: REFUSED — no slice.yml and no .forge/phases/milestone-*/ plans in %s; nothing to dispatch. Plan the slice first. RELAY this refusal to the operator.\n' "$WORKTREE" >&2
|
|
116
|
+
return 2
|
|
117
|
+
fi
|
|
118
|
+
if [ "$_mdirs" -gt 1 ]; then
|
|
119
|
+
printf 'forge-jarvis-dispatch: REFUSED — %s milestone plan dirs in %s and no forge/m-* branch to pick by; ambiguous work order. Write slice.yml explicitly. RELAY this refusal to the operator.\n' "$_mdirs" "$WORKTREE" >&2
|
|
120
|
+
return 2
|
|
121
|
+
fi
|
|
122
|
+
fi
|
|
123
|
+
_tmp="$WORKTREE/slice.yml.tmp"
|
|
124
|
+
# Write-target hygiene: refuse a pre-existing tmp (incl. a pre-committed symlink —
|
|
125
|
+
# a redirect would follow it and truncate whatever it points at).
|
|
126
|
+
if [ -e "$_tmp" ] || [ -L "$_tmp" ]; then
|
|
127
|
+
printf 'forge-jarvis-dispatch: REFUSED — %s already exists; remove it and re-dispatch. RELAY this refusal to the operator.\n' "$_tmp" >&2
|
|
128
|
+
return 2
|
|
129
|
+
fi
|
|
130
|
+
printf '# Materialized by forge-jarvis-dispatch from %s (delete to re-materialize)\nphases:\n' "${_mdir#"$WORKTREE"/}" > "$_tmp"
|
|
131
|
+
_count=0
|
|
132
|
+
# Quoted glob iteration, numeric phase order — never word-split ls output
|
|
133
|
+
# (phase/plan names are repo content; unquoted expansion would let a crafted
|
|
134
|
+
# name pull arbitrary *.md into the runner's prompt).
|
|
135
|
+
_names=$(for _d in "$_mdir"/*/; do [ -d "$_d" ] || continue; basename "$_d"; done | sort -n)
|
|
136
|
+
while IFS= read -r _p; do
|
|
137
|
+
[ -n "$_p" ] || continue
|
|
138
|
+
_have=0
|
|
139
|
+
for _f in "$_mdir/$_p"/plan-*.md; do [ -f "$_f" ] && { _have=1; break; }; done
|
|
140
|
+
[ "$_have" = 1 ] || continue
|
|
141
|
+
mkdir -p "$WORKTREE/phases/$_p"
|
|
142
|
+
_pl="$WORKTREE/phases/$_p/plan.md"
|
|
143
|
+
if [ -L "$_pl" ]; then
|
|
144
|
+
rm -f "$_tmp"
|
|
145
|
+
printf 'forge-jarvis-dispatch: REFUSED — %s is a symlink; refusing to write through it. RELAY this refusal to the operator.\n' "$_pl" >&2
|
|
146
|
+
return 2
|
|
147
|
+
fi
|
|
148
|
+
: > "$_pl"
|
|
149
|
+
for _f in "$_mdir/$_p"/plan-*.md; do
|
|
150
|
+
[ -f "$_f" ] || continue
|
|
151
|
+
cat "$_f" >> "$_pl"
|
|
152
|
+
done
|
|
153
|
+
printf ' - %s\n' "$_p" >> "$_tmp"
|
|
154
|
+
_count=$((_count + 1))
|
|
155
|
+
done <<MATERIALIZE_EOF
|
|
156
|
+
$_names
|
|
157
|
+
MATERIALIZE_EOF
|
|
158
|
+
if [ "$_count" -eq 0 ]; then
|
|
159
|
+
rm -f "$_tmp"
|
|
160
|
+
printf 'forge-jarvis-dispatch: REFUSED — %s has no phase plans (plan-*.md); nothing to dispatch. RELAY this refusal to the operator.\n' "$_mdir" >&2
|
|
161
|
+
return 2
|
|
162
|
+
fi
|
|
163
|
+
mv "$_tmp" "$WORKTREE/slice.yml"
|
|
164
|
+
printf 'forge-jarvis-dispatch: materialized slice.yml (%s phase(s)) from %s\n' "$_count" "${_mdir#"$WORKTREE"/}" >&2
|
|
165
|
+
return 0
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# --- caller-side env (the two seams; nothing else) ------------------------------------------
|
|
169
|
+
LOG="$WORKTREE/.forge/runner-work/notify.log" # INTO the slice worktree (B4 derives this)
|
|
170
|
+
|
|
171
|
+
if [ "$DRY_RUN" = "1" ]; then
|
|
172
|
+
# Side-effect-free: report work-order state to stderr, print the command to stdout.
|
|
173
|
+
if [ -f "$WORKTREE/slice.yml" ]; then
|
|
174
|
+
printf 'forge-jarvis-dispatch: (dry-run) slice.yml present — will be used as-is\n' >&2
|
|
175
|
+
else
|
|
176
|
+
printf 'forge-jarvis-dispatch: (dry-run) no slice.yml — a real dispatch materializes it from .forge/phases/milestone-*/\n' >&2
|
|
177
|
+
fi
|
|
178
|
+
# Print exactly what would run — greppable: worktree-scoped log, log-only channel, nohup, forwarded args.
|
|
179
|
+
printf 'nohup env FORGE_SLICE_NOTIFY_LOG=%s FORGE_SLICE_NOTIFY=%s %s' "$LOG" "$CHANNEL" "$RUNNER"
|
|
180
|
+
for a in "$@"; do printf ' %s' "$a"; done
|
|
181
|
+
printf ' >%s 2>&1 &\n' "$WORKTREE/.forge/runner-work/dispatch.out"
|
|
182
|
+
exit 0
|
|
183
|
+
fi
|
|
184
|
+
|
|
185
|
+
if [ "$MAT_ONLY" = "1" ]; then
|
|
186
|
+
materialize_work_order; exit $?
|
|
187
|
+
fi
|
|
188
|
+
|
|
189
|
+
materialize_work_order || exit 2
|
|
190
|
+
|
|
191
|
+
# --- dispatch DETACHED (nohup-& — survives Jarvis's death, B1 probe viii) --------------------
|
|
192
|
+
mkdir -p "$WORKTREE/.forge/runner-work" 2>/dev/null || true
|
|
193
|
+
printf 'forge-jarvis-dispatch: dispatching runner for %s (detached)\n' "$WORKTREE" >&2
|
|
194
|
+
nohup env FORGE_SLICE_NOTIFY_LOG="$LOG" FORGE_SLICE_NOTIFY="$CHANNEL" \
|
|
195
|
+
"$RUNNER" "$@" >"$WORKTREE/.forge/runner-work/dispatch.out" 2>&1 &
|
|
196
|
+
|
|
197
|
+
printf 'forge-jarvis-dispatch: dispatched (pid %s); notify log → %s\n' "$!" "$LOG" >&2
|
|
198
|
+
exit 0
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# forge-jarvis-instruments.sh — the week's DIRECTION-TEST numbers, derived from what B4 already
|
|
3
|
+
# wrote: the notify logs + relay markers. NO new store (m-37 B5 instruments; FR-205; NFR-036).
|
|
4
|
+
#
|
|
5
|
+
# Risk 1 (contract): did the front door REDUCE operator touches, or just RELOCATE them? These four
|
|
6
|
+
# numbers are how we would tell, measured from existing artifacts ONLY (no instrumentation added):
|
|
7
|
+
# 1. PINGS RELAYED BY TYPE — how many buzzes, of what kind (ambiguity / fork / irreversible /
|
|
8
|
+
# budget / done / halt). Only RELAYED pings count (a marker exists).
|
|
9
|
+
# 2. RELAY LATENCY — log-line emit-time → marker relay-time, per ping (mean + max secs).
|
|
10
|
+
# 3. DISCREPANCY COUNT — how often B4(c) host-verification caught a payload's state claim
|
|
11
|
+
# disagreeing with the host (the standing lesson, counted).
|
|
12
|
+
# 4. PRESENCE PROXY — pings relayed LIVE vs pings that landed while the relay was down
|
|
13
|
+
# (relayed late, on catch-up). Uptime here is a PROXY derived from
|
|
14
|
+
# relay latency, NOT an uptime store: a ping whose relay lagged past
|
|
15
|
+
# --live-window seconds is counted "landed while away". Labelled a
|
|
16
|
+
# proxy in the output so it is never read as a measured SLA.
|
|
17
|
+
#
|
|
18
|
+
# SOURCES (read-only): each slice's <worktree>/.forge/runner-work/notify.log (TSV
|
|
19
|
+
# emit-ts<TAB>type<TAB>payload) and its sibling relay-markers/<offset>.relayed
|
|
20
|
+
# (relay-ts<TAB>offset=N<TAB>relay-text). A marker's offset (line number) joins it to its log line.
|
|
21
|
+
# Nothing is written; the numbers are a pure function of the existing markers + logs.
|
|
22
|
+
#
|
|
23
|
+
# forge-jarvis-instruments.sh [--log <notify.log> ...] [--marker-dir <dir>] \
|
|
24
|
+
# [--live-window <sec>] [--since <ISO-Z>] [--worktree-root <dir>] [--dry-run]
|
|
25
|
+
# (no --log) disk-glob live slice worktrees (git worktree list, forge/m-*) for their logs.
|
|
26
|
+
# --marker-dir override the marker dir (default: sibling relay-markers/ of each log).
|
|
27
|
+
# --live-window seconds under which a relay counts LIVE (default 120).
|
|
28
|
+
# --since only count pings emitted at/after this ISO-Z timestamp (default: all).
|
|
29
|
+
# --worktree-root glob this dir for slice worktrees instead of `git worktree list` (tests).
|
|
30
|
+
# --dry-run print the resolved logs/markers and exit (plumbing check).
|
|
31
|
+
# Portable date (BSD then GNU) so latency math works on macOS and Linux.
|
|
32
|
+
set -u
|
|
33
|
+
|
|
34
|
+
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
35
|
+
REPO_ROOT="$(cd "$SELF_DIR/../.." && pwd)"
|
|
36
|
+
|
|
37
|
+
LOGS=""
|
|
38
|
+
MARKER_DIR_OVERRIDE=""
|
|
39
|
+
LIVE_WINDOW=120
|
|
40
|
+
SINCE=""
|
|
41
|
+
WT_ROOT=""
|
|
42
|
+
DRY_RUN=0
|
|
43
|
+
while [ $# -gt 0 ]; do
|
|
44
|
+
case "$1" in
|
|
45
|
+
--log) LOGS="${LOGS}${LOGS:+
|
|
46
|
+
}${2:-}"; shift 2 ;;
|
|
47
|
+
--marker-dir) MARKER_DIR_OVERRIDE="${2:-}"; shift 2 ;;
|
|
48
|
+
--live-window) LIVE_WINDOW="${2:-120}"; shift 2 ;;
|
|
49
|
+
--since) SINCE="${2:-}"; shift 2 ;;
|
|
50
|
+
--worktree-root) WT_ROOT="${2:-}"; shift 2 ;;
|
|
51
|
+
--dry-run) DRY_RUN=1; shift ;;
|
|
52
|
+
*) printf 'forge-jarvis-instruments: unknown argument: %s\n' "$1" >&2; exit 2 ;;
|
|
53
|
+
esac
|
|
54
|
+
done
|
|
55
|
+
|
|
56
|
+
# ISO-8601 Z → epoch seconds; empty on failure (GNU `date -d` then BSD `date -j -f`).
|
|
57
|
+
to_epoch() { # to_epoch <iso-z>
|
|
58
|
+
_t="${1:-}"; [ -n "$_t" ] || return 1
|
|
59
|
+
date -u -d "$_t" +%s 2>/dev/null || date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$_t" +%s 2>/dev/null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# ── resolve the log set: explicit --log wins; else disk-glob live slice worktrees ────────────────
|
|
63
|
+
if [ -z "$LOGS" ]; then
|
|
64
|
+
if [ -n "$WT_ROOT" ]; then
|
|
65
|
+
for _wt in "$WT_ROOT"/*/; do
|
|
66
|
+
[ -d "$_wt" ] || continue
|
|
67
|
+
_l="${_wt}.forge/runner-work/notify.log"
|
|
68
|
+
[ -f "$_l" ] && LOGS="${LOGS}${LOGS:+
|
|
69
|
+
}$_l"
|
|
70
|
+
done
|
|
71
|
+
else
|
|
72
|
+
_globbed="$(git -C "$REPO_ROOT" worktree list --porcelain 2>/dev/null | awk '
|
|
73
|
+
/^worktree / { p = substr($0, 10) } # full remainder — paths may contain spaces
|
|
74
|
+
/^branch / { if ($2 ~ /refs\/heads\/forge\/m-/) print p "/.forge/runner-work/notify.log" }')"
|
|
75
|
+
while IFS= read -r _l; do
|
|
76
|
+
[ -n "$_l" ] || continue
|
|
77
|
+
[ -f "$_l" ] && LOGS="${LOGS}${LOGS:+
|
|
78
|
+
}$_l"
|
|
79
|
+
done <<GLOBBED_EOF
|
|
80
|
+
$_globbed
|
|
81
|
+
GLOBBED_EOF
|
|
82
|
+
fi
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
if [ "$DRY_RUN" -eq 1 ]; then
|
|
86
|
+
printf 'forge-jarvis-instruments (--dry-run): resolved logs:\n'
|
|
87
|
+
if [ -n "$LOGS" ]; then printf '%s\n' "$LOGS" | sed 's/^/ /'; else printf ' (none)\n'; fi
|
|
88
|
+
exit 0
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
# ── build one record per RELAYED ping: type, latency_sec, live(1/0), discrepancy(1/0) ────────────
|
|
92
|
+
TMP="$(mktemp)"; trap 'rm -f "$TMP"' EXIT INT TERM
|
|
93
|
+
_since_epoch=""; [ -n "$SINCE" ] && _since_epoch="$(to_epoch "$SINCE")"
|
|
94
|
+
|
|
95
|
+
printf '%s\n' "$LOGS" | while IFS= read -r LOG; do
|
|
96
|
+
[ -n "$LOG" ] && [ -f "$LOG" ] || continue
|
|
97
|
+
MK="$MARKER_DIR_OVERRIDE"; [ -n "$MK" ] || MK="$(dirname "$LOG")/relay-markers"
|
|
98
|
+
_n=0
|
|
99
|
+
while IFS= read -r _line || [ -n "$_line" ]; do
|
|
100
|
+
_n=$((_n + 1))
|
|
101
|
+
_emit="$(printf '%s' "$_line" | cut -f1)"
|
|
102
|
+
_type="$(printf '%s' "$_line" | cut -f2)"
|
|
103
|
+
if [ -n "$_since_epoch" ]; then
|
|
104
|
+
_e="$(to_epoch "$_emit")"
|
|
105
|
+
{ [ -n "$_e" ] && [ "$_e" -lt "$_since_epoch" ]; } && continue
|
|
106
|
+
fi
|
|
107
|
+
_marker="$MK/$_n.relayed"
|
|
108
|
+
[ -f "$_marker" ] || continue # only RELAYED pings carry instruments data
|
|
109
|
+
_relayts="$(cut -f1 "$_marker" 2>/dev/null)"
|
|
110
|
+
_text="$(cut -f3- "$_marker" 2>/dev/null)"
|
|
111
|
+
_ee="$(to_epoch "$_emit")"; _re="$(to_epoch "$_relayts")"
|
|
112
|
+
if [ -n "$_ee" ] && [ -n "$_re" ]; then _lat=$((_re - _ee)); else _lat=-1; fi
|
|
113
|
+
if [ "$_lat" -ge 0 ] && [ "$_lat" -le "$LIVE_WINDOW" ]; then _live=1; else _live=0; fi
|
|
114
|
+
case "$_text" in
|
|
115
|
+
*DISCREPANCY*|*"could NOT be verified"*) _disc=1 ;;
|
|
116
|
+
*) _disc=0 ;;
|
|
117
|
+
esac
|
|
118
|
+
printf '%s\t%s\t%s\t%s\n' "$_type" "$_lat" "$_live" "$_disc" >> "$TMP"
|
|
119
|
+
done < "$LOG"
|
|
120
|
+
done
|
|
121
|
+
|
|
122
|
+
# ── emit the four numbers ────────────────────────────────────────────────────────────────────────
|
|
123
|
+
_total="$(wc -l < "$TMP" 2>/dev/null | tr -d ' ')"; [ -n "$_total" ] || _total=0
|
|
124
|
+
|
|
125
|
+
printf '── Jarvis week-zero instruments (from relay markers + notify logs) ──\n\n'
|
|
126
|
+
|
|
127
|
+
printf '1. Pings relayed by type (%s total):\n' "$_total"
|
|
128
|
+
if [ "$_total" -gt 0 ]; then
|
|
129
|
+
cut -f1 "$TMP" | sort | uniq -c | while read -r _c _t; do
|
|
130
|
+
printf ' %-14s %s\n' "$_t" "$_c"
|
|
131
|
+
done
|
|
132
|
+
else
|
|
133
|
+
printf ' (no relayed pings in range)\n'
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
awk -F'\t' -v total="$_total" -v win="$LIVE_WINDOW" '
|
|
137
|
+
{ if ($2 + 0 >= 0) { s += $2; n++; if ($2 + 0 > mx) mx = $2 }
|
|
138
|
+
live += $3; disc += $4 }
|
|
139
|
+
END {
|
|
140
|
+
mean = (n > 0) ? s / n : 0
|
|
141
|
+
printf "2. Relay latency: mean %.1fs, max %ds (over %d timed ping(s); live window %ds)\n", mean, mx + 0, n + 0, win + 0
|
|
142
|
+
printf "3. Discrepancies caught (host-verification): %d\n", disc + 0
|
|
143
|
+
away = total - live
|
|
144
|
+
up = (total > 0) ? (live * 100.0 / total) : 0
|
|
145
|
+
printf "4. Presence proxy: %d/%d relayed live (%.0f%%); %d landed while away", live + 0, total + 0, up, away + 0
|
|
146
|
+
printf " (proxy — derived from relay latency, not an uptime store)\n"
|
|
147
|
+
}' "$TMP"
|
|
148
|
+
|
|
149
|
+
exit 0
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# forge-jarvis-notify-logonly.sh <type> <payload> — a LOG-ONLY notify channel for
|
|
3
|
+
# Jarvis-dispatched slices (m-37, Brief-R2 step 4 B3; contract handoff-step4-jarvis.md REV2).
|
|
4
|
+
#
|
|
5
|
+
# The default runner channel (.claude/hooks/forge-slice-notify.sh) does TWO things per
|
|
6
|
+
# ping: (1) append it to the durable log, and (2) a best-effort headless `claude -p`
|
|
7
|
+
# PushNotification micro-call. Under a live Jarvis relay (B4), delivery to the phone is the
|
|
8
|
+
# RELAY's job — the Monitor `tail -F` on this log IS the delivery path. Running the default
|
|
9
|
+
# channel's own push in parallel risks a double-buzz (B1 probe x). So a Jarvis dispatch
|
|
10
|
+
# points FORGE_SLICE_NOTIFY at THIS channel, which does step (1) ONLY.
|
|
11
|
+
#
|
|
12
|
+
# Why an EXPLICIT channel and not just "the keychain box has no exported token so the
|
|
13
|
+
# default is already quiet": that quiet is ambient (it depends on CLAUDE_CODE_OAUTH_TOKEN /
|
|
14
|
+
# ANTHROPIC_API_KEY being unset). On a box where a token IS exported, the default WOULD
|
|
15
|
+
# push and double-buzz. Pointing at this channel makes log-only EXPLICIT and PORTABLE
|
|
16
|
+
# (FR-197) — the relay is the one delivery path regardless of the ambient auth shape.
|
|
17
|
+
#
|
|
18
|
+
# This is the runner's swappable channel seam used exactly as designed: the runner is
|
|
19
|
+
# UNTOUCHED; the caller (forge-jarvis-dispatch.sh) owns the env. The log FORMAT is identical
|
|
20
|
+
# to the default channel (same tab-separated timestamp/type/payload) so the log is uniform
|
|
21
|
+
# whichever channel wrote a line — the relay parses one format.
|
|
22
|
+
#
|
|
23
|
+
# BEST-EFFORT by contract (same as the default channel): exit 0 even if the log write
|
|
24
|
+
# fails — the runner treats a non-zero exit as a best-effort miss, never a slice failure.
|
|
25
|
+
#
|
|
26
|
+
# type/payload: the runner calls `$FORGE_SLICE_NOTIFY <type> <payload>`; the type is one of
|
|
27
|
+
# the §8 surface (ambiguity | irreversible | fork | budget | done) plus `halt`.
|
|
28
|
+
set -u
|
|
29
|
+
|
|
30
|
+
TYPE="${1:-note}"
|
|
31
|
+
PAYLOAD="${2:-}"
|
|
32
|
+
|
|
33
|
+
# The log is TAB-separated, one ping per line, and the relay parses it as such —
|
|
34
|
+
# scrub field-forging bytes from caller input so a crafted payload cannot mint
|
|
35
|
+
# extra pings or extra fields (noise-bounded anyway: state claims are host-recomputed).
|
|
36
|
+
TYPE=$(printf '%s' "$TYPE" | tr '\n\t' ' ')
|
|
37
|
+
PAYLOAD=$(printf '%s' "$PAYLOAD" | tr '\n\t' ' ')
|
|
38
|
+
|
|
39
|
+
# Durable local record — the ONLY thing this channel does. Default location travels with
|
|
40
|
+
# the slice (FORGE_SLICE_WORK_DIR), but a Jarvis dispatch always sets FORGE_SLICE_NOTIFY_LOG
|
|
41
|
+
# explicitly INTO the slice worktree.
|
|
42
|
+
LOG="${FORGE_SLICE_NOTIFY_LOG:-${FORGE_SLICE_WORK_DIR:-.}/notify.log}"
|
|
43
|
+
mkdir -p "$(dirname "$LOG")" 2>/dev/null || true
|
|
44
|
+
printf '%s\t%s\t%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo now)" "$TYPE" "$PAYLOAD" >> "$LOG" 2>/dev/null || true
|
|
45
|
+
|
|
46
|
+
exit 0
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# forge-jarvis-promote.sh — the promotion VOICE (m-37, Brief-R2 step 4 B6; contract
|
|
3
|
+
# handoff-step4-jarvis.md REV2; FR-208; NFR-038; operator ruling 3 "trigger-not-approve").
|
|
4
|
+
#
|
|
5
|
+
# The operator says "promote <sha>". Jarvis does exactly two things, in order:
|
|
6
|
+
# 1. SHOW what that SHA carries — recomputed from HOST truth (the board projection, whose
|
|
7
|
+
# rows carry fold-derived release_state), never a session's word. The merged-but-not-yet-
|
|
8
|
+
# validated units are the ones a promotion covers, by CONTAINMENT: a unit becomes
|
|
9
|
+
# validated when its landed SHA is an ancestor of the signed promotion tag's commit
|
|
10
|
+
# (ADR-024) — verified offline, host-side.
|
|
11
|
+
# 2. TRIGGER the step-2 seam: gh workflow run promote.yml -f sha=<full-sha>
|
|
12
|
+
#
|
|
13
|
+
# That is ALL it does. It NEVER approves, waives, or lowers anything: the flow STOPS at
|
|
14
|
+
# GitHub's Environment gates (both taps: deploy + mint) until the operator presses there.
|
|
15
|
+
# Triggering is not approving — the approval is not Jarvis's to give (operator ruling 3).
|
|
16
|
+
# There is deliberately NO Environment-approval call anywhere in this file; the neg-control in
|
|
17
|
+
# the test suite greps this file to prove it (an approval path would fail the acceptance test).
|
|
18
|
+
#
|
|
19
|
+
# forge-jarvis-promote.sh <full-sha> [--dry-run]
|
|
20
|
+
# <full-sha> the full 40-hex deploy SHA. The promotion tag is promotion/<full-sha>
|
|
21
|
+
# (FROZEN with the operator); a short sha is refused — containment needs the
|
|
22
|
+
# full one, and Jarvis never invents or truncates a sha.
|
|
23
|
+
# --dry-run SHOW host state, then PRINT the trigger command without running it.
|
|
24
|
+
#
|
|
25
|
+
# HOST-TRUTH commands (env-overridable so tests inject stubs and degradation is explicit):
|
|
26
|
+
# FORGE_JARVIS_BOARD default .forge/checks/forge-board-render.sh (the rollup projection;
|
|
27
|
+
# its rows already carry the fold-derived release_state)
|
|
28
|
+
# FORGE_JARVIS_GH default `gh` (the trigger — never an approval)
|
|
29
|
+
# BEST-EFFORT: a missing board/fold degrades to a stated '(unavailable)' — the show still
|
|
30
|
+
# renders; a missing gh in a real (non-dry-run) trigger is a clear error, never a silent success.
|
|
31
|
+
set -u
|
|
32
|
+
|
|
33
|
+
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
34
|
+
REPO_ROOT="$(cd "$SELF_DIR/../.." && pwd)" # .forge/checks → repo root
|
|
35
|
+
BOARD="${FORGE_JARVIS_BOARD:-$REPO_ROOT/.forge/checks/forge-board-render.sh}"
|
|
36
|
+
GH="${FORGE_JARVIS_GH:-gh}"
|
|
37
|
+
WORKFLOW="promote.yml"
|
|
38
|
+
|
|
39
|
+
SHA=""
|
|
40
|
+
DRY_RUN=0
|
|
41
|
+
while [ $# -gt 0 ]; do
|
|
42
|
+
case "$1" in
|
|
43
|
+
--dry-run) DRY_RUN=1; shift ;;
|
|
44
|
+
--*)
|
|
45
|
+
# LOUD refusal, never a silent swallow: this is the one helper where a typo'd
|
|
46
|
+
# flag (--dryrun) falling through would fire a REAL workflow trigger.
|
|
47
|
+
printf 'forge-jarvis-promote: unknown flag: %s — usage: forge-jarvis-promote.sh <full-sha> [--dry-run]\n' "$1" >&2; exit 2 ;;
|
|
48
|
+
*)
|
|
49
|
+
if [ -z "$SHA" ]; then SHA="$1"; shift; else
|
|
50
|
+
printf 'forge-jarvis-promote: unexpected extra argument: %s\n' "$1" >&2; exit 2
|
|
51
|
+
fi ;;
|
|
52
|
+
esac
|
|
53
|
+
done
|
|
54
|
+
|
|
55
|
+
# ── refuse a missing or non-full sha (Jarvis never invents/truncates a sha) ─────────────────────
|
|
56
|
+
case "$SHA" in
|
|
57
|
+
"") printf 'forge-jarvis-promote: need a full deploy SHA — usage: forge-jarvis-promote.sh <full-sha> [--dry-run]\n' >&2; exit 2 ;;
|
|
58
|
+
esac
|
|
59
|
+
if ! printf '%s' "$SHA" | grep -qE '^[0-9a-f]{40}$'; then
|
|
60
|
+
printf 'forge-jarvis-promote: %s is not a full 40-hex SHA. Promotion validates by containment against promotion/<full-sha> — pass the full deploy SHA, not a short one.\n' "$SHA" >&2
|
|
61
|
+
exit 2
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
# ── 1. SHOW what this SHA carries — host truth, recomputed now (never a session's claim) ─────────
|
|
65
|
+
printf '── Promote %s ────────────────────────────────────\n' "$SHA"
|
|
66
|
+
printf '\n> What this promotion covers (board projection — merged units validate by containment):\n'
|
|
67
|
+
if [ -f "$BOARD" ]; then
|
|
68
|
+
_emit="$(sh "$BOARD" --emit 2>/dev/null)"
|
|
69
|
+
if [ -n "$_emit" ]; then
|
|
70
|
+
# release-relevant units: merged (candidate -> validated) + validated (already covered).
|
|
71
|
+
# line-oriented JSON -> grep/sed, no jq (Article X native-first) — same parse as awareness.
|
|
72
|
+
_rel="$(printf '%s\n' "$_emit" | grep '"forge_id"' | grep -E '"release_state":[[:space:]]*"(merged|validated)"')"
|
|
73
|
+
if [ -n "$_rel" ]; then
|
|
74
|
+
# Order-independent field extraction — one sed per key (kept aligned with awareness),
|
|
75
|
+
# so a key-order change in the board emit degrades a field to '?' rather than
|
|
76
|
+
# silently dropping every line into a misleading "(no merged units)".
|
|
77
|
+
printf '%s\n' "$_rel" | head -60 | while IFS= read -r _ln; do
|
|
78
|
+
_id="$(printf '%s' "$_ln" | sed -n 's/.*"forge_id":[[:space:]]*"\([^"]*\)".*/\1/p')"
|
|
79
|
+
_rs="$(printf '%s' "$_ln" | sed -n 's/.*"release_state":[[:space:]]*"\([^"]*\)".*/\1/p')"
|
|
80
|
+
printf ' %s release_state=%s\n' "${_id:-?}" "${_rs:-?}"
|
|
81
|
+
done
|
|
82
|
+
else
|
|
83
|
+
printf ' (no merged units in the board projection — nothing waiting on a promotion)\n'
|
|
84
|
+
fi
|
|
85
|
+
else
|
|
86
|
+
printf ' (board projection unavailable — degraded, not fatal)\n'
|
|
87
|
+
fi
|
|
88
|
+
else
|
|
89
|
+
printf ' (forge-board-render.sh not found — projection unavailable)\n'
|
|
90
|
+
fi
|
|
91
|
+
printf '\n Validation is by CONTAINMENT: each merged unit becomes validated when its landed SHA is an\n'
|
|
92
|
+
printf ' ancestor of the signed promotion/%s tag commit (ADR-024) — host-verified offline, not a claim.\n' "$SHA"
|
|
93
|
+
|
|
94
|
+
# ── 2. TRIGGER the step-2 seam — and STOP. Jarvis triggers; the operator presses the gates. ──────
|
|
95
|
+
CMD="$GH workflow run $WORKFLOW -f sha=$SHA"
|
|
96
|
+
printf '\n> Trigger (step-2 seam — Jarvis triggers; the operator presses GitHub Environment gates):\n'
|
|
97
|
+
if [ "$DRY_RUN" -eq 1 ]; then
|
|
98
|
+
printf ' [--dry-run] would run: %s\n' "$CMD"
|
|
99
|
+
exit 0
|
|
100
|
+
fi
|
|
101
|
+
if ! command -v "$GH" >/dev/null 2>&1; then
|
|
102
|
+
printf ' gh not found — cannot trigger the promotion. Run it from a host that has gh: %s\n' "$CMD" >&2
|
|
103
|
+
exit 3
|
|
104
|
+
fi
|
|
105
|
+
printf ' %s\n' "$CMD"
|
|
106
|
+
# gh workflow run promote.yml -f sha=<full-sha> — the trigger, never an approval call.
|
|
107
|
+
# Pinned to REPO_ROOT: gh resolves the target repo from cwd, and this project's cwd is
|
|
108
|
+
# known to snap between checkouts across turns — the trigger must always hit THIS repo.
|
|
109
|
+
(cd "$REPO_ROOT" && "$GH" workflow run "$WORKFLOW" -f sha="$SHA")
|
|
110
|
+
_rc=$?
|
|
111
|
+
if [ "$_rc" -eq 0 ]; then
|
|
112
|
+
printf '\n[ok] Triggered promote.yml for %s. It now STOPS at GitHub Environment gates — press them there; Jarvis does not.\n' "$SHA"
|
|
113
|
+
else
|
|
114
|
+
printf '\n[!] gh workflow run exited %s — the promotion was NOT triggered. Check the workflow name / host auth.\n' "$_rc" >&2
|
|
115
|
+
fi
|
|
116
|
+
exit "$_rc"
|