forge-orkes 0.72.2 → 0.73.1
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/bin/create-forge.js +6 -4
- package/experimental/m10/source/mcp-server/README.md +2 -2
- package/experimental/m10/source/mcp-server/index.js +2 -2
- package/experimental/m10/source/skills/orchestrating/SKILL.md +7 -3
- package/package.json +1 -1
- package/template/.claude/hooks/forge-reserve.sh +367 -166
- package/template/.claude/hooks/tests/forge-reserve.test.sh +169 -0
- package/template/.claude/skills/chief-of-staff/SKILL.md +1 -0
- package/template/.claude/skills/forge/SKILL.md +8 -6
- package/template/.forge/FORGE.md +3 -3
- package/template/.forge/checks/forge-check-lint.sh +12 -3
- package/template/.forge/checks/forge-hold-check.sh +2 -0
- package/template/.forge/checks/forge-jarvis-awareness.sh +62 -11
- package/template/.forge/checks/forge-jarvis-dispatch.sh +25 -1
- package/template/.forge/checks/forge-jarvis-relay.sh +15 -6
- package/template/.forge/checks/tests/forge-jarvis-awareness.test.sh +53 -0
- package/template/.forge/checks/tests/forge-jarvis-dispatch.test.sh +50 -0
- package/template/.forge/checks/tests/forge-jarvis-relay.test.sh +30 -0
- package/template/.forge/templates/state/desire-path.yml +15 -45
|
@@ -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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
#
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
120
|
-
# write it). Refuse to write THROUGH a symlink — a
|
|
121
|
-
# redirect our seed/append to an arbitrary path with
|
|
122
|
-
# content. Absent or a regular file → proceed. Checked
|
|
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
|
-
# ---
|
|
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
|
-
|
|
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
|
-
#
|
|
160
|
-
trap
|
|
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
|
-
# ---
|
|
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
|
-
|
|
170
|
-
if [ -n "$
|
|
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
|
-
#
|
|
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
|
|
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
|
-
# ---
|
|
510
|
+
# --- 6. emit the id (stdout only); trap releases the lock on exit ------------
|
|
310
511
|
printf '%s\n' "$id"
|