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 +1 -1
- package/template/.claude/agents/verifier.md +8 -4
- package/template/.claude/hooks/forge-model-outcome.sh +177 -0
- package/template/.claude/hooks/forge-reserve.sh +162 -0
- package/template/.claude/hooks/tests/forge-model-outcome.test.sh +108 -0
- package/template/.claude/hooks/tests/forge-reserve.test.sh +121 -0
- package/template/.claude/skills/architecting/SKILL.md +1 -1
- package/template/.claude/skills/executing/SKILL.md +31 -1
- package/template/.claude/skills/forge/SKILL.md +30 -3
- package/template/.claude/skills/planning/SKILL.md +29 -3
- package/template/.claude/skills/prototyping/SKILL.md +88 -0
- package/template/.claude/skills/verifying/SKILL.md +13 -8
- package/template/.forge/FORGE.md +39 -14
- package/template/.forge/migrations/0.46.0-id-reservation-ledger.md +44 -0
- package/template/.forge/templates/plan.md +35 -1
- package/template/.forge/templates/project.yml +4 -0
- package/template/.forge/templates/prototypes/prototype.md +31 -0
package/package.json
CHANGED
|
@@ -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 |
|
|
28
|
-
|
|
29
|
-
| {from must_haves} |
|
|
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
|
|
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
|
|
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
|
|