@svayam-opensource/prj 0.5.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.
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env bash
2
+ # Script: close-project
3
+ # Purpose: Closes project work. Validates completion, merges branches to base,
4
+ # archives, then triggers close-knowledge.
5
+ # Usage: bash close-project.sh <project_id>
6
+ # Compliance: C01 for pre-close gate (POL-087 to POL-096)
7
+
8
+ set -euo pipefail
9
+ source "$(dirname "$0")/lib.sh"
10
+ load_config
11
+
12
+ # ── Inputs ────────────────────────────────────────────────────────────────────
13
+
14
+ PROJECT_ID="${1:-}"
15
+ [[ -n "$PROJECT_ID" ]] || hard_stop "Usage: $0 <project_id>"
16
+
17
+ echo "=== close-project: $PROJECT_ID"
18
+ echo ""
19
+
20
+ PROJECT_YAML=$(get_project_yaml "$PROJECT_ID")
21
+ PROJECT_DIR=$(get_project_dir "$PROJECT_ID")
22
+ check_project_exists "$PROJECT_ID"
23
+
24
+ # ── C01 Pre-close Gate ────────────────────────────────────────────────────────
25
+
26
+ echo "[ C01 ] Running pre-close gate..."
27
+ GATE_FAILURES=()
28
+
29
+ # 1. knowledge/ contains at least one file
30
+ KNOWLEDGE_DIR="$PROJECT_DIR/knowledge"
31
+ if [[ ! -d "$KNOWLEDGE_DIR" ]] || [[ -z "$(find "$KNOWLEDGE_DIR" -type f 2>/dev/null)" ]]; then
32
+ GATE_FAILURES+=("projects/$PROJECT_ID/knowledge/ is empty — document project learnings first.")
33
+ fi
34
+
35
+ # 2. compliance.md exists
36
+ if [[ ! -f "$KNOWLEDGE_DIR/compliance.md" ]]; then
37
+ GATE_FAILURES+=("projects/$PROJECT_ID/knowledge/compliance.md is missing — required before close.")
38
+ fi
39
+
40
+ # 2b. knowledge-close.md manifest present + structurally complete (POL-413/414).
41
+ # Presence + structure ONLY — quality is the Harvest Protocol + Owner PR review.
42
+ MANIFEST="$KNOWLEDGE_DIR/knowledge-close.md"
43
+ if [[ ! -f "$MANIFEST" ]]; then
44
+ GATE_FAILURES+=("knowledge-close.md is missing — run the Knowledge Harvest Protocol (knowledge/development/procedures/knowledge-harvest.md) first.")
45
+ else
46
+ for section in "## Graduated to org knowledge" "## Kept project-local" "## Discarded" "## Journeys created / updated" "## Completeness critic"; do
47
+ grep -qF "$section" "$MANIFEST" || GATE_FAILURES+=("knowledge-close.md missing required section: '$section'")
48
+ done
49
+ # Case-SENSITIVE: placeholder markers are uppercase by convention; this avoids
50
+ # false-positives on the lowercase 'todo.md' filename (the standard project file).
51
+ if grep -qE '\b(TBD|TODO|FIXME|XXX)\b' "$MANIFEST"; then
52
+ GATE_FAILURES+=("knowledge-close.md still contains a TBD/TODO/FIXME placeholder — harvest incomplete.")
53
+ fi
54
+ fi
55
+
56
+ # 3. project.yaml mandatory fields populated
57
+ for field in id slug assigned_to seeded_by started_at; do
58
+ val=$(yaml_get "$PROJECT_YAML" "$field")
59
+ [[ -z "$val" || "$val" == "~" ]] && GATE_FAILURES+=("project.yaml field '$field' is not populated.")
60
+ done
61
+
62
+ if [[ ${#GATE_FAILURES[@]} -gt 0 ]]; then
63
+ echo "" >&2
64
+ echo "[ C01 ] Pre-close gate FAILED:" >&2
65
+ for f in "${GATE_FAILURES[@]}"; do
66
+ echo " - $f" >&2
67
+ done
68
+ hard_stop "Fix the above issues before closing the project."
69
+ fi
70
+
71
+ echo "[ C01 ] Pre-close gate passed."
72
+ echo ""
73
+
74
+ # Allow re-runs after partial failure: status may be 'active' (first run)
75
+ # or 'completed' (re-run after step 2/3 succeeded but later step failed).
76
+ require_any_project_status "$PROJECT_YAML" "active" "completed"
77
+
78
+ # close-project is C01-destructive (merges to protected base branches, archives
79
+ # branches). The person closing must be authorized on the project — assigned_to
80
+ # individual or a member of the assigned_to team (POL-046/047). Mirrors the gate
81
+ # in create-task.sh. (H9: authz was previously missing on this destructive op.)
82
+ CURRENT_USER=$(git config user.email 2>/dev/null || echo "")
83
+ ASSIGNED_TO=$(yaml_get "$PROJECT_YAML" "assigned_to") # display/audit cache
84
+ GH_PROJECT=$(yaml_get "$PROJECT_YAML" "github_project")
85
+ is_authorized_for_project "$GH_PROJECT" "$ASSIGNED_TO" \
86
+ || hard_stop "You ($CURRENT_USER) are not authorized to close this project — you need write access to its GitHub Project ($GH_PROJECT)."
87
+
88
+ BRANCH=$(project_branch_for_id "$PROJECT_ID")
89
+ TODAY=$(today)
90
+
91
+ # Tasks-on-board: a task is a sub-branch (<branch>.<task-slug>). Refuse to close
92
+ # while any remain unmerged — merge them (prj merge) or cancel first. The "$BRANCH.*"
93
+ # glob matches task sub-branches only (not "$BRANCH" itself, nor "$BRANCH-knowledge").
94
+ OPEN_TASKS=$(git -C "$REPO_ROOT" ls-remote --heads origin "$BRANCH.*" 2>/dev/null | awk '{print $2}' | sed 's|refs/heads/||')
95
+ [[ -n "$OPEN_TASKS" ]] && hard_stop "Unmerged task sub-branches exist — merge or cancel them first:
96
+ $OPEN_TASKS"
97
+
98
+ # ── Update state on project branch (so the gate validates it) ────────────────
99
+
100
+ echo "Updating project state on '$BRANCH'..."
101
+ cd "$REPO_ROOT"
102
+ git fetch origin "$DEFAULT_BRANCH"
103
+ git fetch origin "$BRANCH" 2>/dev/null || true
104
+ git checkout "$BRANCH"
105
+ git pull --ff-only origin "$BRANCH" 2>/dev/null || true
106
+
107
+ # Sync project branch with latest default — needed to pick up registry.yaml updates
108
+ # from any other projects that closed after this one was seeded.
109
+ if ! git merge --no-edit "origin/$DEFAULT_BRANCH" 2>/dev/null; then
110
+ echo ""
111
+ echo "MERGE CONFLICT: $DEFAULT_BRANCH → $BRANCH in workspace repo."
112
+ echo "Resolve conflicts manually, commit, then re-run: bash close-project.sh $PROJECT_ID"
113
+ exit 2
114
+ fi
115
+
116
+ yaml_set "$PROJECT_YAML" "status" "completed"
117
+ yaml_set "$PROJECT_YAML" "completed_at" "$TODAY"
118
+
119
+ # project.yaml status lives on the project branch (merges to $DEFAULT_BRANCH
120
+ # below). The registry index entry lives on $DEFAULT_BRANCH (authored at seed)
121
+ # and is flipped to 'completed' after the merge, near the end of this script.
122
+ git add "projects/$PROJECT_ID/project.yaml"
123
+ if ! git diff --cached --quiet; then
124
+ git commit -m "close-project: $PROJECT_ID — mark completed"
125
+ git push origin "$BRANCH"
126
+ fi
127
+
128
+ # ── Merge code repo branches → base_branch — LOCAL ONLY, NO PUSH (gate-before-push) ──
129
+ #
130
+ # H5/#64: previously each code repo was merged AND pushed here, BEFORE the
131
+ # workspace test-merge gate ran. A gate failure therefore shipped the code while
132
+ # leaving the registry 'active'. We now merge every repo LOCALLY first, then run
133
+ # the gate, and only push base branches once ALL repos merged cleanly. Re-runs
134
+ # are safe: a repo already merged (its base branch contains $BRANCH) is skipped.
135
+ #
136
+ # MERGED_REPOS queues "<name>|<base>" entries for the deferred-push phase below.
137
+ # A '|'-delimited list is used (not a bash4 associative array) to stay bash-3.2
138
+ # compatible — see the same convention in seed.sh.
139
+ MERGED_REPOS=()
140
+
141
+ echo ""
142
+ echo "Merging code repo branches locally (no push yet)..."
143
+
144
+ while IFS= read -r repo_url; do
145
+ REPO_NAME=$(get_repo_name "$repo_url")
146
+ REPO_DIR="$(repo_clone_dir "$PROJECT_ID" "$REPO_NAME")"
147
+ REPO_BASE=$(get_repo_base_branch "$PROJECT_YAML" "$repo_url")
148
+
149
+ if [[ ! -e "$REPO_DIR/.git" ]]; then
150
+ warn "Repo $REPO_NAME not cloned locally — skipping merge (merge manually)."
151
+ continue
152
+ fi
153
+
154
+ git -C "$REPO_DIR" fetch origin "$REPO_BASE"
155
+ git -C "$REPO_DIR" fetch origin "$BRANCH" 2>/dev/null || true
156
+ git -C "$REPO_DIR" checkout "$REPO_BASE"
157
+
158
+ MERGED_REPOS+=("$REPO_NAME|$REPO_BASE")
159
+
160
+ # Idempotency: if $BRANCH is already an ancestor of $REPO_BASE, this repo was
161
+ # merged on a prior run — nothing to merge, but still queued for push below in
162
+ # case a previous run failed before pushing.
163
+ if git -C "$REPO_DIR" merge-base --is-ancestor "$BRANCH" "$REPO_BASE" 2>/dev/null; then
164
+ info "$REPO_NAME: '$BRANCH' already merged into '$REPO_BASE' — skipping merge."
165
+ continue
166
+ fi
167
+
168
+ echo "Merging '$BRANCH' → '$REPO_BASE' in $REPO_NAME (local)..."
169
+ if ! git -C "$REPO_DIR" merge --no-edit "$BRANCH" 2>/dev/null; then
170
+ echo ""
171
+ echo "MERGE CONFLICT: $BRANCH → $REPO_BASE in $REPO_NAME."
172
+ echo "Resolve conflicts manually, commit, then re-run: bash close-project.sh $PROJECT_ID"
173
+ exit 2
174
+ fi
175
+ info "$REPO_NAME: merged locally (push deferred until after gate)."
176
+ done < <(get_project_repos "$PROJECT_YAML")
177
+
178
+ # ── Test-merge gate: $BRANCH → $DEFAULT_BRANCH (workspace repo only) ─────────
179
+ # Runs BEFORE any base-branch push. A failure here aborts with nothing shipped.
180
+
181
+ echo ""
182
+ echo "Running test-merge gate for workspace repo..."
183
+ bash "$SCRIPT_DIR/test-merge.sh" "$BRANCH"
184
+
185
+ # ── All repos merged cleanly AND the gate passed — now push base branches ─────
186
+ # Order: code-repo base branches first, then $DEFAULT_BRANCH. Pushes are
187
+ # idempotent (re-pushing an unchanged base branch is a no-op).
188
+
189
+ echo ""
190
+ echo "Gate passed — pushing code repo base branches..."
191
+ for entry in "${MERGED_REPOS[@]+"${MERGED_REPOS[@]}"}"; do
192
+ REPO_NAME="${entry%%|*}"
193
+ REPO_BASE="${entry#*|}"
194
+ REPO_DIR="$(repo_clone_dir "$PROJECT_ID" "$REPO_NAME")"
195
+ git -C "$REPO_DIR" push origin "$REPO_BASE"
196
+ info "$REPO_NAME: pushed '$REPO_BASE'."
197
+ done
198
+
199
+ # ── Push $DEFAULT_BRANCH ──────────────────────────────────────────────────────
200
+
201
+ cd "$REPO_ROOT"
202
+ git push origin "$DEFAULT_BRANCH"
203
+
204
+ # ── Flip the registry index entry to completed (on $DEFAULT_BRANCH) + mirror ──
205
+ registry_set_status_on_main "$PROJECT_ID" "completed"
206
+ project_readme_mirror "$PROJECT_ID" "$(yaml_get "$PROJECT_YAML" github_project)" "completed" \
207
+ "$(yaml_get "$PROJECT_YAML" assigned_to)" "$(yaml_get "$PROJECT_YAML" seeded_by)" "$BRANCH" || true
208
+
209
+ # Close the GitHub Project board so it stops reading as active (#56 Facet A).
210
+ close_project_board "$GH_PROJECT"
211
+
212
+ # ── Archive branches ──────────────────────────────────────────────────────────
213
+
214
+ echo ""
215
+ echo "Archiving branches..."
216
+
217
+ archive_branch "$REPO_ROOT" "$BRANCH"
218
+
219
+ while IFS= read -r repo_url; do
220
+ REPO_DIR="$(repo_clone_dir "$PROJECT_ID" "$(get_repo_name "$repo_url")")"
221
+ [[ -e "$REPO_DIR/.git" ]] && archive_branch "$REPO_DIR" "$BRANCH"
222
+ done < <(get_project_repos "$PROJECT_YAML")
223
+
224
+ echo ""
225
+ echo "=== Project closed."
226
+ echo " Status: completed"
227
+ echo " completed_at: $TODAY"
228
+ echo ""
229
+
230
+ # ── Automatically trigger close-knowledge ────────────────────────────────────
231
+
232
+ echo "Triggering close-knowledge..."
233
+ bash "$(dirname "$0")/close-knowledge.sh" "$PROJECT_ID"
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env bash
2
+ # Script: create-task
3
+ # Purpose: Creates a sub-branch for parallel work on one or more GitHub Issues.
4
+ # Usage: bash create-task.sh <project_id> <issue_url[,issue_url2,...]> <assignee>
5
+ # Compliance: C02 (POL-070, POL-073 to POL-075)
6
+ #
7
+ # Scheme B (POL-070): the sub-branch is keyed on the GitHub issue NUMBER(s):
8
+ # <project-branch>.ISSUE-<n> (single issue)
9
+ # <project-branch>.ISSUE-<n1>-<n2>-... (combined branch for related issues)
10
+ # Repo-on-demand: an issue whose repo isn't yet in the project is brought in
11
+ # automatically (add-repo), so new-repo issues "just work" — no manual add-repo.
12
+
13
+ set -euo pipefail
14
+ source "$(dirname "$0")/lib.sh"
15
+ load_config
16
+
17
+ # ── Inputs ────────────────────────────────────────────────────────────────────
18
+
19
+ PROJECT_ID="${1:-}"
20
+ ISSUE_ARG="${2:-}" # one URL, or a comma-separated list for a combined branch
21
+ ASSIGNEE="${3:-}"
22
+
23
+ [[ -n "$PROJECT_ID" ]] || hard_stop "Usage: $0 <project_id> <issue_url[,issue_url2,...]> <assignee>"
24
+ [[ -n "$ISSUE_ARG" ]] || hard_stop "Usage: $0 <project_id> <issue_url[,issue_url2,...]> <assignee>"
25
+ [[ -n "$ASSIGNEE" ]] || hard_stop "Usage: $0 <project_id> <issue_url[,issue_url2,...]> <assignee>"
26
+
27
+ # Split the (comma-separated) issue list into an array, trimming whitespace.
28
+ ISSUE_URLS=()
29
+ while IFS= read -r __u; do
30
+ __u="${__u#"${__u%%[![:space:]]*}"}"; __u="${__u%"${__u##*[![:space:]]}"}"
31
+ [[ -n "$__u" ]] && ISSUE_URLS+=("$__u")
32
+ done < <(printf '%s' "$ISSUE_ARG" | tr ',' '\n')
33
+ [[ ${#ISSUE_URLS[@]} -gt 0 ]] || hard_stop "No issue URLs given."
34
+
35
+ echo "=== create-task: $PROJECT_ID"
36
+ echo " Issues: ${ISSUE_URLS[*]}"
37
+ echo " Assignee: $ASSIGNEE"
38
+ echo ""
39
+
40
+ PROJECT_YAML=$(get_project_yaml "$PROJECT_ID")
41
+ check_project_exists "$PROJECT_ID"
42
+
43
+ # ── Pre-conditions ────────────────────────────────────────────────────────────
44
+
45
+ require_project_status "$PROJECT_YAML" "active"
46
+
47
+ # Derive the project branch (reads the stored registry branch field; see lib.sh).
48
+ BRANCH=$(project_branch_for_id "$PROJECT_ID")
49
+
50
+ # Normalize a git remote URL to a comparable "owner/repo" tail (lowercased, no
51
+ # scheme/host/.git/trailing-slash) so https and git@ forms compare equal.
52
+ normalize_repo_url() {
53
+ local u="$1"
54
+ u="${u%.git}"; u="${u%/}"
55
+ u="${u#git@*:}"; u="${u#https://*/}"; u="${u#http://*/}"
56
+ printf '%s' "$(printf '%s' "$u" | tr '[:upper:]' '[:lower:]')"
57
+ }
58
+
59
+ # True if the given repo URL is already in the project's repos[].
60
+ repo_in_project() {
61
+ local target; target="$(normalize_repo_url "$1")"
62
+ local r
63
+ while IFS= read -r r; do
64
+ [[ "$(normalize_repo_url "$r")" == "$target" ]] && return 0
65
+ done < <(get_project_repos "$PROJECT_YAML")
66
+ return 1
67
+ }
68
+
69
+ # ── Per-issue validation + repo-on-demand ─────────────────────────────────────
70
+ # For each issue: refuse if closed; ensure its repo is in the project (auto-add
71
+ # unless it's the workspace repo, which create_subbranch_in handles directly).
72
+ ISSUE_NUMS=()
73
+ for ISSUE_URL in "${ISSUE_URLS[@]}"; do
74
+ ISSUE_NUM=$(printf '%s' "$ISSUE_URL" | grep -oE '/issues/[0-9]+' | grep -oE '[0-9]+$') \
75
+ || hard_stop "Could not extract an issue number from '$ISSUE_URL'."
76
+ [[ -n "$ISSUE_NUM" ]] || hard_stop "Could not extract an issue number from '$ISSUE_URL'."
77
+ ISSUE_NUMS+=("$ISSUE_NUM")
78
+
79
+ # Tasks-on-board: the issue + its sub-branch ARE the task. Refuse a closed issue.
80
+ ISSUE_STATE=$(gh issue view "$ISSUE_URL" --json state -q '.state' 2>/dev/null || echo "")
81
+ [[ "$ISSUE_STATE" == "CLOSED" ]] && hard_stop "Issue $ISSUE_URL is closed — cannot start a task on it."
82
+
83
+ ISSUE_REPO_URL=$(echo "$ISSUE_URL" | sed 's|/issues/[0-9]*$||')
84
+ if [[ -n "$ORG_REPO_URL" && "$(normalize_repo_url "$ISSUE_REPO_URL")" == "$(normalize_repo_url "$ORG_REPO_URL")" ]]; then
85
+ info "Issue #$ISSUE_NUM is on the workspace repo (POL-057, C5) — no add-repo needed."
86
+ elif repo_in_project "$ISSUE_REPO_URL"; then
87
+ : # already a project repo
88
+ else
89
+ info "Issue #$ISSUE_NUM is in '$ISSUE_REPO_URL', not yet in the project — bringing it in (repo-on-demand)."
90
+ bash "$SCRIPTS/add-repo.sh" "$PROJECT_ID" "$ISSUE_REPO_URL" "dependency" \
91
+ "auto: brought in for task on issue #$ISSUE_NUM" "${DEFAULT_CODE_BRANCH:-dev}" \
92
+ || hard_stop "Could not bring in '$ISSUE_REPO_URL' (check access), required to task issue #$ISSUE_NUM."
93
+ fi
94
+ done
95
+
96
+ # Combined sub-branch name: numbers sorted ascending, de-duplicated, joined by '-'.
97
+ # NOTE the '.' separator (not '/'): git refuses to hold a branch '<x>' and
98
+ # '<x>/<y>' at once (refs/heads/<x> is a file, not a dir), so '<branch>/<...>'
99
+ # would collide with the project branch. '<branch>.ISSUE-...' is collision-free
100
+ # and still lets close-project glob tasks as "<branch>.*".
101
+ SUFFIX=$(printf '%s\n' "${ISSUE_NUMS[@]}" | sort -n -u | paste -sd '-' -)
102
+ TASK_ID="${BRANCH}.ISSUE-${SUFFIX}"
103
+
104
+ # The person creating the task must be authorized on the project — write access to
105
+ # its GitHub Project board (the authoritative gate; assigned_to is a display cache).
106
+ CURRENT_USER=$(git config user.email 2>/dev/null || echo "")
107
+ ASSIGNED_TO=$(yaml_get "$PROJECT_YAML" "assigned_to")
108
+ GH_PROJECT=$(yaml_get "$PROJECT_YAML" "github_project")
109
+ is_authorized_for_project "$GH_PROJECT" "$ASSIGNED_TO" \
110
+ || hard_stop "You ($CURRENT_USER) are not authorized on this project — you need write access to its GitHub Project ($GH_PROJECT)."
111
+
112
+ echo "Task ID : $TASK_ID"
113
+ echo ""
114
+
115
+ # ── Create sub-branches ───────────────────────────────────────────────────────
116
+
117
+ # Multi-repo branch creation is not atomic (H5): a failure on repo K used to leave
118
+ # repos 1..K-1 with branches created+pushed and no way to recover, and a re-run then
119
+ # hard-stopped on "branch already exists". We now (a) track every local/remote branch
120
+ # this run creates and roll them back on failure via an EXIT trap (mirrors seed.sh),
121
+ # and (b) treat a pre-existing sub-branch that already points at the expected base as
122
+ # a resumable no-op instead of a hard stop. Branches created by a *previous* successful
123
+ # run are left untouched on rollback (only this run's creations are reverted).
124
+ CREATED_LOCAL_BRANCHES=() # '<repo_path>|<branch>'
125
+ PUSHED_REMOTE_BRANCHES=() # '<repo_path>|<branch>'
126
+ TASK_OK=0
127
+
128
+ run_rollback() {
129
+ local exit_code=$?
130
+ if [[ "$TASK_OK" == "1" ]]; then return 0; fi
131
+ if [[ ${#CREATED_LOCAL_BRANCHES[@]} -eq 0 && ${#PUSHED_REMOTE_BRANCHES[@]} -eq 0 ]]; then
132
+ return 0
133
+ fi
134
+ echo ""
135
+ warn "create-task failed (exit $exit_code). Rolling back sub-branches created this run..."
136
+
137
+ # Delete remote branches this run pushed.
138
+ for ((i=${#PUSHED_REMOTE_BRANCHES[@]}-1; i>=0; i--)); do
139
+ local entry="${PUSHED_REMOTE_BRANCHES[$i]}"
140
+ local path="${entry%%|*}"
141
+ local branch="${entry#*|}"
142
+ git -C "$path" push origin --delete "$branch" 2>/dev/null || true
143
+ done
144
+
145
+ # Delete local branches this run created (switch off them first).
146
+ for ((i=${#CREATED_LOCAL_BRANCHES[@]}-1; i>=0; i--)); do
147
+ local entry="${CREATED_LOCAL_BRANCHES[$i]}"
148
+ local path="${entry%%|*}"
149
+ local branch="${entry#*|}"
150
+ if [[ -d "$path/.git" ]]; then
151
+ local current=$(git -C "$path" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
152
+ if [[ "$current" == "$branch" ]]; then
153
+ git -C "$path" checkout "$BRANCH" 2>/dev/null \
154
+ || git -C "$path" checkout "$DEFAULT_BRANCH" 2>/dev/null || true
155
+ fi
156
+ git -C "$path" branch -D "$branch" 2>/dev/null || true
157
+ fi
158
+ done
159
+
160
+ warn "Rollback complete. Sub-branches created this run were deleted."
161
+ }
162
+
163
+ trap 'run_rollback' EXIT
164
+
165
+ # Create (or resume) the sub-branch in one repo. If the sub-branch already exists
166
+ # and points at the expected base ($BRANCH), treat it as already-done (resumable).
167
+ # If it exists but diverges from the base, hard-stop for investigation.
168
+ create_subbranch_in() {
169
+ local path="$1" label="$2"
170
+ git -C "$path" fetch origin "$BRANCH" 2>/dev/null || true
171
+ if git -C "$path" rev-parse --verify "$TASK_ID" &>/dev/null; then
172
+ local task_sha base_sha
173
+ task_sha=$(git -C "$path" rev-parse "$TASK_ID" 2>/dev/null || echo "")
174
+ base_sha=$(git -C "$path" rev-parse "origin/$BRANCH" 2>/dev/null \
175
+ || git -C "$path" rev-parse "$BRANCH" 2>/dev/null || echo "")
176
+ if [[ -n "$task_sha" && "$task_sha" == "$base_sha" ]]; then
177
+ info "Sub-branch '$TASK_ID' already exists at base in $label — resuming (no-op)."
178
+ return 0
179
+ fi
180
+ hard_stop "Sub-branch '$TASK_ID' already exists in $label and diverges from '$BRANCH' — investigate before proceeding."
181
+ fi
182
+ git -C "$path" checkout "$BRANCH"
183
+ git -C "$path" checkout -b "$TASK_ID"
184
+ CREATED_LOCAL_BRANCHES+=("$path|$TASK_ID")
185
+ git -C "$path" push -u origin "$TASK_ID"
186
+ PUSHED_REMOTE_BRANCHES+=("$path|$TASK_ID")
187
+ info "Sub-branch '$TASK_ID' pushed to $label"
188
+ }
189
+
190
+ # Workspace repo
191
+ create_subbranch_in "$REPO_ROOT" "workspace repo"
192
+
193
+ # Each code repo (re-read after any repo-on-demand add above).
194
+ TODAY=$(today)
195
+ while IFS= read -r repo_url; do
196
+ REPO_NAME=$(get_repo_name "$repo_url")
197
+ REPO_DIR="$(repo_clone_dir "$PROJECT_ID" "$REPO_NAME")"
198
+ if [[ ! -e "$REPO_DIR/.git" ]]; then
199
+ warn "Repo $REPO_NAME not cloned locally — skipping sub-branch creation (clone it first)."
200
+ continue
201
+ fi
202
+ create_subbranch_in "$REPO_DIR" "$repo_url"
203
+ done < <(get_project_repos "$PROJECT_YAML")
204
+
205
+ # All sub-branches created (or already present at base) — disarm rollback so the
206
+ # post-branch board/issue steps below don't trigger branch deletion on a soft failure.
207
+ TASK_OK=1
208
+ trap - EXIT
209
+
210
+ # ── Assign + mark active on the board, per issue ──────────────────────────────
211
+ # The issue + its sub-branch are the task record (tasks-on-board: no project.yaml
212
+ # tasks[]); reflect each issue on the board Status.
213
+ GHPROJ=$(yaml_get "$PROJECT_YAML" "github_project")
214
+ for ISSUE_URL in "${ISSUE_URLS[@]}"; do
215
+ gh issue edit "$ISSUE_URL" --add-assignee "$ASSIGNEE" 2>/dev/null \
216
+ || warn "Could not assign $ISSUE_URL to $ASSIGNEE — assign manually."
217
+ board_set_status "$GHPROJ" "$ISSUE_URL" "In progress" || true
218
+ done
219
+
220
+ echo ""
221
+ echo "=== Task created successfully!"
222
+ echo " Task ID: $TASK_ID"
223
+ echo " Issues: ${ISSUE_URLS[*]}"
224
+ echo " Assignee: $ASSIGNEE"
225
+ echo ""
226
+ echo " Sub-branches merge back to '$BRANCH' ONLY — never directly to $DEFAULT_BRANCH."