forge-orkes 0.80.0 → 0.80.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 -0
- package/package.json +1 -1
- package/template/.claude/hooks/forge-context-migrate.sh +26 -6
- package/template/.claude/hooks/forge-release-fold.sh +35 -2
- package/template/.claude/hooks/tests/forge-context-migrate.test.sh +82 -0
- package/template/.claude/hooks/tests/forge-release-fold.test.sh +53 -2
- package/template/.claude/settings.json +1 -1
- package/template/.forge/migrations/0.80.0-context-md-sharding.md +18 -1
package/bin/create-forge.js
CHANGED
|
@@ -885,6 +885,12 @@ async function upgrade() {
|
|
|
885
885
|
// initializing/upgrading skills' job, NOT the installer's — see makeGitHooksExecutable.
|
|
886
886
|
upgradeAdditiveDir('.forge/git-hooks', results);
|
|
887
887
|
makeGitHooksExecutable(path.join(targetDir, '.forge'));
|
|
888
|
+
// 2-bin. Sync the tracked worktree helper (0.78.0, ADR-031) — omitted from the
|
|
889
|
+
// additive-sync category set until issue #41, so upgraded projects never
|
|
890
|
+
// received .forge/bin/new-worktree.sh even though the weld's deny message
|
|
891
|
+
// and migration guide both point at it. Additive, same reasoning as the
|
|
892
|
+
// other .forge/ categories above.
|
|
893
|
+
upgradeAdditiveDir('.forge/bin', results);
|
|
888
894
|
|
|
889
895
|
// 2a. Confirm stale framework-file removals before deleting (R066a). Defaults
|
|
890
896
|
// to yes (cleanup is the normal case); auto-confirms when non-interactive
|
package/package.json
CHANGED
|
@@ -26,8 +26,10 @@
|
|
|
26
26
|
#
|
|
27
27
|
# Classification rules (see phase 95 plan-01 task 1):
|
|
28
28
|
# - `## Locked Decisions` sub-headings (`### ...`) whose first word resolves
|
|
29
|
-
# to a milestone id (`M{N}`, `m-{N}`, `m{N}` — normalized to `m-{N}
|
|
30
|
-
#
|
|
29
|
+
# to a milestone id (`M{N}`, `m-{N}`, `m{N}` — normalized to `m-{N}`; or a
|
|
30
|
+
# labelled id like `M-EVT01`, `m-SEQPRIM01` — normalized to lowercase with
|
|
31
|
+
# the label preserved, e.g. `m-evt01`, `m-seqprim01`, never digit-stripped)
|
|
32
|
+
# each become `context/{id}.md`. A heading with no resolvable id (e.g. a
|
|
31
33
|
# drafting block, or a pointer-stub heading like "Historical Decisions")
|
|
32
34
|
# is NOT sharded — it lands in context-log.md verbatim.
|
|
33
35
|
# - `## Deferred Ideas` / `## Discretion Areas` top-level bullets whose text
|
|
@@ -120,7 +122,7 @@ awk_prog="$(mktemp 2>/dev/null || mktemp -t forge-context-migrate)"
|
|
|
120
122
|
trap 'rm -f "$awk_prog"' EXIT INT TERM
|
|
121
123
|
|
|
122
124
|
cat > "$awk_prog" <<'AWKEOF'
|
|
123
|
-
function extract_id(line, w, arr, n, tok, digits) {
|
|
125
|
+
function extract_id(line, w, arr, n, tok, digits, suffix) {
|
|
124
126
|
w = line
|
|
125
127
|
sub(/^### /, "", w)
|
|
126
128
|
sub(/^- /, "", w)
|
|
@@ -129,12 +131,21 @@ function extract_id(line, w, arr, n, tok, digits) {
|
|
|
129
131
|
if (n < 1) return ""
|
|
130
132
|
tok = arr[1]
|
|
131
133
|
gsub(/[:,.]+$/, "", tok)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
+
# Purely-numeric ids (M53, m-53, m53) keep the original digit-only
|
|
135
|
+
# normalization. Labelled ids (M-EVT01, m-SEQPRIM01) must NOT be digit-
|
|
136
|
+
# stripped — that would collapse distinct labelled ids onto the same
|
|
137
|
+
# numeric suffix (m-EVT01 and m-SEQPRIM01 would both become m-01) — so
|
|
138
|
+
# their alphanumeric suffix is preserved verbatim (lowercased) instead.
|
|
139
|
+
if (tok !~ /^[Mm]-?[A-Za-z0-9]+$/) return ""
|
|
140
|
+
suffix = tok
|
|
141
|
+
sub(/^[Mm]-?/, "", suffix)
|
|
142
|
+
if (suffix == "") return ""
|
|
143
|
+
if (suffix ~ /^[0-9]+$/) {
|
|
144
|
+
digits = suffix
|
|
134
145
|
gsub(/[^0-9]/, "", digits)
|
|
135
146
|
return "m-" digits
|
|
136
147
|
}
|
|
137
|
-
return ""
|
|
148
|
+
return "m-" tolower(suffix)
|
|
138
149
|
}
|
|
139
150
|
|
|
140
151
|
function add_id(id) {
|
|
@@ -288,6 +299,15 @@ END {
|
|
|
288
299
|
}
|
|
289
300
|
}
|
|
290
301
|
|
|
302
|
+
if (n_ids == 0 && n_noid > 0) {
|
|
303
|
+
print "forge-context-migrate: ERROR — 0 milestone id(s) resolved but " n_noid \
|
|
304
|
+
" Locked Decision heading(s) found with no resolvable id; this usually means the" \
|
|
305
|
+
" heading's id scheme doesn't match m-<number> or a labelled m-<label> form" \
|
|
306
|
+
" (check context.md's heading format before proceeding)" > "/dev/stderr"
|
|
307
|
+
print "forge-context-migrate: " n_ids " milestone id(s) processed"
|
|
308
|
+
exit 1
|
|
309
|
+
}
|
|
310
|
+
|
|
291
311
|
print "forge-context-migrate: " n_ids " milestone id(s) processed"
|
|
292
312
|
}
|
|
293
313
|
AWKEOF
|
|
@@ -28,7 +28,13 @@
|
|
|
28
28
|
# (3) unknown in both → exit 2 — the fold cannot ask the question, so it
|
|
29
29
|
# never emits a false verdict.
|
|
30
30
|
#
|
|
31
|
-
# merged = `git merge-base --is-ancestor <sha> <main-ref
|
|
31
|
+
# merged = `git merge-base --is-ancestor <sha> <main-ref>`, OR — when that
|
|
32
|
+
# fails — a squash-merge equivalence check (see is_merged() below). GitHub's
|
|
33
|
+
# squash-and-merge creates a brand-new commit on <main-ref> with no ancestor
|
|
34
|
+
# relationship to the source branch, so the direct check alone is a permanent
|
|
35
|
+
# false negative for any squash-merged unit whose branch/stream ref is still
|
|
36
|
+
# live (issue #39, m-55; reproduced live via m-53/PR#38). The fallback stays
|
|
37
|
+
# git-only, no network — NFR-030 unaffected.
|
|
32
38
|
#
|
|
33
39
|
# Validation is dispatched on forge.validation_event from the project's
|
|
34
40
|
# .forge/project.yml (default: in_session_signoff; promotion_approval is the
|
|
@@ -171,6 +177,33 @@ hv_is_set() {
|
|
|
171
177
|
'
|
|
172
178
|
}
|
|
173
179
|
|
|
180
|
+
# is_merged <sha> — merged rung of the fold. Direct ancestor check first
|
|
181
|
+
# (unchanged cost/behavior for fast-forward / true-merge-commit units). On
|
|
182
|
+
# failure, falls back to a squash-merge equivalence check: build an
|
|
183
|
+
# unreferenced synthetic commit representing <sha>'s cumulative diff since it
|
|
184
|
+
# diverged from $main_ref, then ask `git cherry` (patch-id based — the same
|
|
185
|
+
# primitive git itself uses to detect already-applied/rebased/squashed
|
|
186
|
+
# commits) whether an equivalent patch already landed on $main_ref. Squash-
|
|
187
|
+
# and-merge creates a brand-new commit with no ancestor relationship to the
|
|
188
|
+
# source branch, so the direct check alone is a permanent false negative for
|
|
189
|
+
# every squash-merged unit whose branch/stream ref is still live (m-53/PR#38,
|
|
190
|
+
# issue #39). Git-only, no network (NFR-030 preserved) — the synthetic commit
|
|
191
|
+
# is never attached to a ref, so it's naturally garbage-collectable.
|
|
192
|
+
is_merged() {
|
|
193
|
+
target="$1"
|
|
194
|
+
if git -C "$root" merge-base --is-ancestor "$target" "$main_ref" 2>/dev/null; then
|
|
195
|
+
return 0
|
|
196
|
+
fi
|
|
197
|
+
mb="$(git -C "$root" merge-base "$main_ref" "$target" 2>/dev/null)" || return 1
|
|
198
|
+
[ -n "$mb" ] || return 1
|
|
199
|
+
tgt_tree="$(git -C "$root" rev-parse -q --verify "$target^{tree}" 2>/dev/null)" || return 1
|
|
200
|
+
synth="$(git -C "$root" commit-tree "$tgt_tree" -p "$mb" -m squash-probe 2>/dev/null)" || return 1
|
|
201
|
+
case "$(git -C "$root" cherry "$main_ref" "$synth" 2>/dev/null)" in
|
|
202
|
+
-*) return 0 ;;
|
|
203
|
+
*) return 1 ;;
|
|
204
|
+
esac
|
|
205
|
+
}
|
|
206
|
+
|
|
174
207
|
# --- argument parsing ------------------------------------------------------
|
|
175
208
|
|
|
176
209
|
unit=""
|
|
@@ -281,7 +314,7 @@ fi
|
|
|
281
314
|
# validated REQUIRES merged — checked first, always. An unmerged unit is
|
|
282
315
|
# unmerged no matter what any approval artifact says.
|
|
283
316
|
|
|
284
|
-
if !
|
|
317
|
+
if ! is_merged "$sha"; then
|
|
285
318
|
printf 'release_state=unmerged reason=not-on-main\n'
|
|
286
319
|
exit 0
|
|
287
320
|
fi
|
|
@@ -159,6 +159,88 @@ src_after="$(cat "$SOURCE" | wc -c)"
|
|
|
159
159
|
[ -n "$src_after" ] && pass "source context.md still readable after migration" || fail "source context.md unreadable after migration"
|
|
160
160
|
grep -q "### M7 — Widget Registry" "$SOURCE" && pass "source context.md left untouched" || fail "source context.md was modified"
|
|
161
161
|
|
|
162
|
+
# --- labelled milestone ids (issue #42) -------------------------------------
|
|
163
|
+
# Non-numeric ids (M-EVT01, m-SEQPRIM01) must shard into distinct files —
|
|
164
|
+
# digit-stripping them onto a shared "m-01" was the exact silent-collision
|
|
165
|
+
# bug reported upstream. Purely-numeric ids must keep their existing
|
|
166
|
+
# normalization (m-53 stays m-53, unaffected by the labelled-id path).
|
|
167
|
+
|
|
168
|
+
LBLROOT="$(mktemp -d)"
|
|
169
|
+
trap 'rm -rf "$ROOT" "$LBLROOT"' EXIT INT TERM
|
|
170
|
+
LBLSRC="$LBLROOT/context.md"
|
|
171
|
+
LBLDEST="$LBLROOT/.forge"
|
|
172
|
+
mkdir -p "$LBLDEST"
|
|
173
|
+
|
|
174
|
+
cat > "$LBLSRC" <<'FIXTURE'
|
|
175
|
+
## Locked Decisions
|
|
176
|
+
|
|
177
|
+
### M-EVT01 — Event pipeline
|
|
178
|
+
|
|
179
|
+
- **Decision**: Emit events. Reason: because.
|
|
180
|
+
|
|
181
|
+
### m-SEQPRIM01 — Sequencer primitives
|
|
182
|
+
|
|
183
|
+
- **Decision**: Order things. Reason: also because.
|
|
184
|
+
|
|
185
|
+
### m-53 — Numeric id alongside labelled ones
|
|
186
|
+
|
|
187
|
+
- **Decision**: Still works numerically.
|
|
188
|
+
|
|
189
|
+
## Deferred Ideas
|
|
190
|
+
|
|
191
|
+
- m-EVT01: a follow-up scoped to the labelled id.
|
|
192
|
+
|
|
193
|
+
## Needs Resolution
|
|
194
|
+
|
|
195
|
+
(none)
|
|
196
|
+
|
|
197
|
+
## Amendment Log
|
|
198
|
+
|
|
199
|
+
(none)
|
|
200
|
+
FIXTURE
|
|
201
|
+
|
|
202
|
+
LBLOUT="$("$MIGRATE" --source "$LBLSRC" --dest "$LBLDEST" 2>&1)"
|
|
203
|
+
LBLRC=$?
|
|
204
|
+
[ "$LBLRC" -eq 0 ] && pass "labelled-id migration exits 0" || fail "labelled-id migration exits 0 (exit $LBLRC)"
|
|
205
|
+
|
|
206
|
+
check_file "$LBLDEST/context/m-evt01.md" "context/m-evt01.md created (labelled id, lowercased)"
|
|
207
|
+
check_file "$LBLDEST/context/m-seqprim01.md" "context/m-seqprim01.md created (distinct from m-evt01, not digit-collapsed)"
|
|
208
|
+
check_file "$LBLDEST/context/m-53.md" "context/m-53.md still created (numeric id unaffected by labelled-id path)"
|
|
209
|
+
|
|
210
|
+
lbl_count="$(ls "$LBLDEST"/context/*.md 2>/dev/null | wc -l | tr -d ' ')"
|
|
211
|
+
[ "$lbl_count" -eq 3 ] && pass "labelled + numeric ids produce 3 distinct files, none collapsed" || fail "labelled-id run produced $lbl_count file(s), want 3"
|
|
212
|
+
|
|
213
|
+
evt="$(cat "$LBLDEST/context/m-evt01.md" 2>/dev/null)"
|
|
214
|
+
check_has "m-evt01.md carries its own Deferred entry (labelled-prefix match)" "$evt" "a follow-up scoped to the labelled id"
|
|
215
|
+
|
|
216
|
+
# --- unresolvable id scheme guard (issue #42) --------------------------------
|
|
217
|
+
# A context.md whose Locked Decisions headings don't match m-<number> or a
|
|
218
|
+
# labelled m-<label> form at all (e.g. a different project's own convention)
|
|
219
|
+
# must fail loudly instead of silently producing zero shards.
|
|
220
|
+
|
|
221
|
+
BADROOT="$(mktemp -d)"
|
|
222
|
+
trap 'rm -rf "$ROOT" "$LBLROOT" "$BADROOT"' EXIT INT TERM
|
|
223
|
+
BADSRC="$BADROOT/context.md"
|
|
224
|
+
BADDEST="$BADROOT/.forge"
|
|
225
|
+
mkdir -p "$BADDEST"
|
|
226
|
+
|
|
227
|
+
cat > "$BADSRC" <<'FIXTURE'
|
|
228
|
+
## Locked Decisions
|
|
229
|
+
|
|
230
|
+
### FEATURE-ABC — unrelated id scheme
|
|
231
|
+
|
|
232
|
+
- **Decision**: Something.
|
|
233
|
+
|
|
234
|
+
### FEATURE-XYZ — another unrelated id
|
|
235
|
+
|
|
236
|
+
- **Decision**: Something else.
|
|
237
|
+
FIXTURE
|
|
238
|
+
|
|
239
|
+
BADOUT="$("$MIGRATE" --source "$BADSRC" --dest "$BADDEST" --dry-run 2>&1)"
|
|
240
|
+
BADRC=$?
|
|
241
|
+
[ "$BADRC" -ne 0 ] && pass "unresolvable id scheme exits non-zero instead of silently producing 0 shards" || fail "unresolvable id scheme should exit non-zero (exit $BADRC)"
|
|
242
|
+
check_has "unresolvable id scheme prints an ERROR line" "$BADOUT" "ERROR"
|
|
243
|
+
|
|
162
244
|
# --- summary -----------------------------------------------------------------
|
|
163
245
|
|
|
164
246
|
printf '\n%s passed, %s failed\n' "$PASSED" "$FAILED"
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
# ./.claude/hooks/tests/forge-release-fold.test.sh ordering # ordering controls
|
|
25
25
|
# Filter tags (one arg, selects matching case blocks):
|
|
26
26
|
# unmerged merged grandfather human-verified signoff-tag stored-field
|
|
27
|
-
# exit-2 stable-sha ordering promotion forgery labeled
|
|
27
|
+
# exit-2 stable-sha ordering promotion forgery labeled squash
|
|
28
28
|
# `promotion` + `forgery` (plan 51-01): validation_event=promotion_approval —
|
|
29
29
|
# a CI-signed tag on a deploy sha CONTAINING the unit's landed sha validates;
|
|
30
30
|
# tag deletion reverts; unsigned/wrong-key lookalikes and non-containing tags
|
|
@@ -791,9 +791,60 @@ lifecycle:
|
|
|
791
791
|
check_absent "unsafe labeled id: no verdict on stdout" "$OUT" "release_state="
|
|
792
792
|
fi
|
|
793
793
|
|
|
794
|
+
# --- case: squash-merged unit resolves merged (issue #39, m-53/PR#38 shape) -----
|
|
795
|
+
# GitHub squash-and-merge creates a brand-new commit on main with no ancestor
|
|
796
|
+
# relationship to the source branch — the direct ancestor check alone
|
|
797
|
+
# permanently misreports this as unmerged. Reproduces the real bug shape:
|
|
798
|
+
# a live branch tip (resolved via streams/m-N.yml runtime.branch, exactly
|
|
799
|
+
# resolution rule 1's `else` route — no lifecycle.worktree_branch in the state
|
|
800
|
+
# file, matching the real m-53 fixture), plus an unrelated commit on main
|
|
801
|
+
# between the branch's fork point and the squash (mirrors main advancing
|
|
802
|
+
# independently in between — and would defeat a naive whole-tree-hash
|
|
803
|
+
# comparison, which this repo's own investigation found fails under exactly
|
|
804
|
+
# this kind of drift).
|
|
805
|
+
if running squash; then
|
|
806
|
+
printf '\n--- case: squash-merged unit resolves merged (git merge --squash, not --no-ff) ---\n'
|
|
807
|
+
d="$(new_repo squash)"
|
|
808
|
+
write_milestone "$d" 120 'milestone:
|
|
809
|
+
id: 120
|
|
810
|
+
name: "fixture: squash-merged unit"
|
|
811
|
+
|
|
812
|
+
current:
|
|
813
|
+
tier: standard
|
|
814
|
+
status: executing
|
|
815
|
+
last_updated: "2026-07-29"
|
|
816
|
+
human_verified: null
|
|
817
|
+
|
|
818
|
+
lifecycle:
|
|
819
|
+
worktree_mode: active'
|
|
820
|
+
write_stream "$d" m-120 forge/m-120
|
|
821
|
+
git -C "$d" checkout -qb forge/m-120
|
|
822
|
+
# Real file content — an --allow-empty commit here would give the branch a
|
|
823
|
+
# zero-diff tip, making the squash-probe trivially match ANY zero-diff main
|
|
824
|
+
# commit regardless of the mechanism under test. Genuine content is what
|
|
825
|
+
# makes this a meaningful positive fixture.
|
|
826
|
+
printf 'feature work\n' > "$d/FEATURE.txt"
|
|
827
|
+
git -C "$d" add FEATURE.txt
|
|
828
|
+
git -C "$d" commit -qm "work on forge/m-120 (will be squash-merged)"
|
|
829
|
+
git -C "$d" checkout -q main
|
|
830
|
+
# Unrelated main-side drift between fork and squash, with its OWN real file
|
|
831
|
+
# content — mirrors main advancing independently in between, exactly like
|
|
832
|
+
# m-53/PR#38 (main gained m-54's declaration in between). Would defeat a
|
|
833
|
+
# naive whole-tree-hash comparison, which this repo's own investigation
|
|
834
|
+
# found fails under exactly this kind of drift.
|
|
835
|
+
printf 'unrelated\n' > "$d/OTHER.txt"
|
|
836
|
+
git -C "$d" add OTHER.txt
|
|
837
|
+
git -C "$d" commit -qm "unrelated main-side work"
|
|
838
|
+
git -C "$d" merge -q --squash forge/m-120 >/dev/null 2>&1
|
|
839
|
+
git -C "$d" commit -q -m "squash merge forge/m-120 (#120)"
|
|
840
|
+
run_fold "$d" m-120
|
|
841
|
+
check "squash-merged: exit 0" "$RC" "0"
|
|
842
|
+
check "squash-merged: resolves merged (no false negative on ancestor-check failure)" "$OUT" "release_state=merged reason=no-signoff"
|
|
843
|
+
fi
|
|
844
|
+
|
|
794
845
|
# --- summary -------------------------------------------------------------------
|
|
795
846
|
if [ -n "$FILTER" ] && [ "$SELECTED" -eq 0 ]; then
|
|
796
|
-
printf 'ERROR: filter "%s" matched no cases (tags: unmerged merged grandfather human-verified signoff-tag stored-field exit-2 stable-sha ordering promotion forgery labeled)\n' "$FILTER" >&2
|
|
847
|
+
printf 'ERROR: filter "%s" matched no cases (tags: unmerged merged grandfather human-verified signoff-tag stored-field exit-2 stable-sha ordering promotion forgery labeled squash)\n' "$FILTER" >&2
|
|
797
848
|
exit 2
|
|
798
849
|
fi
|
|
799
850
|
|
|
@@ -76,6 +76,22 @@ echo "MIGRATE — .forge/context.md is a tracked, ${size}-byte monolithic file w
|
|
|
76
76
|
|
|
77
77
|
## Migration steps
|
|
78
78
|
|
|
79
|
+
### 0. Dry-run first and check the processed count
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
.claude/hooks/forge-context-migrate.sh --dry-run
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Confirm the `forge-context-migrate: N milestone id(s) processed` line names a
|
|
86
|
+
plausible number of ids for your `context.md`'s actual Locked Decisions
|
|
87
|
+
headings. The tool exits non-zero and prints an `ERROR` line instead of a
|
|
88
|
+
silent `0 milestone id(s) processed` when it finds Locked Decisions headings
|
|
89
|
+
it can't resolve to any milestone id (issue #42) — but a dry-run check is
|
|
90
|
+
still worth doing before the destructive untrack step below, since a partial
|
|
91
|
+
match (some headings resolve, some don't) still exits 0. Milestone ids must
|
|
92
|
+
be `m-<number>` (`M53`, `m-53`) or a labelled `m-<label>` form (`M-EVT01`,
|
|
93
|
+
`m-SEQPRIM01`); anything else won't shard.
|
|
94
|
+
|
|
79
95
|
### 1. Run the migration tool for real (not dry-run)
|
|
80
96
|
|
|
81
97
|
From the repo root, with no flags — the tool resolves `--source` and `--dest`
|
|
@@ -87,7 +103,8 @@ omitted, defaulting to `<repo-root>/.forge/context.md` and `<repo-root>/.forge`:
|
|
|
87
103
|
```
|
|
88
104
|
|
|
89
105
|
This reads `.forge/context.md` and writes `.forge/context/{id}.md` (one file
|
|
90
|
-
per resolvable milestone heading, ids normalized to `m-{N}`
|
|
106
|
+
per resolvable milestone heading, numeric ids normalized to `m-{N}` and
|
|
107
|
+
labelled ids lowercased and preserved, e.g. `m-evt01`),
|
|
91
108
|
`.forge/context-log.md` (Amendment Log, Needs Resolution, and any undated
|
|
92
109
|
drafting block that has no milestone id to shard by), and
|
|
93
110
|
`.forge/context/_shared.md` if any Deferred Ideas / Discretion Areas entry
|