Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .beads/issues.jsonl
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{"id":"forge-1bo","title":"Restore post-merge workflow runtime dependencies","description":".github/workflows/post-merge.yml now correctly fires on master after PR #5 / merge 662b43ae, proving forge-2fq's trigger fix worked. However, the workflow fails in Classify Merge because it depends on .claude/scripts/classify-merge-pr.sh, which is not present in the CI checkout after .claude/ was untracked by commit 931d4c89.\n\nThe workflow was structurally broken but hidden while the trigger was dormant.\n\nRun: https://github.com/0xElCapitan/forge/actions/runs/26529891069\n\nDecide whether to selectively re-track workflow-critical scripts, refactor post-merge.yml to remove .claude dependencies, or adopt a proper Loa/submodule/script-install strategy.","status":"open","priority":2,"issue_type":"task","created_at":"2026-05-27T18:19:04.088423400Z","created_by":"unknown","updated_at":"2026-05-27T18:19:04.088423400Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["ci","cycle-002-sprint-01-followup","follow-up"]}
{"id":"forge-1ce","title":"Add PR-level CI for FORGE","description":"FORGE currently has no PR-level CI workflow, so PR #4 (cycle-002 sprint-01 ir-0.2.0-bundle) could not receive pre-merge checks. Sprint 01 used local verification plus review/audit as the merge gate.\n\nAdd a PR workflow that runs at minimum:\n- npm run test:unit\n- npm run test:all\n\nTriggers: pull_request / push as appropriate for the repo's PR-flow conventions.\n\nContext: surfaced 2026-05-27 during cycle-002 sprint-01 ir-0.2.0-bundle release sequence. Local 777/792 pass + Bridgebuilder review APPROVED + Paranoid Cypherpunk Auditor PASS were the substitute gate; this is acceptable for a contract-surface change but should not be the long-term default.","status":"open","priority":2,"issue_type":"task","created_at":"2026-05-27T17:54:59.147203200Z","created_by":"unknown","updated_at":"2026-05-27T17:54:59.147203200Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["ci","cycle-002-sprint-01-followup","follow-up"]}
{"id":"forge-2fq","title":"Fix dormant post-merge workflow branch trigger","description":".github/workflows/post-merge.yml is configured for 'branches: [main]', but the repo default branch is 'master'. As a result, the post-merge workflow did not run after Sprint 01 merge commit 16451951f48a25d0741fd27525c9d9066abf8907 (PR #4, 'cycle-002 sprint-01 ir-0.2.0-bundle').\n\nUpdate the workflow trigger to match 'master' or otherwise align the repo branch strategy.\n\nSurfaced 2026-05-27 during the cycle-002 sprint-01 release sequence. Pre-existing repo defect, not introduced by Sprint 01. Every recent run in 'gh run list' is 'Dependabot Updates'; 'Post-Merge Pipeline' has been dormant for an unknown duration prior to this discovery.\n\nSuggested fix: change line 5 of .github/workflows/post-merge.yml from 'branches: [main]' to 'branches: [master, main]' (covers both names), or settle on one canonical default-branch name and align everything accordingly.\n\nVerification after fix: push a no-op commit to master (or merge a trivial PR) and confirm 'gh run list --workflow=\"Post-Merge Pipeline\"' shows a new run.","status":"open","priority":2,"issue_type":"task","created_at":"2026-05-27T17:55:10.416523700Z","created_by":"unknown","updated_at":"2026-05-27T17:55:10.416523700Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["ci","cycle-002-sprint-01-followup","follow-up"]}
{"id":"forge-ewa","title":"Reconcile FORGE construct snapshot fixtures with current emitter output","description":"Sprint 01 dry-run found fixtures/forge-snapshots-{tremor,corona,breath}.json are stale/orphan documentation fixtures. Regeneration introduces unrelated serializeProfile field renames and usefulness_score normalization drift in addition to expected IR/Lane 1 fields. Decide whether to modernize, retire, or reconnect these fixtures to tests in a dedicated fixture hygiene task.\n\nContext:\n- Sprint cycle-002-sprint-01-ir-0.2.0-bundle deferred FR-7 / T-E1.\n- Operator authorization 2026-05-27: defer rather than bundle the broader drift into the IR 0.2.0 commit set.\n- No current test consumes these fixtures.\n- Drift categories observed: serializeProfile field renames (cadence.median_gap_ms/cv -> median_ms/jitter_coefficient; noise.spike_ratio -> spike_rate; density.stream_count -> sensor_count); usefulness_score going from 0.0594 to null (analyze() invocation signature change); plus expected IR 0.2.0 + Lane 1 additions.\n\nOptions to consider in the dedicated task:\n1. Modernize: regenerate fully with current emitter output, accept the broader diff.\n2. Retire: delete fixtures; remove README references; replace with generated test fixtures if any consumer surfaces.\n3. Reconnect: wire fixtures back into a snapshot test so drift is detected automatically.","status":"open","priority":2,"issue_type":"task","created_at":"2026-05-27T16:40:44.748530300Z","created_by":"unknown","updated_at":"2026-05-27T16:40:44.748530300Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["cycle-002-sprint-01-deferred","fixture-hygiene","follow-up"]}
30 changes: 26 additions & 4 deletions .github/workflows/post-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,11 @@ jobs:
# readability only — do NOT re-parse $GITHUB_OUTPUT (it's a
# producer-write, runner-read contract; reading it back inverts
# GitHub's intent and breaks if the wrapper ever uses heredoc syntax).
chmod +x .claude/scripts/classify-merge-pr.sh
RESULT=$(.claude/scripts/classify-merge-pr.sh --merge-sha "$MERGE_SHA")
# forge-1bo migration (cycle-002): runtime scripts moved from
# untracked `.claude/scripts/` into tracked `scripts/ci/` so the
# workflow survives `.claude/` being gitignored (commit 931d4c89).
chmod +x scripts/ci/classify-merge-pr.sh scripts/ci/classify-pr-type.sh
RESULT=$(scripts/ci/classify-merge-pr.sh --merge-sha "$MERGE_SHA")
echo "Classifier output:"
echo "$RESULT"

Expand Down Expand Up @@ -116,8 +119,14 @@ jobs:
- name: Compute semver
id: semver
run: |
chmod +x .claude/scripts/semver-bump.sh .claude/scripts/bootstrap.sh
RESULT=$(.claude/scripts/semver-bump.sh 2>/dev/null || echo '{}')
# forge-1bo migration (cycle-002): runtime scripts moved from
# untracked `.claude/scripts/` into tracked `scripts/ci/`.
# The migrated semver-bump.sh additionally honors `[skip release]`,
# `[skip ci-release]`, and `[no-bump]` markers in commits between
# the latest tag and HEAD — if found, it emits `{}` and exits 0,
# causing the workflow to skip the "Create tag" step below.
chmod +x scripts/ci/semver-bump.sh scripts/ci/bootstrap.sh
RESULT=$(scripts/ci/semver-bump.sh 2>/dev/null || echo '{}')
NEXT=$(echo "$RESULT" | jq -r '.next // empty')
BUMP=$(echo "$RESULT" | jq -r '.bump // empty')

Expand Down Expand Up @@ -203,6 +212,19 @@ jobs:
PM_PR_NUMBER: ${{ needs.classify.outputs.pr_number }}
PM_MERGE_SHA: ${{ github.sha }}
run: |
# KNOWN LIMITATION (forge-1bo scope): the cycle-PR shell-only path
# below still calls `.claude/scripts/*`, which are not tracked in
# CI checkout. This path was DEFERRED from the forge-1bo migration
# (chore-PR / simple-release path was the bounded scope). For
# cycle PRs WITHOUT secrets.ANTHROPIC_API_KEY set, this step will
# fail with `chmod: cannot access '.claude/scripts/...'`. The
# Claude Code Action path (runs inside the Anthropic container
# with Loa loaded) is the supported cycle-PR route.
# If a future cycle PR needs shell-only fallback, a follow-up
# bead must migrate post-merge-orchestrator.sh + transitive deps
# (release-notes-gen.sh, classify-pr-type.sh, ground-truth-gen.sh,
# classify-commit-zone.sh, lore-promote.sh, path-lib.sh) into
# scripts/ci/.
echo "Running shell-only pipeline (no ANTHROPIC_API_KEY)"
chmod +x .claude/scripts/post-merge-orchestrator.sh .claude/scripts/semver-bump.sh .claude/scripts/release-notes-gen.sh .claude/scripts/bootstrap.sh
.claude/scripts/post-merge-orchestrator.sh \
Expand Down
99 changes: 99 additions & 0 deletions scripts/ci/bootstrap.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# bootstrap.sh - Workspace context initialization
# Version: 1.0.0
#
# Sourced by all Loa scripts before any other operations.
# Establishes PROJECT_ROOT and sources path-lib.sh.
#
# Usage:
# source "$SCRIPT_DIR/bootstrap.sh"
#
# Environment Variables Set:
# PROJECT_ROOT - Canonical workspace root path
# CONFIG_FILE - Path to .loa.config.yaml
#
# Detection Priority:
# 1. Existing PROJECT_ROOT (inheritance from parent script)
# 2. git rev-parse --show-toplevel (git repo root)
# 3. Walk up to find .claude/ directory
# 4. Walk up to find .loa.config.yaml file
# 5. Fallback to current directory (with warning)

set -euo pipefail

# =============================================================================
# PROJECT_ROOT Detection
# =============================================================================

_detect_project_root() {
# Strategy 1: Git repository root (handles worktrees and submodules)
if command -v git &>/dev/null; then
local git_root
# --show-toplevel works with worktrees
# For submodules, it returns the submodule root, which is correct
git_root=$(git rev-parse --show-toplevel 2>/dev/null) || true
if [[ -n "$git_root" && -d "$git_root" ]]; then
echo "$git_root"
return 0
fi
fi

# Strategy 2: Walk up looking for .claude/ directory
local dir="$PWD"
while [[ "$dir" != "/" ]]; do
if [[ -d "$dir/.claude" ]]; then
echo "$dir"
return 0
fi
dir=$(dirname "$dir")
done

# Strategy 3: Walk up looking for .loa.config.yaml
dir="$PWD"
while [[ "$dir" != "/" ]]; do
if [[ -f "$dir/.loa.config.yaml" ]]; then
echo "$dir"
return 0
fi
dir=$(dirname "$dir")
done

# Fallback: current directory (may be wrong)
echo "WARNING: Could not detect PROJECT_ROOT, using current directory" >&2
echo "$PWD"
return 1
}

# Only initialize if not already set (allows inheritance from parent script)
if [[ -z "${PROJECT_ROOT:-}" ]]; then
PROJECT_ROOT=$(_detect_project_root)
fi

# Canonicalize PROJECT_ROOT to absolute path
# Use realpath for consistency across all scripts
if command -v realpath &>/dev/null; then
PROJECT_ROOT=$(realpath "$PROJECT_ROOT")
else
# Fallback for systems without realpath (rare)
PROJECT_ROOT=$(cd "$PROJECT_ROOT" && pwd)
fi
export PROJECT_ROOT

# Config file location
CONFIG_FILE="${PROJECT_ROOT}/.loa.config.yaml"
export CONFIG_FILE

# =============================================================================
# Source Path Library
# =============================================================================

# Determine script directory (may differ from PROJECT_ROOT/.claude/scripts if symlinked)
_BOOTSTRAP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Source path-lib.sh if it exists
if [[ -f "$_BOOTSTRAP_DIR/path-lib.sh" ]]; then
source "$_BOOTSTRAP_DIR/path-lib.sh"
fi

# Cleanup internal variable
unset _BOOTSTRAP_DIR
147 changes: 147 additions & 0 deletions scripts/ci/classify-merge-pr.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
#!/usr/bin/env bash
# =============================================================================
# classify-merge-pr.sh — workflow-level merge classifier (Issue #668)
# =============================================================================
# Wraps classify-pr-type.sh with merge-context handling so the post-merge
# pipeline classifies cycle PRs correctly even when `gh pr view` returns
# empty title/labels in the GitHub Actions runner. The merge commit subject
# is in-tree state and never empty by the time post-merge runs, so it is the
# PRIMARY signal. `gh pr view` labels are SECONDARY enrichment — when gh
# fails, the failure is surfaced loudly to stderr (no silent swallow) and
# the wrapper falls through to subject-only classification.
#
# Usage:
# classify-merge-pr.sh --merge-sha <sha> [--pr-number <n>]
# classify-merge-pr.sh --merge-msg "<commit subject>" [--pr-number <n>]
#
# Output (stdout):
# pr_type=<cycle|bugfix|other>
# pr_number=<n|empty>
#
# When $GITHUB_OUTPUT is set, the same key=value lines are appended there.
#
# Exit codes:
# 0 — classified successfully
# 2 — bad arguments
# =============================================================================

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

MERGE_SHA=""
MERGE_MSG=""
PR_NUMBER=""
MERGE_MSG_SET=0

usage() {
cat <<EOF
Usage: classify-merge-pr.sh --merge-sha <sha> [--pr-number <n>]
classify-merge-pr.sh --merge-msg "<subject>" [--pr-number <n>]

Classifies a merged PR by inspecting the merge commit subject (PRIMARY) and
optionally enriching with gh pr view labels (SECONDARY). Output:
pr_type=<cycle|bugfix|other>
pr_number=<n|empty>
EOF
}

while [[ $# -gt 0 ]]; do
case "$1" in
--merge-sha)
[[ $# -ge 2 ]] || { echo "ERROR: --merge-sha requires a value" >&2; exit 2; }
MERGE_SHA="$2"; shift 2 ;;
--merge-msg)
[[ $# -ge 2 ]] || { echo "ERROR: --merge-msg requires a value" >&2; exit 2; }
MERGE_MSG="$2"; MERGE_MSG_SET=1; shift 2 ;;
--pr-number)
[[ $# -ge 2 ]] || { echo "ERROR: --pr-number requires a value" >&2; exit 2; }
PR_NUMBER="$2"; shift 2 ;;
-h|--help)
usage; exit 0 ;;
*)
echo "ERROR: unknown argument: $1" >&2
usage >&2
exit 2 ;;
esac
done

# Must have either --merge-sha or --merge-msg (an empty --merge-msg is valid;
# the classifier returns "other" for empty subjects — graceful path).
if [[ -z "$MERGE_SHA" && "$MERGE_MSG_SET" -eq 0 ]]; then
echo "ERROR: one of --merge-sha or --merge-msg is required" >&2
usage >&2
exit 2
fi

# Resolve subject from SHA if --merge-sha was passed and --merge-msg was not
if [[ -z "$MERGE_MSG" && -n "$MERGE_SHA" ]]; then
if MERGE_MSG=$(git log -1 --format='%s' "$MERGE_SHA" 2>/dev/null); then
: # got it
else
echo "[classify-merge-pr] WARN: failed to resolve subject from sha=$MERGE_SHA" >&2
MERGE_MSG=""
fi
fi

# Extract PR number from merge message if not provided
if [[ -z "$PR_NUMBER" && -n "$MERGE_MSG" ]]; then
# Match (#NNN) or trailing #NNN
PR_NUMBER=$(echo "$MERGE_MSG" | grep -oE '#[0-9]+' | head -1 | tr -d '#' || true)
fi

# Source the shared rules engine
CLASSIFIER="${SCRIPT_DIR}/classify-pr-type.sh"
if [[ ! -f "$CLASSIFIER" ]]; then
echo "[classify-merge-pr] ERROR: classify-pr-type.sh not found at $CLASSIFIER" >&2
exit 2
fi
# shellcheck source=classify-pr-type.sh
source "$CLASSIFIER"

# PRIMARY: classify by merge subject alone (in-tree state, never empty)
PR_TYPE_PRIMARY=$(classify_pr_type "$MERGE_MSG" "")

# SECONDARY: try gh pr view for label enrichment. If it fails, log loud
# but continue with PRIMARY result. Capture stderr to a temp file so the
# real failure surfaces in the workflow log.
LABELS=""
GH_FAILED=0
if [[ -n "$PR_NUMBER" ]] && command -v gh >/dev/null 2>&1; then
gh_stderr=$(mktemp)
if gh_json=$(gh pr view "$PR_NUMBER" --json title,labels 2>"$gh_stderr"); then
# Successful gh call — extract labels (jq '.labels[].name')
LABELS=$(echo "$gh_json" | jq -r '[.labels[]?.name] | join(",")' 2>/dev/null || echo "")
else
GH_FAILED=1
# Surface the failure loudly. Caller can grep for [classify-merge-pr] in logs.
echo "[classify-merge-pr] WARN: gh pr view failed for PR #${PR_NUMBER}; falling through to subject-only classification" >&2
if [[ -s "$gh_stderr" ]]; then
echo "[classify-merge-pr] gh stderr: $(cat "$gh_stderr")" >&2
fi
fi
rm -f "$gh_stderr"
elif [[ -n "$PR_NUMBER" ]]; then
echo "[classify-merge-pr] WARN: gh CLI not available; skipping label enrichment for PR #${PR_NUMBER}" >&2
fi

# If enrichment succeeded AND labels imply cycle, override PRIMARY
if [[ -n "$LABELS" ]] && echo "$LABELS" | grep -qi "cycle"; then
PR_TYPE="cycle"
else
PR_TYPE="$PR_TYPE_PRIMARY"
fi

# Emit result
echo "pr_type=${PR_TYPE}"
echo "pr_number=${PR_NUMBER}"

# Also append to $GITHUB_OUTPUT for GitHub Actions consumers
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
{
echo "pr_type=${PR_TYPE}"
echo "pr_number=${PR_NUMBER}"
} >>"$GITHUB_OUTPUT"
fi

exit 0
89 changes: 89 additions & 0 deletions scripts/ci/classify-pr-type.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# =============================================================================
# classify-pr-type.sh — shared classifier for post-merge pipeline routing
# =============================================================================
# Part of Issue #550 fix. Previously, two separate sites embedded the same
# classifier logic and drifted:
# - .github/workflows/post-merge.yml (narrow regex, missed feat(models):…)
# - .claude/scripts/post-merge-orchestrator.sh (added `feat:` catch-all,
# false-positive on any feature PR)
#
# This helper is the single source of truth. Both sites now source it.
#
# Usage (sourced):
# source .claude/scripts/classify-pr-type.sh
# pr_type=$(classify_pr_type "$TITLE" "$LABELS")
#
# Usage (standalone, for scripts that can't source):
# .claude/scripts/classify-pr-type.sh --title "<title>" --labels "<labels>"
#
# Output: one of "cycle", "bugfix", "other" (on stdout).
# =============================================================================

# classify_pr_type — classify a PR by title and labels
#
# Returns one of:
# cycle — PR represents a full cycle (CHANGELOG, GT, RTFM, Release run)
# bugfix — PR is a bugfix (tag + simple release only)
# other — PR doesn't match either (tag only)
#
# Rules (in precedence order):
# 1. Label contains "cycle" (case-insensitive) → cycle
# 2. Title matches cycle-NNN anywhere → cycle
# 3. Title starts with one of the cycle prefixes
# (Run Mode, Sprint Plan, feat(sprint, feat(cycle) → cycle
# 4. Title starts with "fix" → bugfix
# 5. Otherwise → other
#
# NOTE: `^feat:` (bare) is deliberately NOT treated as cycle — that was a
# pre-existing false-positive in post-merge-orchestrator.sh that classified
# every feat: PR (even small features) as a full cycle. Use `feat(cycle-NNN):`
# or `feat(sprint-NNN):` for cycle-scoped PRs.
classify_pr_type() {
local title="${1:-}"
local labels="${2:-}"

if echo "$labels" | grep -qi "cycle"; then
echo "cycle"
return 0
fi

if echo "$title" | grep -qE '\bcycle-[0-9]+\b'; then
echo "cycle"
return 0
fi

if echo "$title" | grep -qE "^(Run Mode|Sprint Plan|feat\(sprint|feat\(cycle)"; then
echo "cycle"
return 0
fi

if echo "$title" | grep -qE "^fix"; then
echo "bugfix"
return 0
fi

echo "other"
}

# When executed directly (not sourced), parse flags and invoke
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
TITLE=""
LABELS=""
while [[ $# -gt 0 ]]; do
case "$1" in
--title)
if [[ $# -lt 2 ]]; then echo "ERROR: --title requires a value" >&2; exit 2; fi
TITLE="$2"; shift 2 ;;
--labels)
if [[ $# -lt 2 ]]; then echo "ERROR: --labels requires a value" >&2; exit 2; fi
LABELS="$2"; shift 2 ;;
-h|--help)
echo "Usage: classify-pr-type.sh --title <title> [--labels <labels>]"
exit 0 ;;
*)
echo "Unknown arg: $1" >&2; exit 2 ;;
esac
done
classify_pr_type "$TITLE" "$LABELS"
fi
Loading
Loading