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,109 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# forge-jarvis-prwatch.sh — the bounded "merged, on staging — come try" watch
|
|
3
|
+
# (m-37, Brief-R2 step 4 B4(c); contract handoff-step4-jarvis.md REV2).
|
|
4
|
+
#
|
|
5
|
+
# The runner's `done` ping fires at PR-open; step-2 auto-merge + the staging deploy land minutes
|
|
6
|
+
# LATER, so that flagship "come try it" moment never appears in the notify log. This watch is the
|
|
7
|
+
# SECOND bounded source for it: on a `done`, poll `gh` for the PR's merge + staging-deploy state
|
|
8
|
+
# with a HARD bound and, on merge+deploy, emit the come-try text once.
|
|
9
|
+
#
|
|
10
|
+
# STRICTLY BOUNDED + self-terminating (NFR-036 / Fork 3): it stops on merge+deploy, on a closed
|
|
11
|
+
# PR, or when the iteration/wall bound is hit — it NEVER becomes a standing daemon and owns no
|
|
12
|
+
# durable state beyond the at-most-once come-try marker (keyed by PR number).
|
|
13
|
+
#
|
|
14
|
+
# HOST TRUTH ONLY: merge + deploy state come from `gh`, never from a build session's words
|
|
15
|
+
# (operator ruling 5). The gh binary is env-overridable (FORGE_JARVIS_GH) so fixtures drive tests.
|
|
16
|
+
#
|
|
17
|
+
# --pr <num> the PR to watch (required)
|
|
18
|
+
# --repo <owner/repo> default: the current repo (gh resolves it)
|
|
19
|
+
# --staging-env <name> deployment environment that means "on staging" (default: staging)
|
|
20
|
+
# --max-iterations <N> hard poll bound (default 30)
|
|
21
|
+
# --interval <seconds> sleep between polls (default 20; 0 = no sleep, for fixtures)
|
|
22
|
+
# --marker-dir <dir> where the at-most-once come-try marker lives
|
|
23
|
+
# (default: <git-common-dir>/forge/relay-markers — stable across
|
|
24
|
+
# worktrees and cwds; cwd/.relay-markers only outside a repo)
|
|
25
|
+
# --dry-run print the resolved plan (pr, repo, env, bound) and exit; no polling
|
|
26
|
+
set -u
|
|
27
|
+
|
|
28
|
+
PR=""; REPO=""; ENV_STAGING="staging"; MAX_ITER=30; INTERVAL=20; MARKER_DIR=""
|
|
29
|
+
GH="${FORGE_JARVIS_GH:-gh}"
|
|
30
|
+
DRY=0
|
|
31
|
+
while [ $# -gt 0 ]; do
|
|
32
|
+
case "$1" in
|
|
33
|
+
--pr) PR="${2:-}"; shift 2 ;;
|
|
34
|
+
--repo) REPO="${2:-}"; shift 2 ;;
|
|
35
|
+
--staging-env) ENV_STAGING="${2:-staging}"; shift 2 ;;
|
|
36
|
+
--max-iterations) MAX_ITER="${2:-30}"; shift 2 ;;
|
|
37
|
+
--interval) INTERVAL="${2:-20}"; shift 2 ;;
|
|
38
|
+
--marker-dir) MARKER_DIR="${2:-}"; shift 2 ;;
|
|
39
|
+
--dry-run) DRY=1; shift ;;
|
|
40
|
+
*) printf 'forge-jarvis-prwatch: unknown argument: %s\n' "$1" >&2; exit 2 ;;
|
|
41
|
+
esac
|
|
42
|
+
done
|
|
43
|
+
[ -n "$PR" ] || { printf 'forge-jarvis-prwatch: --pr <num> is required\n' >&2; exit 2; }
|
|
44
|
+
# The PR number is carried by a runner `done` ping (semi-trusted log content) and is used
|
|
45
|
+
# in the marker filename and as a gh argument — require a plain number before any use.
|
|
46
|
+
case "$PR" in
|
|
47
|
+
*[!0-9]*|'') printf 'forge-jarvis-prwatch: --pr must be numeric, got: %s\n' "$PR" >&2; exit 2 ;;
|
|
48
|
+
esac
|
|
49
|
+
# --repo, when given, must be a plain owner/repo — anything else could smuggle gh flags.
|
|
50
|
+
if [ -n "$REPO" ]; then
|
|
51
|
+
case "$REPO" in
|
|
52
|
+
*/*) case "$REPO" in *[!A-Za-z0-9._/-]*|*/*/*) printf 'forge-jarvis-prwatch: --repo must be owner/repo, got: %s\n' "$REPO" >&2; exit 2 ;; esac ;;
|
|
53
|
+
*) printf 'forge-jarvis-prwatch: --repo must be owner/repo, got: %s\n' "$REPO" >&2; exit 2 ;;
|
|
54
|
+
esac
|
|
55
|
+
fi
|
|
56
|
+
# At-most-once marker home: stable per machine+repo (git common dir — shared across this
|
|
57
|
+
# repo's worktrees, never cwd-relative droppings), falling back to cwd only outside a repo.
|
|
58
|
+
if [ -z "$MARKER_DIR" ]; then
|
|
59
|
+
_cd="$(git rev-parse --git-common-dir 2>/dev/null)"
|
|
60
|
+
if [ -n "$_cd" ]; then MARKER_DIR="$_cd/forge/relay-markers"; else MARKER_DIR="./.relay-markers"; fi
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
if [ "$DRY" = 1 ]; then
|
|
64
|
+
printf 'prwatch plan: pr=%s repo=%s env=%s bound=%s×%ss marker-dir=%s\n' \
|
|
65
|
+
"$PR" "${REPO:-<current>}" "$ENV_STAGING" "$MAX_ITER" "$INTERVAL" "$MARKER_DIR"
|
|
66
|
+
exit 0
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
# --- host-truth probes (gh only) -------------------------------------------------------------
|
|
70
|
+
# The two probes are literal gh queries (env-overridable via $GH for fixtures):
|
|
71
|
+
# merge state → `gh pr view <num> --json state --jq .state`
|
|
72
|
+
# staging deploy → `gh api repos/{owner}/{repo}/deployments?environment=<env>` + its statuses
|
|
73
|
+
# Both read host truth; neither consults anything a build session emitted.
|
|
74
|
+
pr_merge_state() { # → MERGED | CLOSED | OPEN (empty on gh error → treated as OPEN, keep polling)
|
|
75
|
+
# ${REPO:+…} expands to exactly two argv words when --repo was given, none otherwise.
|
|
76
|
+
"$GH" pr view "$PR" ${REPO:+--repo "$REPO"} --json state --jq '.state' 2>/dev/null
|
|
77
|
+
}
|
|
78
|
+
deploy_succeeded() { # → exit 0 iff the latest staging deployment is in a success state
|
|
79
|
+
_st="$("$GH" api "repos/{owner}/{repo}/deployments?environment=$ENV_STAGING&per_page=1" \
|
|
80
|
+
--jq '.[0].statuses_url' 2>/dev/null)"
|
|
81
|
+
[ -n "$_st" ] || return 1
|
|
82
|
+
_s="$("$GH" api "$_st?per_page=1" --jq '.[0].state' 2>/dev/null)"
|
|
83
|
+
[ "$_s" = success ]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fire_come_try() { # at-most-once, keyed by PR (the ONE owned state class)
|
|
87
|
+
mkdir -p "$MARKER_DIR" 2>/dev/null || true
|
|
88
|
+
_mk="$MARKER_DIR/pr-$PR-cometry.relayed"
|
|
89
|
+
[ -f "$_mk" ] && return 0 # already fired — never re-buzz
|
|
90
|
+
_text="🚀 Merged, on staging — come try: PR #$PR is live on $ENV_STAGING."
|
|
91
|
+
printf '%s\toffset=pr-%s\t%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo now)" "$PR" "$_text" > "$_mk" 2>/dev/null || true
|
|
92
|
+
printf '%s\n' "$_text" # the skill fires PushNotification with this
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# --- the bounded poll loop -------------------------------------------------------------------
|
|
96
|
+
i=0
|
|
97
|
+
while [ "$i" -lt "$MAX_ITER" ]; do
|
|
98
|
+
case "$(pr_merge_state)" in
|
|
99
|
+
MERGED)
|
|
100
|
+
if deploy_succeeded; then fire_come_try; exit 0; fi ;; # the come-try moment — fire + stop
|
|
101
|
+
CLOSED)
|
|
102
|
+
exit 0 ;; # closed without a live deploy — stop, no fire
|
|
103
|
+
*) : ;; # OPEN (or gh hiccup) — keep polling
|
|
104
|
+
esac
|
|
105
|
+
i=$((i + 1))
|
|
106
|
+
[ "$i" -lt "$MAX_ITER" ] && [ "$INTERVAL" -gt 0 ] && sleep "$INTERVAL"
|
|
107
|
+
done
|
|
108
|
+
# bound reached without a merge+deploy — self-terminate cleanly, no fire (never a daemon).
|
|
109
|
+
exit 0
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# forge-jarvis-relay.sh — the callback-relay CORE (m-37, Brief-R2 step 4 B4(a/b/c/e);
|
|
3
|
+
# contract handoff-step4-jarvis.md REV2). Turns one notify-log line into a relay
|
|
4
|
+
# decision: HOST-TRUTH recompute → typed relay text → at-most-once offset marker.
|
|
5
|
+
#
|
|
6
|
+
# The runner writes every §8 ping to a slice's notify.log (tab-separated
|
|
7
|
+
# `timestamp<TAB>type<TAB>payload`; forge-slice-notify.sh / -notify-logonly.sh). Under a
|
|
8
|
+
# live Jarvis, that log IS the event bus: the skill arms a Monitor `tail -F` on it and pipes
|
|
9
|
+
# each new line through THIS helper, then fires PushNotification with the returned text.
|
|
10
|
+
#
|
|
11
|
+
# THE LOAD-BEARING RULE (operator ruling 5): every relayed STATE claim (merged / landed /
|
|
12
|
+
# validated) is recomputed from HOST truth (the fold / gh / board) BEFORE relay — NEVER taken
|
|
13
|
+
# from the payload. A payload that claims what the host does not show relays the DISCREPANCY,
|
|
14
|
+
# loudly. A question (ambiguity) is relayed verbatim — it is a question, not a state claim.
|
|
15
|
+
#
|
|
16
|
+
# OWNED STATE (the ONE admitted class, NFR-036): durable marker files keyed by the log-line
|
|
17
|
+
# OFFSET (1-based line number — stable in an append-only log; the runner's internal ping-dedup
|
|
18
|
+
# keys never reach the log, F10). Marker present → already relayed → never re-buzz (at-most-once).
|
|
19
|
+
# Everything else Jarvis "knows" is recomputed; kill Jarvis and only the live connection + armed
|
|
20
|
+
# watches are lost — the markers on disk survive and re-drive the while-you-were-away catch-up.
|
|
21
|
+
#
|
|
22
|
+
# MODES
|
|
23
|
+
# --one "<tsv-line>" [--milestone m-N] pure text-builder: print the relay text for ONE
|
|
24
|
+
# line; NO marker, NO side effects (the unit tests hit).
|
|
25
|
+
# --scan <logfile> [--marker-dir <dir>] [--milestone m-N] [--mode live|catchup]
|
|
26
|
+
# stateful driver: relay every UNMARKED line, write
|
|
27
|
+
# its marker. live → one text line per new ping;
|
|
28
|
+
# catchup → ONE "while you were away" summary. Both
|
|
29
|
+
# write markers, so a line is relayed at-most-once.
|
|
30
|
+
# --audit-sources <?logfile> self-grep: fail if any state claim is sourced from
|
|
31
|
+
# the payload rather than fold/gh/board (acceptance #3).
|
|
32
|
+
#
|
|
33
|
+
# HOST-TRUTH COMMANDS (env-overridable so tests inject stubs, and so degradation is explicit):
|
|
34
|
+
# FORGE_JARVIS_FOLD default .claude/hooks/forge-release-fold.sh (release_state recompute)
|
|
35
|
+
# (gh-based facts live in forge-jarvis-prwatch.sh, which documents its own FORGE_JARVIS_GH;
|
|
36
|
+
# the board recompute lives in awareness/promote — this helper consults the fold only.)
|
|
37
|
+
#
|
|
38
|
+
# BEST-EFFORT: a fold/gh/board that is absent or errors degrades to release_state=unknown and
|
|
39
|
+
# says so — never a silent success, never a hard fail of the relay path.
|
|
40
|
+
set -u
|
|
41
|
+
|
|
42
|
+
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
43
|
+
REPO_ROOT="$(cd "$SELF_DIR/../.." && pwd)" # .forge/checks → repo root
|
|
44
|
+
FOLD="${FORGE_JARVIS_FOLD:-$REPO_ROOT/.claude/hooks/forge-release-fold.sh}"
|
|
45
|
+
|
|
46
|
+
# ── host truth: the slice's release_state, recomputed from the fold (never the payload) ───────
|
|
47
|
+
host_release_state() { # host_release_state <milestone-id> → prints unmerged|merged|validated|unknown
|
|
48
|
+
# Kept textually aligned with fold_state in forge-jarvis-awareness.sh — same guard,
|
|
49
|
+
# same invocation, same degradation, so a fold-format change is a mechanical two-file edit.
|
|
50
|
+
_mid="${1:-}"
|
|
51
|
+
[ -n "$_mid" ] || { printf 'unknown'; return 0; }
|
|
52
|
+
[ -f "$FOLD" ] || { printf 'unknown'; return 0; }
|
|
53
|
+
_out="$(sh "$FOLD" "$_mid" 2>/dev/null)" || { printf 'unknown'; return 0; }
|
|
54
|
+
_rs="$(printf '%s\n' "$_out" | grep -m1 'release_state=' | sed 's/.*release_state=//; s/[[:space:]].*//')"
|
|
55
|
+
[ -n "$_rs" ] && printf '%s' "$_rs" || printf 'unknown'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Does the payload TEXT assert a landing/merge/promotion? (only then can it contradict the host)
|
|
59
|
+
payload_claims_landed() { # payload_claims_landed <payload>
|
|
60
|
+
printf '%s' "${1:-}" | grep -qiE 'merged|landed|validated|promoted|on main|is live|shipped|deployed'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# ── build the relay text for ONE parsed ping (the pure host-truth text-builder) ───────────────
|
|
64
|
+
# Reads: $2=type $3=payload ; $MILESTONE (optional, for fold). Prints one line to stdout.
|
|
65
|
+
build_relay_text() { # build_relay_text <type> <payload>
|
|
66
|
+
_type="${1:-note}"; _payload="${2:-}"
|
|
67
|
+
case "$_type" in
|
|
68
|
+
ambiguity)
|
|
69
|
+
# A QUESTION — relay verbatim, named. Legal: it is not a state claim (acceptance #2).
|
|
70
|
+
printf '❓ Needs you (ambiguity): %s' "$_payload" ;;
|
|
71
|
+
fork)
|
|
72
|
+
# payload is a PATH to a sketch the operator LOOKS AT (§8) — name it.
|
|
73
|
+
printf '🎨 Design fork — review the sketch: %s' "$_payload" ;;
|
|
74
|
+
irreversible)
|
|
75
|
+
# a gate on an irreversible act — your call; relay verbatim, named.
|
|
76
|
+
printf '⚠️ Irreversible action — your call: %s' "$_payload" ;;
|
|
77
|
+
budget)
|
|
78
|
+
# the runner's OWN spend tally — no host source to recompute against; relay verbatim, named.
|
|
79
|
+
printf '💸 Budget cap reached: %s' "$_payload" ;;
|
|
80
|
+
done|halt)
|
|
81
|
+
# STATE-CLAIM territory. Recompute host truth FIRST; build the claim from it, not the payload.
|
|
82
|
+
_rs="$(host_release_state "${MILESTONE:-}")"
|
|
83
|
+
if [ "$_type" = done ]; then _base='✅ Slice done'; else _base='🛑 Slice HALTED'; fi
|
|
84
|
+
if payload_claims_landed "$_payload"; then
|
|
85
|
+
if [ "$_rs" = unmerged ]; then
|
|
86
|
+
# The standing lesson: payload says merged, host says not. Relay the host, loudly.
|
|
87
|
+
printf '⚠️ DISCREPANCY — payload claims landed/merged, but host shows release_state=unmerged. %s (host truth wins). Payload said: %s' "$_base" "$_payload"
|
|
88
|
+
elif [ "$_rs" = unknown ]; then
|
|
89
|
+
printf '⚠️ %s — payload claims landed/merged but the host could NOT be verified (release_state=unknown). Payload said: %s' "$_base" "$_payload"
|
|
90
|
+
else
|
|
91
|
+
# payload claims landed AND host agrees (merged/validated) — no discrepancy.
|
|
92
|
+
printf '%s — host confirms: release_state=%s. %s' "$_base" "$_rs" "$_payload"
|
|
93
|
+
fi
|
|
94
|
+
else
|
|
95
|
+
printf '%s — host: release_state=%s. %s' "$_base" "$_rs" "$_payload"
|
|
96
|
+
fi ;;
|
|
97
|
+
*)
|
|
98
|
+
# unknown type — relay raw, named (never silently drop).
|
|
99
|
+
printf '(%s): %s' "$_type" "$_payload" ;;
|
|
100
|
+
esac
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Parse a TSV log line (ts<TAB>type<TAB>payload) then build text. Prints one line.
|
|
104
|
+
relay_one_line() { # relay_one_line <tsv-line>
|
|
105
|
+
_line="$1"
|
|
106
|
+
_type="$(printf '%s' "$_line" | cut -f2)"
|
|
107
|
+
_payload="$(printf '%s' "$_line" | cut -f3-)"
|
|
108
|
+
# A malformed line with no tabs → cut returns the whole line for f2/f3; treat as a raw note.
|
|
109
|
+
case "$_line" in
|
|
110
|
+
*" "*) : ;; # has a tab — well-formed
|
|
111
|
+
*) _type='note'; _payload="$_line" ;;
|
|
112
|
+
esac
|
|
113
|
+
build_relay_text "$_type" "$_payload"
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# ── modes — one function per mode, each owning its own arg loop ───────────────────────────────
|
|
117
|
+
MILESTONE=""
|
|
118
|
+
RELAY_MODE="live" # 'live' | 'catchup' — how --scan emits (not a boolean)
|
|
119
|
+
MARKER_DIR=""
|
|
120
|
+
|
|
121
|
+
mode_one() {
|
|
122
|
+
LINE="${1:-}"; shift 2>/dev/null || true
|
|
123
|
+
while [ $# -gt 0 ]; do case "$1" in
|
|
124
|
+
--milestone) MILESTONE="${2:-}"; shift 2 ;;
|
|
125
|
+
*) printf 'forge-jarvis-relay: unknown argument: %s\n' "$1" >&2; exit 2 ;;
|
|
126
|
+
esac; done
|
|
127
|
+
relay_one_line "$LINE"; printf '\n'
|
|
128
|
+
exit 0
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
mode_list_logs() {
|
|
132
|
+
# The watch set the skill arms `tail -F` on — recomputed from a DISK GLOB on EVERY call,
|
|
133
|
+
# never held in memory (Amendment 2: kill Jarvis and the armed watches re-derive from disk).
|
|
134
|
+
# Live slices are worktrees on a forge/m-* branch; each runner writes to
|
|
135
|
+
# <worktree>/.forge/runner-work/notify.log (the dispatch's FORGE_SLICE_NOTIFY_LOG). Emit the
|
|
136
|
+
# EXPECTED path even when the log does not exist yet — `tail -F` (capital F) is chosen exactly
|
|
137
|
+
# so a not-yet-created log is picked up when the first ping lands (F8).
|
|
138
|
+
WT_ROOT=""
|
|
139
|
+
while [ $# -gt 0 ]; do case "$1" in
|
|
140
|
+
--worktree-root) WT_ROOT="${2:-}"; shift 2 ;;
|
|
141
|
+
*) printf 'forge-jarvis-relay: unknown argument: %s\n' "$1" >&2; exit 2 ;;
|
|
142
|
+
esac; done
|
|
143
|
+
if [ -n "$WT_ROOT" ]; then
|
|
144
|
+
# override path (tests): disk-glob the given root for slice worktrees.
|
|
145
|
+
for _wt in "$WT_ROOT"/*/; do
|
|
146
|
+
[ -d "$_wt" ] || continue
|
|
147
|
+
printf '%s.forge/runner-work/notify.log\n' "$_wt"
|
|
148
|
+
done
|
|
149
|
+
else
|
|
150
|
+
# live path: `git worktree list` is the on-disk registry of worktrees; glob it to the
|
|
151
|
+
# forge/m-* slice branches and emit each slice's notify-log path. Pinned with -C so the
|
|
152
|
+
# watch list never depends on the caller's cwd (the cwd-reset footgun: a snapped cwd
|
|
153
|
+
# would silently arm ZERO watches).
|
|
154
|
+
git -C "$REPO_ROOT" worktree list --porcelain 2>/dev/null | awk '
|
|
155
|
+
/^worktree / { p=substr($0, 10) } # full remainder — paths may contain spaces
|
|
156
|
+
/^branch / { if ($2 ~ /refs\/heads\/forge\/m-/) print p "/.forge/runner-work/notify.log" }
|
|
157
|
+
'
|
|
158
|
+
fi
|
|
159
|
+
exit 0
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
mode_audit_sources() {
|
|
163
|
+
# Static guard: the done/halt (state-claim) branch must recompute from the fold, and the
|
|
164
|
+
# authoritative release-state var must NEVER be assigned from the payload. Catches a future
|
|
165
|
+
# rewrite that sources a merge verdict from the session's own payload rather than host truth.
|
|
166
|
+
# The guard's own two grep lines carry a #@audit tag and are excluded from the scan so their
|
|
167
|
+
# literal patterns cannot self-match the file they scan.
|
|
168
|
+
_body="$(grep -vE '#@audit' "$0")"
|
|
169
|
+
if ! printf '%s\n' "$_body" | grep -qE 'host_release_state|FORGE_JARVIS_FOLD|forge-release-fold'; then #@audit
|
|
170
|
+
printf 'forge-jarvis-relay: AUDIT FAIL — no host-truth (fold) recompute in the relay path\n' >&2
|
|
171
|
+
exit 3
|
|
172
|
+
fi
|
|
173
|
+
if printf '%s\n' "$_body" | grep -qE '_rs=["'\''$]*_payload'; then #@audit
|
|
174
|
+
printf 'forge-jarvis-relay: AUDIT FAIL — a state claim is sourced from the payload, not the host\n' >&2
|
|
175
|
+
exit 3
|
|
176
|
+
fi
|
|
177
|
+
printf 'relay speaks host truth only\n'
|
|
178
|
+
exit 0
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
mode_scan() {
|
|
182
|
+
LOG="${1:-}"; shift 2>/dev/null || true
|
|
183
|
+
while [ $# -gt 0 ]; do
|
|
184
|
+
case "$1" in
|
|
185
|
+
--marker-dir) MARKER_DIR="${2:-}"; shift 2 ;;
|
|
186
|
+
--milestone) MILESTONE="${2:-}"; shift 2 ;;
|
|
187
|
+
--mode) RELAY_MODE="${2:-live}"; shift 2 ;;
|
|
188
|
+
*) printf 'forge-jarvis-relay: unknown argument: %s\n' "$1" >&2; exit 2 ;;
|
|
189
|
+
esac
|
|
190
|
+
done
|
|
191
|
+
[ -n "$LOG" ] || { printf 'forge-jarvis-relay: --scan needs a logfile path\n' >&2; exit 2; }
|
|
192
|
+
# A dispatched-but-not-yet-pinged slice has no log YET — --list-logs deliberately emits
|
|
193
|
+
# expected paths, so catchup over that set must compose: a missing log is an EMPTY log
|
|
194
|
+
# (nothing to relay, exit 0), never a refusal. A missing path ARGUMENT stays exit 2 above.
|
|
195
|
+
[ -f "$LOG" ] || exit 0
|
|
196
|
+
[ -n "$MARKER_DIR" ] || MARKER_DIR="$(dirname "$LOG")/relay-markers"
|
|
197
|
+
mkdir -p "$MARKER_DIR" 2>/dev/null || true
|
|
198
|
+
|
|
199
|
+
_n=0; _fresh=0; _summary=""
|
|
200
|
+
# Read every line; relay only those whose offset (line number) has no marker yet.
|
|
201
|
+
while IFS= read -r _l || [ -n "$_l" ]; do
|
|
202
|
+
_n=$((_n + 1))
|
|
203
|
+
_marker="$MARKER_DIR/$_n.relayed"
|
|
204
|
+
[ -f "$_marker" ] && continue # at-most-once: already relayed, never re-buzz
|
|
205
|
+
_text="$(relay_one_line "$_l")"
|
|
206
|
+
# Write the marker BEFORE emitting — a crash after emit must not double-buzz; a crash
|
|
207
|
+
# before emit leaves it unmarked and it re-surfaces as while-you-were-away (no silent loss).
|
|
208
|
+
# A FAILED marker write must not emit either: emitting unmarked would re-buzz on every
|
|
209
|
+
# scan (at-most-once silently inverted). Say so loudly and skip — the ping stays unmarked
|
|
210
|
+
# and re-surfaces once the marker dir is fixed.
|
|
211
|
+
if ! printf '%s\toffset=%s\t%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo now)" "$_n" "$_text" > "$_marker" 2>/dev/null; then
|
|
212
|
+
printf 'forge-jarvis-relay: WARNING — cannot write marker %s; relay of line %s SUPPRESSED to preserve at-most-once. Fix the marker dir.\n' "$_marker" "$_n" >&2
|
|
213
|
+
continue
|
|
214
|
+
fi
|
|
215
|
+
_fresh=$((_fresh + 1))
|
|
216
|
+
if [ "$RELAY_MODE" = catchup ]; then
|
|
217
|
+
_summary="${_summary}${_summary:+ | }$_text"
|
|
218
|
+
else
|
|
219
|
+
printf '%s\n' "$_text" # live: one relay text per new ping → one buzz
|
|
220
|
+
fi
|
|
221
|
+
done < "$LOG"
|
|
222
|
+
|
|
223
|
+
if [ "$RELAY_MODE" = catchup ] && [ "$_fresh" -gt 0 ]; then
|
|
224
|
+
printf '📨 While you were away — %s ping(s): %s\n' "$_fresh" "$_summary"
|
|
225
|
+
fi
|
|
226
|
+
# exit 0 whether or not anything was fresh (a no-op scan is success, not failure).
|
|
227
|
+
exit 0
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# ── mode dispatch ─────────────────────────────────────────────────────────────────────────────
|
|
231
|
+
case "${1:-}" in
|
|
232
|
+
--one) shift; mode_one "$@" ;;
|
|
233
|
+
--list-logs) shift; mode_list_logs "$@" ;;
|
|
234
|
+
--audit-sources) shift; mode_audit_sources ;;
|
|
235
|
+
--scan) shift; mode_scan "$@" ;;
|
|
236
|
+
*)
|
|
237
|
+
printf 'usage: forge-jarvis-relay.sh --one "<tsv-line>" [--milestone m-N]\n' >&2
|
|
238
|
+
printf ' forge-jarvis-relay.sh --scan <logfile> [--marker-dir <dir>] [--milestone m-N] [--mode live|catchup]\n' >&2
|
|
239
|
+
printf ' forge-jarvis-relay.sh --list-logs [--worktree-root <dir>]\n' >&2
|
|
240
|
+
printf ' forge-jarvis-relay.sh --audit-sources\n' >&2
|
|
241
|
+
exit 2 ;;
|
|
242
|
+
esac
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# forge-jarvis.sh — the Jarvis launcher (m-37, Brief-R2 step 4 B2; contract handoff-step4-jarvis.md REV2).
|
|
3
|
+
#
|
|
4
|
+
# Starts the repo's ONE standing front door: a named `claude remote-control` server whose
|
|
5
|
+
# PRE-CREATED session is Jarvis — intake, dispatch, callback relay, awareness (B3-B5 attach
|
|
6
|
+
# to the session this launcher creates; this script is only the presence). Operator-started
|
|
7
|
+
# when he sits down (presence ops v1-minimal, operator-ruled 2026-07-20): no login items,
|
|
8
|
+
# no watchdogs, no daemon. The launcher is STATELESS — killing the server loses only the
|
|
9
|
+
# live connection and armed watches; everything re-establishes at restart from files
|
|
10
|
+
# (Amendment 2; B1 probe viii: dispatched work survives Jarvis's death via nohup-&).
|
|
11
|
+
#
|
|
12
|
+
# RESOLVED COMMAND SHAPE (operator ruling 6, 2026-07-21):
|
|
13
|
+
# caffeinate -s claude remote-control --name <sticky-name> --spawn worktree --capacity <N>
|
|
14
|
+
#
|
|
15
|
+
# caffeinate -s B1 probe (iii): sleep suspends local COMPUTE (dispatches, Monitor-
|
|
16
|
+
# triggered autonomous turns, sandbox allocation) while the RC
|
|
17
|
+
# connection persists — "present but asleep". -s keeps the machine
|
|
18
|
+
# awake on AC POWER ONLY; on battery, normal sleep + queue-until-wake
|
|
19
|
+
# apply. One word in the launch command, not nursing machinery.
|
|
20
|
+
# (caffeinate absent on PATH → warn + launch unwrapped, portably.)
|
|
21
|
+
# --name B1 probe (ii): the phone lists sessions by their first typed message;
|
|
22
|
+
# the pre-created session's --name is the ONLY sticky, recognizable
|
|
23
|
+
# discoverability anchor. Default: jarvis-<repo-basename>; a per-machine
|
|
24
|
+
# suffix names each machine on multi-machine repos (forge:
|
|
25
|
+
# jarvis-forge-personal / jarvis-forge-work).
|
|
26
|
+
# --spawn worktree on-demand sessions get isolated worktrees (step 0, platform-enforced).
|
|
27
|
+
# NOTE (probe ii finding): spawned bridge worktrees live under
|
|
28
|
+
# .claude/worktrees/ and do NOT auto-clean — B5 presence-ops owns that.
|
|
29
|
+
# --capacity sane for one operator; default 4.
|
|
30
|
+
#
|
|
31
|
+
# AUTH — NO TOKEN EXPORT (B1 probe iv): headless `claude -p` dispatches authenticate via
|
|
32
|
+
# the machine's KEYCHAIN credential with CLAUDE_CODE_OAUTH_TOKEN unset. This launcher must
|
|
33
|
+
# never export a token; if a thinner env ever surfaces the need, `claude setup-token` is
|
|
34
|
+
# the operator's minting path — never committed, never set here.
|
|
35
|
+
#
|
|
36
|
+
# PERMISSIONS — NO FLAGS NEEDED FOR THE PRE-CREATED SESSION (B1 probe vii): typed Bash,
|
|
37
|
+
# Monitor-arm, and PushNotification all run prompt-free in the pre-created session as-is;
|
|
38
|
+
# `--permission-mode` governs only SPAWNED sessions. Nothing to provision here. (Probe vii
|
|
39
|
+
# still owes an explicit check for any OTHER tool a later phase uses unattended.)
|
|
40
|
+
#
|
|
41
|
+
# CONFIG — optional `jarvis:` block in .forge/project.yml (board-block precedent):
|
|
42
|
+
# jarvis:
|
|
43
|
+
# enabled: true # explicit false = this launcher refuses (door switched off)
|
|
44
|
+
# name_suffix: "" # appended: jarvis-<repo>-<suffix>; env FORGE_JARVIS_NAME_SUFFIX
|
|
45
|
+
# # overrides per machine (the multi-machine seam)
|
|
46
|
+
# capacity: 4 # RC server session capacity
|
|
47
|
+
# NO block → sensible defaults (the launcher is operator-invoked, not ambient — unlike
|
|
48
|
+
# the board, absence does not mean inert).
|
|
49
|
+
#
|
|
50
|
+
# USAGE:
|
|
51
|
+
# .forge/checks/forge-jarvis.sh [--dry-run] [--name <full-name>] [--capacity <N>]
|
|
52
|
+
# --dry-run print the fully resolved command on stdout and exit 0 — never executes.
|
|
53
|
+
# --name full sticky-name override (skips derivation entirely).
|
|
54
|
+
# --capacity override config/default capacity.
|
|
55
|
+
#
|
|
56
|
+
# BOUNDARIES (contract "Not this step"): no routing, no approval surface, no new
|
|
57
|
+
# notification infrastructure, no Jarvis-owned state. This script keeps none.
|
|
58
|
+
set -u
|
|
59
|
+
|
|
60
|
+
# --- args -------------------------------------------------------------------
|
|
61
|
+
DRY_RUN=0
|
|
62
|
+
NAME_OVERRIDE=""
|
|
63
|
+
CAP_OVERRIDE=""
|
|
64
|
+
while [ $# -gt 0 ]; do
|
|
65
|
+
case "$1" in
|
|
66
|
+
--dry-run) DRY_RUN=1; shift ;;
|
|
67
|
+
--name) shift; NAME_OVERRIDE="${1:-}"; [ $# -gt 0 ] && shift || true ;;
|
|
68
|
+
--name=*) NAME_OVERRIDE="${1#*=}"; shift ;;
|
|
69
|
+
--capacity) shift; CAP_OVERRIDE="${1:-}"; [ $# -gt 0 ] && shift || true ;;
|
|
70
|
+
--capacity=*) CAP_OVERRIDE="${1#*=}"; shift ;;
|
|
71
|
+
*) printf 'forge-jarvis: unknown argument: %s\n' "$1" >&2; exit 2 ;;
|
|
72
|
+
esac
|
|
73
|
+
done
|
|
74
|
+
|
|
75
|
+
# --- repo (refusal, not fallback: --spawn worktree needs a real git repo) ----
|
|
76
|
+
ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || {
|
|
77
|
+
printf 'forge-jarvis: not inside a git repo — `claude remote-control --spawn worktree` needs one.\n' >&2
|
|
78
|
+
exit 2
|
|
79
|
+
}
|
|
80
|
+
PROJECT="$ROOT/.forge/project.yml"
|
|
81
|
+
# Repo identity for the sticky name: the git COMMON dir's parent, not the checkout's
|
|
82
|
+
# toplevel — stable across worktrees (a worktree basename like m-37 must never leak
|
|
83
|
+
# into the phone-visible name). The presence itself belongs on the MAIN checkout
|
|
84
|
+
# (the shape: Jarvis runs on the repo's main checkout in a real terminal).
|
|
85
|
+
COMMON="$(git rev-parse --git-common-dir)"
|
|
86
|
+
REPO_BASE="$(basename "$(cd "$(dirname "$COMMON")" && pwd)")"
|
|
87
|
+
|
|
88
|
+
# --- config: read a scalar under the top-level `jarvis:` block ---------------
|
|
89
|
+
jarvis_cfg() { # $1 = key under jarvis:
|
|
90
|
+
[ -f "$PROJECT" ] || return 0
|
|
91
|
+
awk -v k="$1" '
|
|
92
|
+
/^jarvis:/ { inb=1; next }
|
|
93
|
+
inb && /^[A-Za-z]/ { inb=0 }
|
|
94
|
+
inb && $0 ~ "^[[:space:]]+"k":" {
|
|
95
|
+
sub(/^[[:space:]]*[^:]+:[[:space:]]*/, "")
|
|
96
|
+
sub(/[[:space:]]*#.*/, "")
|
|
97
|
+
gsub(/^["'"'"']|["'"'"']$/, "")
|
|
98
|
+
print; exit
|
|
99
|
+
}
|
|
100
|
+
' "$PROJECT"
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# --- enabled gate (explicit false refuses; absent block = defaults) ----------
|
|
104
|
+
if [ "$(jarvis_cfg enabled)" = "false" ]; then
|
|
105
|
+
printf 'forge-jarvis: jarvis.enabled is false in .forge/project.yml — the door is switched off.\n' >&2
|
|
106
|
+
exit 2
|
|
107
|
+
fi
|
|
108
|
+
|
|
109
|
+
# --- sticky name (probe ii: the only discoverability anchor) -----------------
|
|
110
|
+
if [ -n "$NAME_OVERRIDE" ]; then
|
|
111
|
+
NAME="$NAME_OVERRIDE"
|
|
112
|
+
else
|
|
113
|
+
NAME="jarvis-$REPO_BASE"
|
|
114
|
+
# per-machine seam: env overrides config (multi-machine repos name each machine)
|
|
115
|
+
SUFFIX="${FORGE_JARVIS_NAME_SUFFIX:-$(jarvis_cfg name_suffix)}"
|
|
116
|
+
[ -n "$SUFFIX" ] && NAME="$NAME-$SUFFIX"
|
|
117
|
+
fi
|
|
118
|
+
# The resolved name reaches the phone's session list AND the --dry-run line the
|
|
119
|
+
# operator may copy-paste — keep it to a safe, phone-legible charset regardless of
|
|
120
|
+
# where it came from (override flag, config name_suffix, env).
|
|
121
|
+
case "$NAME" in
|
|
122
|
+
*[!A-Za-z0-9._-]*|'')
|
|
123
|
+
printf 'forge-jarvis: sticky name must be [A-Za-z0-9._-] only, got: %s\n' "$NAME" >&2; exit 2 ;;
|
|
124
|
+
esac
|
|
125
|
+
|
|
126
|
+
# --- capacity ----------------------------------------------------------------
|
|
127
|
+
CAP="${CAP_OVERRIDE:-$(jarvis_cfg capacity)}"
|
|
128
|
+
CAP="${CAP:-4}"
|
|
129
|
+
case "$CAP" in
|
|
130
|
+
*[!0-9]*|'') printf 'forge-jarvis: capacity must be a positive integer, got: %s\n' "$CAP" >&2; exit 2 ;;
|
|
131
|
+
esac
|
|
132
|
+
|
|
133
|
+
# --- resolve -----------------------------------------------------------------
|
|
134
|
+
# The ONE command shape, built once — --dry-run prints exactly the argv launch
|
|
135
|
+
# execs (modulo the caffeinate degradation below), so the two can never drift.
|
|
136
|
+
set -- claude remote-control --name "$NAME" --spawn worktree --capacity "$CAP"
|
|
137
|
+
|
|
138
|
+
if [ "$DRY_RUN" = "1" ]; then
|
|
139
|
+
printf 'caffeinate -s %s\n' "$*"
|
|
140
|
+
exit 0
|
|
141
|
+
fi
|
|
142
|
+
|
|
143
|
+
# Boot arming (contract B4(a)) is the PRE-CREATED SESSION's act, not the launcher's:
|
|
144
|
+
# the session arms Monitor `tail -F` watches on the live notify logs per
|
|
145
|
+
# .claude/skills/jarvis/SKILL.md ("Session binding" / arm-the-watches). The launcher
|
|
146
|
+
# stays presence-only and deliberately keeps no relay glue.
|
|
147
|
+
|
|
148
|
+
# --- launch ------------------------------------------------------------------
|
|
149
|
+
command -v claude >/dev/null 2>&1 || {
|
|
150
|
+
printf 'forge-jarvis: `claude` not found on PATH.\n' >&2
|
|
151
|
+
exit 2
|
|
152
|
+
}
|
|
153
|
+
printf 'forge-jarvis: starting %s (capacity %s) in %s\n' "$NAME" "$CAP" "$ROOT" >&2
|
|
154
|
+
cd "$ROOT" || exit 2
|
|
155
|
+
if command -v caffeinate >/dev/null 2>&1; then
|
|
156
|
+
exec caffeinate -s "$@"
|
|
157
|
+
else
|
|
158
|
+
# non-macOS: no caffeinate — launch unwrapped; sleep then queues-until-wake (probe iii).
|
|
159
|
+
printf 'forge-jarvis: caffeinate not found — launching without keep-awake (lid-closed autonomy degrades to queue-until-wake).\n' >&2
|
|
160
|
+
exec "$@"
|
|
161
|
+
fi
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Fixture test suite for .forge/checks/forge-jarvis-answer.sh (m-37, step 4 B4(d); plan 68-02).
|
|
3
|
+
#
|
|
4
|
+
# Contract under test (handoff-step4-jarvis.md REV2 B4(d); NFR-037; acceptance #2):
|
|
5
|
+
# - the answer lands at the EXACT m-35 path: <work-dir>/phase-<N>/answer.md, with the reply text.
|
|
6
|
+
# - after writing, the slice is re-dispatched via forge-jarvis-dispatch.sh (stubbed here).
|
|
7
|
+
# - NEG-CONTROL: no answer content → nothing written, nothing re-dispatched (slice stays parked).
|
|
8
|
+
# - the reply may arrive via --answer or via stdin.
|
|
9
|
+
#
|
|
10
|
+
# The dispatch is stubbed via FORGE_JARVIS_DISPATCH (records that it was called) so no real runner
|
|
11
|
+
# spawns. Each assertion prints WHY. RED-friendly: a missing script fails every case.
|
|
12
|
+
set -u
|
|
13
|
+
|
|
14
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
15
|
+
ANSWER="$SCRIPT_DIR/../forge-jarvis-answer.sh"
|
|
16
|
+
[ -f "$ANSWER" ] || printf 'NOTE: %s missing — RED phase, every case should FAIL\n' "$ANSWER" >&2
|
|
17
|
+
|
|
18
|
+
ROOT="$(mktemp -d)"
|
|
19
|
+
trap 'rm -rf "$ROOT"' EXIT INT TERM
|
|
20
|
+
PASSED=0; FAILED=0
|
|
21
|
+
ok() { PASSED=$((PASSED+1)); printf ' ok %s\n' "$1"; }
|
|
22
|
+
bad() { FAILED=$((FAILED+1)); printf ' FAIL %s\n %s\n' "$1" "$2"; }
|
|
23
|
+
|
|
24
|
+
# stub dispatch: record the call so we can assert re-dispatch happened (no real runner spawns)
|
|
25
|
+
STUBD="$ROOT/dispatch-stub.sh"
|
|
26
|
+
CALLED="$ROOT/dispatch.called"
|
|
27
|
+
printf '#!/usr/bin/env sh\nprintf "%%s\\n" "$*" > "%s"\n' "$CALLED" > "$STUBD"
|
|
28
|
+
chmod +x "$STUBD"
|
|
29
|
+
SLICE="$ROOT/slice"; mkdir -p "$SLICE"
|
|
30
|
+
|
|
31
|
+
# 1. answer via --answer → lands at the EXACT m-35 path with the reply text
|
|
32
|
+
WD="$ROOT/w1"; rm -f "$CALLED"
|
|
33
|
+
FORGE_JARVIS_DISPATCH="$STUBD" sh "$ANSWER" --work-dir "$WD" --phase 3 --slice "$SLICE" --answer "Use JWT." >/dev/null 2>&1
|
|
34
|
+
if [ -f "$WD/phase-3/answer.md" ] && grep -q 'Use JWT.' "$WD/phase-3/answer.md"; then
|
|
35
|
+
ok "answer lands at the EXACT m-35 path <work-dir>/phase-3/answer.md with the reply text"
|
|
36
|
+
else
|
|
37
|
+
bad "m-35 answer path" "missing or wrong content at $WD/phase-3/answer.md"
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
# 2. re-dispatch invoked (the stub recorded the call, carrying --slice + --work-dir)
|
|
41
|
+
if [ -f "$CALLED" ] && grep -q -- "--slice $SLICE" "$CALLED" && grep -q -- "--work-dir $WD" "$CALLED"; then
|
|
42
|
+
ok "slice re-dispatched after the answer landed (forge-jarvis-dispatch called with --slice/--work-dir)"
|
|
43
|
+
else
|
|
44
|
+
bad "re-dispatch" "dispatch not called or missing args: $(cat "$CALLED" 2>/dev/null)"
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
# 3. NEG-CONTROL: no answer (empty stdin) → nothing written, NO re-dispatch, non-zero exit
|
|
48
|
+
WD2="$ROOT/w2"; rm -f "$CALLED"
|
|
49
|
+
FORGE_JARVIS_DISPATCH="$STUBD" sh "$ANSWER" --work-dir "$WD2" --phase 3 --slice "$SLICE" </dev/null >/dev/null 2>&1; rc=$?
|
|
50
|
+
if [ "$rc" != 0 ] && [ ! -f "$WD2/phase-3/answer.md" ] && [ ! -f "$CALLED" ]; then
|
|
51
|
+
ok "neg-control: no answer → nothing written, no re-dispatch (slice stays parked), non-zero exit"
|
|
52
|
+
else
|
|
53
|
+
bad "neg-control no-answer" "rc=$rc wrote=$([ -f "$WD2/phase-3/answer.md" ] && echo yes) dispatched=$([ -f "$CALLED" ] && echo yes)"
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
# 4. answer via stdin also works (phone/typed reply piped in)
|
|
57
|
+
WD3="$ROOT/w3"; rm -f "$CALLED"
|
|
58
|
+
printf 'sessions, not JWT' | FORGE_JARVIS_DISPATCH="$STUBD" sh "$ANSWER" --work-dir "$WD3" --phase 7 --slice "$SLICE" >/dev/null 2>&1
|
|
59
|
+
if [ -f "$WD3/phase-7/answer.md" ] && grep -q 'sessions, not JWT' "$WD3/phase-7/answer.md" && [ -f "$CALLED" ]; then
|
|
60
|
+
ok "answer via stdin lands at phase-7/answer.md and re-dispatches"
|
|
61
|
+
else
|
|
62
|
+
bad "stdin answer" "missing write or dispatch for stdin path"
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
# 5. --dry-run: nothing written, nothing dispatched
|
|
66
|
+
WD4="$ROOT/w4"; rm -f "$CALLED"
|
|
67
|
+
FORGE_JARVIS_DISPATCH="$STUBD" sh "$ANSWER" --work-dir "$WD4" --phase 2 --slice "$SLICE" --answer "x" --dry-run >/dev/null 2>&1
|
|
68
|
+
if [ ! -f "$WD4/phase-2/answer.md" ] && [ ! -f "$CALLED" ]; then
|
|
69
|
+
ok "--dry-run writes nothing and dispatches nothing"
|
|
70
|
+
else
|
|
71
|
+
bad "dry-run side effects" "dry-run wrote or dispatched"
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
printf '\n%s: %d passed, %d failed\n' "$(basename "$0")" "$PASSED" "$FAILED"
|
|
75
|
+
[ "$FAILED" = 0 ]
|