forge-orkes 0.72.2 → 0.74.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.
@@ -2,7 +2,7 @@
2
2
  # forge-reserve — allocate a collision-safe sequential id (ADR/DEF/FR/NFR/
3
3
  # milestone/refactor/phase/wave) or record a version claim.
4
4
  #
5
- # Implements ADR-021: the coordination point is a machine-local ledger in the
5
+ # Implements ADR-021: the coordination point is a per-machine ledger in the
6
6
  # git COMMON dir (.git/forge/id-reservations), shared instantly across every
7
7
  # sibling worktree of the repo on this machine — the input ADR-016's
8
8
  # branch-tracked reservations.yml could not see. Same primitive as the
@@ -11,6 +11,16 @@
11
11
  # forge-reserve <kind> --milestone <id> [--summary <text>] [--value <X.Y.Z>]
12
12
  # kind ∈ {adr|def|fr|nfr|milestone|refactor|phase|wave|version}
13
13
  #
14
+ # forge-reserve compact [--days N]
15
+ # R104: floor-preserving compaction of .forge/reservations.yml. Explicit
16
+ # invocation only — the reserve paths above NEVER auto-compact. Per kind,
17
+ # keeps the max-id row (the only row the arm (c) floor below needs) plus
18
+ # any row reserved in the last N days (default 14; version rows use the
19
+ # same rule against semver, not a timestamp order). Everything else moves
20
+ # VERBATIM to .forge/reservations-archive.yml (append-only, created on
21
+ # first use). Idempotent — a second run with nothing newly stale archives
22
+ # nothing. Prints a one-line kept/archived/bytes summary to stderr.
23
+ #
14
24
  # phase/wave (ADR-029): roadmap phase ids + wave numbers are GLOBAL shared
15
25
  # spaces (the waves block keys phases globally) — the exact scan-and-increment
16
26
  # collision class ADR-021/023 killed elsewhere, observed 4x (m-34 x2, m-39,
@@ -41,71 +51,99 @@ WAIT="${FORGE_RESERVE_WAIT:-5}" # seconds: bounded lock-acquire budget
41
51
  STALE="${FORGE_RESERVE_STALE:-15}" # seconds: a lock older than this is broken
42
52
  SLEEP="0.1" # retry interval while the lock is held
43
53
 
54
+ # R106: the 9-kind vocabulary lives here ONCE and feeds both runtime error
55
+ # messages below. The usage comment (top of file) and the seeded reservations.yml
56
+ # heredoc stay manually written — a quoted heredoc can't interpolate a variable.
57
+ KINDS='adr|def|fr|nfr|milestone|refactor|phase|wave|version'
58
+
59
+ # --- 0. compact subcommand short-circuit (R104) ------------------------------
60
+ # Explicit invocation only — kept OUTSIDE the kind vocabulary above so a normal
61
+ # reserve call can never accidentally trigger it. Parsed before the kind/
62
+ # milestone requirements below, which do not apply to compact.
63
+ compact_mode=0
64
+ days=14
65
+ if [ "${1:-}" = "compact" ]; then
66
+ compact_mode=1
67
+ shift
68
+ while [ $# -gt 0 ]; do
69
+ case "$1" in
70
+ --days) shift; days="${1:-}"; [ $# -gt 0 ] && shift || true ;;
71
+ --days=*) days="${1#*=}"; shift ;;
72
+ *) printf 'forge-reserve: unknown compact argument: %s\n' "$1" >&2; exit 2 ;;
73
+ esac
74
+ done
75
+ case "$days" in
76
+ ''|*[!0-9]*) printf 'forge-reserve: --days must be a non-negative integer, got: %s\n' "$days" >&2; exit 2 ;;
77
+ esac
78
+ fi
79
+
44
80
  # --- 1. parse args ----------------------------------------------------------
45
81
  kind=""
46
82
  milestone=""
47
83
  summary=""
48
84
  value=""
49
- while [ $# -gt 0 ]; do
50
- case "$1" in
51
- --milestone) shift; milestone="${1:-}"; [ $# -gt 0 ] && shift || true ;;
52
- --milestone=*) milestone="${1#*=}"; shift ;;
53
- --summary) shift; summary="${1:-}"; [ $# -gt 0 ] && shift || true ;;
54
- --summary=*) summary="${1#*=}"; shift ;;
55
- --value) shift; value="${1:-}"; [ $# -gt 0 ] && shift || true ;;
56
- --value=*) value="${1#*=}"; shift ;;
57
- -*) printf 'forge-reserve: unknown option: %s\n' "$1" >&2; exit 2 ;;
58
- *)
59
- if [ -z "$kind" ]; then kind="$1"; shift
60
- else printf 'forge-reserve: unexpected argument: %s\n' "$1" >&2; exit 2; fi
61
- ;;
62
- esac
63
- done
64
-
65
- # Validate kind BEFORE touching git, the lock, or any file — a bad kind must
66
- # exit non-zero having written nothing (must_have truth #3).
67
- case "$kind" in
68
- adr) prefix="ADR" ;;
69
- def) prefix="DEF" ;;
70
- fr) prefix="FR" ;;
71
- nfr) prefix="NFR" ;;
72
- milestone) prefix="M" ;; # ADR-023 — unpadded (M-26); ids used bare across the framework
73
- refactor) prefix="R" ;; # ADR-023 — zero-padded R0NN, matching the backlog format
74
- phase) prefix="P" ;; # ADR-029 — registry token P-0NN; roadmap.yml keeps the bare int
75
- wave) prefix="W" ;; # ADR-029 — registry token W-0NN; roadmap.yml keeps the bare int
76
- version) prefix="" ;; # ADR-029 — record-only; id IS the --value string verbatim
77
- "") printf 'forge-reserve: missing kind (adr|def|fr|nfr|milestone|refactor|phase|wave|version)\n' >&2; exit 2 ;;
78
- *) printf 'forge-reserve: unknown kind: %s (expected adr|def|fr|nfr|milestone|refactor|phase|wave|version)\n' "$kind" >&2; exit 2 ;;
79
- esac
80
- [ -n "$milestone" ] || { printf 'forge-reserve: --milestone <id> is required\n' >&2; exit 2; }
81
- if [ "$kind" = "version" ] && [ -z "$value" ]; then
82
- printf 'forge-reserve: version kind requires --value <X.Y.Z>\n' >&2; exit 2
83
- fi
85
+ if [ "$compact_mode" -eq 0 ]; then
86
+ while [ $# -gt 0 ]; do
87
+ case "$1" in
88
+ --milestone) shift; milestone="${1:-}"; [ $# -gt 0 ] && shift || true ;;
89
+ --milestone=*) milestone="${1#*=}"; shift ;;
90
+ --summary) shift; summary="${1:-}"; [ $# -gt 0 ] && shift || true ;;
91
+ --summary=*) summary="${1#*=}"; shift ;;
92
+ --value) shift; value="${1:-}"; [ $# -gt 0 ] && shift || true ;;
93
+ --value=*) value="${1#*=}"; shift ;;
94
+ -*) printf 'forge-reserve: unknown option: %s\n' "$1" >&2; exit 2 ;;
95
+ *)
96
+ if [ -z "$kind" ]; then kind="$1"; shift
97
+ else printf 'forge-reserve: unexpected argument: %s\n' "$1" >&2; exit 2; fi
98
+ ;;
99
+ esac
100
+ done
84
101
 
85
- # Reject control characters in the caller-supplied fields BEFORE the lock or any
86
- # write. milestone + id are printf'd UNescaped into both the ledger TSV (\t-
87
- # separated) and reservations.yml below; a newline or tab would split the ledger
88
- # row or inject a sibling YAML key, and (for version) forge a holder record that
89
- # spoofs the conflict check. summary's YAML scalar breaks on a raw newline too.
90
- # Nothing is written yet, so exit 2 is clean.
91
- CR="$(printf '\r')"
92
- NL="$(printf '\nX')"; NL="${NL%X}"
93
- TAB="$(printf '\tX')"; TAB="${TAB%X}"
94
- for _pair in "milestone=$milestone" "value=$value" "summary=$summary"; do
95
- case "${_pair#*=}" in
96
- *"$NL"*|*"$TAB"*|*"$CR"*)
97
- printf 'forge-reserve: control characters are not allowed in --%s\n' "${_pair%%=*}" >&2
98
- exit 2 ;;
102
+ # Validate kind BEFORE touching git, the lock, or any file a bad kind must
103
+ # exit non-zero having written nothing (must_have truth #3).
104
+ case "$kind" in
105
+ adr) prefix="ADR" ;;
106
+ def) prefix="DEF" ;;
107
+ fr) prefix="FR" ;;
108
+ nfr) prefix="NFR" ;;
109
+ milestone) prefix="M" ;; # ADR-023 — unpadded (M-26); ids used bare across the framework
110
+ refactor) prefix="R" ;; # ADR-023 — zero-padded R0NN, matching the backlog format
111
+ phase) prefix="P" ;; # ADR-029 registry token P-0NN; roadmap.yml keeps the bare int
112
+ wave) prefix="W" ;; # ADR-029 — registry token W-0NN; roadmap.yml keeps the bare int
113
+ version) prefix="" ;; # ADR-029 — record-only; id IS the --value string verbatim
114
+ "") printf 'forge-reserve: missing kind (%s)\n' "$KINDS" >&2; exit 2 ;;
115
+ *) printf 'forge-reserve: unknown kind: %s (expected %s)\n' "$kind" "$KINDS" >&2; exit 2 ;;
99
116
  esac
100
- done
117
+ [ -n "$milestone" ] || { printf 'forge-reserve: --milestone <id> is required\n' >&2; exit 2; }
118
+ if [ "$kind" = "version" ] && [ -z "$value" ]; then
119
+ printf 'forge-reserve: version kind requires --value <X.Y.Z>\n' >&2; exit 2
120
+ fi
121
+
122
+ # Reject control characters in the caller-supplied fields BEFORE the lock or any
123
+ # write. milestone + id are printf'd UNescaped into both the ledger TSV (\t-
124
+ # separated) and reservations.yml below; a newline or tab would split the ledger
125
+ # row or inject a sibling YAML key, and (for version) forge a holder record that
126
+ # spoofs the conflict check. summary's YAML scalar breaks on a raw newline too.
127
+ # Nothing is written yet, so exit 2 is clean.
128
+ CR="$(printf '\r')"
129
+ NL="$(printf '\nX')"; NL="${NL%X}"
130
+ TAB="$(printf '\tX')"; TAB="${TAB%X}"
131
+ for _pair in "milestone=$milestone" "value=$value" "summary=$summary"; do
132
+ case "${_pair#*=}" in
133
+ *"$NL"*|*"$TAB"*|*"$CR"*)
134
+ printf 'forge-reserve: control characters are not allowed in --%s\n' "${_pair%%=*}" >&2
135
+ exit 2 ;;
136
+ esac
137
+ done
101
138
 
102
- # version --value is the id verbatim (record-only kind), so it must match the
103
- # advertised X.Y.Z semver shape (optional -pre / +build) — enforce the contract
104
- # before the lock rather than writing an arbitrary string into the registries.
105
- if [ "$kind" = "version" ] \
106
- && ! printf '%s' "$value" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.]+)?$'; then
107
- printf 'forge-reserve: version --value must be X.Y.Z semver, got: %s\n' "$value" >&2
108
- exit 2
139
+ # version --value is the id verbatim (record-only kind), so it must match the
140
+ # advertised X.Y.Z semver shape (optional -pre / +build) — enforce the contract
141
+ # before the lock rather than writing an arbitrary string into the registries.
142
+ if [ "$kind" = "version" ] \
143
+ && ! printf '%s' "$value" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.]+)?$'; then
144
+ printf 'forge-reserve: version --value must be X.Y.Z semver, got: %s\n' "$value" >&2
145
+ exit 2
146
+ fi
109
147
  fi
110
148
 
111
149
  # --- 2. resolve paths -------------------------------------------------------
@@ -115,13 +153,14 @@ mkdir -p "$common/forge"
115
153
  ledger="$common/forge/id-reservations"
116
154
  lock="$common/forge/id-reservations.lock"
117
155
  reservations="$top/.forge/reservations.yml"
156
+ archive="$top/.forge/reservations-archive.yml"
118
157
 
119
- # reservations.yml is committed, semi-trusted repo content (sibling worktrees
120
- # write it). Refuse to write THROUGH a symlink — a symlinked target would
121
- # redirect our seed/append to an arbitrary path with partly caller-supplied
122
- # content. Absent or a regular file → proceed. Checked before the lock so a
123
- # hostile target fails fast having written nothing.
124
- for _p in "$ledger" "$reservations"; do
158
+ # reservations.yml (and its compaction archive) is committed, semi-trusted repo
159
+ # content (sibling worktrees write it). Refuse to write THROUGH a symlink — a
160
+ # symlinked target would redirect our seed/append to an arbitrary path with
161
+ # partly caller-supplied content. Absent or a regular file → proceed. Checked
162
+ # before the lock so a hostile target fails fast having written nothing.
163
+ for _p in "$ledger" "$reservations" "$archive"; do
125
164
  if [ -L "$_p" ]; then
126
165
  printf 'forge-reserve: refusing to write through symlink: %s\n' "$_p" >&2
127
166
  exit 2
@@ -135,18 +174,252 @@ get_mtime() {
135
174
  stat -f %m "$1" 2>/dev/null || stat -c %Y "$1" 2>/dev/null || true
136
175
  }
137
176
 
138
- # --- 3. acquire lock (atomic mkdir, bounded retry, staleness-break) ----------
177
+ # --- computed-kinds id allocation: three-way max(ledger in-tree
178
+ # main:reservations.yml) + 1, then per-kind id format (ADR-023, ADR-029).
179
+ # Version never calls this — it records --value verbatim under the lock
180
+ # (section 4 below). Reads the globals set during arg-parsing (kind, prefix)
181
+ # and path resolution (top, ledger); prints the allocated id on stdout.
182
+ compute_id() {
183
+ # Everything numeric goes through awk (n+0) so leading-zero ids like 021 are
184
+ # read as decimal 21, never octal — no shell $(( )) octal trap.
185
+
186
+ # (a) ledger — same-machine siblings, INCLUDING uncommitted (the ADR-016-blind input).
187
+ # Extract the trailing number by stripping non-digits, NOT by split on "-": refactor
188
+ # ids (R100) carry no hyphen (ADR-023), so a hyphen-split would read them as 0 and
189
+ # reallocate a live id. gsub handles ADR-014, M-26, R100, FR-023, P-066 uniformly.
190
+ ledger_max=0
191
+ if [ -f "$ledger" ]; then
192
+ ledger_max="$(awk -F'\t' -v k="$kind" \
193
+ '$1==k { x=$2; gsub(/[^0-9]/,"",x); n=x+0; if(n>m)m=n } END{print m+0}' "$ledger")"
194
+ fi
195
+
196
+ # (b) in-tree — landed ids in THIS worktree. Exact-prefix match in awk ($1==p) so
197
+ # FR does not swallow NFR (FR is a substring of NFR). milestone + refactor (ADR-023)
198
+ # and phase (ADR-029) each scan BOTH live and archive locations so a burned id is
199
+ # never reallocated.
200
+ case "$kind" in
201
+ adr)
202
+ intree_max="$(ls "$top/docs/decisions" 2>/dev/null \
203
+ | awk -F- '/^ADR-[0-9]/ { n=$2+0; if(n>m)m=n } END{print m+0}')"
204
+ ;;
205
+ milestone)
206
+ # live state/milestone-<N>.yml ∪ archived .forge/archive/milestone-<N>/
207
+ intree_max="$( { ls "$top/.forge/state"/milestone-*.yml 2>/dev/null; \
208
+ ls -d "$top/.forge/archive"/milestone-*/ 2>/dev/null; } \
209
+ | grep -oE 'milestone-[0-9]+' \
210
+ | awk -F- '{ n=$2+0; if(n>m)m=n } END{print m+0}')"
211
+ ;;
212
+ refactor)
213
+ # live refactor-backlog.yml ∪ refactor-backlog-archive.yml
214
+ intree_max="$(grep -hoE 'R[0-9]+' \
215
+ "$top/.forge/refactor-backlog.yml" \
216
+ "$top/.forge/refactor-backlog-archive.yml" 2>/dev/null \
217
+ | awk '{ n=substr($0,2)+0; if(n>m)m=n } END{print m+0}')"
218
+ ;;
219
+ phase)
220
+ # live roadmap.yml `- id: N` lines ∪ archived phase dirs
221
+ # .forge/archive/milestone-*/phases/<N>-*. The roadmap scan deliberately
222
+ # takes ALL bare-int `- id:` values (milestone AND phase entries share the
223
+ # indent): over-counting can only RAISE the floor — safe; an under-count
224
+ # (a missed phase id) reintroduces the collision. Quoted milestone ids
225
+ # ("m-42") don't match the bare-int pattern. Archive-delete removes a
226
+ # milestone's roadmap entries, so the archive arm is what keeps its phase
227
+ # ids burned (ADR-029, mirroring the milestone arm above).
228
+ intree_max="$( { cat "$top/.forge/roadmap.yml" 2>/dev/null; \
229
+ ls "$top/.forge/archive"/milestone-*/phases/ 2>/dev/null; } \
230
+ | awk '/^[ ]*- id: [0-9]+/ { n=$3+0; if(n>m)m=n }
231
+ /^[0-9]+-/ { x=$0; sub(/-.*/,"",x); n=x+0; if(n>m)m=n }
232
+ END{print m+0}')"
233
+ ;;
234
+ wave)
235
+ # live roadmap.yml waves-map KEYS only (` N: [...]`) — never the phase ids
236
+ # inside the brackets. No archive arm: waves are removed with their roadmap
237
+ # entry on archive-delete; ledger + main floor cover the gap (ADR-029).
238
+ intree_max="$(cat "$top/.forge/roadmap.yml" 2>/dev/null \
239
+ | awk '/^[ ]+[0-9]+:[ ]*\[/ { k=$1; gsub(/[^0-9]/,"",k); n=k+0; if(n>m)m=n } END{print m+0}')"
240
+ ;;
241
+ *)
242
+ intree_max="$(grep -rhoE '[A-Z]+-[0-9]+' "$top/.forge/requirements/" 2>/dev/null \
243
+ | awk -F- -v p="$prefix" '$1==p { n=$2+0; if(n>m)m=n } END{print m+0}')"
244
+ ;;
245
+ esac
246
+
247
+ # (c) main:reservations.yml — cross-machine / cold-start floor (survives a
248
+ # .git/forge wipe; carries another machine's MERGED reservations). Absent → 0.
249
+ # Hyphen is OPTIONAL in the token ([A-Z]+-?[0-9]+): refactor ids (R100) carry none
250
+ # (ADR-023), so requiring a hyphen would hide them and drop the floor to 0. Split the
251
+ # alpha prefix from the digits in awk so R does not match e.g. FR (anchor ^prefix$).
252
+ main_max="$(git show main:.forge/reservations.yml 2>/dev/null \
253
+ | grep -oE '[A-Z]+-?[0-9]+' \
254
+ | awk -v p="$prefix" '{ a=$0; sub(/[-0-9].*$/,"",a); d=$0; gsub(/[^0-9]/,"",d);
255
+ if(a==p){ n=d+0; if(n>m)m=n } } END{print m+0}')"
256
+ [ -n "$main_max" ] || main_max=0
257
+
258
+ next="$(awk -v a="$ledger_max" -v b="$intree_max" -v c="$main_max" \
259
+ 'BEGIN{m=a; if(b>m)m=b; if(c>m)m=c; print m+1}')"
260
+ # Per-kind id format (ADR-023, ADR-029). The prefix-hyphen-paddednumber form
261
+ # (ADR-021's ADR-014, FR-023) is NOT universal:
262
+ # milestone → M-26 : hyphen, UNPADDED — ids are used bare (milestone-26.yml, m-26)
263
+ # refactor → R064 : NO hyphen, zero-padded 3-wide — matches the backlog convention
264
+ # adr/def/fr/nfr/phase/wave → ADR-014, P-066, W-046 : hyphen, zero-padded 3-wide
265
+ case "$kind" in
266
+ milestone) id="$(printf '%s-%d' "$prefix" "$next")" ;;
267
+ refactor) id="$(printf '%s%03d' "$prefix" "$next")" ;;
268
+ *) id="$(printf '%s-%03d' "$prefix" "$next")" ;;
269
+ esac
270
+ printf '%s\n' "$id"
271
+ }
272
+
273
+ # --- compact subcommand: floor-preserving rewrite of reservations.yml (R104) -
274
+ # Per kind, keeps (a) the highest-id row — the ONLY row compute_id's arm (c)
275
+ # three-way max above needs, so the floor is unchanged by construction — and
276
+ # (b) any row reserved within the last $days days (default 14). Everything
277
+ # else moves VERBATIM to reservations-archive.yml. Runs entirely inside one
278
+ # awk pass (recency is a plain string compare against a same-width ISO8601
279
+ # cutoff — no per-row date parsing needed) so there is nothing to keep in sync
280
+ # between two scans. Called under the same mkdir lock as a normal reserve
281
+ # (section 3), so it can never race a concurrent append. Reads/sets the
282
+ # globals from sections 0/2: days, reservations, archive.
283
+ do_compact() {
284
+ if [ ! -f "$reservations" ]; then
285
+ printf 'forge-reserve: nothing to compact — %s does not exist\n' "$reservations" >&2
286
+ return 0
287
+ fi
288
+
289
+ before_bytes="$(wc -c < "$reservations" | tr -d ' ')"
290
+
291
+ # BSD (macOS) vs GNU date, same ADR-021-point-3 platform branch as get_mtime.
292
+ cutoff_iso="$(date -u -v-"${days}"d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
293
+ || date -u -d "-${days} days" +%Y-%m-%dT%H:%M:%SZ)"
294
+
295
+ tmp_keep="$(mktemp "${TMPDIR:-/tmp}/forge-reserve-compact-keep.XXXXXX")"
296
+ tmp_arch="$(mktemp "${TMPDIR:-/tmp}/forge-reserve-compact-arch.XXXXXX")"
297
+
298
+ counts="$(awk -v cutoff="$cutoff_iso" -v keepfile="$tmp_keep" -v archfile="$tmp_arch" '
299
+ BEGIN { n = 0; havedash = 0; header = "" }
300
+ /^ - kind: / {
301
+ if (havedash) { n++; blocks[n]=block; kinds[n]=curkind; ids[n]=curid; rats[n]=currat }
302
+ block = $0 "\n"
303
+ curkind = $0; sub(/^ - kind: */, "", curkind)
304
+ curid = ""; currat = ""
305
+ havedash = 1
306
+ next
307
+ }
308
+ havedash == 0 { header = header $0 "\n"; next }
309
+ {
310
+ block = block $0 "\n"
311
+ if ($0 ~ /^ id: /) { curid = $0; sub(/^ id: */, "", curid) }
312
+ if ($0 ~ /^ reserved_at: /) { currat = $0; sub(/^ reserved_at: *"?/, "", currat); sub(/"$/, "", currat) }
313
+ }
314
+ END {
315
+ if (havedash) { n++; blocks[n]=block; kinds[n]=curkind; ids[n]=curid; rats[n]=currat }
316
+
317
+ # Sort key per record: numeric id (non-digits stripped) for every kind
318
+ # except version, which compares the leading X.Y.Z semver triple —
319
+ # pre-release/build suffixes never affect ordering here.
320
+ for (i = 1; i <= n; i++) {
321
+ k = kinds[i]; idv = ids[i]
322
+ if (k == "version") {
323
+ if (match(idv, /^[0-9]+\.[0-9]+\.[0-9]+/)) {
324
+ s = substr(idv, RSTART, RLENGTH)
325
+ split(s, parts, ".")
326
+ key = parts[1] * 1000000 + parts[2] * 1000 + parts[3]
327
+ } else key = 0
328
+ } else {
329
+ numid = idv
330
+ gsub(/[^0-9]/, "", numid)
331
+ key = numid + 0
332
+ }
333
+ keys[i] = key
334
+ if (!(k in maxkey) || key > maxkey[k]) maxkey[k] = key
335
+ }
336
+
337
+ printf "%s", header > keepfile
338
+ kept = 0; archived = 0
339
+ for (i = 1; i <= n; i++) {
340
+ k = kinds[i]
341
+ if (keys[i] == maxkey[k] || rats[i] >= cutoff) {
342
+ printf "%s", blocks[i] >> keepfile
343
+ kept++
344
+ } else {
345
+ printf "%s", blocks[i] >> archfile
346
+ archived++
347
+ }
348
+ }
349
+ printf "%d %d", kept, archived
350
+ }
351
+ ' "$reservations")"
352
+
353
+ kept="${counts%% *}"
354
+ archived="${counts##* }"
355
+
356
+ mv "$tmp_keep" "$reservations"
357
+
358
+ if [ -s "$tmp_arch" ]; then
359
+ if [ ! -f "$archive" ]; then
360
+ cat > "$archive" <<'YML'
361
+ # Forge ID Reservations — Archive (R104)
362
+ #
363
+ # Append-only overflow for .forge/reservations.yml, written ONLY by
364
+ # `forge-reserve compact` (.claude/hooks/forge-reserve.sh). reservations.yml
365
+ # has a 50KB size gate (FORGE.md Accumulating Artifacts); this file holds
366
+ # every reservation row compaction has ever dropped from the working file,
367
+ # byte-verbatim, so the durable audit trail is never lost. Never edit,
368
+ # reorder, or hand-truncate existing entries.
369
+ #
370
+ # Floor invariant: compaction always keeps, per kind, the highest-id row (or
371
+ # for `version`, the highest semver row) plus any row from the retention
372
+ # window — so a max-scan over reservations.yml ALONE (never this archive)
373
+ # always finds the true per-kind ceiling. See FORGE.md → ID Reservation
374
+ # Protocol / Accumulating Artifacts.
375
+ reservations:
376
+ YML
377
+ fi
378
+ cat "$tmp_arch" >> "$archive"
379
+ fi
380
+ rm -f "$tmp_keep" "$tmp_arch" 2>/dev/null || true
381
+
382
+ after_bytes="$(wc -c < "$reservations" | tr -d ' ')"
383
+ printf 'forge-reserve: compact — kept %s, archived %s, reservations.yml %s -> %s bytes\n' \
384
+ "$kept" "$archived" "$before_bytes" "$after_bytes" >&2
385
+ }
386
+
387
+ # --- 3. acquire lock (atomic mkdir, bounded retry, ownable staleness-break) --
139
388
  # mkdir is POSIX-atomic: it succeeds for exactly one racer and fails if the dir
140
389
  # already exists — a lock without flock. A crashed helper's lock is broken after
141
390
  # STALE seconds so it can't wedge the namespace forever.
391
+ #
392
+ # R103: the lock is ownable. An owner stamp ("$$ epoch") is written inside the
393
+ # lock dir once acquired. Before breaking a lock judged stale by mtime, the
394
+ # breaker re-reads the mtime AND the owner stamp immediately before rmdir — if
395
+ # either changed since the staleness read, another process refreshed this lock
396
+ # in the gap, so the break is skipped and this iteration falls through to the
397
+ # normal bounded wait instead. This shrinks the unsafe window from the full
398
+ # STALE/WAIT budget down to the read-vs-rmdir instruction gap — not zero (no
399
+ # flock on macOS, ADR-021 point 3), but far narrower than an unconditional
400
+ # path-based rmdir. The EXIT/INT/TERM trap is guarded the same way: it only
401
+ # tears down a lock this process' stamp still occupies, never a lock a racer
402
+ # broke-and-re-acquired after us.
142
403
  max_iters="$(awk -v w="$WAIT" -v s="$SLEEP" 'BEGIN{print int(w/s)}')"
143
404
  iters=0
144
405
  while ! mkdir "$lock" 2>/dev/null; do
145
406
  mt="$(get_mtime "$lock")"
146
407
  now_epoch="$(date +%s)"
408
+ broke=0
147
409
  if [ -n "$mt" ] && [ "$((now_epoch - mt))" -ge "$STALE" ]; then
410
+ seen_owner="$(cat "$lock/owner" 2>/dev/null || true)"
148
411
  printf 'forge-reserve: breaking stale lock (age %ss): %s\n' "$((now_epoch - mt))" "$lock" >&2
149
- rmdir "$lock" 2>/dev/null || true
412
+ mt2="$(get_mtime "$lock")"
413
+ now_owner="$(cat "$lock/owner" 2>/dev/null || true)"
414
+ if [ "$mt2" = "$mt" ] && [ "$now_owner" = "$seen_owner" ]; then
415
+ rm -f "$lock/owner" 2>/dev/null || true
416
+ rmdir "$lock" 2>/dev/null || true
417
+ broke=1
418
+ else
419
+ printf 'forge-reserve: stale-lock owner changed just before break — treating as fresh, waiting: %s\n' "$lock" >&2
420
+ fi
421
+ fi
422
+ if [ "$broke" -eq 1 ]; then
150
423
  continue
151
424
  fi
152
425
  iters="$((iters + 1))"
@@ -156,124 +429,52 @@ while ! mkdir "$lock" 2>/dev/null; do
156
429
  fi
157
430
  sleep "$SLEEP"
158
431
  done
159
- # Only now that the lock is ours do we install the release trap.
160
- trap 'rmdir "$lock" 2>/dev/null || true' EXIT INT TERM
432
+ # The lock is ours: stamp an owner marker before installing the release trap
433
+ # so the trap and any future racer's stale-break identity check — can tell
434
+ # this process apart from a later one that broke and re-acquired the same path.
435
+ own_stamp="$$ $(date +%s)"
436
+ printf '%s\n' "$own_stamp" > "$lock/owner"
437
+ trap '
438
+ if [ "$(cat "$lock/owner" 2>/dev/null)" = "$own_stamp" ]; then
439
+ rm -f "$lock/owner" 2>/dev/null || true
440
+ rmdir "$lock" 2>/dev/null || true
441
+ fi
442
+ ' EXIT INT TERM
161
443
 
162
- # --- 4v. version kind: record-only claim (ADR-029) ---------------------------
444
+ # --- 4a. compact subcommand: runs under the lock just acquired, then exits --
445
+ if [ "$compact_mode" -eq 1 ]; then
446
+ do_compact
447
+ exit 0
448
+ fi
449
+
450
+ # --- 4. version kind: record-only claim (ADR-029) ----------------------------
163
451
  # No max computation — the caller derived the number from releases.yml + semver
164
452
  # bump; this block only makes the claim ledger-visible. Runs UNDER the lock so
165
453
  # two racing claims of the same string serialize: the second sees the first.
454
+ # Single awk pass emits holder<TAB>held_at (R106); POSIX parameter expansion
455
+ # splits the pair instead of two near-identical ledger scans.
166
456
  if [ "$kind" = "version" ]; then
167
457
  id="$value"
168
458
  if [ -f "$ledger" ]; then
169
- holder="$(awk -F'\t' -v v="$id" '$1=="version" && $2==v { print $3; exit }' "$ledger")"
170
- if [ -n "$holder" ]; then
459
+ out="$(awk -F'\t' -v v="$id" '$1=="version" && $2==v { printf "%s\t%s", $3, $4; exit }' "$ledger")"
460
+ if [ -n "$out" ]; then
461
+ holder="${out%%"$TAB"*}"
462
+ held_at="${out#*"$TAB"}"
171
463
  if [ "$holder" = "$milestone" ]; then
172
464
  # Idempotent re-claim by the same milestone: already recorded, write nothing.
173
465
  printf '%s\n' "$id"
174
466
  exit 0
175
467
  fi
176
- held_at="$(awk -F'\t' -v v="$id" '$1=="version" && $2==v { print $4; exit }' "$ledger")"
177
468
  printf 'forge-reserve: version %s already claimed by %s (%s) — re-read max()+bump and take the next number\n' \
178
469
  "$id" "$holder" "$held_at" >&2
179
470
  exit 1
180
471
  fi
181
472
  fi
182
473
  else
183
-
184
- # --- 4/5. three-way max(ledger ∪ in-tree ∪ main:reservations.yml) + 1 --------
185
- # (computed kinds only — version above records verbatim.)
186
- # Everything numeric goes through awk (n+0) so leading-zero ids like 021 are read
187
- # as decimal 21, never octal — no shell $(( )) octal trap.
188
-
189
- # (a) ledger — same-machine siblings, INCLUDING uncommitted (the ADR-016-blind input).
190
- # Extract the trailing number by stripping non-digits, NOT by split on "-": refactor
191
- # ids (R100) carry no hyphen (ADR-023), so a hyphen-split would read them as 0 and
192
- # reallocate a live id. gsub handles ADR-014, M-26, R100, FR-023, P-066 uniformly.
193
- ledger_max=0
194
- if [ -f "$ledger" ]; then
195
- ledger_max="$(awk -F'\t' -v k="$kind" \
196
- '$1==k { x=$2; gsub(/[^0-9]/,"",x); n=x+0; if(n>m)m=n } END{print m+0}' "$ledger")"
474
+ id="$(compute_id)"
197
475
  fi
198
476
 
199
- # (b) in-tree — landed ids in THIS worktree. Exact-prefix match in awk ($1==p) so
200
- # FR does not swallow NFR (FR is a substring of NFR). milestone + refactor (ADR-023)
201
- # and phase (ADR-029) each scan BOTH live and archive locations so a burned id is
202
- # never reallocated.
203
- case "$kind" in
204
- adr)
205
- intree_max="$(ls "$top/docs/decisions" 2>/dev/null \
206
- | awk -F- '/^ADR-[0-9]/ { n=$2+0; if(n>m)m=n } END{print m+0}')"
207
- ;;
208
- milestone)
209
- # live state/milestone-<N>.yml ∪ archived .forge/archive/milestone-<N>/
210
- intree_max="$( { ls "$top/.forge/state"/milestone-*.yml 2>/dev/null; \
211
- ls -d "$top/.forge/archive"/milestone-*/ 2>/dev/null; } \
212
- | grep -oE 'milestone-[0-9]+' \
213
- | awk -F- '{ n=$2+0; if(n>m)m=n } END{print m+0}')"
214
- ;;
215
- refactor)
216
- # live refactor-backlog.yml ∪ refactor-backlog-archive.yml
217
- intree_max="$(grep -hoE 'R[0-9]+' \
218
- "$top/.forge/refactor-backlog.yml" \
219
- "$top/.forge/refactor-backlog-archive.yml" 2>/dev/null \
220
- | awk '{ n=substr($0,2)+0; if(n>m)m=n } END{print m+0}')"
221
- ;;
222
- phase)
223
- # live roadmap.yml `- id: N` lines ∪ archived phase dirs
224
- # .forge/archive/milestone-*/phases/<N>-*. The roadmap scan deliberately
225
- # takes ALL bare-int `- id:` values (milestone AND phase entries share the
226
- # indent): over-counting can only RAISE the floor — safe; an under-count
227
- # (a missed phase id) reintroduces the collision. Quoted milestone ids
228
- # ("m-42") don't match the bare-int pattern. Archive-delete removes a
229
- # milestone's roadmap entries, so the archive arm is what keeps its phase
230
- # ids burned (ADR-029, mirroring the milestone arm above).
231
- intree_max="$( { cat "$top/.forge/roadmap.yml" 2>/dev/null; \
232
- ls "$top/.forge/archive"/milestone-*/phases/ 2>/dev/null; } \
233
- | awk '/^[[:space:]]*- id: [0-9]+/ { n=$3+0; if(n>m)m=n }
234
- /^[0-9]+-/ { x=$0; sub(/-.*/,"",x); n=x+0; if(n>m)m=n }
235
- END{print m+0}')"
236
- ;;
237
- wave)
238
- # live roadmap.yml waves-map KEYS only (` N: [...]`) — never the phase ids
239
- # inside the brackets. No archive arm: waves are removed with their roadmap
240
- # entry on archive-delete; ledger + main floor cover the gap (ADR-029).
241
- intree_max="$(cat "$top/.forge/roadmap.yml" 2>/dev/null \
242
- | awk '/^[[:space:]]+[0-9]+:[[:space:]]*\[/ { k=$1; gsub(/[^0-9]/,"",k); n=k+0; if(n>m)m=n } END{print m+0}')"
243
- ;;
244
- *)
245
- intree_max="$(grep -rhoE '[A-Z]+-[0-9]+' "$top/.forge/requirements/" 2>/dev/null \
246
- | awk -F- -v p="$prefix" '$1==p { n=$2+0; if(n>m)m=n } END{print m+0}')"
247
- ;;
248
- esac
249
-
250
- # (c) main:reservations.yml — cross-machine / cold-start floor (survives a
251
- # .git/forge wipe; carries another machine's MERGED reservations). Absent → 0.
252
- # Hyphen is OPTIONAL in the token ([A-Z]+-?[0-9]+): refactor ids (R100) carry none
253
- # (ADR-023), so requiring a hyphen would hide them and drop the floor to 0. Split the
254
- # alpha prefix from the digits in awk so R does not match e.g. FR (anchor ^prefix$).
255
- main_max="$(git show main:.forge/reservations.yml 2>/dev/null \
256
- | grep -oE '[A-Z]+-?[0-9]+' \
257
- | awk -v p="$prefix" '{ a=$0; sub(/[-0-9].*$/,"",a); d=$0; gsub(/[^0-9]/,"",d);
258
- if(a==p){ n=d+0; if(n>m)m=n } } END{print m+0}')"
259
- [ -n "$main_max" ] || main_max=0
260
-
261
- next="$(awk -v a="$ledger_max" -v b="$intree_max" -v c="$main_max" \
262
- 'BEGIN{m=a; if(b>m)m=b; if(c>m)m=c; print m+1}')"
263
- # Per-kind id format (ADR-023, ADR-029). The prefix-hyphen-paddednumber form
264
- # (ADR-021's ADR-014, FR-023) is NOT universal:
265
- # milestone → M-26 : hyphen, UNPADDED — ids are used bare (milestone-26.yml, m-26)
266
- # refactor → R064 : NO hyphen, zero-padded 3-wide — matches the backlog convention
267
- # adr/def/fr/nfr/phase/wave → ADR-014, P-066, W-046 : hyphen, zero-padded 3-wide
268
- case "$kind" in
269
- milestone) id="$(printf '%s-%d' "$prefix" "$next")" ;;
270
- refactor) id="$(printf '%s%03d' "$prefix" "$next")" ;;
271
- *) id="$(printf '%s-%03d' "$prefix" "$next")" ;;
272
- esac
273
-
274
- fi # end computed-kinds branch (version skipped to the dual-write below)
275
-
276
- # --- 6. dual-write (still under the lock) -----------------------------------
477
+ # --- 5. dual-write (still under the lock) -----------------------------------
277
478
  now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
278
479
  printf '%s\t%s\t%s\t%s\n' "$kind" "$id" "$milestone" "$now" >> "$ledger"
279
480
 
@@ -285,7 +486,7 @@ if [ ! -f "$reservations" ]; then
285
486
  mkdir -p "$(dirname "$reservations")"
286
487
  cat > "$reservations" <<'YML'
287
488
  # Forge ID Reservations — cross-worktree coordination for Forge sequential ids + version claims
288
- # Written by .claude/hooks/forge-reserve.sh (ADR-021). The machine-local ledger
489
+ # Written by .claude/hooks/forge-reserve.sh (ADR-021). The per-machine ledger
289
490
  # (.git/forge/id-reservations) is the same-machine coordination point; this file
290
491
  # is the durable committed audit trail + cross-machine floor. Append-only; never
291
492
  # edit prior entries. Next number for a kind = max(ledger, in-tree, main) + 1
@@ -306,5 +507,5 @@ esc_summary="$(printf '%s' "$summary" | sed 's/\\/\\\\/g; s/"/\\"/g')"
306
507
  printf ' summary: "%s"\n' "$esc_summary"
307
508
  } >> "$reservations"
308
509
 
309
- # --- 7. emit the id (stdout only); trap releases the lock on exit ------------
510
+ # --- 6. emit the id (stdout only); trap releases the lock on exit ------------
310
511
  printf '%s\n' "$id"