forge-orkes 0.44.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-orkes",
3
- "version": "0.44.0",
3
+ "version": "0.49.0",
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"
@@ -24,9 +24,9 @@ Plan+must_haves (`.forge/phases/milestone-{id}/{phase}-{name}/plan.md`), executi
24
24
  **Status**: PASS | FAIL | PARTIAL
25
25
 
26
26
  ## Observable Truths
27
- | Truth | Status | Evidence |
28
- |-------|--------|----------|
29
- | {from must_haves} | PASS/FAIL | {observed} |
27
+ | Truth | Evidence (command → exit code; output excerpt) | Status |
28
+ |-------|-----------------------------------------------|--------|
29
+ | {from must_haves} | {executed command} {exit}; {excerpt} | PASS/FAIL/UNCERTAIN/HUMAN |
30
30
 
31
31
  ## Artifacts
32
32
  | Artifact | Exists | Substantive | Wired | Evidence |
@@ -64,7 +64,10 @@ Read: .forge/deferred-issues.md → known pre-existing failures (if exists; trea
64
64
  ```
65
65
 
66
66
  ### 2. Truths
67
- Per truth: determine method, execute, record PASS+evidence or FAIL+actual.
67
+ Per truth, three branches (executed-evidence rule — canonical statement: verifying SKILL Level 1):
68
+ 1. Declared `check:` in must_haves → EXECUTE it. Exit 0 = PASS, non-zero = FAIL. Record command + exit code + output excerpt.
69
+ 2. No declared check → run your OWN command and record the same evidence triple — executed evidence qualifies regardless of plan vintage.
70
+ 3. No executed evidence → UNCERTAIN, or HUMAN for `manual:`-routed truths (never execute the route text as shell). **Never PASS on judgment alone** — the exit code, not your assessment, is the verdict.
68
71
 
69
72
  ### 3. Artifacts
70
73
  Per artifact:
@@ -138,6 +141,7 @@ gaps:
138
141
 
139
142
  ## Anti-Patterns
140
143
  - **Fix-while-verifying**: Report, don't fix
144
+ - **Evidence-free PASS**: No executed command recorded → verdict must be UNCERTAIN, never PASS
141
145
  - **Surface-level**: Exists != substantive
142
146
  - **Skip wired**: Code exists but unused
143
147
  - **Trust count**: Passing != correct coverage
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env sh
2
+ # forge-model-outcome — advisory per-task model-routing outcome log + promotion
3
+ # ladder (M24 phases 37-38).
4
+ #
5
+ # forge-model-outcome.sh record [--repo R] [--milestone M] [--skill S]
6
+ # [--task-type T] [--complexity C] [--model MO] [--first-try-pass true|false]
7
+ # [--check-exit N]
8
+ #
9
+ # Appends ONE printf-built JSON line to ${FORGE_HOME:-$HOME}/.forge/model-outcomes.jsonl
10
+ # (honors $HOME so tests can redirect it). Lazily creates the dir + file.
11
+ #
12
+ # ADVISORY, always: any failure (unwritable home, etc.) is swallowed and this
13
+ # helper still exits 0 — logging must never block or fail a task. The write
14
+ # path is dependency-free (printf only, no jq/python — NFR-026).
15
+ #
16
+ # forge-model-outcome.sh recommend
17
+ #
18
+ # Reads the log and prints an untested -> probation -> proven promotion ladder
19
+ # per (model, task_type), plus concrete routing recommendations for proven
20
+ # buckets. The READ path may use jq (unlike record's write path). Degrades to
21
+ # an "insufficient data" message + exit 0 when jq is absent or the log is
22
+ # absent/empty — never errors (NFR-026).
23
+ #
24
+ # Pinned field set (contract between record + recommend; keep names stable):
25
+ # ts repo milestone skill task_type complexity model first_try_pass check_exit
26
+ set -u
27
+
28
+ usage() {
29
+ cat <<'EOF' >&2
30
+ usage: forge-model-outcome.sh record [--repo R] [--milestone M] [--skill S]
31
+ [--task-type T] [--complexity C] [--model MO] [--first-try-pass true|false]
32
+ [--check-exit N]
33
+ forge-model-outcome.sh recommend
34
+ EOF
35
+ }
36
+
37
+ log_path() {
38
+ home="${FORGE_HOME:-$HOME}"
39
+ printf '%s/.forge/model-outcomes.jsonl' "$home"
40
+ }
41
+
42
+ do_record() {
43
+ # --- parse flags (ALL optional — missing -> empty string / null) ---------
44
+ repo=""; milestone=""; skill=""; task_type=""; complexity=""; model=""
45
+ first_try_pass=""; check_exit=""
46
+ while [ $# -gt 0 ]; do
47
+ case "$1" in
48
+ --repo) shift; repo="${1:-}"; [ $# -gt 0 ] && shift || true ;;
49
+ --repo=*) repo="${1#*=}"; shift ;;
50
+ --milestone) shift; milestone="${1:-}"; [ $# -gt 0 ] && shift || true ;;
51
+ --milestone=*) milestone="${1#*=}"; shift ;;
52
+ --skill) shift; skill="${1:-}"; [ $# -gt 0 ] && shift || true ;;
53
+ --skill=*) skill="${1#*=}"; shift ;;
54
+ --task-type) shift; task_type="${1:-}"; [ $# -gt 0 ] && shift || true ;;
55
+ --task-type=*) task_type="${1#*=}"; shift ;;
56
+ --complexity) shift; complexity="${1:-}"; [ $# -gt 0 ] && shift || true ;;
57
+ --complexity=*) complexity="${1#*=}"; shift ;;
58
+ --model) shift; model="${1:-}"; [ $# -gt 0 ] && shift || true ;;
59
+ --model=*) model="${1#*=}"; shift ;;
60
+ --first-try-pass) shift; first_try_pass="${1:-}"; [ $# -gt 0 ] && shift || true ;;
61
+ --first-try-pass=*) first_try_pass="${1#*=}"; shift ;;
62
+ --check-exit) shift; check_exit="${1:-}"; [ $# -gt 0 ] && shift || true ;;
63
+ --check-exit=*) check_exit="${1#*=}"; shift ;;
64
+ -*) printf 'forge-model-outcome: unknown option: %s (ignored)\n' "$1" >&2; shift ;;
65
+ *) shift ;;
66
+ esac
67
+ done
68
+
69
+ dir="$(dirname "$(log_path)")"
70
+ log="$(log_path)"
71
+
72
+ # Escape backslash then double-quote for a JSON string value — these are
73
+ # controlled enums/basenames, so this minimal escape is enough (no jq).
74
+ esc() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; }
75
+
76
+ ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || true)"
77
+
78
+ fp="false"
79
+ case "$first_try_pass" in
80
+ true|1|yes) fp="true" ;;
81
+ *) fp="false" ;;
82
+ esac
83
+
84
+ ce="null"
85
+ case "$check_exit" in
86
+ ''|*[!0-9-]*) ce="null" ;;
87
+ *) ce="$check_exit" ;;
88
+ esac
89
+
90
+ line=$(printf '{"ts":"%s","repo":"%s","milestone":"%s","skill":"%s","task_type":"%s","complexity":"%s","model":"%s","first_try_pass":%s,"check_exit":%s}' \
91
+ "$(esc "$ts")" "$(esc "$repo")" "$(esc "$milestone")" "$(esc "$skill")" \
92
+ "$(esc "$task_type")" "$(esc "$complexity")" "$(esc "$model")" "$fp" "$ce")
93
+
94
+ # The whole create+append is best-effort: swallow any failure (unwritable
95
+ # home, read-only fs, a ".forge" that's a regular file, ...) and exit 0
96
+ # regardless — a task must never be blocked or failed by this log.
97
+ ( mkdir -p "$dir" && printf '%s\n' "$line" >> "$log" ) 2>/dev/null
98
+ exit 0
99
+ }
100
+
101
+ do_recommend() {
102
+ # Promotion-ladder thresholds (Ringer parity) — documented constants.
103
+ VOLUME_MIN=5 # count < VOLUME_MIN -> untested
104
+ PROVEN_CUTOFF=0.90 # count >= VOLUME_MIN and pass_rate >= PROVEN_CUTOFF -> proven; else probation
105
+
106
+ log="$(log_path)"
107
+
108
+ if ! command -v jq >/dev/null 2>&1; then
109
+ printf 'recommendations unavailable: jq not found (insufficient data)\n'
110
+ exit 0
111
+ fi
112
+ if [ ! -s "$log" ]; then
113
+ printf 'recommendations unavailable: no outcome data yet (insufficient data)\n'
114
+ exit 0
115
+ fi
116
+
117
+ buckets="$(jq -sc --argjson min "$VOLUME_MIN" --argjson cutoff "$PROVEN_CUTOFF" '
118
+ group_by([.model, .task_type])
119
+ | map(
120
+ {
121
+ model: .[0].model,
122
+ task_type: .[0].task_type,
123
+ count: length,
124
+ pass_rate: (([ .[] | select(.first_try_pass == true) ] | length) / length)
125
+ }
126
+ | . + {
127
+ status: (
128
+ if .count < $min then "untested"
129
+ elif .pass_rate >= $cutoff then "proven"
130
+ else "probation"
131
+ end
132
+ )
133
+ }
134
+ )
135
+ | .[]
136
+ ' "$log" 2>/dev/null)"
137
+
138
+ if [ -z "$buckets" ]; then
139
+ printf 'recommendations unavailable: no outcome data yet (insufficient data)\n'
140
+ exit 0
141
+ fi
142
+
143
+ cutoff_pct="$(awk -v c="$PROVEN_CUTOFF" 'BEGIN{printf "%.0f", c*100}')"
144
+ printf 'Model-outcome promotion ladder (volume >= %s runs, proven cutoff >= %s%% first-try):\n\n' "$VOLUME_MIN" "$cutoff_pct"
145
+
146
+ printf '%s\n' "$buckets" | while IFS= read -r b; do
147
+ model="$(printf '%s' "$b" | jq -r '.model')"
148
+ task_type="$(printf '%s' "$b" | jq -r '.task_type')"
149
+ count="$(printf '%s' "$b" | jq -r '.count')"
150
+ status="$(printf '%s' "$b" | jq -r '.status')"
151
+ pct="$(printf '%s' "$b" | jq -r '.pass_rate' | awk '{printf "%.0f", $1*100}')"
152
+ printf ' model=%-10s task_type=%-14s status=%-10s count=%-4s first-try=%s%%\n' "$model" "$task_type" "$status" "$count" "$pct"
153
+ done
154
+
155
+ printf '\nRouting recommendations:\n'
156
+ printf '%s\n' "$buckets" | while IFS= read -r b; do
157
+ status="$(printf '%s' "$b" | jq -r '.status')"
158
+ [ "$status" = "proven" ] || continue
159
+ model="$(printf '%s' "$b" | jq -r '.model')"
160
+ task_type="$(printf '%s' "$b" | jq -r '.task_type')"
161
+ count="$(printf '%s' "$b" | jq -r '.count')"
162
+ pct="$(printf '%s' "$b" | jq -r '.pass_rate' | awk '{printf "%.0f", $1*100}')"
163
+ printf ' %s is proven on %s tasks (%s runs, %s%% first-try) -> route %s tasks to %s\n' "$model" "$task_type" "$count" "$pct" "$task_type" "$model"
164
+ done
165
+
166
+ exit 0
167
+ }
168
+
169
+ cmd="${1:-}"
170
+ [ $# -gt 0 ] && shift || true
171
+
172
+ case "$cmd" in
173
+ record) do_record "$@" ;;
174
+ recommend) do_recommend ;;
175
+ ""|-h|--help) usage; exit 0 ;;
176
+ *) printf 'forge-model-outcome: unknown subcommand: %s\n' "$cmd" >&2; usage; exit 2 ;;
177
+ esac
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env sh
2
+ # forge-reserve — allocate a collision-safe sequential id (ADR/DEF/FR/NFR).
3
+ #
4
+ # Implements ADR-021: the coordination point is a machine-local ledger in the
5
+ # git COMMON dir (.git/forge/id-reservations), shared instantly across every
6
+ # sibling worktree of the repo on this machine — the input ADR-016's
7
+ # branch-tracked reservations.yml could not see. Same primitive as the
8
+ # integration flag (.git/forge/integration-pending).
9
+ #
10
+ # forge-reserve <kind> --milestone <id> [--summary <text>]
11
+ # kind ∈ {adr|def|fr|nfr}
12
+ #
13
+ # stdout: the allocated id and NOTHING else (e.g. ADR-022). All logs → stderr.
14
+ # exit: 0 success; 2 bad/missing kind or args; 3 lock timeout. On non-zero
15
+ # exit nothing is written.
16
+ #
17
+ # Side effects (both UNcommitted — the caller commits reservations.yml with its
18
+ # work): appends one TSV line to the common-dir ledger and one YAML block to the
19
+ # worktree's .forge/reservations.yml, both under a portable mkdir lock.
20
+ #
21
+ # Pure POSIX sh — no bashisms, no flock (absent on macOS, ADR-021 point 3),
22
+ # no node/python. Only git/mkdir/awk/grep/sed/date/stat.
23
+ set -eu
24
+
25
+ WAIT="${FORGE_RESERVE_WAIT:-5}" # seconds: bounded lock-acquire budget
26
+ STALE="${FORGE_RESERVE_STALE:-15}" # seconds: a lock older than this is broken
27
+ SLEEP="0.1" # retry interval while the lock is held
28
+
29
+ # --- 1. parse args ----------------------------------------------------------
30
+ kind=""
31
+ milestone=""
32
+ summary=""
33
+ while [ $# -gt 0 ]; do
34
+ case "$1" in
35
+ --milestone) shift; milestone="${1:-}"; [ $# -gt 0 ] && shift || true ;;
36
+ --milestone=*) milestone="${1#*=}"; shift ;;
37
+ --summary) shift; summary="${1:-}"; [ $# -gt 0 ] && shift || true ;;
38
+ --summary=*) summary="${1#*=}"; shift ;;
39
+ -*) printf 'forge-reserve: unknown option: %s\n' "$1" >&2; exit 2 ;;
40
+ *)
41
+ if [ -z "$kind" ]; then kind="$1"; shift
42
+ else printf 'forge-reserve: unexpected argument: %s\n' "$1" >&2; exit 2; fi
43
+ ;;
44
+ esac
45
+ done
46
+
47
+ # Validate kind BEFORE touching git, the lock, or any file — a bad kind must
48
+ # exit non-zero having written nothing (must_have truth #3).
49
+ case "$kind" in
50
+ adr) prefix="ADR" ;;
51
+ def) prefix="DEF" ;;
52
+ fr) prefix="FR" ;;
53
+ nfr) prefix="NFR" ;;
54
+ "") printf 'forge-reserve: missing kind (adr|def|fr|nfr)\n' >&2; exit 2 ;;
55
+ *) printf 'forge-reserve: unknown kind: %s (expected adr|def|fr|nfr)\n' "$kind" >&2; exit 2 ;;
56
+ esac
57
+ [ -n "$milestone" ] || { printf 'forge-reserve: --milestone <id> is required\n' >&2; exit 2; }
58
+
59
+ # --- 2. resolve paths -------------------------------------------------------
60
+ common="$(git rev-parse --git-common-dir)"
61
+ top="$(git rev-parse --show-toplevel)"
62
+ mkdir -p "$common/forge"
63
+ ledger="$common/forge/id-reservations"
64
+ lock="$common/forge/id-reservations.lock"
65
+ reservations="$top/.forge/reservations.yml"
66
+
67
+ # Portable mtime → epoch: BSD/macOS `stat -f %m`, GNU `stat -c %Y`. This is the
68
+ # one place a platform branch is warranted (ADR-021 point 3: macOS is a
69
+ # first-class platform and its stat flags differ from GNU's).
70
+ get_mtime() {
71
+ stat -f %m "$1" 2>/dev/null || stat -c %Y "$1" 2>/dev/null || true
72
+ }
73
+
74
+ # --- 3. acquire lock (atomic mkdir, bounded retry, staleness-break) ----------
75
+ # mkdir is POSIX-atomic: it succeeds for exactly one racer and fails if the dir
76
+ # already exists — a lock without flock. A crashed helper's lock is broken after
77
+ # STALE seconds so it can't wedge the namespace forever.
78
+ max_iters="$(awk -v w="$WAIT" -v s="$SLEEP" 'BEGIN{print int(w/s)}')"
79
+ iters=0
80
+ while ! mkdir "$lock" 2>/dev/null; do
81
+ mt="$(get_mtime "$lock")"
82
+ now_epoch="$(date +%s)"
83
+ if [ -n "$mt" ] && [ "$((now_epoch - mt))" -ge "$STALE" ]; then
84
+ printf 'forge-reserve: breaking stale lock (age %ss): %s\n' "$((now_epoch - mt))" "$lock" >&2
85
+ rmdir "$lock" 2>/dev/null || true
86
+ continue
87
+ fi
88
+ iters="$((iters + 1))"
89
+ if [ "$iters" -ge "$max_iters" ]; then
90
+ printf 'forge-reserve: lock timeout after ~%ss: %s\n' "$WAIT" "$lock" >&2
91
+ exit 3
92
+ fi
93
+ sleep "$SLEEP"
94
+ done
95
+ # Only now that the lock is ours do we install the release trap.
96
+ trap 'rmdir "$lock" 2>/dev/null || true' EXIT INT TERM
97
+
98
+ # --- 4/5. three-way max(ledger ∪ in-tree ∪ main:reservations.yml) + 1 --------
99
+ # Everything numeric goes through awk (n+0) so leading-zero ids like 021 are read
100
+ # as decimal 21, never octal — no shell $(( )) octal trap.
101
+
102
+ # (a) ledger — same-machine siblings, INCLUDING uncommitted (the ADR-016-blind input)
103
+ ledger_max=0
104
+ if [ -f "$ledger" ]; then
105
+ ledger_max="$(awk -F'\t' -v k="$kind" \
106
+ '$1==k { split($2,a,"-"); n=a[2]+0; if(n>m)m=n } END{print m+0}' "$ledger")"
107
+ fi
108
+
109
+ # (b) in-tree — landed ids in THIS worktree. Exact-prefix match in awk ($1==p) so
110
+ # FR does not swallow NFR (FR is a substring of NFR).
111
+ if [ "$kind" = "adr" ]; then
112
+ intree_max="$(ls "$top/docs/decisions" 2>/dev/null \
113
+ | awk -F- '/^ADR-[0-9]/ { n=$2+0; if(n>m)m=n } END{print m+0}')"
114
+ else
115
+ intree_max="$(grep -rhoE '[A-Z]+-[0-9]+' "$top/.forge/requirements/" 2>/dev/null \
116
+ | awk -F- -v p="$prefix" '$1==p { n=$2+0; if(n>m)m=n } END{print m+0}')"
117
+ fi
118
+
119
+ # (c) main:reservations.yml — cross-machine / cold-start floor (survives a
120
+ # .git/forge wipe; carries another machine's MERGED reservations). Absent → 0.
121
+ main_max="$(git show main:.forge/reservations.yml 2>/dev/null \
122
+ | grep -oE '[A-Z]+-[0-9]+' \
123
+ | awk -F- -v p="$prefix" '$1==p { n=$2+0; if(n>m)m=n } END{print m+0}')"
124
+ [ -n "$main_max" ] || main_max=0
125
+
126
+ next="$(awk -v a="$ledger_max" -v b="$intree_max" -v c="$main_max" \
127
+ 'BEGIN{m=a; if(b>m)m=b; if(c>m)m=c; print m+1}')"
128
+ id="$(printf '%s-%03d' "$prefix" "$next")"
129
+
130
+ # --- 6. dual-write (still under the lock) -----------------------------------
131
+ now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
132
+ printf '%s\t%s\t%s\t%s\n' "$kind" "$id" "$milestone" "$now" >> "$ledger"
133
+
134
+ # reservations.yml missing → seed the 0.42.0 starter HEADER, but with a bare
135
+ # `reservations:` key (not the guide's `reservations: []`) so the block items we
136
+ # append below form a valid sequence — appending `- kind:` under a `[]` flow list
137
+ # is malformed YAML. Existing repo files already use the bare-key block form.
138
+ if [ ! -f "$reservations" ]; then
139
+ mkdir -p "$(dirname "$reservations")"
140
+ cat > "$reservations" <<'YML'
141
+ # Forge ID Reservations — cross-worktree coordination for ADR / DEF / FR / NFR numbers
142
+ # Written by .claude/hooks/forge-reserve.sh (ADR-021). The machine-local ledger
143
+ # (.git/forge/id-reservations) is the same-machine coordination point; this file
144
+ # is the durable committed audit trail + cross-machine floor. Append-only; never
145
+ # edit prior entries. Next number for a kind = max(ledger, in-tree, main) + 1.
146
+ # kind ∈ {adr,def,fr,nfr}. See FORGE.md → ID Reservation Protocol + ADR-021.
147
+ reservations:
148
+ YML
149
+ fi
150
+
151
+ # YAML double-quoted scalar: escape backslash then double-quote.
152
+ esc_summary="$(printf '%s' "$summary" | sed 's/\\/\\\\/g; s/"/\\"/g')"
153
+ {
154
+ printf ' - kind: %s\n' "$kind"
155
+ printf ' id: %s\n' "$id"
156
+ printf ' milestone: %s\n' "$milestone"
157
+ printf ' reserved_at: "%s"\n' "$now"
158
+ printf ' summary: "%s"\n' "$esc_summary"
159
+ } >> "$reservations"
160
+
161
+ # --- 7. emit the id (stdout only); trap releases the lock on exit ------------
162
+ printf '%s\n' "$id"
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env sh
2
+ # Self-contained unit test for .claude/hooks/forge-model-outcome.sh (M24 phase 37).
3
+ #
4
+ # Needs no network and no fixtures beyond a temp dir. Each case runs the helper
5
+ # with an isolated $HOME and asserts its exit code + the jsonl file's contents.
6
+ # jq is used HERE to parse the helper's output for assertions — the helper's
7
+ # own write path never uses jq (printf only; see forge-model-outcome.sh, NFR-026).
8
+ # Run:
9
+ # ./.claude/hooks/tests/forge-model-outcome.test.sh
10
+ # Exits 0 iff every case passes; non-zero on any failure. Self-cleans on exit.
11
+ set -u
12
+
13
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
14
+ HELPER="$SCRIPT_DIR/../forge-model-outcome.sh"
15
+
16
+ [ -x "$HELPER" ] || { printf 'FATAL: helper not executable: %s\n' "$HELPER" >&2; exit 1; }
17
+ command -v jq >/dev/null 2>&1 || { printf 'FATAL: jq required for test assertions\n' >&2; exit 1; }
18
+
19
+ ROOT="$(mktemp -d)"
20
+ trap 'rm -rf "$ROOT"' EXIT INT TERM
21
+
22
+ PASSED=0
23
+ FAILED=0
24
+ pass() { PASSED=$((PASSED + 1)); printf 'PASS: %s\n' "$1"; }
25
+ fail() { FAILED=$((FAILED + 1)); printf 'FAIL: %s\n' "$1" >&2; }
26
+
27
+ # --- Case 1: record appends one valid jsonl line with the pinned fields -----
28
+ d="$ROOT/case1"; mkdir -p "$d"
29
+ HOME="$d" "$HELPER" record --repo forge --milestone m24 --skill executing \
30
+ --task-type docs --complexity trivial --model haiku --first-try-pass true --check-exit 0 >/dev/null
31
+ if [ -f "$d/.forge/model-outcomes.jsonl" ]; then
32
+ pass "case1 log file created"
33
+ else
34
+ fail "case1 log file not created"
35
+ fi
36
+ line="$(tail -1 "$d/.forge/model-outcomes.jsonl" 2>/dev/null)"
37
+ if printf '%s' "$line" | jq -e \
38
+ '.task_type=="docs" and .complexity=="trivial" and .model=="haiku" and .first_try_pass==true and .check_exit==0 and .repo=="forge" and .milestone=="m24" and .skill=="executing"' \
39
+ >/dev/null 2>&1; then
40
+ pass "case1 line carries pinned fields"
41
+ else
42
+ fail "case1 line missing/incorrect fields: $line"
43
+ fi
44
+
45
+ # --- Case 2: unwritable home degrades to exit 0, never blocks ---------------
46
+ d="$ROOT/case2"; mkdir -p "$d"
47
+ : > "$d/.forge" # regular file where a dir is expected -> mkdir -p fails
48
+ HOME="$d" "$HELPER" record --repo forge --milestone m24 --skill executing --model haiku >/dev/null 2>&1
49
+ rc=$?
50
+ check_rc() { [ "$1" -eq "$2" ] && pass "$3" || fail "$3 (got exit $1, want $2)"; }
51
+ check_rc "$rc" 0 "case2 unwritable home still exits 0"
52
+
53
+ # --- Case 3: two record calls append, they do not overwrite -----------------
54
+ d="$ROOT/case3"; mkdir -p "$d"
55
+ HOME="$d" "$HELPER" record --repo forge --milestone m24 --skill executing --model haiku --task-type docs >/dev/null
56
+ HOME="$d" "$HELPER" record --repo forge --milestone m24 --skill executing --model opus --task-type code-feature >/dev/null
57
+ lines="$(wc -l < "$d/.forge/model-outcomes.jsonl" | tr -d ' ')"
58
+ if [ "$lines" = "2" ]; then
59
+ pass "case3 two calls -> two lines (append, not overwrite)"
60
+ else
61
+ fail "case3 expected 2 lines, got $lines"
62
+ fi
63
+
64
+ # --- Case 4: recommend — proven bucket (volume met, high pass rate) --------
65
+ d="$ROOT/case4"; mkdir -p "$d"
66
+ i=1
67
+ while [ "$i" -le 6 ]; do
68
+ HOME="$d" "$HELPER" record --repo x --milestone m24 --skill executing \
69
+ --task-type docs --complexity trivial --model haiku --first-try-pass true --check-exit 0 >/dev/null
70
+ i=$((i + 1))
71
+ done
72
+ out="$(HOME="$d" "$HELPER" recommend)"
73
+ if printf '%s' "$out" | grep -qi 'proven' && printf '%s' "$out" | grep -qi 'docs' && printf '%s' "$out" | grep -qi 'haiku'; then
74
+ pass "case4 recommend marks (haiku,docs) proven + recommends routing"
75
+ else
76
+ fail "case4 recommend missing proven/docs/haiku: $out"
77
+ fi
78
+
79
+ # --- Case 5: recommend — probation bucket (volume met, sub-cutoff pass rate) -
80
+ d="$ROOT/case5"; mkdir -p "$d"
81
+ i=1
82
+ while [ "$i" -le 5 ]; do
83
+ fp="true"; [ "$i" -le 2 ] && fp="false" # 3/5 = 60% < 90% cutoff
84
+ HOME="$d" "$HELPER" record --repo x --milestone m24 --skill executing \
85
+ --task-type code-fix --complexity standard --model sonnet --first-try-pass "$fp" --check-exit 0 >/dev/null
86
+ i=$((i + 1))
87
+ done
88
+ out="$(HOME="$d" "$HELPER" recommend)"
89
+ if printf '%s' "$out" | grep -qi 'probation'; then
90
+ pass "case5 recommend marks sub-cutoff bucket probation"
91
+ else
92
+ fail "case5 recommend did not mark bucket probation: $out"
93
+ fi
94
+
95
+ # --- Case 6: recommend degrades on an empty/absent log — exit 0 ------------
96
+ d="$ROOT/case6"; mkdir -p "$d"
97
+ out="$(HOME="$d" "$HELPER" recommend)"
98
+ rc=$?
99
+ check_rc "$rc" 0 "case6 recommend on absent log exits 0"
100
+ if printf '%s' "$out" | grep -qiE 'insufficient|unavailable|no data|not enough'; then
101
+ pass "case6 recommend on absent log prints insufficient-data message"
102
+ else
103
+ fail "case6 recommend missing insufficient-data message: $out"
104
+ fi
105
+
106
+ # --- Report -----------------------------------------------------------------
107
+ printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
108
+ [ "$FAILED" -eq 0 ]
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env sh
2
+ # Self-contained unit test for .claude/hooks/forge-reserve.sh (ADR-021).
3
+ #
4
+ # Needs no network and no fixtures beyond a temp dir. Each case builds a throwaway
5
+ # git repo, seeds ids, and asserts the helper's stdout + file side effects. Run:
6
+ # ./.claude/hooks/tests/forge-reserve.test.sh
7
+ # Exits 0 iff every case passes; non-zero on any failure. Self-cleans on exit.
8
+ set -u
9
+
10
+ # Resolve the helper from THIS script's own location so the test works from any cwd.
11
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
12
+ HELPER="$SCRIPT_DIR/../forge-reserve.sh"
13
+
14
+ [ -x "$HELPER" ] || { printf 'FATAL: helper not executable: %s\n' "$HELPER" >&2; exit 1; }
15
+
16
+ # One temp root; every throwaway repo lives under it; single cleanup.
17
+ ROOT="$(mktemp -d)"
18
+ trap 'rm -rf "$ROOT"' EXIT INT TERM
19
+
20
+ PASSED=0
21
+ FAILED=0
22
+ pass() { PASSED=$((PASSED + 1)); printf 'PASS: %s\n' "$1"; }
23
+ fail() { FAILED=$((FAILED + 1)); printf 'FAIL: %s\n' "$1" >&2; }
24
+ check() { # desc, actual, expected
25
+ if [ "$2" = "$3" ]; then pass "$1"; else fail "$1 (got '$2', want '$3')"; fi
26
+ }
27
+
28
+ # Fresh repo on branch `main` (symbolic-ref sets the unborn branch name portably,
29
+ # no git ≥2.28 -b flag needed). Prints its path.
30
+ new_repo() {
31
+ d="$ROOT/repo.$1"
32
+ mkdir -p "$d"
33
+ git -C "$d" init -q
34
+ git -C "$d" symbolic-ref HEAD refs/heads/main
35
+ git -C "$d" config user.email t@example.com
36
+ git -C "$d" config user.name tester
37
+ printf '%s\n' "$d"
38
+ }
39
+
40
+ # --- Case 1: allocation from in-tree ADR ------------------------------------
41
+ d="$(new_repo case1)"; cd "$d"
42
+ mkdir -p docs/decisions
43
+ : > docs/decisions/ADR-005-something.md
44
+ git add -A && git commit -qm init
45
+ got="$("$HELPER" adr --milestone m-x)"
46
+ check "case1 in-tree ADR-005 -> ADR-006" "$got" "ADR-006"
47
+
48
+ # --- Case 2: ledger beats tree (uncommitted sibling — the ADR-016-blind case) -
49
+ d="$(new_repo case2)"; cd "$d"
50
+ mkdir -p docs/decisions
51
+ : > docs/decisions/ADR-005-something.md
52
+ git add -A && git commit -qm init
53
+ mkdir -p .git/forge
54
+ printf 'adr\tADR-050\tm-x\t2026-01-01T00:00:00Z\n' > .git/forge/id-reservations
55
+ got="$("$HELPER" adr --milestone m-x)"
56
+ check "case2 ledger(ADR-050) beats tree(ADR-005) -> ADR-051" "$got" "ADR-051"
57
+
58
+ # --- Case 3: main:reservations.yml floor (committed, then removed from tree) --
59
+ d="$(new_repo case3)"; cd "$d"
60
+ mkdir -p .forge
61
+ cat > .forge/reservations.yml <<'YML'
62
+ reservations:
63
+ - kind: fr
64
+ id: FR-200
65
+ milestone: m-x
66
+ reserved_at: "2026-01-01"
67
+ summary: "seed"
68
+ YML
69
+ git add -A && git commit -qm init
70
+ rm .forge/reservations.yml # gone from the working tree; survives only on main
71
+ got="$("$HELPER" fr --milestone m-x)"
72
+ check "case3 main floor FR-200 -> FR-201" "$got" "FR-201"
73
+
74
+ # --- Case 4: dual-write side effects + no commit made by the helper ----------
75
+ d="$(new_repo case4)"; cd "$d"
76
+ git commit --allow-empty -qm init
77
+ ledger=".git/forge/id-reservations"
78
+ before=0; [ -f "$ledger" ] && before="$(wc -l < "$ledger" | tr -d ' ')"
79
+ got="$("$HELPER" nfr --milestone m-x --summary "dual write check")"
80
+ after="$(wc -l < "$ledger" | tr -d ' ')"
81
+ check "case4 ledger grew by exactly 1" "$((after - before))" "1"
82
+ if grep -q "id: $got" .forge/reservations.yml; then
83
+ pass "case4 reservations.yml has a $got block"
84
+ else
85
+ fail "case4 reservations.yml missing $got block"
86
+ fi
87
+ check "case4 no new commit (HEAD count still 1)" "$(git rev-list --count HEAD)" "1"
88
+ if [ -n "$(git status --porcelain .forge/reservations.yml)" ]; then
89
+ pass "case4 reservations.yml is uncommitted (dirty)"
90
+ else
91
+ fail "case4 reservations.yml unexpectedly clean"
92
+ fi
93
+
94
+ # --- Case 5: unknown kind — non-zero exit, empty stdout, nothing written -----
95
+ d="$(new_repo case5)"; cd "$d"
96
+ git commit --allow-empty -qm init
97
+ out="$("$HELPER" zzz --milestone m-x 2>/dev/null)"; rc=$?
98
+ check "case5 unknown kind exits non-zero" "$( [ "$rc" -ne 0 ] && echo yes || echo no )" "yes"
99
+ check "case5 unknown kind prints nothing on stdout" "$out" ""
100
+ [ ! -f .git/forge/id-reservations ] && pass "case5 ledger not created" || fail "case5 ledger was created"
101
+ [ ! -f .forge/reservations.yml ] && pass "case5 reservations.yml not created" || fail "case5 reservations.yml was created"
102
+
103
+ # --- Case 6: N-way concurrency yields N distinct ids, N ledger lines ---------
104
+ d="$(new_repo case6)"; cd "$d"
105
+ git commit --allow-empty -qm init
106
+ outdir="$d/outs"; mkdir -p "$outdir"
107
+ N=8
108
+ i=1
109
+ while [ "$i" -le "$N" ]; do
110
+ ( "$HELPER" fr --milestone m-x > "$outdir/out.$i" 2>/dev/null ) &
111
+ i=$((i + 1))
112
+ done
113
+ wait
114
+ distinct="$(cat "$outdir"/out.* | sort -u | grep -c .)"
115
+ check "case6 $N concurrent runs -> $N distinct ids" "$distinct" "$N"
116
+ ledger_lines="$(grep -c '^fr ' .git/forge/id-reservations)"
117
+ check "case6 $N distinct ledger fr lines" "$ledger_lines" "$N"
118
+
119
+ # --- Report -----------------------------------------------------------------
120
+ printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
121
+ [ "$FAILED" -eq 0 ]
@@ -28,7 +28,7 @@ When an architectural decision conflicts with vertical slicing (e.g., a framewor
28
28
 
29
29
  ## Architecture Decision Record (ADR)
30
30
 
31
- **Reserve the ADR number first (cross-worktree safety — blocking gate).** Before authoring a new ADR, allocate its number via the **ID Reservation Protocol** (FORGE.md). **Do not create the ADR file until its reservation is appended and committed** the reserve step is mandatory, not advisory. The next number is `max(highest `kind: adr` in `.forge/reservations.yml`, highest `ADR-NNN` in `.forge/decisions/`) + 1; append one `{kind: adr, id, milestone, reserved_at, summary}` entry to `.forge/reservations.yml`, **commit + push it**, *then* write the ADR file. Bare "scan `decisions/` for the highest + increment" only sees committed state, so two worktrees architecting in parallel both pick the same `ADR-NNN` and collide silently until merge (a multi-file renumber). See FORGE.md **ID Reservation Protocol** for the full rule. No `reservations.yml` yet scan as before and the first reservation creates it (lazy migration).
31
+ **Reserve the ADR number first (cross-worktree safety — blocking gate).** Before authoring a new ADR, allocate its number with the `forge-reserve` helper (ADR-021, supersedes ADR-016): run `id=$(.claude/hooks/forge-reserve.sh adr --milestone <id> --summary "<one-line>")`, then author `docs/decisions/$id-*.md` and commit `.forge/reservations.yml` **with** the ADR (unchanged handoff — the helper writes `reservations.yml` uncommitted; the caller commits it alongside the work). **This reserve step is mandatory, not advisory: do not create the ADR file until `forge-reserve` has printed its id.** WHY: the number is allocated against a machine-local ledger (`.git/forge/id-reservations`) that every sibling worktree of the repo shares the moment it is written, so two worktrees architecting in parallel can no longer pick the same `ADR-NNN` and collide silently until merge (a multi-file renumber) the exact failure ADR-016's "commit + push before allocating" could not prevent, because reservations sat on unmerged branches nothing else reads. The helper self-heals a missing ledger (lazy migration). See FORGE.md → **ID Reservation Protocol** and ADR-021 for the mechanism.
32
32
 
33
33
  Record every significant decision in `.forge/decisions/`:
34
34