forge-orkes 0.71.0 → 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.
Files changed (35) hide show
  1. package/bin/create-forge.js +6 -4
  2. package/experimental/m10/source/mcp-server/README.md +2 -2
  3. package/experimental/m10/source/mcp-server/index.js +2 -2
  4. package/experimental/m10/source/skills/orchestrating/SKILL.md +7 -3
  5. package/package.json +1 -1
  6. package/template/.claude/hooks/forge-model-outcome.sh +5 -3
  7. package/template/.claude/hooks/forge-reserve.sh +419 -106
  8. package/template/.claude/hooks/forge-slice-notify.sh +12 -3
  9. package/template/.claude/hooks/forge-slice-runner.sh +40 -19
  10. package/template/.claude/hooks/tests/forge-reserve.test.sh +169 -0
  11. package/template/.claude/skills/chief-of-staff/SKILL.md +1 -0
  12. package/template/.claude/skills/forge/SKILL.md +8 -6
  13. package/template/.claude/skills/notion-integration/SKILL.md +1 -0
  14. package/template/.claude/skills/planning/SKILL.md +3 -3
  15. package/template/.forge/FORGE.md +5 -5
  16. package/template/.forge/checks/forge-board-render.sh +21 -18
  17. package/template/.forge/checks/forge-check-lint.sh +48 -5
  18. package/template/.forge/checks/forge-hold-check.sh +123 -2
  19. package/template/.forge/checks/forge-jarvis-awareness.sh +62 -11
  20. package/template/.forge/checks/forge-jarvis-dispatch.sh +88 -8
  21. package/template/.forge/checks/forge-jarvis-relay.sh +15 -6
  22. package/template/.forge/checks/forge-jarvis.sh +28 -0
  23. package/template/.forge/checks/forge-roadmap-check.sh +106 -22
  24. package/template/.forge/checks/tests/forge-check-lint.test.sh +126 -4
  25. package/template/.forge/checks/tests/forge-hold-check.test.sh +166 -1
  26. package/template/.forge/checks/tests/forge-jarvis-awareness.test.sh +53 -0
  27. package/template/.forge/checks/tests/forge-jarvis-dispatch.test.sh +114 -0
  28. package/template/.forge/checks/tests/forge-jarvis-relay.test.sh +30 -0
  29. package/template/.forge/checks/tests/forge-jarvis.test.sh +78 -4
  30. package/template/.forge/checks/tests/forge-reserve.test.sh +235 -0
  31. package/template/.forge/checks/tests/forge-roadmap-check.test.sh +487 -0
  32. package/template/.forge/templates/hold-config.yml +15 -0
  33. package/template/.forge/templates/project.yml +10 -0
  34. package/template/.forge/templates/state/desire-path.yml +15 -45
  35. package/template/.forge/templates/waive-hold.yml +63 -0
@@ -80,13 +80,14 @@ cfg_get() {
80
80
  [ -n "$_v" ] && printf '%s' "$_v" || printf '%s' "$2"
81
81
  }
82
82
  MAX_TURNS="$(cfg_get max_turns_per_phase 60)"
83
- # per-phase budget cap: bounded by the per-slice cap for now. A per-phase
84
- # --max-budget-usd <= the slice cap.
85
- MAX_BUDGET="$(cfg_get per_slice_budget_usd 25)"
86
83
  # per-SLICE budget cap (plan-47 task 2): the runner accumulates usage.cost_usd
87
84
  # across phase reports and parks with a §8.4 `budget` ping when the running total
88
- # breaches this. Same config key as the per-phase bound (the cap IS the slice cap);
89
- # the accumulation is what makes it a slice-level guard, not a phase-level one.
85
+ # breaches this. The SAME value is also passed as the launcher's per-phase
86
+ # --max-budget-usd one config key, deliberately: the per-phase cap IS the slice
87
+ # cap, so a single phase may burn the whole slice budget before the launcher-level
88
+ # cap trips; the accumulation is what makes this a slice-level guard. A genuinely
89
+ # distinct per-phase bound would need its own max_phase_budget_usd key (R070
90
+ # ruled: collapse to one variable, not a second knob).
90
91
  SLICE_BUDGET="$(cfg_get per_slice_budget_usd 25)"
91
92
  # phase-boundary refresh + divergence budget (plan-03).
92
93
  INT_BRANCH="$(cfg_get integration_branch main)"
@@ -109,7 +110,10 @@ PHASES="$(awk '
109
110
 
110
111
  # --- report readers (grep/sed, dependency-free) ------------------------------
111
112
  report_field() { # report_field <file> <key> — a TOP-LEVEL `key:` (col 0)
112
- grep -E "^$2:" "$1" 2>/dev/null | head -1 | sed -E "s/^$2:[[:space:]]*//; s/[[:space:]]*(#.*)?$//; s/^\"//; s/\"$//"
113
+ # Comment-strip requires whitespace BEFORE the '#': a '#' embedded in a token
114
+ # (an operator-authored fork-payload path like sketch.html#hero) is value, not
115
+ # comment — truncating it broke the payload existence check (R071).
116
+ grep -E "^$2:" "$1" 2>/dev/null | head -1 | sed -E "s/^$2:[[:space:]]*//; s/[[:space:]]+#.*$//; s/[[:space:]]+$//; s/^\"//; s/\"$//"
113
117
  }
114
118
  report_field_flat() { # report_field_flat <file> <key> — report_field + YAML block scalars
115
119
  # A long interrupt question is naturally written as a block scalar (`key: |` /
@@ -140,7 +144,11 @@ report_nested() { # report_nested <file> <key> — an INDENTED `key:` (checks.*
140
144
  # file, so a plain indented grep is enough. NOTE: usage.cost_usd/tokens are NO
141
145
  # longer read from the report — the authoritative numbers come from the launcher's
142
146
  # result.json (see result_num), because a phase can't know its own usage.
143
- grep -E "^[[:space:]]+$2:" "$1" 2>/dev/null | head -1 | sed -E "s/^[[:space:]]+$2:[[:space:]]*//; s/[[:space:]]*(#.*)?$//; s/^\"//; s/\"$//"
147
+ grep -E "^[[:space:]]+$2:" "$1" 2>/dev/null | head -1 | sed -E "s/^[[:space:]]+$2:[[:space:]]*//; s/[[:space:]]+#.*$//; s/[[:space:]]+$//; s/^\"//; s/\"$//"
148
+ }
149
+ emit_result() { # the LAST stdout line at every slice end (R069) — one writer, three globals
150
+ printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s
151
+ ' "$COMPLETED" "$TOUCHES" "$LAST_PING"
144
152
  }
145
153
  extract_session() { # extract_session <result.json> — the launcher's session id
146
154
  # Fresh-context proof: a real `claude -p` mints a new session_id per call; the
@@ -541,7 +549,7 @@ for phase in $PHASES; do
541
549
  "$LAUNCHER" -p "$@" \
542
550
  --output-format json \
543
551
  --max-turns "$MAX_TURNS" \
544
- --max-budget-usd "$MAX_BUDGET" \
552
+ --max-budget-usd "$SLICE_BUDGET" \
545
553
  --dangerously-skip-permissions \
546
554
  --append-system-prompt-file "$sysprompt" \
547
555
  < "$prompt" \
@@ -581,7 +589,7 @@ for phase in $PHASES; do
581
589
  slice_notify halt "$phase-crash" \
582
590
  "slice HALTED at phase $phase ($idx/$total) — launcher crash, no phase-report.yml written (attempt $attempt); see $wdir/result.json + $wdir/launch.err"
583
591
  printf '[runner] phase %s: no phase-report.yml written at %s — halting\n' "$phase" "$report" >&2
584
- printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
592
+ emit_result
585
593
  exit 1
586
594
  fi
587
595
 
@@ -603,7 +611,7 @@ for phase in $PHASES; do
603
611
  slice_notify halt slice \
604
612
  "slice HALTED at phase $phase ($idx/$total) after 1 retry — verdict=$verdict checks.failed=$cfailed; evidence: $report${_ev:+ ; $_ev}"
605
613
  printf '[runner] phase %s: HALTED after retry — failure evidence at %s\n' "$phase" "$report" >&2
606
- printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
614
+ emit_result
607
615
  exit 1
608
616
  fi
609
617
  break # phase passed its own checks — proceed to the verdict branch below
@@ -636,6 +644,11 @@ for phase in $PHASES; do
636
644
  # intended_model = what routing ASKED for (map value, "-" when unmapped)
637
645
  # actual_model = the PRIMARY model actually used (result.json modelUsage,
638
646
  # max outputTokens) — reality, not intent
647
+ # NOTE (R090): intended_model is an operator-mapped ALIAS (`sonnet`);
648
+ # actual_model is the launcher-reported FULL id
649
+ # (`claude-sonnet-5`). Different spellings alone are the
650
+ # convention, not a routing mismatch — compare via the
651
+ # alias→id mapping, never string equality.
639
652
  # fallback = yes when a routed launch fell back unrouted (routed → fallback)
640
653
  # cost_usd = per-phase spend (result.json total_cost_usd) — the spend
641
654
  # the by-phase-type-and-model attribution surface sums
@@ -696,21 +709,21 @@ for phase in $PHASES; do
696
709
  slice_notify budget slice \
697
710
  "per-slice budget breached after phase $phase ($idx/$total): spent \$$ACCUM > cap \$$SLICE_BUDGET — split the slice or raise per_slice_budget_usd"
698
711
  printf '[runner] phase %s: BUDGET CAP breached ($%s > $%s) — parking slice\n' "$phase" "$ACCUM" "$SLICE_BUDGET" >&2
699
- printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
712
+ emit_result
700
713
  exit 3
701
714
  fi
702
715
  # Phase-boundary refresh + divergence budget. A non-zero return means the
703
716
  # slice PARKED (fork ping already fired) — STOP before the next phase.
704
717
  if ! slice_refresh_from_main "$phase"; then
705
718
  printf '[runner] phase %s: slice PARKED at phase boundary (fork ping) — stopping before next phase\n' "$phase" >&2
706
- printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
719
+ emit_result
707
720
  exit 3
708
721
  fi
709
722
  ;;
710
723
  done)
711
724
  COMPLETED=$((COMPLETED + 1)) # the final phase completed the slice
712
725
  slice_notify done slice "slice complete: $total phases, $COMPLETED run, 0 operator touches"
713
- printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
726
+ emit_result
714
727
  exit 0
715
728
  ;;
716
729
  park)
@@ -725,7 +738,7 @@ for phase in $PHASES; do
725
738
  # sketch/mockup PATH that exists, never prose describing a visual (§8).
726
739
  if [ "$interrupt" = "fork" ] && { [ -z "$ipayload" ] || [ ! -e "$ipayload" ]; }; then
727
740
  printf '[runner] phase %s: fork interrupt payload %s is not an existing path — a design fork must be a sketch/mockup to look at, not prose; rejecting\n' "$phase" "${ipayload:-<empty>}" >&2
728
- printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
741
+ emit_result
729
742
  exit 1
730
743
  fi
731
744
 
@@ -740,19 +753,19 @@ for phase in $PHASES; do
740
753
  [ -f "$wdir/answer.md" ] && _ans_fp="$(cksum "$wdir/answer.md" 2>/dev/null | cut -d' ' -f1)"
741
754
  slice_notify "$interrupt" "$phase-a$_ans_fp" "$ipayload"
742
755
  printf '[runner] phase %s: PARKED — §8 %s interrupt (stopping before next phase)\n' "$phase" "$interrupt" >&2
743
- printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
756
+ emit_result
744
757
  exit 3
745
758
  ;;
746
759
  *)
747
760
  printf '[runner] phase %s: park with interrupt %s — not one of the five §8 types (ambiguity|irreversible|fork|budget) — rejecting loudly\n' "$phase" "${interrupt:-<none>}" >&2
748
- printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
761
+ emit_result
749
762
  exit 1
750
763
  ;;
751
764
  esac
752
765
  ;;
753
766
  *)
754
767
  printf '[runner] phase %s: unrecognized verdict "%s" — halting\n' "$phase" "$verdict" >&2
755
- printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
768
+ emit_result
756
769
  exit 1
757
770
  ;;
758
771
  esac
@@ -761,7 +774,15 @@ set +f
761
774
 
762
775
  # The loop runs in the main shell, so a `done` verdict already `exit 0`'d above.
763
776
  # Reaching here means the phase list was exhausted WITHOUT a `done` verdict — the
764
- # slice ran every phase but the last one never marked itself done.
777
+ # slice ran every phase but the last one never marked itself done. This is still a
778
+ # slice END, so it MUST ping like the other ends — a silent phone is never success.
779
+ # Live gap 2026-07-23: a --plan-scoped single-plan order (the m-45 re-dispatch
780
+ # pattern) ended `proceed` — a phase can't know it is last — and the finished build
781
+ # sat dark for an hour. Ping `done` on the shared `slice` key: the §8 surface stays
782
+ # closed, and the append-once marker keeps a re-run (or an already-done-pinged
783
+ # slice) from double-buzzing.
784
+ slice_notify done slice \
785
+ "slice complete: all $total phase(s) ran ($COMPLETED completed, 0 operator touches) — no phase declared verdict=done (last was proceed); verify before landing"
765
786
  printf '[runner] phase list exhausted with no `done` verdict\n' >&2
766
- printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
787
+ emit_result
767
788
  exit 0
@@ -116,6 +116,175 @@ check "case6 $N concurrent runs -> $N distinct ids" "$distinct" "$N"
116
116
  ledger_lines="$(grep -c '^fr ' .git/forge/id-reservations)"
117
117
  check "case6 $N distinct ledger fr lines" "$ledger_lines" "$N"
118
118
 
119
+ # --- Case 7: stale-lock break — identity unchanged -> break + clean re-acquire
120
+ # (R103). A foreign owner stamp sits in a lock dir backdated past STALE with
121
+ # nothing racing to change it: the breaker's re-read-before-rmdir sees the same
122
+ # stamp it saw when it judged the lock stale, so it proceeds to break it and
123
+ # allocate normally.
124
+ d="$(new_repo case7)"; cd "$d"
125
+ git commit --allow-empty -qm init
126
+ lockdir=".git/forge/id-reservations.lock"
127
+ mkdir -p "$lockdir"
128
+ printf '999999 1700000000\n' > "$lockdir/owner"
129
+ touch -t 202001010000 "$lockdir" 2>/dev/null || touch -d '2020-01-01' "$lockdir" 2>/dev/null || true
130
+ out="$(FORGE_RESERVE_STALE=1 FORGE_RESERVE_WAIT=3 "$HELPER" adr --milestone m-x 2>case7-err.log)"; rc=$?
131
+ check "case7 stale lock (unchanged owner) is broken and allocation succeeds" "$out" "ADR-001"
132
+ check "case7 stale-break exits 0" "$rc" "0"
133
+ if grep -qi 'stale' case7-err.log; then
134
+ pass "case7 stderr reports the stale-lock break"
135
+ else
136
+ fail "case7 stderr missing stale-lock break message"
137
+ fi
138
+
139
+ # --- Case 8: stale-lock break — identity refreshed mid-wait -> no break ------
140
+ # (R103). A lock dir is backdated past STALE (so every poll judges it stale by
141
+ # mtime), but a background racer continuously rewrites the owner stamp inside
142
+ # it — simulating the original holder renewing the lock. The breaker's
143
+ # re-read-before-rmdir must never see a stable (seen == now) pair, so it never
144
+ # tears the lock down; it falls through to the bounded wait and exits 3.
145
+ # Racy-but-reliable, same style as case6's real concurrency: the writer refreshes
146
+ # far faster than the breaker's poll interval, so cumulative miss probability
147
+ # across the whole wait window is negligible.
148
+ d="$(new_repo case8)"; cd "$d"
149
+ git commit --allow-empty -qm init
150
+ lockdir=".git/forge/id-reservations.lock"
151
+ mkdir -p "$lockdir"
152
+ printf 'racer-0\n' > "$lockdir/owner"
153
+ touch -t 202001010000 "$lockdir" 2>/dev/null || touch -d '2020-01-01' "$lockdir" 2>/dev/null || true
154
+ ( i=0
155
+ while [ -d "$lockdir" ] && [ "$i" -lt 200000 ]; do
156
+ printf 'racer-%s\n' "$i" > "$lockdir/owner" 2>/dev/null
157
+ i=$((i + 1))
158
+ done
159
+ ) &
160
+ writer_pid=$!
161
+ out="$(FORGE_RESERVE_STALE=1 FORGE_RESERVE_WAIT=1 "$HELPER" adr --milestone m-x 2>case8-err.log)"; rc=$?
162
+ kill "$writer_pid" 2>/dev/null
163
+ wait "$writer_pid" 2>/dev/null
164
+ check "case8 continuously-refreshed lock is never broken (no allocation)" "$out" ""
165
+ check "case8 exits with lock-timeout (3)" "$rc" "3"
166
+ [ -d "$lockdir" ] && pass "case8 lock dir still stands (never rmdir'd)" || fail "case8 lock dir was removed"
167
+
168
+ # --- Case 9: compact — floor preserved, archive verbatim, idempotent (R104) --
169
+ # One kind (fr) with three rows: an OLD low id (should archive), an OLD row
170
+ # that happens to be the kind's MAX id (kept regardless of age — this is the
171
+ # row the floor needs), and a RECENT low id (kept via the retention window,
172
+ # not via max). Commit-then-remove-from-tree (same trick as case 3) proves the
173
+ # post-compaction file still yields the correct arm (c) floor: a subsequent
174
+ # reserve allocates immediately above the pre-compaction max, never reusing an
175
+ # archived id. A second compact run must archive nothing further.
176
+ d="$(new_repo case9)"; cd "$d"
177
+ mkdir -p .forge
178
+ now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
179
+ cat > .forge/reservations.yml <<YML
180
+ reservations:
181
+ - kind: fr
182
+ id: FR-050
183
+ milestone: m-x
184
+ reserved_at: "2020-01-01T00:00:00Z"
185
+ summary: "old, not max -> archive"
186
+ - kind: fr
187
+ id: FR-300
188
+ milestone: m-x
189
+ reserved_at: "2020-06-01T00:00:00Z"
190
+ summary: "old but MAX -> keep"
191
+ - kind: fr
192
+ id: FR-105
193
+ milestone: m-x
194
+ reserved_at: "$now"
195
+ summary: "recent, not max -> keep"
196
+ YML
197
+ git add -A && git commit -qm init
198
+ out="$("$HELPER" compact 2>compact-err.log)"; rc=$?
199
+ check "case9 compact exits 0" "$rc" "0"
200
+ if grep -q 'id: FR-300' .forge/reservations.yml; then
201
+ pass "case9 compact kept the old MAX row (FR-300)"
202
+ else
203
+ fail "case9 compact dropped the kind's max row"
204
+ fi
205
+ if grep -q 'id: FR-105' .forge/reservations.yml; then
206
+ pass "case9 compact kept the recent non-max row (FR-105)"
207
+ else
208
+ fail "case9 compact dropped a within-window row"
209
+ fi
210
+ if grep -q 'id: FR-050' .forge/reservations.yml; then
211
+ fail "case9 compact left the old non-max row (FR-050) in the working file"
212
+ else
213
+ pass "case9 compact removed the old non-max row (FR-050) from the working file"
214
+ fi
215
+ if grep -q 'id: FR-050' .forge/reservations-archive.yml && grep -q 'summary: "old, not max -> archive"' .forge/reservations-archive.yml; then
216
+ pass "case9 compact archived FR-050 byte-verbatim (id + summary intact)"
217
+ else
218
+ fail "case9 compact archive missing or corrupted the dropped FR-050 row"
219
+ fi
220
+
221
+ git add -A && git commit -qm "compact"
222
+ rm .forge/reservations.yml
223
+ got="$("$HELPER" fr --milestone m-x)"
224
+ check "case9 compact preserves the floor: next fr id is FR-301, not a reuse" "$got" "FR-301"
225
+
226
+ # Restore + idempotency: a second compact on the same (already-compacted)
227
+ # registry must archive nothing new.
228
+ git checkout -q .forge/reservations.yml
229
+ arch_lines_before="$(wc -l < .forge/reservations-archive.yml | tr -d ' ')"
230
+ "$HELPER" compact >/dev/null 2>compact-err2.log
231
+ arch_lines_after="$(wc -l < .forge/reservations-archive.yml | tr -d ' ')"
232
+ check "case9 second compact is a no-op (archive line count unchanged)" "$arch_lines_after" "$arch_lines_before"
233
+ if grep -qi 'kept 2, archived 0' compact-err2.log; then
234
+ pass "case9 second compact summary reports archived 0"
235
+ else
236
+ fail "case9 second compact summary did not report archived 0"
237
+ fi
238
+
239
+ # --- Case 10: compact --days override tightens the retention window ---------
240
+ d="$(new_repo case10)"; cd "$d"
241
+ mkdir -p .forge
242
+ five_days_ago="$(date -u -v-5d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '-5 days' +%Y-%m-%dT%H:%M:%SZ)"
243
+ cat > .forge/reservations.yml <<YML
244
+ reservations:
245
+ - kind: nfr
246
+ id: NFR-001
247
+ milestone: m-x
248
+ reserved_at: "$five_days_ago"
249
+ summary: "5 days old, not max"
250
+ - kind: nfr
251
+ id: NFR-002
252
+ milestone: m-x
253
+ reserved_at: "$five_days_ago"
254
+ summary: "5 days old, MAX"
255
+ YML
256
+ git add -A && git commit -qm init
257
+ "$HELPER" compact --days 14 >/dev/null 2>&1
258
+ check "case10 --days 14 keeps a 5-day-old non-max row" \
259
+ "$(grep -c 'id: NFR-001' .forge/reservations.yml)" "1"
260
+ d="$(new_repo case10b)"; cd "$d"
261
+ mkdir -p .forge
262
+ cat > .forge/reservations.yml <<YML
263
+ reservations:
264
+ - kind: nfr
265
+ id: NFR-001
266
+ milestone: m-x
267
+ reserved_at: "$five_days_ago"
268
+ summary: "5 days old, not max"
269
+ - kind: nfr
270
+ id: NFR-002
271
+ milestone: m-x
272
+ reserved_at: "$five_days_ago"
273
+ summary: "5 days old, MAX"
274
+ YML
275
+ git add -A && git commit -qm init
276
+ "$HELPER" compact --days 1 >/dev/null 2>&1
277
+ if grep -q 'id: NFR-001' .forge/reservations.yml; then
278
+ fail "case10b --days 1 kept a 5-day-old non-max row it should have archived"
279
+ else
280
+ pass "case10b --days 1 archives a 5-day-old non-max row"
281
+ fi
282
+ if grep -q 'id: NFR-002' .forge/reservations.yml; then
283
+ pass "case10b --days 1 still keeps the kind's max row"
284
+ else
285
+ fail "case10b --days 1 dropped the kind's max row"
286
+ fi
287
+
119
288
  # --- Report -----------------------------------------------------------------
120
289
  printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
121
290
  [ "$FAILED" -eq 0 ]
@@ -227,6 +227,7 @@ Resume:
227
227
  4. **Relocate into the stream worktree (Native Worktree Entry).** When the resumed stream's file records a `runtime.worktree`, apply the canonical Native Worktree Entry contract (identical to the forge ownership-gate option 1):
228
228
  - **If the `EnterWorktree` tool is available**, call `EnterWorktree({path: {runtime.worktree}})` to relocate this session into the stream worktree **in place**, then log `Relocated into worktree {runtime.worktree} ({runtime.branch})` (independent of any prompt, so the cwd move is visible) and continue the resume from inside the worktree.
229
229
  - **If `EnterWorktree` is unavailable** (older Claude Code CLI, or a non-Claude-Code adapter), fall back to printing the `cd {path}` instruction (here `cd {runtime.worktree}`) — today's implicit behavior made explicit.
230
+ - **On an EnterWorktree error/rejection** (not just tool absence) — e.g. a worktree→worktree relocation rejects the call under the tool's path constraint (ADR-010 roots live outside `.claude/worktrees/`) — fall back to the same printed `cd` instruction above, exactly as if the tool were unavailable.
230
231
  - Streams with no `runtime.worktree` resume unchanged — no relocation attempted. Exit is always `ExitWorktree({action: "keep"})`; the Chief never lets the native tool remove a worktree (teardown/merge stays the Chief's existing flow).
231
232
  5. Run conflict detection before continuing.
232
233
 
@@ -157,6 +157,7 @@ Present the four resolutions and wait for an explicit choice:
157
157
  1. **Enter the worktree** *(recommended)* — relocate this session into the worktree **in place** (the operator no longer has to `cd` + re-run). This is the canonical **Native Worktree Entry** contract, reused verbatim at the `chief-of-staff` resume and `orchestrating` hand-back touch points:
158
158
  - **If the `EnterWorktree` tool is available**, call `EnterWorktree({path: {worktree_path}})` — always `{path}` into the recorded `lifecycle.worktree_path`, never `{name}` (Forge owns worktree creation; `{name}` would spawn a new one on the wrong branch convention). Then log one line — `Relocated into worktree {worktree_path} ({branch})` — **independent of any confirmation prompt**, so the cwd move is always visible in the transcript. Continue routing to the requested skill from inside the worktree: writes now land in the owner, and the ownership gate is satisfied because this session **is** the owner.
159
159
  - **If `EnterWorktree` is unavailable** (older Claude Code CLI, or a non-Claude-Code adapter), fall back to today's behavior: print the exact command (`cd {worktree_path}` then `/forge`); re-running there anchors to this milestone and writes land in the owner. This session does nothing further to the milestone.
160
+ - **On an EnterWorktree error/rejection** (not just tool absence) — e.g. a worktree→worktree relocation rejects the call under the tool's path constraint (ADR-010 roots live outside `.claude/worktrees/`) — fall back to the same printed `cd` instruction above, exactly as if the tool were unavailable.
160
161
  - Exit is always `ExitWorktree({action: "keep"})` — Forge never lets the native tool **remove** a worktree; teardown stays option 3 / `git worktree remove`.
161
162
  2. **Sync, then continue in the worktree** — bring main's commits in (`git -C {worktree_path} rebase main`, or merge), then work from the worktree. Use when main has changes the worktree needs first.
162
163
  3. **Retire the worktree** — only if it is dead/superseded. Close the stream, `git worktree remove {worktree_path}`, and clear `lifecycle.worktree_*` on the milestone — which ends its ownership so main may write. **Confirm before removing; never auto-remove a worktree.**
@@ -178,17 +179,18 @@ Check `.forge/refactor-backlog.yml`:
178
179
  **Promotion procedure (effort: standard only):**
179
180
 
180
181
  1. Read backlog item `id` (e.g. `R61-002`). Synthesize milestone id `m-{R-id}` (e.g. `m-R61-002`).
181
- 2. **Enter the worktree first (R10 this runs in Step 1, *before* Step 3's routing gate fires, so do not assume a worktree already exists):** if this session is still on the main checkout (`git rev-parse --git-dir` == `--git-common-dir`), `EnterWorktree` now; if R10 already entered one, this is simply the first write in it. Then write the declaration (`.forge/templates/streams/stream.yml` `.forge/streams/{stream}.yml` `stream.goal`, `stream.milestone: m-{R-id}`, `stream.status: draft`, `stream.tier: standard`, `runtime.branch`/`runtime.worktree` from the worktree just entered, best-known `ownership.owned_surfaces`/`shared_surfaces`) as the worktree's first commit and fast-forward-push it, per FORGE.md's Stream Integration Checkpoints intake-declaration trigger.
182
- 3. Copy `.forge/templates/state/milestone.yml` → `.forge/state/milestone-{m-R-id}.yml`. Populate:
182
+ 2. **Re-check status before writing anything.** Re-read the item's current `status`/`promoted_to` from `.forge/refactor-backlog.yml` (the copy read for the "{N} refactors" prompt may be stale). If `status: in_progress` or `promoted_to` is already set, short-circuit: *"{R-id} already in_progress (promoted to {promoted_to})."* no declaration, no milestone file, no roadmap append.
183
+ 3. **Enter the worktree first (R10 — this runs in Step 1, *before* Step 3's routing gate fires, so do not assume a worktree already exists):** if this session is still on the main checkout (`git rev-parse --git-dir` == `--git-common-dir`), `EnterWorktree` now; if R10 already entered one, this is simply the first write in it. Then write the declaration (`.forge/templates/streams/stream.yml` → `.forge/streams/{stream}.yml` — `stream.goal`, `stream.milestone: m-{R-id}`, `stream.status: draft`, `stream.tier: standard`, `runtime.branch`/`runtime.worktree` from the worktree just entered, best-known `ownership.owned_surfaces`/`shared_surfaces`) as the worktree's first commit and fast-forward-push it, per FORGE.md's Stream Integration Checkpoints intake-declaration trigger.
184
+ 4. Copy `.forge/templates/state/milestone.yml` → `.forge/state/milestone-{m-R-id}.yml`. Populate:
183
185
  - `milestone.id: m-{R-id}`
184
186
  - `milestone.name: "Promoted from {R-id}: {backlog item title}"`
185
187
  - `milestone.origin: {R-id}`
186
188
  - `current.tier: standard`, `current.status: researching`
187
189
  - `current.last_updated: {ISO date}`
188
- 4. Run **Rollup (1.0)** — regenerates `index.yml` from the new milestone file (do not hand-append the registry entry). New milestone id is `m-{R-id}` from step 1; for numeric ids, allocate via `id=$(.claude/hooks/forge-reserve.sh milestone --milestone m-new)` (ADR-023) — never scan `milestone-*.yml` for max+1 inline. The three milestone-id call sites (here, `discussing`, `prototyping`) share this ledger-backed allocator so they cannot disagree, and it scans live **+ archived** milestones so a burned id is never reused.
189
- 5. Append to `.forge/roadmap.yml` — one milestone entry + one phase (`refactor-{R-id}`, `requirements_source: refactor-backlog.yml#{R-id}`, `dependencies: []`).
190
- 6. Update `.forge/refactor-backlog.yml` item: `status: in_progress`, add `promoted_to: m-{R-id}`.
191
- 7. Confirm: *"Promoted {R-id} → milestone m-{R-id}. State + roadmap created. Routing to researching."*
190
+ 5. Run **Rollup (1.0)** — regenerates `index.yml` from the new milestone file (do not hand-append the registry entry). New milestone id is `m-{R-id}` from step 1; for numeric ids, allocate via `id=$(.claude/hooks/forge-reserve.sh milestone --milestone m-new)` (ADR-023) — never scan `milestone-*.yml` for max+1 inline. The three milestone-id call sites (here, `discussing`, `prototyping`) share this ledger-backed allocator so they cannot disagree, and it scans live **+ archived** milestones so a burned id is never reused.
191
+ 6. Append to `.forge/roadmap.yml` — one milestone entry + one phase (`refactor-{R-id}`, `requirements_source: refactor-backlog.yml#{R-id}`, `dependencies: []`).
192
+ 7. Update `.forge/refactor-backlog.yml` item: `status: in_progress`, add `promoted_to: m-{R-id}`.
193
+ 8. Confirm: *"Promoted {R-id} → milestone m-{R-id}. State + roadmap created. Routing to researching."*
192
194
 
193
195
  ```text
194
196
  backlog pickup → effort: standard
@@ -39,6 +39,7 @@ Single Notion database (topology: one workspace, per-repo views). Rows are Forge
39
39
  Every property has exactly one owner. **`render()` writes ONLY agent-owned properties**, addressed by `Forge ID`, and issues **no write, ever** to a human-owned property. Non-overlapping ownership ⇒ **overwrite-on-render is safe by construction** (a hand-edited agent-owned cell is corrected at the next render; a human-owned cell is never touched).
40
40
 
41
41
  - **Agent-owned** (overwritten every render): `Forge ID`, `Name`, `Layer`, `Status`, `Tier`, `Progress`, `Blockers`, `Parent`, `Rollup Summary`, `Release State`, `Repo`, `Last Rendered`.
42
+ - `Blockers`, `Parent`, and `Rollup Summary` are **reserved, not yet populated**: at the current milestone-only scope the renderer emits them empty (`blockers: []`, `parent: null`, no `rollup_summary`) — they gain real values only when finer-grained rows land (DEF-044/045). Ownership is declared now so the schema doesn't shift later (R084).
42
43
  - **Human-owned** (read as inbox proposals in `pull_inbox`, **never** written): `Priority`, `Queue Position`, `Triage Notes`, `Comments`.
43
44
 
44
45
  **No gate-read property exists.** Nothing on the board is read by any merge/deploy/promotion/fold path (acceptance test #6).
@@ -101,7 +101,7 @@ Never write to top-level `.forge/requirements.yml` -- that path is deprecated.
101
101
 
102
102
  ## Step 5: Create or Update Roadmap
103
103
 
104
- `.forge/roadmap.yml` is **multi-milestone by design** -- see `.forge/templates/roadmap.yml`. Top-level `milestones:` lists each milestone with its phase numbers; top-level `phases:` lists every phase. Phase IDs are scoped per-milestone via the dir naming `m{milestone_id}-{phase_num}-{name}/`, so phase `id: 1` can exist in m50 and m51 without collision.
104
+ `.forge/roadmap.yml` is **multi-milestone by design** -- see `.forge/templates/roadmap.yml`. Top-level `milestones:` lists each milestone with its phase numbers; top-level `phases:` lists every phase. **Phase ids and wave numbers are GLOBAL shared spaces** (the `waves:` map keys phases globally), and under the declaration split a sibling worktree's allocations are invisible until its checkpoint — so **allocate every new phase id via `id=$(.claude/hooks/forge-reserve.sh phase --milestone <id>)` and every new wave number via `forge-reserve wave`** (strip the `P-`/`W-` prefix for the bare int written into roadmap.yml; N phases = N calls). NEVER scan-and-increment and NEVER hand-write an "allocated above true max" reconcile guard — the ledger sees sibling worktrees' unlanded claims; a guard structurally cannot (4x collision desire path, ADR-029).
105
105
 
106
106
  **Never overwrite existing roadmap content.** Read first, then route on three cases:
107
107
 
@@ -131,7 +131,7 @@ This is the common case when starting a new milestone alongside an existing one.
131
131
  name: "{milestone name from state/milestone-{N}.yml}"
132
132
  phases: [{list of phase ids you are about to add}]
133
133
  ```
134
- 2. Append each new phase under `roadmap.phases:` with IDs scoped to this milestone (start at `1`, ignore IDs used by other milestones -- dir naming prevents collision).
134
+ 2. Append each new phase under `roadmap.phases:` with an id from `forge-reserve phase` (bare int in the roadmap; ids are globally unique across all milestones ADR-029). Wave assignments take their numbers from `forge-reserve wave` the same way.
135
135
  3. Recompute `coverage_verified` against the union of all active milestones' `requirements/m{N}.yml` files. Every FR-ID in the active milestone must map to exactly one phase in that milestone.
136
136
  4. Update `waves:` only with the new milestone's phase wave assignments. Leave other milestones' wave entries alone.
137
137
 
@@ -473,7 +473,7 @@ Done when approved.
473
473
  ## Handoff
474
474
 
475
475
  1. **Persist** -- plans `.forge/phases/`, reqs `.forge/requirements/m{N}.yml`, roadmap `.forge/roadmap.yml`, context `.forge/context.md`
476
- 2. **Reserve version (if delivery bumps the version)** -- if any plan in this milestone will bump `package.json`/the project version, append ONE entry to `.forge/releases.yml` (Version Reservation Protocol, FORGE.md): pull first, set `version` = highest reserved `max()` bumped by this change's semver level, append `{milestone, version, bump, reserved_at, summary}`. **Never edit prior entries.** No version bump in scope → skip. Surface the reserved number to the user.
476
+ 2. **Reserve version (if delivery bumps the version)** -- if any plan in this milestone will bump `package.json`/the project version: pull first, derive `version` = highest reserved `max()` bumped by this change's semver level, then **claim it in the ledger BEFORE editing releases.yml**: `.claude/hooks/forge-reserve.sh version --value <X.Y.Z> --milestone m-{N}` — a non-zero exit names the sibling milestone already holding that number (its reservation may be worktree-only and invisible in any file you can read): re-read `max()` counting the ledger's version claims and bump again (ADR-029). Then append ONE entry to `.forge/releases.yml` (Version Reservation Protocol, FORGE.md): `{milestone, version, bump, reserved_at, summary}`. **Never edit prior entries.** No version bump in scope → skip. Surface the reserved number to the user.
477
477
  3. **State** -- `current.status` = `executing` in `.forge/state/milestone-{id}.yml`
478
478
  4. **State-sync commit** (State Commit Protocol): `git -C {worktree_path} add .forge/` then `git -C {worktree_path} commit -m "chore(forge): sync state after planning — m{N} {phase-name}"` (worktree form — recorded `lifecycle.worktree_path` is authoritative over cwd; see FORGE.md State Commit Protocol. Scoped; never `git add .`). The `releases.yml` reservation rides in this commit — **push it** so a parallel session sees the number taken before they reserve.
479
479
  5. *"Plan written and synced. `/clear` then `/forge` to continue."*
@@ -127,12 +127,12 @@ Auto-detects complexity. Override: "Use Quick/Standard/Full tier."
127
127
  | `streams/{stream}.yml` | 15 KB | Stream state stays resumable without becoming history |
128
128
  | `streams/{stream}/brief.md` | 8 KB | Context packet must fit into active session context |
129
129
  | `streams/{stream}/packages/{id}.yml` | 10 KB | Delegated work contract stays bounded |
130
- | `reservations.yml` | 50 KB | One short line per reserved id |
130
+ | `reservations.yml` | 50 KB | Compacts to `reservations-archive.yml` via `forge-reserve compact` — per-kind max + 14-day tail retained |
131
131
  | `prototypes/{slug}.md` | 8 KB | Current question + terse iteration log; terminal items archive |
132
132
 
133
133
  ### Accumulating Artifacts
134
134
 
135
- Every new accumulating artifact declares: owner, size gate, archive/compaction rule, `derived` vs source-of-truth status, and migration behavior. Defaults: `roadmap.yml`, `requirements/*.yml`, `releases.yml`, `reservations.yml` are source-of-truth — load scoped slices, not whole history. `phases/`, `audits/`, `research/`, `archive/`, `context-archive.md` are historical — load only by milestone/stream/package/version. `prototypes/{slug}.md` is single-owner mutable while `exploring|parked`; terminal statuses archive to `prototypes/archive/`. Runtime/generated files (`.mcp-server/*.db*`, logs, spike DBs, `.git/forge/*`) are excluded from framework context unless debugging them. The board (ADR-025): `.forge/notion/map.yml` is derived, git-ignored, rebuildable; board config is the `board:` block in `project.yml` (absent ⇒ inert). Template copies sync mechanically — not separate concepts.
135
+ Every new accumulating artifact declares: owner, size gate, archive/compaction rule, `derived` vs source-of-truth status, and migration behavior. Defaults: `roadmap.yml`, `requirements/*.yml`, `releases.yml`, `reservations.yml` are source-of-truth — load scoped slices, not whole history. `phases/`, `audits/`, `research/`, `archive/`, `context-archive.md`, `reservations-archive.yml` are historical — load only by milestone/stream/package/version (R104: `reservations-archive.yml` holds every row `forge-reserve compact` has dropped from `reservations.yml`, verbatim; the working file alone still carries every kind's floor). `prototypes/{slug}.md` is single-owner mutable while `exploring|parked`; terminal statuses archive to `prototypes/archive/`. Runtime/generated files (`.mcp-server/*.db*`, logs, spike DBs, `.git/forge/*`) are excluded from framework context unless debugging them. The board (ADR-025): `.forge/notion/map.yml` is derived, git-ignored, rebuildable; board config is the `board:` block in `project.yml` (absent ⇒ inert). Template copies sync mechanically — not separate concepts.
136
136
 
137
137
  ### Fresh Agent Pattern
138
138
  Task touches 20+ files or complex subsystem → spawn fresh executor with isolated context. Prevents context rot.
@@ -204,7 +204,7 @@ State lives in `.forge/`. One line per artifact; protocol detail lives in the na
204
204
  - `phases/milestone-{id}/{phase}-{name}/plan-{NN}.md` — task plans with must_haves frontmatter (`{id}`=milestone, `{phase}`=phase# verbatim, `{name}`=kebab, `{NN}`=seq)
205
205
  - `refactor-backlog.yml` — refactoring catalog worked via quick-tasking; status vocab `pending | in_progress | done | dismissed | deferred`; terminal items auto-archive to `refactor-backlog-archive.yml` on the next reviewing/quick-tasking write
206
206
  - `releases.yml` — append-only version-reservation registry (Version Reservation Protocol)
207
- - `reservations.yml` — append-only id-reservation audit trail + cross-machine floor (ID Reservation Protocol)
207
+ - `reservations.yml` — append-only id-reservation audit trail + cross-machine floor (ID Reservation Protocol); `forge-reserve compact` (R104) moves dropped rows verbatim to `reservations-archive.yml`, retaining each kind's max + a 14-day tail
208
208
  - `archive/milestone-{id}/` — archived milestone artifacts (archive-delete)
209
209
  - `prototypes/{slug}.md` — resumable disposable-mockup state (`exploring | parked | graduated | discarded`); terminal → `prototypes/archive/`. Single-owner, one driver. See `Skill(prototyping)`.
210
210
 
@@ -250,11 +250,11 @@ Ownership rules (quick reference):
250
250
 
251
251
  ### Version Reservation Protocol
252
252
 
253
- The project version + CHANGELOG slot are shared resources — `.forge/releases.yml` (append-only) removes the number from per-milestone scope. **Reserve early, at planning**: append ONE `{milestone, version, bump, reserved_at, summary}` entry — the highest reserved version bumped by this change's semver level — and **commit + push the reservation BEFORE editing the version file** so a parallel session sees the number taken. Never edit prior entries. **Delivery never invents a number** — write only the reserved version; no reservation → reserve at delivery (pull, `max() + bump`, append, push, then write). **Race:** the second push rebases onto the first (append-only → clean), re-reads `max()`, takes the next number. **Bump criteria (0.x):** patch = bug fix/doc/sync/refinement; minor = new skill/agent/routing-row/auto-trigger/state-artifact/capability; major = post-1.0 breaking. Default to minor when in doubt — a capability shipped as a patch drifts the version away from truth.
253
+ The project version + CHANGELOG slot are shared resources — `.forge/releases.yml` (append-only) removes the number from per-milestone scope. **Reserve early, at planning**: derive `max() + bump`, **claim it in the ledger first** `forge-reserve version --value <X.Y.Z>` (record-only; a conflict names the sibling milestone holding the number, its claim possibly worktree-onlyre-read and re-bump; ADR-029) — then append ONE `{milestone, version, bump, reserved_at, summary}` entry and **commit + push the reservation BEFORE editing the version file** so a parallel session sees the number taken. Never edit prior entries. **Delivery never invents a number** — write only the reserved version; no reservation → reserve at delivery (pull, `max() + bump`, append, push, then write). **Race:** the second push rebases onto the first (append-only → clean), re-reads `max()`, takes the next number. **Bump criteria (0.x):** patch = bug fix/doc/sync/refinement; minor = new skill/agent/routing-row/auto-trigger/state-artifact/capability; major = post-1.0 breaking. Default to minor when in doubt — a capability shipped as a patch drifts the version away from truth.
254
254
 
255
255
  ### ID Reservation Protocol
256
256
 
257
- Sequential ids — `ADR-NNN`, `DEF-NNN`, `FR-`/`NFR-NNN`, milestone `M-NN`, refactor `R0NN` — are shared cross-worktree resources; scan-and-increment sees only committed state, so concurrent worktrees claim the same number and collide silently until merge. **Reserve with the helper before creating the artifact**: `forge-reserve <kind> --milestone <id> [--summary <text>]` (`.claude/hooks/forge-reserve.sh`, kinds `adr|def|fr|nfr|milestone|refactor`; id formats per [ADR-023](../docs/decisions/ADR-023-extend-id-reservation-to-milestone-refactor-ids.md)) prints the id and dual-writes the **machine-local ledger** (`.git/forge/id-reservations`, git common dir — the same-machine coordination point, written under a `mkdir` lock) plus the tracked `.forge/reservations.yml` (durable audit trail + cross-machine floor; the caller commits it with the work). **Next number = `max(ledger ∪ in-tree ∪ main:reservations.yml) + 1`** per kind — the in-tree scan includes archives, so a compacted id is never reallocated. Reserve points: `architecting` (adr), `planning` (fr/nfr/def), `forge` Rollup / `discussing` / `prototyping` (milestone), `reviewing` (refactor). Deferred issues are counter-free per-issue files (`.forge/deferred-issues/{date}-{milestone}-{slug}.md`) — no DI-NNN, nothing to collide on. Accepted gap: concurrent reserves on *different machines* fall back to merge-time renumber, bounded by the floor. On any residual collision (legacy/un-reserved ids), **keep the older milestone's id and renumber the newer**. Lazy migration — the ledger self-creates on first reserve. Full rationale: [ADR-021](../docs/decisions/ADR-021-id-reservation-ledger-git-common-dir.md) (supersedes ADR-016) + ADR-023. Versions stay in `releases.yml` (separate semver/CHANGELOG semantics).
257
+ Sequential ids — `ADR-NNN`, `DEF-NNN`, `FR-`/`NFR-NNN`, milestone `M-NN`, refactor `R0NN`, roadmap phase/wave numbers — are shared cross-worktree resources; scan-and-increment sees only committed state, so concurrent worktrees claim the same number and collide silently until merge. **Reserve with the helper before creating the artifact**: `forge-reserve <kind> --milestone <id> [--summary <text>]` (`.claude/hooks/forge-reserve.sh`, kinds `adr|def|fr|nfr|milestone|refactor|phase|wave|version`; id formats per [ADR-023](../docs/decisions/ADR-023-extend-id-reservation-to-milestone-refactor-ids.md) + ADR-029 — phase/wave registry tokens are `P-0NN`/`W-0NN` while roadmap.yml keeps bare ints; `version` is record-only via `--value` (the ledger makes in-flight claims sibling-visible; `releases.yml` stays the durable registry)) prints the id and dual-writes the **machine-local ledger** (`.git/forge/id-reservations`, git common dir — the same-machine coordination point, written under a `mkdir` lock) plus the tracked `.forge/reservations.yml` (durable audit trail + cross-machine floor; the caller commits it with the work). **Next number = `max(ledger ∪ in-tree ∪ main:reservations.yml) + 1`** per kind — the in-tree scan includes archives, so a compacted id is never reallocated. Reserve points: `architecting` (adr), `planning` (fr/nfr/def, phase/wave, version), `forge` Rollup / `discussing` / `prototyping` (milestone), `reviewing` (refactor). Deferred issues are counter-free per-issue files (`.forge/deferred-issues/{date}-{milestone}-{slug}.md`) — no DI-NNN, nothing to collide on. Accepted gap: concurrent reserves on *different machines* fall back to merge-time renumber, bounded by the floor. On any residual collision (legacy/un-reserved ids), **keep the older milestone's id and renumber the newer**. Lazy migration — the ledger self-creates on first reserve. Full rationale: [ADR-021](../docs/decisions/ADR-021-id-reservation-ledger-git-common-dir.md) (supersedes ADR-016) + ADR-023 + ADR-029. Versions stay in `releases.yml` (separate semver/CHANGELOG semantics); the ledger only carries their claims.
258
258
 
259
259
  ## Deviation Rules
260
260
 
@@ -43,8 +43,12 @@ FOLD="$ROOT/.forge/checks/forge-release-fold.sh"
43
43
  # JSON string escaping (POSIX; escapes \, ", and control chars via tab/newline).
44
44
  # ---------------------------------------------------------------------------
45
45
  json_escape() {
46
- # reads $1, prints a JSON-safe string body (no surrounding quotes)
47
- printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' | tr '\n' ' '
46
+ # reads $1, prints a JSON-safe string body (no surrounding quotes). Raw C0
47
+ # controls other than tab/newline/CR are dropped FIRST a stray control char
48
+ # must not yield invalid JSON that jq then rejects, silently skipping the unit
49
+ # in --deliver (R081); tab escapes, newline AND CR (CRLF checkouts) → spaces.
50
+ printf '%s' "$1" | tr -d '\000-\010\013\014\016-\037' \
51
+ | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' | tr '\n\r' ' '
48
52
  }
49
53
 
50
54
  # Milestone-file id → unit id. Numeric files (milestone-8.yml) need the m-
@@ -56,18 +60,8 @@ unit_id() {
56
60
  # ---------------------------------------------------------------------------
57
61
  # Config: read a scalar under the top-level `board:` block of project.yml.
58
62
  # ---------------------------------------------------------------------------
59
- board_cfg() { # $1 = key under board:
60
- [ -f "$PROJECT" ] || return 0
61
- awk -v k="$1" '
62
- /^board:/ { inb=1; next }
63
- inb && /^[A-Za-z]/ { inb=0 }
64
- inb && $0 ~ "^[[:space:]]+"k":" {
65
- sub(/^[[:space:]]*[^:]+:[[:space:]]*/, "")
66
- sub(/[[:space:]]*#.*/, "")
67
- gsub(/^["'"'"']|["'"'"']$/, "")
68
- print; exit
69
- }
70
- ' "$PROJECT"
63
+ board_cfg() { # $1 = key under board: — same block/key/strip semantics as yget, so it IS yget (R082)
64
+ yget "$PROJECT" board "$1"
71
65
  }
72
66
 
73
67
  board_enabled() { # 0 = enabled, 1 = disabled
@@ -97,7 +91,7 @@ yget() { # $1=file $2=parent $3=key → scalar value of parent.key
97
91
  # The fold prints `release_state=<state> [reason=<code>]`; take ONLY the state token.
98
92
  unit_release_state() { # $1 = unit id (e.g. m-32)
99
93
  rs=""
100
- if [ -x "$FOLD" ] || [ -f "$FOLD" ]; then
94
+ if [ -f "$FOLD" ]; then # always run via `sh` below — an -x check was dead weight (R082)
101
95
  rs=$(sh "$FOLD" "$1" 2>/dev/null | sed -n 's/^release_state=\([A-Za-z_-]*\).*/\1/p' | head -1)
102
96
  fi
103
97
  [ -n "$rs" ] || { rs="unknown"; printf 'board-render: release fold unavailable for %s — release_state: unknown\n' "$1" >&2; }
@@ -214,6 +208,9 @@ emit_projection() {
214
208
  rstate=$(unit_release_state "$unit")
215
209
  prog=$(unit_progress "$id" "$cphase" "$status")
216
210
  [ "$ufirst" = 1 ] && ufirst=0 || printf ',\n'
211
+ # blockers/parent hardcoded empty and rollup_summary not emitted: Parent,
212
+ # Blockers, Rollup Summary are RESERVED properties at the milestone-only
213
+ # scope — they populate when finer-grained rows land (DEF-044/045; R084).
217
214
  printf ' {"forge_id": "%s", "layer": "Milestone", "name": "%s", "status": "%s", "release_state": "%s", "tier": "%s", "progress": "%s", "blockers": [], "parent": null}' \
218
215
  "$unit" "$(json_escape "$nm")" "$(json_escape "$status")" "$(json_escape "$rstate")" "$(json_escape "$tier")" "$(json_escape "$prog")"
219
216
  done
@@ -240,10 +237,16 @@ NOTION_VERSION="2022-06-28"
240
237
 
241
238
  log_deliver() { printf 'board-render: %s\n' "$1" >&2; }
242
239
 
240
+ # The env-var NAME holding the token — board.ci_token_env, default FORGE_NOTION_TOKEN.
241
+ # Single source for the default (R082); the token VALUE never passes through here.
242
+ token_env_name() {
243
+ var="$(board_cfg ci_token_env)"; [ -n "$var" ] || var="FORGE_NOTION_TOKEN"
244
+ printf '%s' "$var"
245
+ }
246
+
243
247
  # The token, read by DYNAMIC env-var name via awk ENVIRON — never eval, never a file.
244
248
  deliver_token() {
245
- var="$(board_cfg ci_token_env)"; [ -n "$var" ] || var="FORGE_NOTION_TOKEN"
246
- awk -v n="$var" 'BEGIN { v = ENVIRON[n]; if (v != "") print v }'
249
+ awk -v n="$(token_env_name)" 'BEGIN { v = ENVIRON[n]; if (v != "") print v }'
247
250
  }
248
251
 
249
252
  # curl wrapper. Prints "<response-body>\n<http_code>" (code is the LAST line).
@@ -311,7 +314,7 @@ unit_properties() { # stdin unit $1 repo $2 ts $3 set_forge_id
311
314
 
312
315
  deliver_headless() {
313
316
  board_enabled || { log_deliver "board disabled (board.enabled != true) — deliver skipped"; return 0; }
314
- var="$(board_cfg ci_token_env)"; [ -n "$var" ] || var="FORGE_NOTION_TOKEN"
317
+ var="$(token_env_name)"
315
318
  tok="$(deliver_token)"
316
319
  [ -n "$tok" ] || { log_deliver "token env \$$var unset — deliver skipped (non-blocking)"; return 0; }
317
320
  db="$(board_cfg database_id)"