forge-orkes 0.72.2 → 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.
@@ -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
@@ -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
 
@@ -104,6 +104,8 @@ JUSTIFY="$(git log --format=%B "$MB..$HEAD_REF" 2>/dev/null \
104
104
  | sed -n 's/^Lint-Justify:[[:space:]]*//p' | head -1 | tr '"' "'")"
105
105
 
106
106
  # Base-side gating_tests globs (same additive config the hold check reads).
107
+ # Twin parser: keep in sync with forge-hold-check.sh:conf_list() —
108
+ # duplicated deliberately (R075, self-contained-hooks convention).
107
109
  GATING="$(git show "$MB:.forge/hold-config.yml" 2>/dev/null \
108
110
  | awk '
109
111
  /^[A-Za-z_]+:/ { insec = (index($0, "gating_tests:") == 1) ? 1 : 0; next }
@@ -314,6 +316,13 @@ add_finding() { # rule, file, line("" ok)
314
316
  CHANGED="$(git diff --name-only "$MB" "$HEAD_REF" 2>/dev/null)" \
315
317
  || { printf 'forge-check-lint: git diff failed for %s\n' "$RANGE" >&2; exit 2; }
316
318
 
319
+ # Scratch file for classify_units | while-read (escapes the pipe subshell so
320
+ # add_finding's FINDINGS/HAS_A accumulation survives past the loop). No
321
+ # predictable path (R077): allocated on first use, trap-cleaned.
322
+ FCL_SCRATCH=""
323
+ cleanup_fcl_scratch() { [ -n "$FCL_SCRATCH" ] && rm -f "$FCL_SCRATCH"; }
324
+ trap cleanup_fcl_scratch EXIT INT TERM
325
+
317
326
  while IFS= read -r path; do
318
327
  [ -n "$path" ] || continue
319
328
  base_name="$(basename "$path")"
@@ -323,6 +332,7 @@ while IFS= read -r path; do
323
332
  .github/workflows/*.yml|.github/workflows/*.yaml|.gitlab-ci.yml)
324
333
  ADDED="$(added_lines "$path")"
325
334
  [ -n "$ADDED" ] || continue
335
+ [ -n "$FCL_SCRATCH" ] || FCL_SCRATCH="$(mktemp)" || { printf 'forge-check-lint: mktemp failed\n' >&2; exit 2; }
326
336
  classify_units "$path" | while IFS=' ' read -r cls ustart uend; do
327
337
  [ "$cls" != "clean" ] || continue
328
338
  hit=""
@@ -333,11 +343,10 @@ while IFS= read -r path; do
333
343
  $ADDED
334
344
  EOF2
335
345
  [ -n "$hit" ] && printf '%s %s %s\n' "$cls" "$path" "$ustart"
336
- done > "${TMPDIR:-/tmp}/fcl_units.$$" || true
346
+ done > "$FCL_SCRATCH" || true
337
347
  while IFS=' ' read -r cls p ln; do
338
348
  [ -n "$cls" ] && add_finding "$cls" "$p" "$ln"
339
- done < "${TMPDIR:-/tmp}/fcl_units.$$"
340
- rm -f "${TMPDIR:-/tmp}/fcl_units.$$"
349
+ done < "$FCL_SCRATCH"
341
350
  continue
342
351
  ;;
343
352
  esac
@@ -132,6 +132,8 @@ fi
132
132
  # --- base-side config (additive; the PR cannot rewrite the rules it is judged by)
133
133
  CONF="$(git show "$MB:.forge/hold-config.yml" 2>/dev/null || true)"
134
134
 
135
+ # Twin parser: keep in sync with forge-check-lint.sh:GATING awk block —
136
+ # duplicated deliberately (R075, self-contained-hooks convention).
135
137
  conf_list() { # key → one value per line from the base-side config
136
138
  [ -n "$CONF" ] || return 0
137
139
  printf '%s\n' "$CONF" | awk -v k="$1" '
@@ -23,20 +23,28 @@
23
23
  # NO OWNED STATE: nothing here is persisted; the next ask recomputes from disk + git. The only
24
24
  # durable Jarvis state anywhere is B4's relay markers (a different helper).
25
25
  #
26
+ # BARE BRANCH LOOSENING (issue #25): the disk glob also matches a bare `m-<id>-slug` or
27
+ # `m<digits>-slug` branch (no `forge/` prefix) — a worktree created by hand or by another tool,
28
+ # not just the Worktree Convention's `forge/{anchor}`. Its milestone id is derived from the
29
+ # LONGEST-matching `.forge/state/milestone-*.yml` filename in that worktree, else the id's first
30
+ # hyphen-segment. A bare NUMBER alone (no `m` token) never matches — see derive_milestone_id.
31
+ #
26
32
  # forge-jarvis-awareness.sh [--dry-run] [--worktree-root <dir>]
27
33
  # --dry-run resolve + print structure without shelling to the fold/board (fast
28
34
  # plumbing check); tests and quick sanity checks use it.
29
35
  # --worktree-root override the worktree scan root (tests); default: real `git worktree list`.
30
36
  #
31
37
  # Host-truth commands are env-overridable so tests inject stubs and degradation is explicit:
32
- # FORGE_JARVIS_FOLD default .claude/hooks/forge-release-fold.sh
33
- # FORGE_JARVIS_BOARD default .forge/checks/forge-board-render.sh
38
+ # FORGE_JARVIS_FOLD default .claude/hooks/forge-release-fold.sh
39
+ # FORGE_JARVIS_BOARD default .forge/checks/forge-board-render.sh
40
+ # FORGE_JARVIS_REPO_ROOT default the repo root derived from this script's own path (test seam:
41
+ # points the git-worktree glob at a throwaway fixture repo)
34
42
  # BEST-EFFORT: a missing fold/board degrades to a stated 'unknown' / '(unavailable)', never a hard
35
43
  # fail — the awareness answer always renders.
36
44
  set -u
37
45
 
38
46
  SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
39
- REPO_ROOT="$(cd "$SELF_DIR/../.." && pwd)" # .forge/checks → repo root
47
+ REPO_ROOT="${FORGE_JARVIS_REPO_ROOT:-$(cd "$SELF_DIR/../.." && pwd)}" # .forge/checks → repo root
40
48
  FOLD="${FORGE_JARVIS_FOLD:-$REPO_ROOT/.claude/hooks/forge-release-fold.sh}"
41
49
  BOARD="${FORGE_JARVIS_BOARD:-$REPO_ROOT/.forge/checks/forge-board-render.sh}"
42
50
  TAB="$(printf '\t')"
@@ -61,9 +69,50 @@ fold_state() { # fold_state <milestone-id>
61
69
  [ -n "$_rs" ] && printf '%s' "$_rs" || printf 'unknown'
62
70
  }
63
71
 
64
- # ── 1. live/parked slices a DISK GLOB of worktrees on forge/m-* branches. Same on-disk registry
65
- # the relay's --list-logs arms tail -F on; recomputed here, never stored. Emits TSV:
66
- # <milestone-id>\t<branch>\t<path>. --worktree-root globs a given dir instead (tests). ──────
72
+ # ── milestone id derivation for a matched branch (issue #25, bare-branch loosening). A
73
+ # forge/m-* branch keeps its original textual derivation unchanged (strip forge/, strip the
74
+ # 8-hex session suffix). A bare branch (m-<id>-slug | m<digits>-slug) derives the id from the
75
+ # LONGEST-matching .forge/state/milestone-*.yml file name in that worktree, else falls back to
76
+ # the id's first hyphen-segment. Same rule text as forge-jarvis-dispatch.sh's branch-derived
77
+ # milestone-dir pick (there against .forge/phases/milestone-*/ dirs instead of state files). ──
78
+ derive_milestone_id() { # derive_milestone_id <branch> <worktree-path>
79
+ _b="$1"; _p="$2"
80
+ case "$_b" in
81
+ forge/*)
82
+ _m="${_b#forge/}" # m-37[-<session>]
83
+ case "$_m" in
84
+ *-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f])
85
+ _m="${_m%-*}" ;; # → m-37
86
+ esac
87
+ printf '%s' "$_m"
88
+ return 0 ;;
89
+ esac
90
+ _base="$_b"
91
+ case "$_base" in
92
+ *-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f])
93
+ _base="${_base%-*}" ;; # strip an 8-hex session suffix
94
+ esac
95
+ _best=""
96
+ for _f in "$_p"/.forge/state/milestone-*.yml; do
97
+ [ -f "$_f" ] || continue
98
+ _id="$(basename "$_f" .yml)"; _id="${_id#milestone-}"
99
+ case "$_base" in
100
+ "m-$_id"|"m-$_id"-*|"m$_id"|"m$_id"-*)
101
+ [ "${#_id}" -gt "${#_best}" ] && _best="$_id" ;;
102
+ esac
103
+ done
104
+ if [ -n "$_best" ]; then
105
+ printf 'm-%s' "$_best"
106
+ else
107
+ _seg="${_base#m}"; _seg="${_seg#-}"; _seg="${_seg%%-*}" # first hyphen-segment fallback
108
+ printf 'm-%s' "$_seg"
109
+ fi
110
+ }
111
+
112
+ # ── 1. live/parked slices — a DISK GLOB of worktrees on forge/m-* branches OR a bare m-<id>
113
+ # branch (issue #25). Same on-disk registry the relay's --list-logs arms tail -F on;
114
+ # recomputed here, never stored. Emits TSV: <milestone-id>\t<branch>\t<path>.
115
+ # --worktree-root globs a given dir instead (tests). ─────────────────────────────────────
67
116
  list_slices() {
68
117
  if [ -n "$WT_ROOT" ]; then
69
118
  for _wt in "$WT_ROOT"/*/; do
@@ -75,12 +124,14 @@ list_slices() {
75
124
  git -C "$REPO_ROOT" worktree list --porcelain 2>/dev/null | awk -v tab="$TAB" '
76
125
  /^worktree / { p = substr($0, 10) } # full remainder — paths may contain spaces
77
126
  /^branch / {
78
- if ($2 ~ /refs\/heads\/forge\/m-/) {
79
- b = $2; sub(/refs\/heads\//, "", b) # forge/m-37[-<session>]
80
- m = b; sub(/^forge\//, "", m); sub(/-[0-9a-f]{8}$/, "", m) # → m-37
81
- print m tab b tab p
127
+ b = $2; sub(/refs\/heads\//, "", b)
128
+ if (b ~ /^forge\/m-/ || b ~ /^m-[0-9A-Za-z]/ || b ~ /^m[0-9]/) {
129
+ print b tab p
82
130
  }
83
- }'
131
+ }' | while IFS="$TAB" read -r _b _p; do
132
+ _m="$(derive_milestone_id "$_b" "$_p")"
133
+ printf '%s%s%s%s%s\n' "$_m" "$TAB" "$_b" "$TAB" "$_p"
134
+ done
84
135
  fi
85
136
  }
86
137
 
@@ -233,6 +233,14 @@ materialize_work_order() {
233
233
  # unconditionally missed milestone-m-EVT01 and fell through to a 38-dir refusal. Try the
234
234
  # anchor verbatim before the m--stripped form, then both again minus one trailing
235
235
  # -{session} token; first existing dir wins (exact/longer forms first).
236
+ #
237
+ # BARE BRANCH LOOSENING (issue #25): a worktree may also sit on a bare `m-<id>-slug` or
238
+ # `m<digits>-slug` branch (no `forge/` prefix) — same match family as the awareness/relay
239
+ # glob. Unlike the forge/ convention (id + at most one session token), a bare branch's slug
240
+ # can carry several descriptive words (m-176-marcy-mcp), so the id is found by repeatedly
241
+ # truncating the LAST hyphen-segment and testing for an existing milestone dir at each
242
+ # length — the longest-matching truncation wins (mirrors awareness's state-file prefix
243
+ # match, here against .forge/phases/milestone-*/ dirs instead).
236
244
  _mdir=""
237
245
  _branch="$(git -C "$WORKTREE" branch --show-current 2>/dev/null)"
238
246
  case "$_branch" in
@@ -246,6 +254,22 @@ materialize_work_order() {
246
254
  break
247
255
  fi
248
256
  done ;;
257
+ m-*|m[0-9]*)
258
+ _cand="$_branch"
259
+ while [ -n "$_cand" ]; do
260
+ for _try in "$_cand" "${_cand#m-}" "${_cand#m}"; do
261
+ [ -n "$_try" ] || continue
262
+ if [ -d "$WORKTREE/.forge/phases/milestone-$_try" ]; then
263
+ _mdir="$WORKTREE/.forge/phases/milestone-$_try"
264
+ break
265
+ fi
266
+ done
267
+ [ -n "$_mdir" ] && break
268
+ case "$_cand" in
269
+ *-*) _cand="${_cand%-*}" ;; # drop the last hyphen-segment, retry shorter
270
+ *) break ;;
271
+ esac
272
+ done ;;
249
273
  esac
250
274
  # Fallback (non-git fixture dirs / unconventional branches): a SINGLE milestone dir is
251
275
  # unambiguous; several without a branch-derived pick is a refusal, never a guess.
@@ -260,7 +284,7 @@ materialize_work_order() {
260
284
  return 2
261
285
  fi
262
286
  if [ "$_mdirs" -gt 1 ]; then
263
- printf 'forge-jarvis-dispatch: REFUSED — %s milestone plan dirs in %s and no forge/m-* branch to pick by; ambiguous work order. Write slice.yml explicitly. RELAY this refusal to the operator.\n' "$_mdirs" "$WORKTREE" >&2
287
+ printf 'forge-jarvis-dispatch: REFUSED — %s milestone plan dirs in %s and no forge/m-* or bare m-<id> branch to pick by; ambiguous work order. Write slice.yml explicitly. RELAY this refusal to the operator.\n' "$_mdirs" "$WORKTREE" >&2
264
288
  return 2
265
289
  fi
266
290
  fi
@@ -31,16 +31,22 @@
31
31
  # the payload rather than fold/gh/board (acceptance #3).
32
32
  #
33
33
  # HOST-TRUTH COMMANDS (env-overridable so tests inject stubs, and so degradation is explicit):
34
- # FORGE_JARVIS_FOLD default .claude/hooks/forge-release-fold.sh (release_state recompute)
34
+ # FORGE_JARVIS_FOLD default .claude/hooks/forge-release-fold.sh (release_state recompute)
35
+ # FORGE_JARVIS_REPO_ROOT default the repo root derived from this script's own path (test seam:
36
+ # points --list-logs's git-worktree glob at a throwaway fixture repo)
35
37
  # (gh-based facts live in forge-jarvis-prwatch.sh, which documents its own FORGE_JARVIS_GH;
36
38
  # the board recompute lives in awareness/promote — this helper consults the fold only.)
37
39
  #
40
+ # BARE BRANCH LOOSENING (issue #25): --list-logs also matches a bare `m-<id>-slug` or
41
+ # `m<digits>-slug` worktree branch (no `forge/` prefix), same match rule as the awareness glob —
42
+ # it only needs the notify-log PATH, not the milestone id, so no id derivation lives here.
43
+ #
38
44
  # BEST-EFFORT: a fold/gh/board that is absent or errors degrades to release_state=unknown and
39
45
  # says so — never a silent success, never a hard fail of the relay path.
40
46
  set -u
41
47
 
42
48
  SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
43
- REPO_ROOT="$(cd "$SELF_DIR/../.." && pwd)" # .forge/checks → repo root
49
+ REPO_ROOT="${FORGE_JARVIS_REPO_ROOT:-$(cd "$SELF_DIR/../.." && pwd)}" # .forge/checks → repo root
44
50
  FOLD="${FORGE_JARVIS_FOLD:-$REPO_ROOT/.claude/hooks/forge-release-fold.sh}"
45
51
 
46
52
  # ── host truth: the slice's release_state, recomputed from the fold (never the payload) ───────
@@ -148,12 +154,15 @@ mode_list_logs() {
148
154
  done
149
155
  else
150
156
  # live path: `git worktree list` is the on-disk registry of worktrees; glob it to the
151
- # forge/m-* slice branches and emit each slice's notify-log path. Pinned with -C so the
152
- # watch list never depends on the caller's cwd (the cwd-reset footgun: a snapped cwd
153
- # would silently arm ZERO watches).
157
+ # forge/m-* slice branches (OR a bare m-<id>-slug / m<digits>-slug branch, issue #25) and
158
+ # emit each slice's notify-log path. Pinned with -C so the watch list never depends on the
159
+ # caller's cwd (the cwd-reset footgun: a snapped cwd would silently arm ZERO watches).
154
160
  git -C "$REPO_ROOT" worktree list --porcelain 2>/dev/null | awk '
155
161
  /^worktree / { p=substr($0, 10) } # full remainder — paths may contain spaces
156
- /^branch / { if ($2 ~ /refs\/heads\/forge\/m-/) print p "/.forge/runner-work/notify.log" }
162
+ /^branch / {
163
+ b=$2; sub(/refs\/heads\//, "", b)
164
+ if (b ~ /^forge\/m-/ || b ~ /^m-[0-9A-Za-z]/ || b ~ /^m[0-9]/) print p "/.forge/runner-work/notify.log"
165
+ }
157
166
  '
158
167
  fi
159
168
  exit 0
@@ -114,5 +114,58 @@ else
114
114
  bad 'unknown flag refused loudly (exit 2)' "rc=$RC out=$OUT"
115
115
  fi
116
116
 
117
+ # --- 9-12. BARE BRANCH LOOSENING (issue #25): a worktree glob against REAL git worktrees on
118
+ # bare m-<id>-slug / m<digits>-slug branches (no forge/ prefix) — FORGE_JARVIS_REPO_ROOT
119
+ # points the git-worktree glob at a throwaway fixture repo instead of --worktree-root's
120
+ # root-scan (which bypasses branch matching entirely). ------------------------------------
121
+ GITROOT="$ROOT/git-fixture"; mkdir -p "$GITROOT/main"
122
+ (
123
+ cd "$GITROOT/main" || exit 1
124
+ git init -q .
125
+ git config user.email t@t.com; git config user.name test
126
+ mkdir -p .forge/state
127
+ printf 'id: 176\n' > .forge/state/milestone-176.yml
128
+ printf 'id: 167\n' > .forge/state/milestone-167.yml
129
+ printf 'id: R61-002\n' > .forge/state/milestone-R61-002.yml
130
+ git add .forge >/dev/null 2>&1 || : > .gitkeep
131
+ git add -A; git commit -q -m init
132
+ git worktree add -q -b m-176-marcy-mcp "$GITROOT/bare-1" >/dev/null 2>&1
133
+ git worktree add -q -b m167-close-sync "$GITROOT/bare-2" >/dev/null 2>&1
134
+ git worktree add -q -b m-R61-002-cleanup "$GITROOT/bare-3" >/dev/null 2>&1
135
+ git worktree add -q -b fix-issue-2 "$GITROOT/guard-1" >/dev/null 2>&1
136
+ ) >/dev/null 2>&1
137
+
138
+ OUT="$(FORGE_JARVIS_REPO_ROOT="$GITROOT/main" FORGE_JARVIS_FOLD="$FOLDSTUB" FORGE_JARVIS_BOARD="$BOARDSTUB" \
139
+ sh "$AWARE" --dry-run 2>&1)"
140
+
141
+ # 9. bare branch m-176-marcy-mcp discovered with milestone id m-176 (previously silently absent)
142
+ if printf '%s' "$OUT" | grep -qE 'm-176[[:space:]]+m-176-marcy-mcp'; then
143
+ ok 'bare branch m-176-marcy-mcp is discovered as milestone id m-176'
144
+ else
145
+ bad 'bare branch m-176-marcy-mcp is discovered as milestone id m-176' "$OUT"
146
+ fi
147
+
148
+ # 10. bare branch m167-close-sync (no hyphen after m) discovered with milestone id m-167
149
+ if printf '%s' "$OUT" | grep -qE 'm-167[[:space:]]+m167-close-sync'; then
150
+ ok 'bare branch m167-close-sync is discovered as milestone id m-167'
151
+ else
152
+ bad 'bare branch m167-close-sync is discovered as milestone id m-167' "$OUT"
153
+ fi
154
+
155
+ # 11. hyphenated id: m-R61-002-cleanup resolves via the longest-matching state-file prefix,
156
+ # not the naive first-hyphen-segment (which would wrongly stop at m-R61)
157
+ if printf '%s' "$OUT" | grep -qE 'm-R61-002[[:space:]]+m-R61-002-cleanup'; then
158
+ ok 'hyphenated milestone id: m-R61-002-cleanup resolves to m-R61-002 (longest state-file prefix)'
159
+ else
160
+ bad 'hyphenated milestone id: m-R61-002-cleanup resolves to m-R61-002 (longest state-file prefix)' "$OUT"
161
+ fi
162
+
163
+ # 12. GUARD: a bare-number branch (fix-issue-2, no m- token) is NEVER discovered
164
+ if ! printf '%s' "$OUT" | grep -q 'fix-issue-2'; then
165
+ ok 'guard: bare-number branch fix-issue-2 (no m- token) is not discovered'
166
+ else
167
+ bad 'guard: bare-number branch fix-issue-2 (no m- token) is not discovered' "$OUT"
168
+ fi
169
+
117
170
  printf '\nforge-jarvis-awareness.test.sh: %s passed, %s failed\n' "$PASSED" "$FAILED"
118
171
  [ "$FAILED" -eq 0 ]
@@ -403,6 +403,56 @@ if [ "$rc" = 0 ] && printf '%s' "$err24" | grep -q 'using it as-is'; then
403
403
  else
404
404
  bad "legacy materialized order" "rc=$rc err=$err24"
405
405
  fi
406
+
407
+ # --- 25-27. BARE BRANCH LOOSENING (issue #25): the branch-derived milestone-dir pick also
408
+ # matches a bare m-<id>-slug / m<digits>-slug branch (no forge/ prefix) — same match family
409
+ # as awareness/relay. A bare slug can carry several descriptive words, so the pick walks
410
+ # truncations of the last hyphen-segment until an existing milestone dir is found.
411
+
412
+ # 25. bare branch m-176-marcy-mcp (hyphenated id + 2-word slug) picks milestone-176 among
413
+ # several dirs
414
+ BSLICE="$ROOT/bare-wt"
415
+ mkdir -p "$BSLICE/.forge/phases/milestone-176/1-num" "$BSLICE/.forge/phases/milestone-5/1-decoy"
416
+ printf 'bare plan\n' > "$BSLICE/.forge/phases/milestone-176/1-num/plan-01.md"
417
+ printf 'decoy\n' > "$BSLICE/.forge/phases/milestone-5/1-decoy/plan-01.md"
418
+ mkbranch "$BSLICE" m-176-marcy-mcp
419
+ err25="$(sh "$DISPATCH" --slice "$BSLICE" --materialize-only 2>&1)"; rc=$?
420
+ if [ "$rc" = 0 ] && grep -q '^ - 1-num$' "$BSLICE/slice.yml" 2>/dev/null \
421
+ && grep -q 'bare plan' "$BSLICE/phases/1-num/plan.md" 2>/dev/null && [ ! -d "$BSLICE/phases/1-decoy" ]; then
422
+ ok "bare branch m-176-marcy-mcp: branch picks milestone-176 among several dirs (no forge/ prefix)"
423
+ else
424
+ bad "bare-branch pick (hyphenated id)" "rc=$rc err=$err25"
425
+ fi
426
+
427
+ # 26. bare branch m167-close-sync (no hyphen after m, 2-word slug) picks milestone-167
428
+ CSLICE="$ROOT/bare-wt2"
429
+ mkdir -p "$CSLICE/.forge/phases/milestone-167/1-num" "$CSLICE/.forge/phases/milestone-9/1-decoy"
430
+ printf 'bare plan\n' > "$CSLICE/.forge/phases/milestone-167/1-num/plan-01.md"
431
+ printf 'decoy\n' > "$CSLICE/.forge/phases/milestone-9/1-decoy/plan-01.md"
432
+ mkbranch "$CSLICE" m167-close-sync
433
+ err26="$(sh "$DISPATCH" --slice "$CSLICE" --materialize-only 2>&1)"; rc=$?
434
+ if [ "$rc" = 0 ] && grep -q '^ - 1-num$' "$CSLICE/slice.yml" 2>/dev/null \
435
+ && grep -q 'bare plan' "$CSLICE/phases/1-num/plan.md" 2>/dev/null && [ ! -d "$CSLICE/phases/1-decoy" ]; then
436
+ ok "bare branch m167-close-sync: branch picks milestone-167 among several dirs (m immediately followed by digits)"
437
+ else
438
+ bad "bare-branch pick (numeric, no hyphen)" "rc=$rc err=$err26"
439
+ fi
440
+
441
+ # 27. GUARD: a bare-number branch (fix-issue-2, no m- token) does NOT resolve — several
442
+ # milestone dirs with no branch-derived pick still refuses as ambiguous
443
+ NSLICE="$ROOT/guard-wt"
444
+ mkdir -p "$NSLICE/.forge/phases/milestone-1/1-a" "$NSLICE/.forge/phases/milestone-2/1-b"
445
+ printf 'a\n' > "$NSLICE/.forge/phases/milestone-1/1-a/plan-01.md"
446
+ printf 'b\n' > "$NSLICE/.forge/phases/milestone-2/1-b/plan-01.md"
447
+ mkbranch "$NSLICE" fix-issue-2
448
+ err27="$(sh "$DISPATCH" --slice "$NSLICE" --materialize-only 2>&1)"; rc=$?
449
+ if [ "$rc" != 0 ] && [ ! -f "$NSLICE/slice.yml" ] && printf '%s' "$err27" | grep -q 'REFUSED' \
450
+ && printf '%s' "$err27" | grep -q 'no forge/m-\* or bare m-<id> branch'; then
451
+ ok "guard: bare-number branch fix-issue-2 (no m- token) refuses ambiguous pick, names both accepted forms"
452
+ else
453
+ bad "guard: bare-number branch fix-issue-2" "rc=$rc err=$err27"
454
+ fi
455
+
406
456
  # --- summary -----------------------------------------------------------------
407
457
  printf '\n%s: %d passed, %d failed\n' "$(basename "$0")" "$PASSED" "$FAILED"
408
458
  [ "$FAILED" = 0 ]