forge-orkes 0.80.0 → 0.80.2

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.
@@ -588,12 +588,21 @@ function extractDetectionBlock(guideContent) {
588
588
  * with the exact guide pointer. There are NO per-version code branches: a new
589
589
  * guide that ships with a conformant Detection block is auto-covered.
590
590
  *
591
- * Range: installed < v <= source. `installedVersion` is the project's
591
+ * Range: installed <= v <= source. `installedVersion` is the project's
592
592
  * settings.json forge.version captured BEFORE the version stamp; `source` is
593
593
  * pkgVersion (this package's package.json) — never the template settings.json
594
594
  * literal, which is a placeholder the installer stamps at write time. Non-
595
595
  * interactive: the bin only warns and points at the guide; it never applies.
596
596
  *
597
+ * Lower bound is INCLUSIVE (issue #43): the version stamp always advances
598
+ * unconditionally, even when a MIGRATE hit was surfaced but declined that
599
+ * run — so an exclusive `installed < v` permanently hides a same-numbered
600
+ * guide the moment its own version gets stamped without being applied. This
601
+ * is safe to widen because the guide's own `## Detection` block is the real
602
+ * gate (repo-state-based: tracked file, size, absence of the post-migration
603
+ * artifact) — the version range only decides which guides are worth asking;
604
+ * a genuinely-already-applied guide's Detection block reports a no-op.
605
+ *
597
606
  * Returns the applying guides as [{version, guide, reason}] — the human warning
598
607
  * and the --json `migrations` key render the same findings.
599
608
  */
@@ -606,7 +615,7 @@ function runPostUpgradeMigrationChecks(installedVersion) {
606
615
  installedVersion && installedVersion !== 'unknown' ? installedVersion : '0.0.0';
607
616
  const source = pkgVersion;
608
617
 
609
- // Select in-range guides: installed < v <= source. Parse {v} from the filename.
618
+ // Select in-range guides: installed <= v <= source. Parse {v} from the filename.
610
619
  let guides;
611
620
  try {
612
621
  guides = fs
@@ -618,7 +627,7 @@ function runPostUpgradeMigrationChecks(installedVersion) {
618
627
  .filter(Boolean)
619
628
  .filter(
620
629
  (g) =>
621
- compareVersions(installed, g.version) < 0 &&
630
+ compareVersions(installed, g.version) <= 0 &&
622
631
  compareVersions(g.version, source) <= 0
623
632
  )
624
633
  .sort((a, b) => compareVersions(a.version, b.version));
@@ -885,6 +894,12 @@ async function upgrade() {
885
894
  // initializing/upgrading skills' job, NOT the installer's — see makeGitHooksExecutable.
886
895
  upgradeAdditiveDir('.forge/git-hooks', results);
887
896
  makeGitHooksExecutable(path.join(targetDir, '.forge'));
897
+ // 2-bin. Sync the tracked worktree helper (0.78.0, ADR-031) — omitted from the
898
+ // additive-sync category set until issue #41, so upgraded projects never
899
+ // received .forge/bin/new-worktree.sh even though the weld's deny message
900
+ // and migration guide both point at it. Additive, same reasoning as the
901
+ // other .forge/ categories above.
902
+ upgradeAdditiveDir('.forge/bin', results);
888
903
 
889
904
  // 2a. Confirm stale framework-file removals before deleting (R066a). Defaults
890
905
  // 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.2",
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,26 @@ 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, case included (issue
139
+ # #44): only the "m-" prefix is normalized, exactly like the numeric path
140
+ # never invents a digit convention of its own. Forge doesn't own the
141
+ # label's spelling — a project's own casing convention (e.g. m-EVT01, all
142
+ # caps) must survive the shard, or citations elsewhere in the repo stop
143
+ # matching the shard filename.
144
+ if (tok !~ /^[Mm]-?[A-Za-z0-9]+$/) return ""
145
+ suffix = tok
146
+ sub(/^[Mm]-?/, "", suffix)
147
+ if (suffix == "") return ""
148
+ if (suffix ~ /^[0-9]+$/) {
149
+ digits = suffix
134
150
  gsub(/[^0-9]/, "", digits)
135
151
  return "m-" digits
136
152
  }
137
- return ""
153
+ return "m-" suffix
138
154
  }
139
155
 
140
156
  function add_id(id) {
@@ -288,6 +304,15 @@ END {
288
304
  }
289
305
  }
290
306
 
307
+ if (n_ids == 0 && n_noid > 0) {
308
+ print "forge-context-migrate: ERROR — 0 milestone id(s) resolved but " n_noid \
309
+ " Locked Decision heading(s) found with no resolvable id; this usually means the" \
310
+ " heading's id scheme doesn't match m-<number> or a labelled m-<label> form" \
311
+ " (check context.md's heading format before proceeding)" > "/dev/stderr"
312
+ print "forge-context-migrate: " n_ids " milestone id(s) processed"
313
+ exit 1
314
+ }
315
+
291
316
  print "forge-context-migrate: " n_ids " milestone id(s) processed"
292
317
  }
293
318
  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,98 @@ 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, case preservation issue #44) --------
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). The
167
+ # label's own casing must survive verbatim (issue #44) — only the "m-"
168
+ # prefix is normalized — since a project's own convention (e.g. an
169
+ # all-caps label) is what the rest of its repo actually cites.
170
+
171
+ LBLROOT="$(mktemp -d)"
172
+ trap 'rm -rf "$ROOT" "$LBLROOT"' EXIT INT TERM
173
+ LBLSRC="$LBLROOT/context.md"
174
+ LBLDEST="$LBLROOT/.forge"
175
+ mkdir -p "$LBLDEST"
176
+
177
+ cat > "$LBLSRC" <<'FIXTURE'
178
+ ## Locked Decisions
179
+
180
+ ### M-EVT01 — Event pipeline
181
+
182
+ - **Decision**: Emit events. Reason: because.
183
+
184
+ ### m-SEQPRIM01 — Sequencer primitives
185
+
186
+ - **Decision**: Order things. Reason: also because.
187
+
188
+ ### m-53 — Numeric id alongside labelled ones
189
+
190
+ - **Decision**: Still works numerically.
191
+
192
+ ## Deferred Ideas
193
+
194
+ - m-EVT01: a follow-up scoped to the labelled id.
195
+
196
+ ## Needs Resolution
197
+
198
+ (none)
199
+
200
+ ## Amendment Log
201
+
202
+ (none)
203
+ FIXTURE
204
+
205
+ LBLOUT="$("$MIGRATE" --source "$LBLSRC" --dest "$LBLDEST" 2>&1)"
206
+ LBLRC=$?
207
+ [ "$LBLRC" -eq 0 ] && pass "labelled-id migration exits 0" || fail "labelled-id migration exits 0 (exit $LBLRC)"
208
+
209
+ check_file "$LBLDEST/context/m-EVT01.md" "context/m-EVT01.md created (labelled id, prefix normalized)"
210
+ check_file "$LBLDEST/context/m-SEQPRIM01.md" "context/m-SEQPRIM01.md created (distinct from m-EVT01, not digit-collapsed)"
211
+ check_file "$LBLDEST/context/m-53.md" "context/m-53.md still created (numeric id unaffected by labelled-id path)"
212
+
213
+ # check_file alone can't prove case survived — macOS's default case-insensitive
214
+ # FS makes m-evt01.md and m-EVT01.md the same lookup. `ls` returns the actual
215
+ # stored name; grep -x is case-sensitive regardless of the FS.
216
+ lbl_listing="$(ls "$LBLDEST/context" 2>/dev/null)"
217
+ echo "$lbl_listing" | grep -qx "m-EVT01.md" && pass "m-EVT01.md label case preserved verbatim on disk (issue #44)" || fail "label case NOT preserved — directory listing: $lbl_listing"
218
+ echo "$lbl_listing" | grep -qx "m-SEQPRIM01.md" && pass "m-SEQPRIM01.md label case preserved verbatim on disk (issue #44)" || fail "label case NOT preserved — directory listing: $lbl_listing"
219
+
220
+ lbl_count="$(ls "$LBLDEST"/context/*.md 2>/dev/null | wc -l | tr -d ' ')"
221
+ [ "$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"
222
+
223
+ evt="$(cat "$LBLDEST/context/m-EVT01.md" 2>/dev/null)"
224
+ check_has "m-EVT01.md carries its own Deferred entry (labelled-prefix match)" "$evt" "a follow-up scoped to the labelled id"
225
+
226
+ # --- unresolvable id scheme guard (issue #42) --------------------------------
227
+ # A context.md whose Locked Decisions headings don't match m-<number> or a
228
+ # labelled m-<label> form at all (e.g. a different project's own convention)
229
+ # must fail loudly instead of silently producing zero shards.
230
+
231
+ BADROOT="$(mktemp -d)"
232
+ trap 'rm -rf "$ROOT" "$LBLROOT" "$BADROOT"' EXIT INT TERM
233
+ BADSRC="$BADROOT/context.md"
234
+ BADDEST="$BADROOT/.forge"
235
+ mkdir -p "$BADDEST"
236
+
237
+ cat > "$BADSRC" <<'FIXTURE'
238
+ ## Locked Decisions
239
+
240
+ ### FEATURE-ABC — unrelated id scheme
241
+
242
+ - **Decision**: Something.
243
+
244
+ ### FEATURE-XYZ — another unrelated id
245
+
246
+ - **Decision**: Something else.
247
+ FIXTURE
248
+
249
+ BADOUT="$("$MIGRATE" --source "$BADSRC" --dest "$BADDEST" --dry-run 2>&1)"
250
+ BADRC=$?
251
+ [ "$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)"
252
+ check_has "unresolvable id scheme prints an ERROR line" "$BADOUT" "ERROR"
253
+
162
254
  # --- summary -----------------------------------------------------------------
163
255
 
164
256
  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.2",
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