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.
- package/bin/create-forge.js +6 -4
- package/experimental/m10/source/mcp-server/README.md +2 -2
- package/experimental/m10/source/mcp-server/index.js +2 -2
- package/experimental/m10/source/skills/orchestrating/SKILL.md +7 -3
- package/package.json +1 -1
- package/template/.claude/hooks/forge-model-outcome.sh +5 -3
- package/template/.claude/hooks/forge-reserve.sh +419 -106
- package/template/.claude/hooks/forge-slice-notify.sh +12 -3
- package/template/.claude/hooks/forge-slice-runner.sh +40 -19
- package/template/.claude/hooks/tests/forge-reserve.test.sh +169 -0
- package/template/.claude/skills/chief-of-staff/SKILL.md +1 -0
- package/template/.claude/skills/forge/SKILL.md +8 -6
- package/template/.claude/skills/notion-integration/SKILL.md +1 -0
- package/template/.claude/skills/planning/SKILL.md +3 -3
- package/template/.forge/FORGE.md +5 -5
- package/template/.forge/checks/forge-board-render.sh +21 -18
- package/template/.forge/checks/forge-check-lint.sh +48 -5
- package/template/.forge/checks/forge-hold-check.sh +123 -2
- package/template/.forge/checks/forge-jarvis-awareness.sh +62 -11
- package/template/.forge/checks/forge-jarvis-dispatch.sh +88 -8
- package/template/.forge/checks/forge-jarvis-relay.sh +15 -6
- package/template/.forge/checks/forge-jarvis.sh +28 -0
- package/template/.forge/checks/forge-roadmap-check.sh +106 -22
- package/template/.forge/checks/tests/forge-check-lint.test.sh +126 -4
- package/template/.forge/checks/tests/forge-hold-check.test.sh +166 -1
- package/template/.forge/checks/tests/forge-jarvis-awareness.test.sh +53 -0
- package/template/.forge/checks/tests/forge-jarvis-dispatch.test.sh +114 -0
- package/template/.forge/checks/tests/forge-jarvis-relay.test.sh +30 -0
- package/template/.forge/checks/tests/forge-jarvis.test.sh +78 -4
- package/template/.forge/checks/tests/forge-reserve.test.sh +235 -0
- package/template/.forge/checks/tests/forge-roadmap-check.test.sh +487 -0
- package/template/.forge/templates/hold-config.yml +15 -0
- package/template/.forge/templates/project.yml +10 -0
- package/template/.forge/templates/state/desire-path.yml +15 -45
- 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.
|
|
89
|
-
#
|
|
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
|
-
|
|
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:]]
|
|
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 "$
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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. **
|
|
182
|
-
3.
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
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
|
|
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
|
|
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."*
|
package/template/.forge/FORGE.md
CHANGED
|
@@ -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 |
|
|
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**:
|
|
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-only — re-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
|
|
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
|
-
|
|
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
|
-
|
|
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 [ -
|
|
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
|
-
|
|
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="$(
|
|
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)"
|