forge-orkes 0.62.0 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/bin/create-forge.js +250 -99
  2. package/experimental/conventions/README.md +87 -0
  3. package/experimental/conventions/install.sh +179 -0
  4. package/experimental/conventions/source/rules/code-quality.md +17 -0
  5. package/experimental/conventions/source/rules/laravel-http.md +21 -0
  6. package/experimental/conventions/source/rules/laravel-jobs.md +19 -0
  7. package/experimental/conventions/source/rules/laravel-migrations.md +19 -0
  8. package/experimental/conventions/source/rules/laravel-services.md +21 -0
  9. package/experimental/conventions/source/rules/python.md +14 -0
  10. package/experimental/conventions/source/rules/testing.md +15 -0
  11. package/experimental/conventions/source/rules/vue-inertia.md +24 -0
  12. package/experimental/conventions/source/skills/laravel-conventions/SKILL.md +124 -0
  13. package/experimental/conventions/source/skills/python-conventions/SKILL.md +100 -0
  14. package/experimental/conventions/source/skills/testing-standards/SKILL.md +142 -0
  15. package/experimental/conventions/source/skills/vue-conventions/SKILL.md +92 -0
  16. package/experimental/conventions/uninstall.sh +97 -0
  17. package/experimental/m10/README.md +130 -0
  18. package/experimental/m10/install.sh +198 -0
  19. package/experimental/m10/source/hooks/forge-branch-guard.sh +61 -0
  20. package/experimental/m10/source/hooks/forge-claim-check-doctor.sh +77 -0
  21. package/experimental/m10/source/hooks/forge-claim-check.sh +113 -0
  22. package/experimental/m10/source/hooks/forge-session-id.sh +22 -0
  23. package/experimental/m10/source/mcp-server/README.md +74 -0
  24. package/experimental/m10/source/mcp-server/example.mcp.json +8 -0
  25. package/experimental/m10/source/mcp-server/index.js +460 -0
  26. package/experimental/m10/source/mcp-server/package.json +17 -0
  27. package/experimental/m10/source/skills/orchestrating/SKILL.md +223 -0
  28. package/experimental/m10/source/skills/orchestrating/bootstrap-checks.md +70 -0
  29. package/experimental/m10/uninstall.sh +69 -0
  30. package/package.json +3 -2
  31. package/template/.claude/hooks/forge-release-fold.sh +9 -1
  32. package/template/.claude/settings.json +1 -7
  33. package/template/.claude/skills/jarvis/SKILL.md +204 -0
  34. package/template/.claude/skills/upgrading/SKILL.md +105 -176
  35. package/template/.forge/FORGE.md +2 -2
  36. package/template/.forge/checks/forge-board-render.sh +11 -5
  37. package/template/.forge/checks/forge-jarvis-answer.sh +79 -0
  38. package/template/.forge/checks/forge-jarvis-awareness.sh +128 -0
  39. package/template/.forge/checks/forge-jarvis-bridge-clean.sh +130 -0
  40. package/template/.forge/checks/forge-jarvis-dispatch.sh +176 -0
  41. package/template/.forge/checks/forge-jarvis-instruments.sh +146 -0
  42. package/template/.forge/checks/forge-jarvis-notify-logonly.sh +40 -0
  43. package/template/.forge/checks/forge-jarvis-promote.sh +103 -0
  44. package/template/.forge/checks/forge-jarvis-prwatch.sh +92 -0
  45. package/template/.forge/checks/forge-jarvis-relay.sh +215 -0
  46. package/template/.forge/checks/forge-jarvis.sh +160 -0
  47. package/template/.forge/checks/tests/forge-jarvis-answer.test.sh +75 -0
  48. package/template/.forge/checks/tests/forge-jarvis-bridge-clean.test.sh +94 -0
  49. package/template/.forge/checks/tests/forge-jarvis-dispatch.test.sh +162 -0
  50. package/template/.forge/checks/tests/forge-jarvis-instruments.test.sh +64 -0
  51. package/template/.forge/checks/tests/forge-jarvis-promote.test.sh +102 -0
  52. package/template/.forge/checks/tests/forge-jarvis-prwatch.test.sh +66 -0
  53. package/template/.forge/checks/tests/forge-jarvis-relay.test.sh +135 -0
  54. package/template/.forge/checks/tests/forge-jarvis.test.sh +122 -0
  55. package/template/.forge/migrations/0.63.0-origin-first-upgrading.md +49 -0
  56. package/template/.forge/templates/project.yml +11 -0
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env sh
2
+ # forge-jarvis-awareness.sh — "what's in play?" answered LIVE, never remembered (m-37,
3
+ # Brief-R2 step 4 B5 awareness; contract handoff-step4-jarvis.md REV2; FR-203/204; NFR-036).
4
+ #
5
+ # The operator asks Jarvis "what's in play?" and every fact in the answer is COMPUTED from a
6
+ # live host source at ask-time — there is no Jarvis-owned status store (NFR-036 / pain 4: the
7
+ # drift class). Three live sources, three questions:
8
+ # 1. WHICH SLICES ARE RUNNING (this machine) → a DISK GLOB of git worktrees on forge/m-*
9
+ # branches (`git worktree list`), NOT the RC session listing (which is blind to detached
10
+ # `claude -p` workers — B1 probe ix). Parked/dispatched slices are worktrees on disk, so a
11
+ # glob sees them; the session listing does not.
12
+ # 2. WHERE EACH SLICE STANDS (cross-machine) → the fold (forge-release-fold.sh m-N) per slice:
13
+ # unmerged | merged | validated — git-derived, identical on any clone.
14
+ # 3. THE BOARD PROJECTION (cross-machine) → forge-board-render.sh --emit: the rollup's view
15
+ # of every unit, offline + deterministic.
16
+ #
17
+ # PER-MACHINE SCOPE (F11 — stated plainly in the output): the disk glob is THIS machine's live/
18
+ # parked slices only; forge is a two-machine repo, so the other machine's in-flight worktrees are
19
+ # invisible here. The git-synced board + fold ARE the cross-machine truth; the glob is the local
20
+ # "what's spinning right now" layer. The answer says so, so a quiet glob is never mistaken for
21
+ # "nothing in flight anywhere".
22
+ #
23
+ # NO OWNED STATE: nothing here is persisted; the next ask recomputes from disk + git. The only
24
+ # durable Jarvis state anywhere is B4's relay markers (a different helper).
25
+ #
26
+ # forge-jarvis-awareness.sh [--dry-run] [--worktree-root <dir>]
27
+ # --dry-run resolve + print structure without shelling to the fold/board (fast
28
+ # plumbing check); tests and quick sanity checks use it.
29
+ # --worktree-root override the worktree scan root (tests); default: real `git worktree list`.
30
+ #
31
+ # Host-truth commands are env-overridable so tests inject stubs and degradation is explicit:
32
+ # FORGE_JARVIS_FOLD default .claude/hooks/forge-release-fold.sh
33
+ # FORGE_JARVIS_BOARD default .forge/checks/forge-board-render.sh
34
+ # BEST-EFFORT: a missing fold/board degrades to a stated 'unknown' / '(unavailable)', never a hard
35
+ # fail — the awareness answer always renders.
36
+ set -u
37
+
38
+ SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
39
+ REPO_ROOT="$(cd "$SELF_DIR/../.." && pwd)" # .forge/checks → repo root
40
+ FOLD="${FORGE_JARVIS_FOLD:-$REPO_ROOT/.claude/hooks/forge-release-fold.sh}"
41
+ BOARD="${FORGE_JARVIS_BOARD:-$REPO_ROOT/.forge/checks/forge-board-render.sh}"
42
+ TAB="$(printf '\t')"
43
+
44
+ DRY_RUN=0
45
+ WT_ROOT=""
46
+ while [ $# -gt 0 ]; do
47
+ case "$1" in
48
+ --dry-run) DRY_RUN=1; shift ;;
49
+ --worktree-root) WT_ROOT="${2:-}"; shift 2 ;;
50
+ *) shift ;;
51
+ esac
52
+ done
53
+
54
+ # ── host truth: a slice's release_state from the fold (never remembered). Degrades to 'unknown'. ─
55
+ fold_state() { # fold_state <milestone-id>
56
+ _mid="${1:-}"
57
+ [ -n "$_mid" ] || { printf 'unknown'; return 0; }
58
+ [ -f "$FOLD" ] || { printf 'unknown'; return 0; }
59
+ _out="$(sh "$FOLD" "$_mid" 2>/dev/null)" || { printf 'unknown'; return 0; }
60
+ _rs="$(printf '%s\n' "$_out" | grep -m1 'release_state=' | sed 's/.*release_state=//; s/[[:space:]].*//')"
61
+ [ -n "$_rs" ] && printf '%s' "$_rs" || printf 'unknown'
62
+ }
63
+
64
+ # ── 1. live/parked slices — a DISK GLOB of worktrees on forge/m-* branches. Same on-disk registry
65
+ # the relay's --list-logs arms tail -F on; recomputed here, never stored. Emits TSV:
66
+ # <milestone-id>\t<branch>\t<path>. --worktree-root globs a given dir instead (tests). ──────
67
+ list_slices() {
68
+ if [ -n "$WT_ROOT" ]; then
69
+ for _wt in "$WT_ROOT"/*/; do
70
+ [ -d "$_wt" ] || continue
71
+ _b="$(basename "$_wt")"
72
+ printf '%s%s(root-scan)%s%s\n' "$_b" "$TAB" "$TAB" "${_wt%/}"
73
+ done
74
+ else
75
+ git -C "$REPO_ROOT" worktree list --porcelain 2>/dev/null | awk -v tab="$TAB" '
76
+ /^worktree / { p = $2 }
77
+ /^branch / {
78
+ if ($2 ~ /refs\/heads\/forge\/m-/) {
79
+ b = $2; sub(/refs\/heads\//, "", b) # forge/m-37[-<session>]
80
+ m = b; sub(/^forge\//, "", m); sub(/-[0-9a-f]{8}$/, "", m) # → m-37
81
+ print m tab b tab p
82
+ }
83
+ }'
84
+ fi
85
+ }
86
+
87
+ # ── render ──────────────────────────────────────────────────────────────────────────────────────
88
+ printf '── What'\''s in play? (host-verified, computed live) ──────────────────\n'
89
+
90
+ printf '\n▸ Live / parked slices on THIS machine (git worktree glob):\n'
91
+ _slices="$(list_slices)"
92
+ if [ -n "$_slices" ]; then
93
+ printf '%s\n' "$_slices" | while IFS="$TAB" read -r _m _br _pt; do
94
+ if [ "$DRY_RUN" -eq 1 ]; then _rs='(dry-run)'; else _rs="$(fold_state "$_m")"; fi
95
+ printf ' %-10s %-26s release_state=%-9s %s\n' "$_m" "$_br" "$_rs" "$_pt"
96
+ done
97
+ else
98
+ printf ' (none on this machine)\n'
99
+ fi
100
+
101
+ printf '\n▸ Board projection (git-synced — cross-machine truth):\n'
102
+ if [ "$DRY_RUN" -eq 1 ]; then
103
+ printf ' (--dry-run: forge-board-render.sh --emit not invoked)\n'
104
+ elif [ -f "$BOARD" ]; then
105
+ _emit="$(sh "$BOARD" --emit 2>/dev/null)"
106
+ if [ -n "$_emit" ]; then
107
+ # In-flight is what's "in play": one line per non-complete unit. Line-oriented JSON → grep/sed,
108
+ # no jq dependency (Article X native-first).
109
+ _inflight="$(printf '%s\n' "$_emit" | grep '"forge_id"' | grep -vE '"status":[[:space:]]*"complete"')"
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
114
+ else
115
+ printf ' (all board units complete — nothing in flight)\n'
116
+ fi
117
+ else
118
+ printf ' (board projection unavailable — degraded, not fatal)\n'
119
+ fi
120
+ else
121
+ printf ' (forge-board-render.sh not found — projection unavailable)\n'
122
+ fi
123
+
124
+ printf '\nScope: the slice list above is PER-MACHINE (this machine'\''s worktrees only).\n'
125
+ printf 'On this two-machine repo the git-synced board + fold are the cross-machine truth;\n'
126
+ printf 'a quiet slice list here does not mean nothing is in flight on the other machine.\n'
127
+
128
+ exit 0
@@ -0,0 +1,130 @@
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
+ # --worktrees-root override the scan root (tests); default: <repo>/.claude/worktrees.
28
+ set -u
29
+
30
+ SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
31
+ REPO_ROOT="$(cd "$SELF_DIR/../.." && pwd)"
32
+
33
+ DRY_RUN=0
34
+ STALE_HOURS=6
35
+ WT_ROOT=""
36
+ while [ $# -gt 0 ]; do
37
+ case "$1" in
38
+ --dry-run) DRY_RUN=1; shift ;;
39
+ --stale-hours) STALE_HOURS="${2:-6}"; shift 2 ;;
40
+ --worktrees-root) WT_ROOT="${2:-}"; shift 2 ;;
41
+ *) shift ;;
42
+ esac
43
+ done
44
+ [ -n "$WT_ROOT" ] || WT_ROOT="$REPO_ROOT/.claude/worktrees"
45
+
46
+ # portable dir mtime epoch (BSD then GNU); 0 on failure.
47
+ _mtime() { # _mtime <path>
48
+ stat -f %m "$1" 2>/dev/null || stat -c %Y "$1" 2>/dev/null || echo 0
49
+ }
50
+
51
+ # The pid embedded in a worktree's git LOCK reason, if any. RC locks each bridge with a reason
52
+ # like "claude agent bridge-<id> (pid N start ...)"; that pid is the persistent server that owns
53
+ # the worktree. Empty when the worktree is unlocked or its lock reason carries no pid.
54
+ worktree_lock_pid() { # worktree_lock_pid <worktree-path> → prints the pid or nothing
55
+ _wt="$1"; _b="$(basename "$_wt")"
56
+ git -C "$_wt" worktree list --porcelain 2>/dev/null | awk -v tgt="$_b" '
57
+ $1=="worktree" { cur=$2; sub(/.*\//,"",cur) }
58
+ /^locked/ && cur==tgt && match($0,/pid [0-9]+/) {
59
+ s=substr($0,RSTART,RLENGTH); sub(/pid /,"",s); print s; exit
60
+ }'
61
+ }
62
+
63
+ # default liveness (best-effort): uncommitted work OR a LIVE lock pid → live; a DEAD lock pid
64
+ # (the owning RC server is gone) → orphan; only an unlocked / no-pid worktree falls back to mtime.
65
+ default_livecheck() { # default_livecheck <worktree-path> → exit 0 if LIVE
66
+ _wt="$1"
67
+ [ -n "$(git -C "$_wt" status --porcelain 2>/dev/null)" ] && return 0 # dirty → live
68
+ _pid="$(worktree_lock_pid "$_wt")"
69
+ if [ -n "$_pid" ]; then
70
+ ps -p "$_pid" >/dev/null 2>&1 && return 0 # lock's server alive → live
71
+ return 1 # lock's server dead → stale lock → orphan
72
+ fi
73
+ _now="$(date +%s 2>/dev/null || echo 0)"
74
+ _mt="$(_mtime "$_wt")"
75
+ [ "$_mt" -gt 0 ] && [ "$_now" -gt 0 ] && [ $(( (_now - _mt) / 3600 )) -lt "$STALE_HOURS" ] && return 0
76
+ return 1
77
+ }
78
+
79
+ is_live() { # is_live <worktree-path>
80
+ if [ -n "${FORGE_JARVIS_BRIDGE_LIVECHECK:-}" ]; then
81
+ $FORGE_JARVIS_BRIDGE_LIVECHECK "$1" >/dev/null 2>&1
82
+ else
83
+ default_livecheck "$1"
84
+ fi
85
+ }
86
+
87
+ # the repo's MAIN worktree — the safe place to run `git worktree remove` from (first list entry).
88
+ main_wt() { # main_wt <any-worktree-path>
89
+ git -C "$1" worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}'
90
+ }
91
+
92
+ if [ ! -d "$WT_ROOT" ]; then
93
+ printf 'forge-jarvis-bridge-clean: no worktrees root (%s) — nothing to reap.\n' "$WT_ROOT"
94
+ exit 0
95
+ fi
96
+
97
+ _kept=0; _cand=0; _reaped=0
98
+ for _wt in "$WT_ROOT"/bridge-*/; do
99
+ [ -d "$_wt" ] || continue # unmatched glob → skip
100
+ _wt="${_wt%/}"
101
+ _base="$(basename "$_wt")"
102
+ case "$_base" in bridge-*) : ;; *) continue ;; esac # scope guard (belt-and-braces)
103
+
104
+ if is_live "$_wt"; then
105
+ _kept=$((_kept + 1))
106
+ printf ' keep %s (live)\n' "$_base"
107
+ continue
108
+ fi
109
+
110
+ _cand=$((_cand + 1))
111
+ if [ "$DRY_RUN" -eq 1 ]; then
112
+ printf ' orphan %s (candidate — dry-run, not removed)\n' "$_base"
113
+ continue
114
+ fi
115
+
116
+ _main="$(main_wt "$_wt")"
117
+ # RC locks every bridge; a confirmed orphan's lock is stale, so unlock before removing.
118
+ # `remove` (no --force) still refuses a DIRTY worktree, so unsaved work stays protected.
119
+ git -C "$_main" worktree unlock "$_wt" 2>/dev/null || true
120
+ if [ -n "$_main" ] && git -C "$_main" worktree remove "$_wt" 2>/dev/null; then
121
+ _reaped=$((_reaped + 1))
122
+ printf ' reaped %s\n' "$_base"
123
+ else
124
+ printf ' SKIP %s (git worktree remove refused — left intact)\n' "$_base"
125
+ fi
126
+ done
127
+
128
+ printf 'bridge-clean: %d kept, %d candidate(s), %d reaped%s.\n' \
129
+ "$_kept" "$_cand" "$_reaped" "$([ "$DRY_RUN" -eq 1 ] && printf ' (dry-run)')"
130
+ exit 0
@@ -0,0 +1,176 @@
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
+ printf '# Materialized by forge-jarvis-dispatch from %s (delete to re-materialize)\nphases:\n' "${_mdir#"$WORKTREE"/}" > "$_tmp"
125
+ _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
130
+ mkdir -p "$WORKTREE/phases/$_p"
131
+ # shellcheck disable=SC2086
132
+ cat $_plans > "$WORKTREE/phases/$_p/plan.md"
133
+ printf ' - %s\n' "$_p" >> "$_tmp"
134
+ _count=$((_count + 1))
135
+ done
136
+ if [ "$_count" -eq 0 ]; then
137
+ rm -f "$_tmp"
138
+ printf 'forge-jarvis-dispatch: REFUSED — %s has no phase plans (plan-*.md); nothing to dispatch. RELAY this refusal to the operator.\n' "$_mdir" >&2
139
+ return 2
140
+ fi
141
+ mv "$_tmp" "$WORKTREE/slice.yml"
142
+ printf 'forge-jarvis-dispatch: materialized slice.yml (%s phase(s)) from %s\n' "$_count" "${_mdir#"$WORKTREE"/}" >&2
143
+ return 0
144
+ }
145
+
146
+ # --- caller-side env (the two seams; nothing else) ------------------------------------------
147
+ LOG="$WORKTREE/.forge/runner-work/notify.log" # INTO the slice worktree (B4 derives this)
148
+
149
+ if [ "$DRY_RUN" = "1" ]; then
150
+ # Side-effect-free: report work-order state to stderr, print the command to stdout.
151
+ if [ -f "$WORKTREE/slice.yml" ]; then
152
+ printf 'forge-jarvis-dispatch: (dry-run) slice.yml present — will be used as-is\n' >&2
153
+ else
154
+ printf 'forge-jarvis-dispatch: (dry-run) no slice.yml — a real dispatch materializes it from .forge/phases/milestone-*/\n' >&2
155
+ fi
156
+ # Print exactly what would run — greppable: worktree-scoped log, log-only channel, nohup, forwarded args.
157
+ printf 'nohup env FORGE_SLICE_NOTIFY_LOG=%s FORGE_SLICE_NOTIFY=%s %s' "$LOG" "$CHANNEL" "$RUNNER"
158
+ for a in "$@"; do printf ' %s' "$a"; done
159
+ printf ' >%s 2>&1 &\n' "$WORKTREE/.forge/runner-work/dispatch.out"
160
+ exit 0
161
+ fi
162
+
163
+ if [ "$MAT_ONLY" = "1" ]; then
164
+ materialize_work_order; exit $?
165
+ fi
166
+
167
+ materialize_work_order || exit 2
168
+
169
+ # --- dispatch DETACHED (nohup-& — survives Jarvis's death, B1 probe viii) --------------------
170
+ mkdir -p "$WORKTREE/.forge/runner-work" 2>/dev/null || true
171
+ printf 'forge-jarvis-dispatch: dispatching runner for %s (detached)\n' "$WORKTREE" >&2
172
+ nohup env FORGE_SLICE_NOTIFY_LOG="$LOG" FORGE_SLICE_NOTIFY="$CHANNEL" \
173
+ "$RUNNER" "$@" >"$WORKTREE/.forge/runner-work/dispatch.out" 2>&1 &
174
+
175
+ printf 'forge-jarvis-dispatch: dispatched (pid %s); notify log → %s\n' "$!" "$LOG" >&2
176
+ exit 0
@@ -0,0 +1,146 @@
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
+ *) shift ;;
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 = $2 }
74
+ /^branch / { if ($2 ~ /refs\/heads\/forge\/m-/) print p "/.forge/runner-work/notify.log" }')"
75
+ for _l in $_globbed; do
76
+ [ -f "$_l" ] && LOGS="${LOGS}${LOGS:+
77
+ }$_l"
78
+ done
79
+ fi
80
+ fi
81
+
82
+ if [ "$DRY_RUN" -eq 1 ]; then
83
+ printf 'forge-jarvis-instruments (--dry-run): resolved logs:\n'
84
+ if [ -n "$LOGS" ]; then printf '%s\n' "$LOGS" | sed 's/^/ /'; else printf ' (none)\n'; fi
85
+ exit 0
86
+ fi
87
+
88
+ # ── build one record per RELAYED ping: type, latency_sec, live(1/0), discrepancy(1/0) ────────────
89
+ TMP="$(mktemp)"; trap 'rm -f "$TMP"' EXIT INT TERM
90
+ _since_epoch=""; [ -n "$SINCE" ] && _since_epoch="$(to_epoch "$SINCE")"
91
+
92
+ printf '%s\n' "$LOGS" | while IFS= read -r LOG; do
93
+ [ -n "$LOG" ] && [ -f "$LOG" ] || continue
94
+ MK="$MARKER_DIR_OVERRIDE"; [ -n "$MK" ] || MK="$(dirname "$LOG")/relay-markers"
95
+ _n=0
96
+ while IFS= read -r _line || [ -n "$_line" ]; do
97
+ _n=$((_n + 1))
98
+ _emit="$(printf '%s' "$_line" | cut -f1)"
99
+ _type="$(printf '%s' "$_line" | cut -f2)"
100
+ if [ -n "$_since_epoch" ]; then
101
+ _e="$(to_epoch "$_emit")"
102
+ { [ -n "$_e" ] && [ "$_e" -lt "$_since_epoch" ]; } && continue
103
+ fi
104
+ _marker="$MK/$_n.relayed"
105
+ [ -f "$_marker" ] || continue # only RELAYED pings carry instruments data
106
+ _relayts="$(cut -f1 "$_marker" 2>/dev/null)"
107
+ _text="$(cut -f3- "$_marker" 2>/dev/null)"
108
+ _ee="$(to_epoch "$_emit")"; _re="$(to_epoch "$_relayts")"
109
+ if [ -n "$_ee" ] && [ -n "$_re" ]; then _lat=$((_re - _ee)); else _lat=-1; fi
110
+ if [ "$_lat" -ge 0 ] && [ "$_lat" -le "$LIVE_WINDOW" ]; then _live=1; else _live=0; fi
111
+ case "$_text" in
112
+ *DISCREPANCY*|*"could NOT be verified"*) _disc=1 ;;
113
+ *) _disc=0 ;;
114
+ esac
115
+ printf '%s\t%s\t%s\t%s\n' "$_type" "$_lat" "$_live" "$_disc" >> "$TMP"
116
+ done < "$LOG"
117
+ done
118
+
119
+ # ── emit the four numbers ────────────────────────────────────────────────────────────────────────
120
+ _total="$(wc -l < "$TMP" 2>/dev/null | tr -d ' ')"; [ -n "$_total" ] || _total=0
121
+
122
+ printf '── Jarvis week-zero instruments (from relay markers + notify logs) ──\n\n'
123
+
124
+ printf '1. Pings relayed by type (%s total):\n' "$_total"
125
+ if [ "$_total" -gt 0 ]; then
126
+ cut -f1 "$TMP" | sort | uniq -c | while read -r _c _t; do
127
+ printf ' %-14s %s\n' "$_t" "$_c"
128
+ done
129
+ else
130
+ printf ' (no relayed pings in range)\n'
131
+ fi
132
+
133
+ awk -F'\t' -v total="$_total" -v win="$LIVE_WINDOW" '
134
+ { if ($2 + 0 >= 0) { s += $2; n++; if ($2 + 0 > mx) mx = $2 }
135
+ live += $3; disc += $4 }
136
+ END {
137
+ mean = (n > 0) ? s / n : 0
138
+ printf "2. Relay latency: mean %.1fs, max %ds (over %d timed ping(s); live window %ds)\n", mean, mx + 0, n + 0, win + 0
139
+ printf "3. Discrepancies caught (host-verification): %d\n", disc + 0
140
+ away = total - live
141
+ up = (total > 0) ? (live * 100.0 / total) : 0
142
+ printf "4. Presence proxy: %d/%d relayed live (%.0f%%); %d landed while away", live + 0, total + 0, up, away + 0
143
+ printf " (proxy — derived from relay latency, not an uptime store)\n"
144
+ }' "$TMP"
145
+
146
+ exit 0
@@ -0,0 +1,40 @@
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
+ # Durable local record — the ONLY thing this channel does. Default location travels with
34
+ # the slice (FORGE_SLICE_WORK_DIR), but a Jarvis dispatch always sets FORGE_SLICE_NOTIFY_LOG
35
+ # explicitly INTO the slice worktree.
36
+ LOG="${FORGE_SLICE_NOTIFY_LOG:-${FORGE_SLICE_WORK_DIR:-.}/notify.log}"
37
+ mkdir -p "$(dirname "$LOG")" 2>/dev/null || true
38
+ 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
39
+
40
+ exit 0