forge-orkes 0.64.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-orkes",
3
- "version": "0.64.0",
3
+ "version": "0.64.2",
4
4
  "description": "Set up the Forge meta-prompting framework for Claude Code in your project",
5
5
  "bin": {
6
6
  "create-forge": "./bin/create-forge.js"
@@ -164,7 +164,7 @@ RC on-demand `--spawn worktree` sessions leave `.claude/worktrees/bridge-{cse-id
164
164
  .forge/checks/forge-jarvis-bridge-clean.sh # then reap
165
165
  ```
166
166
 
167
- Liveness is best-effort (dirty **or** freshly-modified = keep) with `git worktree remove` refusing a dirty worktree as a second net; only `bridge-*` basenames are ever eligible. Run it as light housekeeping, not on a timer — there is no daemon.
167
+ Liveness is decided in this order (FR-207 gap-closure): dirty = keep; a git lock naming a **live** server pid = keep; a lock with a **dead** pid = orphan even when freshly modified (unlock-then-remove — RC locks every spawned bridge under the server's pid, so a fresh-looking bridge whose server died is still reapable); mtime freshness applies only to **unlocked** bridges. `git worktree remove` refusing a dirty worktree stays the second net; only `bridge-*` basenames are ever eligible. Run it as light housekeeping, not on a timer — there is no daemon.
168
168
 
169
169
  ### The hub trigger stays watched, not built
170
170
 
@@ -181,7 +181,7 @@ The last thing Jarvis does — and the sharpest boundary. On the operator's **ex
181
181
 
182
182
  The helper does exactly two things, in order:
183
183
 
184
- 1. **Shows what the SHA carries** — recomputed live from the board projection (and the fold), never a session's word. The merged-but-not-yet-validated units are the ones this promotion covers, **by containment**: a unit becomes `validated` when its landed SHA is an ancestor of the signed `promotion/<sha>` tag's commit (ADR-024), verified offline on the host.
184
+ 1. **Shows what the SHA carries** — recomputed live from the board projection (whose rows carry the fold-derived `release_state`), never a session's word. The merged-but-not-yet-validated units are the ones this promotion covers, **by containment**: a unit becomes `validated` when its landed SHA is an ancestor of the signed `promotion/<sha>` tag's commit (ADR-024), verified offline on the host.
185
185
  2. **Triggers the step-2 seam** — `gh workflow run promote.yml -f sha=<full-sha>` — and stops. The flow halts at GitHub's Environment gates (both taps: deploy + mint) until you press them there.
186
186
 
187
187
  ### Jarvis never approves — it refuses if asked
@@ -29,7 +29,6 @@ SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
29
29
  DISPATCH="${FORGE_JARVIS_DISPATCH:-$SELF_DIR/forge-jarvis-dispatch.sh}"
30
30
 
31
31
  WORK_DIR=""; PHASE=""; SLICE=""; ANSWER=""; ANSWER_FILE=""; DRY=0
32
- FWD=""
33
32
  while [ $# -gt 0 ]; do
34
33
  case "$1" in
35
34
  --work-dir) WORK_DIR="${2:-}"; shift 2 ;;
@@ -38,14 +37,22 @@ while [ $# -gt 0 ]; do
38
37
  --answer) ANSWER="${2:-}"; shift 2 ;;
39
38
  --answer-file) ANSWER_FILE="${2:-}"; shift 2 ;;
40
39
  --dry-run) DRY=1; shift ;;
41
- --) shift; FWD="$*"; break ;;
42
- *) shift ;;
40
+ --) shift; break ;;
41
+ *) printf 'forge-jarvis-answer: unknown argument: %s\n' "$1" >&2; exit 2 ;;
43
42
  esac
44
43
  done
44
+ # After `--`, the remaining "$@" is forwarded to dispatch verbatim — argument
45
+ # boundaries preserved (never flattened to a string; args with spaces survive).
45
46
 
46
47
  [ -n "$WORK_DIR" ] && [ -n "$PHASE" ] && [ -n "$SLICE" ] || {
47
48
  printf 'forge-jarvis-answer: --work-dir, --phase and --slice are all required\n' >&2; exit 2; }
48
49
 
50
+ # The phase index comes from a park ping (semi-trusted runner log) and is built
51
+ # into a write path — a non-numeric value could traverse out of the work dir.
52
+ case "$PHASE" in
53
+ *[!0-9]*|'') printf 'forge-jarvis-answer: --phase must be a numeric phase index, got: %s\n' "$PHASE" >&2; exit 2 ;;
54
+ esac
55
+
49
56
  # Resolve the answer text: --answer, else --answer-file, else stdin.
50
57
  if [ -z "$ANSWER" ]; then
51
58
  if [ -n "$ANSWER_FILE" ] && [ -f "$ANSWER_FILE" ]; then
@@ -65,7 +72,7 @@ DEST="$WORK_DIR/phase-$PHASE/answer.md" # the EXACT m-35 convention
65
72
 
66
73
  if [ "$DRY" = 1 ]; then
67
74
  printf 'answer plan: write → %s ; then re-dispatch %s --slice %s --work-dir %s %s\n' \
68
- "$DEST" "$DISPATCH" "$SLICE" "$WORK_DIR" "$FWD"
75
+ "$DEST" "$DISPATCH" "$SLICE" "$WORK_DIR" "$*"
69
76
  exit 0
70
77
  fi
71
78
 
@@ -75,5 +82,4 @@ printf '%s\n' "$ANSWER" > "$DEST" || { printf 'forge-jarvis-answer: could not wr
75
82
  printf 'forge-jarvis-answer: answer landed at %s\n' "$DEST" >&2
76
83
 
77
84
  # 2) re-dispatch the slice so the runner resumes past the park (m-35 prompt re-assembly)
78
- # shellcheck disable=SC2086
79
- "$DISPATCH" --slice "$SLICE" --work-dir "$WORK_DIR" $FWD
85
+ "$DISPATCH" --slice "$SLICE" --work-dir "$WORK_DIR" "$@"
@@ -47,7 +47,7 @@ while [ $# -gt 0 ]; do
47
47
  case "$1" in
48
48
  --dry-run) DRY_RUN=1; shift ;;
49
49
  --worktree-root) WT_ROOT="${2:-}"; shift 2 ;;
50
- *) shift ;;
50
+ *) printf 'forge-jarvis-awareness: unknown argument: %s\n' "$1" >&2; exit 2 ;;
51
51
  esac
52
52
  done
53
53
 
@@ -73,7 +73,7 @@ list_slices() {
73
73
  done
74
74
  else
75
75
  git -C "$REPO_ROOT" worktree list --porcelain 2>/dev/null | awk -v tab="$TAB" '
76
- /^worktree / { p = $2 }
76
+ /^worktree / { p = substr($0, 10) } # full remainder — paths may contain spaces
77
77
  /^branch / {
78
78
  if ($2 ~ /refs\/heads\/forge\/m-/) {
79
79
  b = $2; sub(/refs\/heads\//, "", b) # forge/m-37[-<session>]
@@ -108,9 +108,14 @@ elif [ -f "$BOARD" ]; then
108
108
  # no jq dependency (Article X native-first).
109
109
  _inflight="$(printf '%s\n' "$_emit" | grep '"forge_id"' | grep -vE '"status":[[:space:]]*"complete"')"
110
110
  if [ -n "$_inflight" ]; then
111
- printf '%s\n' "$_inflight" \
112
- | sed -E 's/.*"forge_id":[[:space:]]*"([^"]+)".*"status":[[:space:]]*"([^"]+)".*"release_state":[[:space:]]*"([^"]+)".*/ \1 status=\2 release_state=\3/' \
113
- | head -40
111
+ # Order-independent field extraction — one sed per key, so a key-order change
112
+ # in the board emit degrades a field to '?' instead of silently dropping the line.
113
+ printf '%s\n' "$_inflight" | head -40 | while IFS= read -r _ln; do
114
+ _id="$(printf '%s' "$_ln" | sed -n 's/.*"forge_id":[[:space:]]*"\([^"]*\)".*/\1/p')"
115
+ _st="$(printf '%s' "$_ln" | sed -n 's/.*"status":[[:space:]]*"\([^"]*\)".*/\1/p')"
116
+ _rs="$(printf '%s' "$_ln" | sed -n 's/.*"release_state":[[:space:]]*"\([^"]*\)".*/\1/p')"
117
+ printf ' %s status=%s release_state=%s\n' "${_id:-?}" "${_st:-?}" "${_rs:-?}"
118
+ done
114
119
  else
115
120
  printf ' (all board units complete — nothing in flight)\n'
116
121
  fi
@@ -24,7 +24,8 @@
24
24
  # forge-jarvis-bridge-clean.sh [--dry-run] [--stale-hours N] [--worktrees-root <dir>]
25
25
  # --dry-run list orphan candidates; remove NOTHING (default is to remove).
26
26
  # --stale-hours N freshness window; a bridge modified within N hours is LIVE (default 6).
27
- # --worktrees-root override the scan root (tests); default: <repo>/.claude/worktrees.
27
+ # --worktree-root override the scan root (tests); default: <repo>/.claude/worktrees.
28
+ # (--worktrees-root accepted as an alias — historical spelling.)
28
29
  set -u
29
30
 
30
31
  SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -37,8 +38,10 @@ while [ $# -gt 0 ]; do
37
38
  case "$1" in
38
39
  --dry-run) DRY_RUN=1; shift ;;
39
40
  --stale-hours) STALE_HOURS="${2:-6}"; shift 2 ;;
40
- --worktrees-root) WT_ROOT="${2:-}"; shift 2 ;;
41
- *) shift ;;
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 ;;
42
45
  esac
43
46
  done
44
47
  [ -n "$WT_ROOT" ] || WT_ROOT="$REPO_ROOT/.claude/worktrees"
@@ -121,18 +121,40 @@ materialize_work_order() {
121
121
  fi
122
122
  fi
123
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
124
130
  printf '# Materialized by forge-jarvis-dispatch from %s (delete to re-materialize)\nphases:\n' "${_mdir#"$WORKTREE"/}" > "$_tmp"
125
131
  _count=0
126
- for _p in $(ls "$_mdir" 2>/dev/null | sort -n); do
127
- [ -d "$_mdir/$_p" ] || continue
128
- _plans="$(ls "$_mdir/$_p"/plan-*.md 2>/dev/null | sort)"
129
- [ -n "$_plans" ] || continue
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
130
141
  mkdir -p "$WORKTREE/phases/$_p"
131
- # shellcheck disable=SC2086
132
- cat $_plans > "$WORKTREE/phases/$_p/plan.md"
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
133
153
  printf ' - %s\n' "$_p" >> "$_tmp"
134
154
  _count=$((_count + 1))
135
- done
155
+ done <<MATERIALIZE_EOF
156
+ $_names
157
+ MATERIALIZE_EOF
136
158
  if [ "$_count" -eq 0 ]; then
137
159
  rm -f "$_tmp"
138
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
@@ -49,7 +49,7 @@ while [ $# -gt 0 ]; do
49
49
  --since) SINCE="${2:-}"; shift 2 ;;
50
50
  --worktree-root) WT_ROOT="${2:-}"; shift 2 ;;
51
51
  --dry-run) DRY_RUN=1; shift ;;
52
- *) shift ;;
52
+ *) printf 'forge-jarvis-instruments: unknown argument: %s\n' "$1" >&2; exit 2 ;;
53
53
  esac
54
54
  done
55
55
 
@@ -70,12 +70,15 @@ if [ -z "$LOGS" ]; then
70
70
  done
71
71
  else
72
72
  _globbed="$(git -C "$REPO_ROOT" worktree list --porcelain 2>/dev/null | awk '
73
- /^worktree / { p = $2 }
73
+ /^worktree / { p = substr($0, 10) } # full remainder — paths may contain spaces
74
74
  /^branch / { if ($2 ~ /refs\/heads\/forge\/m-/) print p "/.forge/runner-work/notify.log" }')"
75
- for _l in $_globbed; do
75
+ while IFS= read -r _l; do
76
+ [ -n "$_l" ] || continue
76
77
  [ -f "$_l" ] && LOGS="${LOGS}${LOGS:+
77
78
  }$_l"
78
- done
79
+ done <<GLOBBED_EOF
80
+ $_globbed
81
+ GLOBBED_EOF
79
82
  fi
80
83
  fi
81
84
 
@@ -30,6 +30,12 @@ set -u
30
30
  TYPE="${1:-note}"
31
31
  PAYLOAD="${2:-}"
32
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
+
33
39
  # Durable local record — the ONLY thing this channel does. Default location travels with
34
40
  # the slice (FORGE_SLICE_WORK_DIR), but a Jarvis dispatch always sets FORGE_SLICE_NOTIFY_LOG
35
41
  # explicitly INTO the slice worktree.
@@ -3,10 +3,11 @@
3
3
  # handoff-step4-jarvis.md REV2; FR-208; NFR-038; operator ruling 3 "trigger-not-approve").
4
4
  #
5
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 + the
7
- # fold), never a session's word. The merged-but-not-yet-validated units are the ones a
8
- # promotion covers, by CONTAINMENT: a unit becomes validated when its landed SHA is an
9
- # ancestor of the signed promotion tag's commit (ADR-024) — verified offline, host-side.
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.
10
11
  # 2. TRIGGER the step-2 seam: gh workflow run promote.yml -f sha=<full-sha>
11
12
  #
12
13
  # That is ALL it does. It NEVER approves, waives, or lowers anything: the flow STOPS at
@@ -22,8 +23,8 @@
22
23
  # --dry-run SHOW host state, then PRINT the trigger command without running it.
23
24
  #
24
25
  # HOST-TRUTH commands (env-overridable so tests inject stubs and degradation is explicit):
25
- # FORGE_JARVIS_BOARD default .forge/checks/forge-board-render.sh (the rollup projection)
26
- # FORGE_JARVIS_FOLD default .claude/hooks/forge-release-fold.sh (per-unit release_state)
26
+ # FORGE_JARVIS_BOARD default .forge/checks/forge-board-render.sh (the rollup projection;
27
+ # its rows already carry the fold-derived release_state)
27
28
  # FORGE_JARVIS_GH default `gh` (the trigger — never an approval)
28
29
  # BEST-EFFORT: a missing board/fold degrades to a stated '(unavailable)' — the show still
29
30
  # renders; a missing gh in a real (non-dry-run) trigger is a clear error, never a silent success.
@@ -32,7 +33,6 @@ set -u
32
33
  SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
33
34
  REPO_ROOT="$(cd "$SELF_DIR/../.." && pwd)" # .forge/checks → repo root
34
35
  BOARD="${FORGE_JARVIS_BOARD:-$REPO_ROOT/.forge/checks/forge-board-render.sh}"
35
- FOLD="${FORGE_JARVIS_FOLD:-$REPO_ROOT/.claude/hooks/forge-release-fold.sh}"
36
36
  GH="${FORGE_JARVIS_GH:-gh}"
37
37
  WORKFLOW="promote.yml"
38
38
 
@@ -41,8 +41,14 @@ DRY_RUN=0
41
41
  while [ $# -gt 0 ]; do
42
42
  case "$1" in
43
43
  --dry-run) DRY_RUN=1; shift ;;
44
- --*) shift ;; # tolerate/ignore unknown flags
45
- *) [ -z "$SHA" ] && SHA="$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 ;;
46
52
  esac
47
53
  done
48
54
 
@@ -65,9 +71,14 @@ if [ -f "$BOARD" ]; then
65
71
  # line-oriented JSON -> grep/sed, no jq (Article X native-first) — same parse as awareness.
66
72
  _rel="$(printf '%s\n' "$_emit" | grep '"forge_id"' | grep -E '"release_state":[[:space:]]*"(merged|validated)"')"
67
73
  if [ -n "$_rel" ]; then
68
- printf '%s\n' "$_rel" \
69
- | sed -E 's/.*"forge_id":[[:space:]]*"([^"]+)".*"release_state":[[:space:]]*"([^"]+)".*/ \1 release_state=\2/' \
70
- | head -60
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
71
82
  else
72
83
  printf ' (no merged units in the board projection — nothing waiting on a promotion)\n'
73
84
  fi
@@ -93,7 +104,9 @@ if ! command -v "$GH" >/dev/null 2>&1; then
93
104
  fi
94
105
  printf ' %s\n' "$CMD"
95
106
  # gh workflow run promote.yml -f sha=<full-sha> — the trigger, never an approval call.
96
- "$GH" workflow run "$WORKFLOW" -f sha="$SHA"
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")
97
110
  _rc=$?
98
111
  if [ "$_rc" -eq 0 ]; then
99
112
  printf '\n[ok] Triggered promote.yml for %s. It now STOPS at GitHub Environment gates — press them there; Jarvis does not.\n' "$SHA"
@@ -19,7 +19,9 @@
19
19
  # --staging-env <name> deployment environment that means "on staging" (default: staging)
20
20
  # --max-iterations <N> hard poll bound (default 30)
21
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 (default: cwd/.relay-markers)
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)
23
25
  # --dry-run print the resolved plan (pr, repo, env, bound) and exit; no polling
24
26
  set -u
25
27
 
@@ -35,12 +37,28 @@ while [ $# -gt 0 ]; do
35
37
  --interval) INTERVAL="${2:-20}"; shift 2 ;;
36
38
  --marker-dir) MARKER_DIR="${2:-}"; shift 2 ;;
37
39
  --dry-run) DRY=1; shift ;;
38
- *) shift ;;
40
+ *) printf 'forge-jarvis-prwatch: unknown argument: %s\n' "$1" >&2; exit 2 ;;
39
41
  esac
40
42
  done
41
43
  [ -n "$PR" ] || { printf 'forge-jarvis-prwatch: --pr <num> is required\n' >&2; exit 2; }
42
- [ -n "$MARKER_DIR" ] || MARKER_DIR="./.relay-markers"
43
- REPO_ARG=""; [ -n "$REPO" ] && REPO_ARG="--repo $REPO"
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
44
62
 
45
63
  if [ "$DRY" = 1 ]; then
46
64
  printf 'prwatch plan: pr=%s repo=%s env=%s bound=%s×%ss marker-dir=%s\n' \
@@ -54,11 +72,10 @@ fi
54
72
  # staging deploy → `gh api repos/{owner}/{repo}/deployments?environment=<env>` + its statuses
55
73
  # Both read host truth; neither consults anything a build session emitted.
56
74
  pr_merge_state() { # → MERGED | CLOSED | OPEN (empty on gh error → treated as OPEN, keep polling)
57
- # shellcheck disable=SC2086
58
- "$GH" pr view "$PR" $REPO_ARG --json state --jq '.state' 2>/dev/null
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
59
77
  }
60
78
  deploy_succeeded() { # → exit 0 iff the latest staging deployment is in a success state
61
- # shellcheck disable=SC2086
62
79
  _st="$("$GH" api "repos/{owner}/{repo}/deployments?environment=$ENV_STAGING&per_page=1" \
63
80
  --jq '.[0].statuses_url' 2>/dev/null)"
64
81
  [ -n "$_st" ] || return 1
@@ -32,8 +32,8 @@
32
32
  #
33
33
  # HOST-TRUTH COMMANDS (env-overridable so tests inject stubs, and so degradation is explicit):
34
34
  # FORGE_JARVIS_FOLD default .claude/hooks/forge-release-fold.sh (release_state recompute)
35
- # FORGE_JARVIS_GH default `gh` (PR/check facts plan-02 leans on it)
36
- # FORGE_JARVIS_BOARD default .forge/checks/forge-board-render.sh (rollup 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
37
  #
38
38
  # BEST-EFFORT: a fold/gh/board that is absent or errors degrades to release_state=unknown and
39
39
  # says so — never a silent success, never a hard fail of the relay path.
@@ -45,9 +45,12 @@ FOLD="${FORGE_JARVIS_FOLD:-$REPO_ROOT/.claude/hooks/forge-release-fold.sh}"
45
45
 
46
46
  # ── host truth: the slice's release_state, recomputed from the fold (never the payload) ───────
47
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.
48
50
  _mid="${1:-}"
49
51
  [ -n "$_mid" ] || { printf 'unknown'; return 0; }
50
- _out="$("$FOLD" "$_mid" 2>/dev/null)" || { printf 'unknown'; return 0; }
52
+ [ -f "$FOLD" ] || { printf 'unknown'; return 0; }
53
+ _out="$(sh "$FOLD" "$_mid" 2>/dev/null)" || { printf 'unknown'; return 0; }
51
54
  _rs="$(printf '%s\n' "$_out" | grep -m1 'release_state=' | sed 's/.*release_state=//; s/[[:space:]].*//')"
52
55
  [ -n "$_rs" ] && printf '%s' "$_rs" || printf 'unknown'
53
56
  }
@@ -110,102 +113,126 @@ relay_one_line() { # relay_one_line <tsv-line>
110
113
  build_relay_text "$_type" "$_payload"
111
114
  }
112
115
 
113
- # ── mode dispatch ─────────────────────────────────────────────────────────────────────────────
116
+ # ── modes — one function per mode, each owning its own arg loop ───────────────────────────────
114
117
  MILESTONE=""
115
- MODE_LIVE="live"
118
+ RELAY_MODE="live" # 'live' | 'catchup' — how --scan emits (not a boolean)
116
119
  MARKER_DIR=""
117
120
 
118
- case "${1:-}" in
119
- --one)
120
- shift
121
- LINE="${1:-}"; shift 2>/dev/null || true
122
- while [ $# -gt 0 ]; do case "$1" in --milestone) MILESTONE="${2:-}"; shift 2 ;; *) shift ;; esac; done
123
- relay_one_line "$LINE"; printf '\n'
124
- exit 0 ;;
125
-
126
- --list-logs)
127
- # The watch set the skill arms `tail -F` on — recomputed from a DISK GLOB on EVERY call,
128
- # never held in memory (Amendment 2: kill Jarvis and the armed watches re-derive from disk).
129
- # Live slices are worktrees on a forge/m-* branch; each runner writes to
130
- # <worktree>/.forge/runner-work/notify.log (the dispatch's FORGE_SLICE_NOTIFY_LOG). Emit the
131
- # EXPECTED path even when the log does not exist yet — `tail -F` (capital F) is chosen exactly
132
- # so a not-yet-created log is picked up when the first ping lands (F8).
133
- shift
134
- WT_ROOT=""
135
- while [ $# -gt 0 ]; do case "$1" in --worktree-root) WT_ROOT="${2:-}"; shift 2 ;; *) shift ;; esac; done
136
- if [ -n "$WT_ROOT" ]; then
137
- # override path (tests): disk-glob the given root for slice worktrees.
138
- for _wt in "$WT_ROOT"/*/; do
139
- [ -d "$_wt" ] || continue
140
- printf '%s.forge/runner-work/notify.log\n' "$_wt"
141
- done
142
- else
143
- # live path: `git worktree list` is the on-disk registry of worktrees; glob it to the
144
- # forge/m-* slice branches and emit each slice's notify-log path.
145
- git worktree list --porcelain 2>/dev/null | awk '
146
- /^worktree / { p=$2 }
147
- /^branch / { if ($2 ~ /refs\/heads\/forge\/m-/) print p "/.forge/runner-work/notify.log" }
148
- '
149
- fi
150
- exit 0 ;;
151
-
152
- --audit-sources)
153
- # Static guard: the done/halt (state-claim) branch must recompute from the fold, and the
154
- # authoritative release-state var must NEVER be assigned from the payload. Catches a future
155
- # rewrite that sources a merge verdict from the session's own payload rather than host truth.
156
- # The guard's own two grep lines carry a #@audit tag and are excluded from the scan so their
157
- # literal patterns cannot self-match the file they scan.
158
- _body="$(grep -vE '#@audit' "$0")"
159
- if ! printf '%s\n' "$_body" | grep -qE 'host_release_state|FORGE_JARVIS_FOLD|forge-release-fold'; then #@audit
160
- printf 'forge-jarvis-relay: AUDIT FAIL — no host-truth (fold) recompute in the relay path\n' >&2
161
- exit 3
162
- fi
163
- if printf '%s\n' "$_body" | grep -qE '_rs=["'\''$]*_payload'; then #@audit
164
- printf 'forge-jarvis-relay: AUDIT FAIL — a state claim is sourced from the payload, not the host\n' >&2
165
- exit 3
166
- fi
167
- printf 'relay speaks host truth only\n'
168
- exit 0 ;;
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
+ }
169
130
 
170
- --scan)
171
- shift
172
- LOG="${1:-}"; shift 2>/dev/null || true
173
- while [ $# -gt 0 ]; do
174
- case "$1" in
175
- --marker-dir) MARKER_DIR="${2:-}"; shift 2 ;;
176
- --milestone) MILESTONE="${2:-}"; shift 2 ;;
177
- --mode) MODE_LIVE="${2:-live}"; shift 2 ;;
178
- *) shift ;;
179
- esac
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"
180
148
  done
181
- [ -n "$LOG" ] && [ -f "$LOG" ] || { printf 'forge-jarvis-relay: --scan needs an existing logfile\n' >&2; exit 2; }
182
- [ -n "$MARKER_DIR" ] || MARKER_DIR="$(dirname "$LOG")/relay-markers"
183
- mkdir -p "$MARKER_DIR" 2>/dev/null || true
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
+ }
184
161
 
185
- _n=0; _fresh=0; _summary=""
186
- # Read every line; relay only those whose offset (line number) has no marker yet.
187
- while IFS= read -r _l || [ -n "$_l" ]; do
188
- _n=$((_n + 1))
189
- _marker="$MARKER_DIR/$_n.relayed"
190
- [ -f "$_marker" ] && continue # at-most-once: already relayed, never re-buzz
191
- _text="$(relay_one_line "$_l")"
192
- # Write the marker BEFORE emitting a crash after emit must not double-buzz; a crash
193
- # before emit leaves it unmarked and it re-surfaces as while-you-were-away (no silent loss).
194
- 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 || true
195
- _fresh=$((_fresh + 1))
196
- if [ "$MODE_LIVE" = catchup ]; then
197
- _summary="${_summary}${_summary:+ | }$_text"
198
- else
199
- printf '%s\n' "$_text" # live: one relay text per new ping → one buzz
200
- fi
201
- done < "$LOG"
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
202
198
 
203
- if [ "$MODE_LIVE" = catchup ] && [ "$_fresh" -gt 0 ]; then
204
- printf '📨 While you were away %s ping(s): %s\n' "$_fresh" "$_summary"
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
205
220
  fi
206
- # exit 0 whether or not anything was fresh (a no-op scan is success, not failure).
207
- exit 0 ;;
221
+ done < "$LOG"
208
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 "$@" ;;
209
236
  *)
210
237
  printf 'usage: forge-jarvis-relay.sh --one "<tsv-line>" [--milestone m-N]\n' >&2
211
238
  printf ' forge-jarvis-relay.sh --scan <logfile> [--marker-dir <dir>] [--milestone m-N] [--mode live|catchup]\n' >&2
@@ -115,6 +115,13 @@ else
115
115
  SUFFIX="${FORGE_JARVIS_NAME_SUFFIX:-$(jarvis_cfg name_suffix)}"
116
116
  [ -n "$SUFFIX" ] && NAME="$NAME-$SUFFIX"
117
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
118
125
 
119
126
  # --- capacity ----------------------------------------------------------------
120
127
  CAP="${CAP_OVERRIDE:-$(jarvis_cfg capacity)}"
@@ -124,25 +131,19 @@ case "$CAP" in
124
131
  esac
125
132
 
126
133
  # --- resolve -----------------------------------------------------------------
127
- # Printed exactly as executed (modulo the caffeinate degradation below).
128
- RESOLVED="caffeinate -s claude remote-control --name $NAME --spawn worktree --capacity $CAP"
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"
129
137
 
130
138
  if [ "$DRY_RUN" = "1" ]; then
131
- printf '%s\n' "$RESOLVED"
139
+ printf 'caffeinate -s %s\n' "$*"
132
140
  exit 0
133
141
  fi
134
142
 
135
- # ============================================================================
136
- # B4 SEAM DO NOT REMOVE (contract B4(a)).
137
- # At boot Jarvis arms a Monitor tail (`tail -F` — capital F) on each active
138
- # slice's notify log, the watch list recomputed by globbing live slice
139
- # worktrees, never from memory (Amendment 2). That boot arming act attaches
140
- # HERE — as the launcher's instruction to the pre-created session — once the
141
- # relay (B4) exists. Deliberately a no-op today: B2 ships the presence only,
142
- # and building any relay glue ahead of B4 is out of this phase's scope.
143
- # ============================================================================
144
- jarvis_boot_arm() { :; }
145
- jarvis_boot_arm
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.
146
147
 
147
148
  # --- launch ------------------------------------------------------------------
148
149
  command -v claude >/dev/null 2>&1 || {
@@ -152,9 +153,9 @@ command -v claude >/dev/null 2>&1 || {
152
153
  printf 'forge-jarvis: starting %s (capacity %s) in %s\n' "$NAME" "$CAP" "$ROOT" >&2
153
154
  cd "$ROOT" || exit 2
154
155
  if command -v caffeinate >/dev/null 2>&1; then
155
- exec caffeinate -s claude remote-control --name "$NAME" --spawn worktree --capacity "$CAP"
156
+ exec caffeinate -s "$@"
156
157
  else
157
158
  # non-macOS: no caffeinate — launch unwrapped; sleep then queues-until-wake (probe iii).
158
159
  printf 'forge-jarvis: caffeinate not found — launching without keep-awake (lid-closed autonomy degrades to queue-until-wake).\n' >&2
159
- exec claude remote-control --name "$NAME" --spawn worktree --capacity "$CAP"
160
+ exec "$@"
160
161
  fi
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env sh
2
+ # Fixture test for .forge/checks/forge-jarvis-awareness.sh (m-37 B5; FR-203/204, NFR-036).
3
+ #
4
+ # Pins the B5 "what's in play?" surface that shipped untested: the slice glob
5
+ # (--worktree-root seam), per-slice fold recompute + degradation, the board
6
+ # projection parse (including key-order independence) + degradation, the
7
+ # always-present per-machine caveat, and the loud unknown-flag refusal.
8
+ # No real repo/network — pure fixtures via the existing env seams.
9
+ set -u
10
+
11
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
12
+ AWARE="$SCRIPT_DIR/../forge-jarvis-awareness.sh"
13
+ [ -f "$AWARE" ] || printf 'NOTE: %s missing — RED phase, every case should FAIL\n' "$AWARE" >&2
14
+
15
+ ROOT="$(mktemp -d)"
16
+ trap 'rm -rf "$ROOT"' EXIT INT TERM
17
+
18
+ PASSED=0; FAILED=0
19
+ ok() { PASSED=$((PASSED+1)); printf ' ok %s\n' "$1"; }
20
+ bad() { FAILED=$((FAILED+1)); printf ' FAIL %s\n %s\n' "$1" "$2"; }
21
+
22
+ # fixture slice worktrees (the --worktree-root seam globs this dir)
23
+ WT="$ROOT/worktrees"; mkdir -p "$WT/m-41" "$WT/m-42"
24
+
25
+ # stub fold: records invocation, answers a fixed state
26
+ FOLDSTUB="$ROOT/fold.sh"
27
+ cat > "$FOLDSTUB" <<'EOF'
28
+ #!/bin/sh
29
+ echo "called $1" >> "${FOLD_CALLS:-/dev/null}"
30
+ echo "release_state=merged"
31
+ EOF
32
+ chmod +x "$FOLDSTUB"
33
+
34
+ # stub board: two units, one complete one executing — and the executing row uses a
35
+ # DIFFERENT key order (release_state before status) to pin the order-independent parse.
36
+ BOARDSTUB="$ROOT/board.sh"
37
+ cat > "$BOARDSTUB" <<'EOF'
38
+ #!/bin/sh
39
+ [ "$1" = "--emit" ] || exit 2
40
+ printf '{"forge_id": "m-40", "status": "complete", "release_state": "validated"}\n'
41
+ printf '{"forge_id": "m-41", "release_state": "unmerged", "status": "executing"}\n'
42
+ EOF
43
+ chmod +x "$BOARDSTUB"
44
+
45
+ # 1. --dry-run: slices listed, fold NOT invoked
46
+ CALLS="$ROOT/fold-calls"; : > "$CALLS"
47
+ OUT="$(FOLD_CALLS="$CALLS" FORGE_JARVIS_FOLD="$FOLDSTUB" FORGE_JARVIS_BOARD="$BOARDSTUB" \
48
+ sh "$AWARE" --dry-run --worktree-root "$WT" 2>&1)"
49
+ if printf '%s' "$OUT" | grep -q 'm-41' && printf '%s' "$OUT" | grep -q '(dry-run)' && [ ! -s "$CALLS" ]; then
50
+ ok '--dry-run lists slices without invoking the fold'
51
+ else
52
+ bad '--dry-run lists slices without invoking the fold' "$OUT"
53
+ fi
54
+
55
+ # 2. real run: per-slice fold recompute rendered
56
+ OUT="$(FOLD_CALLS="$CALLS" FORGE_JARVIS_FOLD="$FOLDSTUB" FORGE_JARVIS_BOARD="$BOARDSTUB" \
57
+ sh "$AWARE" --worktree-root "$WT" 2>&1)"
58
+ if printf '%s' "$OUT" | grep -q 'release_state=merged' && grep -q 'called m-41' "$CALLS"; then
59
+ ok 'live run recomputes each slice release_state from the fold'
60
+ else
61
+ bad 'live run recomputes each slice release_state from the fold' "$OUT"
62
+ fi
63
+
64
+ # 3. fold missing → unknown, exit 0 (degraded, never fatal)
65
+ OUT="$(FORGE_JARVIS_FOLD="$ROOT/no-such-fold" FORGE_JARVIS_BOARD="$BOARDSTUB" \
66
+ sh "$AWARE" --worktree-root "$WT" 2>&1)"; RC=$?
67
+ if [ "$RC" -eq 0 ] && printf '%s' "$OUT" | grep -q 'release_state=unknown'; then
68
+ ok 'missing fold degrades to release_state=unknown, exit 0'
69
+ else
70
+ bad 'missing fold degrades to release_state=unknown, exit 0' "rc=$RC out=$OUT"
71
+ fi
72
+
73
+ # 4. board projection: in-flight only, key-order-independent field extraction
74
+ OUT="$(FORGE_JARVIS_FOLD="$FOLDSTUB" FORGE_JARVIS_BOARD="$BOARDSTUB" \
75
+ sh "$AWARE" --worktree-root "$WT" 2>&1)"
76
+ if printf '%s' "$OUT" | grep -q 'm-41 status=executing release_state=unmerged' \
77
+ && ! printf '%s' "$OUT" | grep -q 'm-40 status=complete'; then
78
+ ok 'board parse: in-flight only, fields extracted regardless of key order'
79
+ else
80
+ bad 'board parse: in-flight only, fields extracted regardless of key order' "$OUT"
81
+ fi
82
+
83
+ # 5. board missing → stated unavailable, exit 0
84
+ OUT="$(FORGE_JARVIS_FOLD="$FOLDSTUB" FORGE_JARVIS_BOARD="$ROOT/no-such-board" \
85
+ sh "$AWARE" --worktree-root "$WT" 2>&1)"; RC=$?
86
+ if [ "$RC" -eq 0 ] && printf '%s' "$OUT" | grep -qi 'projection unavailable'; then
87
+ ok 'missing board degrades to a stated unavailable, exit 0'
88
+ else
89
+ bad 'missing board degrades to a stated unavailable, exit 0' "rc=$RC out=$OUT"
90
+ fi
91
+
92
+ # 6. per-machine caveat always present (F11)
93
+ if printf '%s' "$OUT" | grep -q 'PER-MACHINE'; then
94
+ ok 'per-machine scope caveat is always stated'
95
+ else
96
+ bad 'per-machine scope caveat is always stated' "$OUT"
97
+ fi
98
+
99
+ # 7. empty worktree root → explicit none-line, exit 0
100
+ EMPTY="$ROOT/empty"; mkdir -p "$EMPTY"
101
+ OUT="$(FORGE_JARVIS_FOLD="$FOLDSTUB" FORGE_JARVIS_BOARD="$BOARDSTUB" \
102
+ sh "$AWARE" --worktree-root "$EMPTY" 2>&1)"; RC=$?
103
+ if [ "$RC" -eq 0 ] && printf '%s' "$OUT" | grep -q '(none on this machine)'; then
104
+ ok 'empty glob renders an explicit none-line, not silence'
105
+ else
106
+ bad 'empty glob renders an explicit none-line, not silence' "rc=$RC out=$OUT"
107
+ fi
108
+
109
+ # 8. unknown flag → loud refusal, exit 2
110
+ OUT="$(sh "$AWARE" --no-such-flag 2>&1)"; RC=$?
111
+ if [ "$RC" -eq 2 ] && printf '%s' "$OUT" | grep -q 'unknown argument'; then
112
+ ok 'unknown flag refused loudly (exit 2)'
113
+ else
114
+ bad 'unknown flag refused loudly (exit 2)' "rc=$RC out=$OUT"
115
+ fi
116
+
117
+ printf '\nforge-jarvis-awareness.test.sh: %s passed, %s failed\n' "$PASSED" "$FAILED"
118
+ [ "$FAILED" -eq 0 ]
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env sh
2
+ # Fixture test for .forge/checks/forge-jarvis-notify-logonly.sh (m-37 B3; FR-197).
3
+ #
4
+ # Pins the log-only channel's whole contract: one TSV line per ping at the
5
+ # FORGE_SLICE_NOTIFY_LOG path, field-forging bytes scrubbed from caller input,
6
+ # best-effort exit 0 even when the write fails, and the FORGE_SLICE_WORK_DIR
7
+ # default location. Pure fixtures.
8
+ set -u
9
+
10
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
11
+ CHAN="$SCRIPT_DIR/../forge-jarvis-notify-logonly.sh"
12
+ [ -f "$CHAN" ] || printf 'NOTE: %s missing — RED phase, every case should FAIL\n' "$CHAN" >&2
13
+
14
+ ROOT="$(mktemp -d)"
15
+ trap 'rm -rf "$ROOT"' EXIT INT TERM
16
+
17
+ PASSED=0; FAILED=0
18
+ ok() { PASSED=$((PASSED+1)); printf ' ok %s\n' "$1"; }
19
+ bad() { FAILED=$((FAILED+1)); printf ' FAIL %s\n %s\n' "$1" "$2"; }
20
+ TAB="$(printf '\t')"
21
+
22
+ # 1. appends exactly one TSV line: ts <TAB> type <TAB> payload
23
+ LOG="$ROOT/notify.log"
24
+ FORGE_SLICE_NOTIFY_LOG="$LOG" sh "$CHAN" ambiguity "JWT or sessions?"; RC=$?
25
+ if [ "$RC" -eq 0 ] && [ "$(wc -l < "$LOG" | tr -d ' ')" = 1 ] \
26
+ && grep -q "${TAB}ambiguity${TAB}JWT or sessions?$" "$LOG"; then
27
+ ok 'one ping → one TSV line (ts, type, payload), exit 0'
28
+ else
29
+ bad 'one ping → one TSV line (ts, type, payload), exit 0' "rc=$RC log=$(cat "$LOG" 2>/dev/null)"
30
+ fi
31
+
32
+ # 2. a payload with embedded newline/tab cannot forge lines or fields
33
+ FORGE_SLICE_NOTIFY_LOG="$LOG" sh "$CHAN" done "line1
34
+ line2${TAB}forged-field"
35
+ LINES="$(wc -l < "$LOG" | tr -d ' ')"
36
+ FIELDS="$(tail -1 "$LOG" | awk -F"$TAB" '{print NF}')"
37
+ if [ "$LINES" = 2 ] && [ "$FIELDS" = 3 ]; then
38
+ ok 'newline/tab in payload scrubbed — no forged lines, still 3 TSV fields'
39
+ else
40
+ bad 'newline/tab in payload scrubbed — no forged lines, still 3 TSV fields' "lines=$LINES fields=$FIELDS"
41
+ fi
42
+
43
+ # 3. best-effort contract: unwritable log target → still exit 0 (never a slice failure)
44
+ FORGE_SLICE_NOTIFY_LOG="$ROOT/no-perm/notify.log" sh -c 'mkdir -p "'"$ROOT"'/no-perm" && chmod 555 "'"$ROOT"'/no-perm"'
45
+ FORGE_SLICE_NOTIFY_LOG="$ROOT/no-perm/notify.log" sh "$CHAN" done "cannot land"; RC=$?
46
+ chmod 755 "$ROOT/no-perm" 2>/dev/null
47
+ if [ "$RC" -eq 0 ]; then
48
+ ok 'unwritable log → exit 0 (best-effort, never a slice failure)'
49
+ else
50
+ bad 'unwritable log → exit 0 (best-effort, never a slice failure)' "rc=$RC"
51
+ fi
52
+
53
+ # 4. default location: FORGE_SLICE_WORK_DIR/notify.log when NOTIFY_LOG is unset
54
+ WORK="$ROOT/work"; mkdir -p "$WORK"
55
+ ( unset FORGE_SLICE_NOTIFY_LOG 2>/dev/null; FORGE_SLICE_WORK_DIR="$WORK" sh "$CHAN" note "hello" )
56
+ if [ -f "$WORK/notify.log" ] && grep -q "${TAB}note${TAB}hello$" "$WORK/notify.log"; then
57
+ ok 'default location is FORGE_SLICE_WORK_DIR/notify.log'
58
+ else
59
+ bad 'default location is FORGE_SLICE_WORK_DIR/notify.log' "$(ls "$WORK" 2>/dev/null)"
60
+ fi
61
+
62
+ printf '\nforge-jarvis-notify-logonly.test.sh: %s passed, %s failed\n' "$PASSED" "$FAILED"
63
+ [ "$FAILED" -eq 0 ]