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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-orkes",
3
- "version": "0.80.0",
3
+ "version": "0.80.1",
4
4
  "description": "Set up the Forge meta-prompting framework for Claude Code in your project",
5
5
  "bin": {
6
6
  "create-forge": "./bin/create-forge.js"
@@ -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}`) each
30
- # become `context/{id}.md`. A heading with no resolvable id (e.g. a
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
- if (tok ~ /^[Mm]-?[0-9]+$/) {
133
- digits = tok
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 ! git -C "$root" merge-base --is-ancestor "$sha" "$main_ref" 2>/dev/null; then
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
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "forge": {
3
- "version": "0.80.0",
3
+ "version": "0.80.1",
4
4
  "default_tier": "standard",
5
5
  "beads_integration": false,
6
6
  "context_gates": {
@@ -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