@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.
package/scripts/lib.sh ADDED
@@ -0,0 +1,841 @@
1
+ #!/usr/bin/env bash
2
+ # Shared library for all Agentic Development Framework scripts.
3
+ # Source this at the top of each script:
4
+ # source "$(dirname "$0")/lib.sh"
5
+
6
+ set -euo pipefail
7
+
8
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ # ADR-0001 Phase 4: honor $ADF_WORKSPACE (CLI installed separately from data);
10
+ # otherwise default to the vendored layout (scripts/ inside the workspace repo)
11
+ # — unchanged behavior.
12
+ if [[ -n "${ADF_WORKSPACE:-}" && -f "$ADF_WORKSPACE/org-config.yaml" ]]; then
13
+ REPO_ROOT="$ADF_WORKSPACE"
14
+ else
15
+ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
16
+ fi
17
+ CONFIG="$REPO_ROOT/org-config.yaml"
18
+ REGISTRY="$REPO_ROOT/registry.yaml"
19
+
20
+ # ── Dependency check ─────────────────────────────────────────────────────────
21
+
22
+ check_deps() {
23
+ local missing=()
24
+ # perl is a hidden seed.sh dependency (#65/H6 audit) — required, no fallback.
25
+ for dep in git gh yq python3 perl; do
26
+ command -v "$dep" &>/dev/null || missing+=("$dep")
27
+ done
28
+ # yq optional if python3 present; python3 optional if yq present
29
+ local missing_str=" ${missing[*]:-} "
30
+ if [[ "$missing_str" == *" yq "* && "$missing_str" != *" python3 "* ]]; then
31
+ missing=("${missing[@]/yq}") # python3 covers for yq
32
+ fi
33
+ missing_str=" ${missing[*]:-} "
34
+ if [[ "$missing_str" == *" python3 "* && "$missing_str" != *" yq "* ]]; then
35
+ missing=("${missing[@]/python3}") # yq covers for python3
36
+ fi
37
+ # Remove empty entries
38
+ local truly_missing=()
39
+ for m in "${missing[@]:-}"; do [[ -n "$m" ]] && truly_missing+=("$m"); done
40
+
41
+ if [[ ${#truly_missing[@]} -gt 0 ]]; then
42
+ echo "" >&2
43
+ echo "Missing dependencies: ${truly_missing[*]}" >&2
44
+ echo "Run: bash scripts/install-deps.sh" >&2
45
+ exit 1
46
+ fi
47
+
48
+ # #65/H7: presence is not enough — an unauthenticated gh fails cryptically
49
+ # deep inside lifecycle ops (e.g. a misleading "Project not found"). Fail fast.
50
+ gh auth status >/dev/null 2>&1 \
51
+ || hard_stop "gh is not authenticated — run: gh auth login"
52
+ }
53
+
54
+ # ── Config ────────────────────────────────────────────────────────────────────
55
+
56
+ load_config() {
57
+ check_deps
58
+ if command -v yq &>/dev/null; then
59
+ ORG_NAME=$(yq '.org_name' "$CONFIG")
60
+ ORG_SHORT_NAME=$(yq '.org_short_name' "$CONFIG")
61
+ ORG_SLUG=$(yq '.org_slug' "$CONFIG")
62
+ ORG_SLUG_LOWER=$(yq '.org_slug_lower' "$CONFIG")
63
+ ORG_REPO_URL=$(yq '.org_repo_url' "$CONFIG" 2>/dev/null || echo "")
64
+ GITHUB_ORG=$(yq '.github_org' "$CONFIG")
65
+ WORKSPACE_REPO=$(yq '.workspace_repo' "$CONFIG")
66
+ DEFAULT_BRANCH=$(yq '.default_branch' "$CONFIG")
67
+ DEFAULT_CODE_BRANCH=$(yq '.default_code_branch' "$CONFIG")
68
+ AGENT_WORK_ROOT_CFG=$(yq '.agent_work_root' "$CONFIG" 2>/dev/null || echo "")
69
+ POLICY_OWNER_EMAIL=$(yq '.policy_owner_email' "$CONFIG")
70
+ else
71
+ _py() { python3 -c "import yaml; v = yaml.safe_load(open('$CONFIG')).get('$1', ''); print(v if v is not None else '')"; }
72
+ ORG_NAME=$(_py org_name)
73
+ ORG_SHORT_NAME=$(_py org_short_name)
74
+ ORG_SLUG=$(_py org_slug)
75
+ ORG_SLUG_LOWER=$(_py org_slug_lower)
76
+ ORG_REPO_URL=$(_py org_repo_url)
77
+ GITHUB_ORG=$(_py github_org)
78
+ WORKSPACE_REPO=$(_py workspace_repo)
79
+ DEFAULT_BRANCH=$(_py default_branch)
80
+ DEFAULT_CODE_BRANCH=$(_py default_code_branch)
81
+ AGENT_WORK_ROOT_CFG=$(_py agent_work_root)
82
+ POLICY_OWNER_EMAIL=$(_py policy_owner_email)
83
+ fi
84
+ # yq emits the literal "null" for missing keys; treat that as empty.
85
+ [[ "$AGENT_WORK_ROOT_CFG" == "null" ]] && AGENT_WORK_ROOT_CFG=""
86
+ [[ "$ORG_REPO_URL" == "null" ]] && ORG_REPO_URL=""
87
+ [[ "$ORG_SHORT_NAME" == "null" ]] && ORG_SHORT_NAME=""
88
+ export ORG_NAME ORG_SHORT_NAME ORG_SLUG ORG_SLUG_LOWER ORG_REPO_URL \
89
+ GITHUB_ORG WORKSPACE_REPO \
90
+ DEFAULT_BRANCH DEFAULT_CODE_BRANCH POLICY_OWNER_EMAIL
91
+
92
+ # Resolve AGENT_WORK_ROOT in priority order:
93
+ # 1. Env var (escape hatch for tests / overrides)
94
+ # 2. org-config.yaml agent_work_root
95
+ # 3. Fallback: ~/.<org_slug_lower>/projects (the documented default)
96
+ if [[ -z "${AGENT_WORK_ROOT:-}" ]]; then
97
+ if [[ -n "$AGENT_WORK_ROOT_CFG" ]]; then
98
+ AGENT_WORK_ROOT="$AGENT_WORK_ROOT_CFG"
99
+ else
100
+ AGENT_WORK_ROOT="$HOME/.${ORG_SLUG_LOWER:-org}/projects"
101
+ fi
102
+ fi
103
+ # Expand a leading ~ against the current user's $HOME. The config value is
104
+ # committed and shared, so it must stay portable (~/.svm/projects); without
105
+ # this, a literal "~" would be treated as a directory name and a path like
106
+ # "/Users/<other-user>" would fail on Windows/other machines.
107
+ case "$AGENT_WORK_ROOT" in
108
+ "~") AGENT_WORK_ROOT="$HOME" ;;
109
+ "~/"*) AGENT_WORK_ROOT="$HOME/${AGENT_WORK_ROOT#\~/}" ;;
110
+ esac
111
+ export AGENT_WORK_ROOT
112
+
113
+ # Lazy-create the current user's prefs file if setup.sh didn't already.
114
+ # No-op if gh login is unavailable; the file gets created on a later run
115
+ # once gh is configured. Failures here are non-fatal — preferences are C03.
116
+ ensure_user_prefs_file 2>/dev/null || true
117
+ }
118
+
119
+ # Resolve the current developer's preferences file path.
120
+ # Returns the path on stdout, or empty string if no gh login is available.
121
+ # Callers that need the file should also call ensure_user_prefs_file to
122
+ # lazily create it from the template when missing.
123
+ current_user_prefs_path() {
124
+ local login
125
+ login=$(gh api user --jq .login 2>/dev/null || echo "")
126
+ [[ -z "$login" ]] && return 0
127
+ echo "$AGENT_WORK_ROOT/preferences/$login.md"
128
+ }
129
+
130
+ # Lazily create the current user's prefs file from the template if absent.
131
+ # No-op if:
132
+ # - gh login is unavailable, OR
133
+ # - the prefs file already exists, OR
134
+ # - the template still contains {{PLACEHOLDER}} markers (workspace is
135
+ # in template state, not yet configured by setup.sh — copying now
136
+ # would persist unresolved placeholders into the user's prefs).
137
+ ensure_user_prefs_file() {
138
+ local path template
139
+ path=$(current_user_prefs_path)
140
+ [[ -z "$path" ]] && return 0
141
+ [[ -f "$path" ]] && return 0
142
+ template="$REPO_ROOT/framework/knowledge/guidance/preferences-template.md"
143
+ [[ -f "$template" ]] || return 0
144
+ # Refuse to seed from an un-substituted template. setup.sh is the
145
+ # right tool to substitute placeholders; only then can we copy.
146
+ if grep -q '{{[A-Z_a-z0-9][A-Z_a-z0-9]*}}' "$template" 2>/dev/null; then
147
+ return 0
148
+ fi
149
+ mkdir -p "$(dirname "$path")"
150
+ cp "$template" "$path"
151
+ }
152
+
153
+ # ── Terminal helpers ──────────────────────────────────────────────────────────
154
+
155
+ hard_stop() {
156
+ echo "" >&2
157
+ echo "HARD STOP [C01]: $*" >&2
158
+ exit 1
159
+ }
160
+
161
+ warn() { echo "WARNING [C02]: $*"; }
162
+
163
+ info() { echo " $*"; }
164
+
165
+ confirm() {
166
+ local _ans
167
+ printf "%s [y/N] " "$*"
168
+ if ! IFS= read -r _ans; then
169
+ echo ""
170
+ echo "Aborted (no input)."
171
+ exit 1
172
+ fi
173
+ if [[ "$_ans" != [yY] && "$_ans" != [yY][eE][sS] ]]; then
174
+ echo "Aborted."
175
+ exit 1
176
+ fi
177
+ }
178
+
179
+ # ── String helpers ────────────────────────────────────────────────────────────
180
+
181
+ slugify() {
182
+ echo "$1" | tr '[:upper:]' '[:lower:]' \
183
+ | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//'
184
+ }
185
+
186
+ today() { date +%Y-%m-%d; }
187
+
188
+ # ── Concurrency / atomic writes ───────────────────────────────────────────────
189
+ # Shared state (project.yaml, registry.yaml) is mutated by parallel agents
190
+ # (audit C7/C8). Two guarantees protect it:
191
+ # * _with_lock — serialize a mutation behind an advisory file lock.
192
+ # * never truncate-write in place — write a temp file beside the target and
193
+ # atomically rename() over it, so a crash mid-write leaves the old file
194
+ # intact (POL-002 "recoverable").
195
+
196
+ # Run <cmd...> while holding an exclusive advisory lock on <lockfile>.
197
+ # Uses flock(1) when available. macOS/BSD ships no flock by default; when it is
198
+ # absent we proceed WITHOUT the lock (still safe-ish thanks to the atomic
199
+ # temp+rename writes below) rather than failing the operation.
200
+ _with_lock() {
201
+ local lockfile="$1"; shift
202
+ if command -v flock &>/dev/null; then
203
+ exec 9>"$lockfile"
204
+ flock 9
205
+ "$@"
206
+ local rc=$?
207
+ flock -u 9
208
+ exec 9>&-
209
+ return $rc
210
+ fi
211
+ # flock absent (e.g. stock macOS): degrade gracefully — no lock, atomic write
212
+ # via temp+rename still prevents torn/partial files.
213
+ "$@"
214
+ }
215
+
216
+ # Atomically replace <file> with the contents written to stdin: write to a
217
+ # temp file in the same directory (so rename() is atomic on the same FS) then
218
+ # mv over the target. Preserves the original on any failure mid-write.
219
+ atomic_write() {
220
+ local file="$1"
221
+ local tmp; tmp="$(mktemp "${file}.XXXXXX")" || return 1
222
+ if cat >"$tmp"; then
223
+ mv -f "$tmp" "$file"
224
+ else
225
+ rm -f "$tmp"
226
+ return 1
227
+ fi
228
+ }
229
+
230
+ # ── YAML read/write ───────────────────────────────────────────────────────────
231
+
232
+ yaml_get() {
233
+ local file="$1" key="$2"
234
+ if command -v yq &>/dev/null; then
235
+ local v
236
+ v=$(yq ".$key" "$file" 2>/dev/null)
237
+ [[ "$v" == "null" ]] && echo "" || echo "$v"
238
+ else
239
+ python3 - "$file" "$key" <<'PY'
240
+ import sys, yaml
241
+ c = yaml.safe_load(open(sys.argv[1]))
242
+ v = c
243
+ for k in sys.argv[2].split('.'):
244
+ v = (v or {}).get(k) if isinstance(v, dict) else None
245
+ print('' if v is None else v)
246
+ PY
247
+ fi
248
+ }
249
+
250
+ yaml_set() {
251
+ local file="$1" key="$2" value="$3"
252
+ _with_lock "$file.lock" _yaml_set_impl "$file" "$key" "$value"
253
+ }
254
+
255
+ # Internal: the actual mutation, run under _with_lock. KEY is an internal
256
+ # constant and stays interpolated; VALUE is untrusted and is NEVER interpolated
257
+ # into the yq expression (audit C9 — PoC value `x" | .assigned_to = "evil@x.com`
258
+ # would otherwise rewrite an unrelated field). The value is passed via the
259
+ # environment and read with yq's strenv(). The python3 fallback already passes
260
+ # the value through argv (safe). Both backends write atomically (temp+rename,
261
+ # audit C8) instead of truncating the file in place.
262
+ _yaml_set_impl() {
263
+ local file="$1" key="$2" value="$3"
264
+ if command -v yq &>/dev/null; then
265
+ if [[ "$value" == "~" || "$value" == "null" ]]; then
266
+ yq ".$key = null" "$file" | atomic_write "$file"
267
+ else
268
+ VAL="$value" yq ".$key = strenv(VAL)" "$file" | atomic_write "$file"
269
+ fi
270
+ else
271
+ python3 - "$file" "$key" "$value" <<'PY' | atomic_write "$file"
272
+ import sys, yaml
273
+ file, key, value = sys.argv[1], sys.argv[2], sys.argv[3]
274
+ c = yaml.safe_load(open(file))
275
+ c[key] = None if value in ('~', 'null') else value
276
+ yaml.dump(c, sys.stdout, default_flow_style=False, allow_unicode=True, sort_keys=False)
277
+ PY
278
+ fi
279
+ }
280
+
281
+ # ── Project YAML helpers ──────────────────────────────────────────────────────
282
+
283
+ get_project_yaml() { echo "$REPO_ROOT/projects/$1/project.yaml"; }
284
+ get_project_dir() { echo "$REPO_ROOT/projects/$1"; }
285
+
286
+ check_project_exists() {
287
+ local pf; pf=$(get_project_yaml "$1")
288
+ [[ -f "$pf" ]] || hard_stop "project.yaml not found: $pf"
289
+ }
290
+
291
+ require_project_status() {
292
+ local pf="$1" expected="$2"
293
+ local s; s=$(yaml_get "$pf" "status")
294
+ [[ "$s" == "$expected" ]] || hard_stop "Project status is '$s', expected '$expected'"
295
+ }
296
+
297
+ require_any_project_status() {
298
+ local pf="$1"; shift
299
+ local s; s=$(yaml_get "$pf" "status")
300
+ for e in "$@"; do [[ "$s" == "$e" ]] && return 0; done
301
+ hard_stop "Project status is '$s', expected one of: $*"
302
+ }
303
+
304
+ # Print one repo URL per line from project.yaml repos[]
305
+ get_project_repos() {
306
+ python3 - "$1" <<'PY'
307
+ import sys, yaml
308
+ sys.stdout.reconfigure(newline='\n')
309
+ c = yaml.safe_load(open(sys.argv[1]))
310
+ for r in (c.get('repos') or []):
311
+ if r and r.get('url'):
312
+ print(r['url'])
313
+ PY
314
+ }
315
+
316
+ # Print base_branch for a specific repo URL
317
+ get_repo_base_branch() {
318
+ python3 - "$1" "$2" <<'PY'
319
+ import sys, yaml
320
+ sys.stdout.reconfigure(newline='\n')
321
+ c = yaml.safe_load(open(sys.argv[1]))
322
+ for r in (c.get('repos') or []):
323
+ if r and r.get('url') == sys.argv[2]:
324
+ print(r.get('base_branch') or 'dev')
325
+ sys.exit(0)
326
+ print('dev')
327
+ PY
328
+ }
329
+
330
+ get_repo_name() { basename "$1" .git; }
331
+
332
+ # Resolve the project branch name for a given PROJECT_ID. Prefers the
333
+ # canonical value from registry.yaml's projects[<id>].branch; falls back to
334
+ # deriving from the ID for backwards compatibility:
335
+ # - PRJ-NNN-slug → brnch-NNN-slug (v0.2.0+ convention)
336
+ # - <ANY>-NNN-slug → lowercase form (pre-v0.2.0 convention; ORG_SLUG-NNN → org_slug-NNN)
337
+ project_branch_for_id() {
338
+ local pid="$1" branch
339
+ branch=$(python3 - "$REGISTRY" "$pid" 2>/dev/null <<'PY'
340
+ import sys, yaml
341
+ c = yaml.safe_load(open(sys.argv[1])) or {}
342
+ for p in (c.get('projects') or []):
343
+ if p and p.get('id') == sys.argv[2]:
344
+ b = p.get('branch')
345
+ if b:
346
+ print(b); sys.exit(0)
347
+ sys.exit(1)
348
+ PY
349
+ )
350
+ if [[ -n "$branch" && "$branch" != "null" ]]; then
351
+ echo "$branch"
352
+ return 0
353
+ fi
354
+ # Fallback: derive from ID. PRJ- prefix gets the new brnch- mapping;
355
+ # legacy uppercase prefixes (e.g. ACME-001-foo) get the historical
356
+ # lowercase mapping (acme-001-foo).
357
+ if [[ "$pid" == PRJ-* ]]; then
358
+ echo "brnch-${pid#PRJ-}"
359
+ else
360
+ echo "$pid" | tr '[:upper:]' '[:lower:]'
361
+ fi
362
+ }
363
+
364
+ # Per-project workspace paths (Direction A layout)
365
+ project_work_root() { echo "$AGENT_WORK_ROOT/$1"; }
366
+ org_gov_clone() { echo "$AGENT_WORK_ROOT/$1/$WORKSPACE_REPO"; }
367
+ repo_clone_dir() { echo "$AGENT_WORK_ROOT/$1/$2"; }
368
+ # Back-compat aliases used by join.sh
369
+ project_clone_root() { project_work_root "$1"; }
370
+
371
+ # Base clone shared by all per-project worktrees of a repo (ADR-0001 Phase 2).
372
+ base_clone_dir() { echo "$AGENT_WORK_ROOT/.bases/$(get_repo_name "$1")"; }
373
+
374
+ # Materialize <branch> of <repo_url> at <target_dir> as a git WORKTREE of a
375
+ # single shared base clone (one base per repo under $AGENT_WORK_ROOT/.bases/),
376
+ # instead of a full per-project clone. This is the ADR-0001 Phase 2 storage
377
+ # model: one fetch/identity per repo, shared object store, far less disk.
378
+ #
379
+ # Backward compatible: if <target_dir> already exists (a legacy full clone or
380
+ # an existing worktree), it is just fetched + checked out, never re-created.
381
+ # Returns non-zero on failure so callers can warn/skip.
382
+ ensure_repo_worktree() {
383
+ local url="$1" target="$2" branch="$3"
384
+ local base; base="$(base_clone_dir "$url")"
385
+
386
+ # Already materialized (legacy clone OR existing worktree) — update in place.
387
+ if [[ -e "$target/.git" ]]; then
388
+ git -C "$target" fetch origin "$branch" 2>/dev/null || true
389
+ git -C "$target" checkout "$branch" 2>/dev/null || return 1
390
+ return 0
391
+ fi
392
+
393
+ # Ensure the single shared base clone exists and knows the branch.
394
+ if [[ ! -e "$base/.git" ]]; then
395
+ mkdir -p "$(dirname "$base")"
396
+ git_clone_retry "$url" "$base" || return 1
397
+ fi
398
+ git -C "$base" fetch origin "$branch" 2>/dev/null \
399
+ || git -C "$base" fetch origin 2>/dev/null || true
400
+
401
+ mkdir -p "$(dirname "$target")"
402
+ # Add the worktree on the branch, tracking origin/<branch> when needed.
403
+ if git -C "$base" show-ref --verify --quiet "refs/heads/$branch"; then
404
+ git -C "$base" worktree add "$target" "$branch"
405
+ elif git -C "$base" show-ref --verify --quiet "refs/remotes/origin/$branch"; then
406
+ git -C "$base" worktree add --track -b "$branch" "$target" "origin/$branch"
407
+ else
408
+ git -C "$base" worktree add -b "$branch" "$target"
409
+ fi
410
+ }
411
+
412
+ # Clone with retry + backoff.
413
+ # "early EOF / unexpected disconnect while reading sideband packet"; a couple of
414
+ # retries usually rides through a transient drop. Honors GIT_CLONE_ATTEMPTS
415
+ # (default 3). Any extra git-clone args (a branch, --depth, …) pass through after
416
+ # <dest>. Removes a partial <dest> before each attempt. Returns non-zero if all
417
+ # attempts fail (callers decide whether that's fatal).
418
+ git_clone_retry() {
419
+ local url="$1" dest="$2"; shift 2
420
+ local attempts="${GIT_CLONE_ATTEMPTS:-3}" n=1 delay=5
421
+ while true; do
422
+ rm -rf "$dest"
423
+ if git -c http.postBuffer=524288000 clone "$@" "$url" "$dest"; then
424
+ return 0
425
+ fi
426
+ if [[ "$n" -ge "$attempts" ]]; then
427
+ return 1
428
+ fi
429
+ warn "Clone of $url failed (attempt $n/$attempts) — retrying in ${delay}s..."
430
+ sleep "$delay"
431
+ n=$((n + 1)); delay=$((delay * 3))
432
+ done
433
+ }
434
+
435
+ # Is the current user authorized to work this project? (per-task/team model)
436
+ # assigned_to is either an individual email (contains '@') or a GitHub team slug.
437
+ # Authorized when: assigned_to is empty/~ (unrestricted), OR equals the current
438
+ # git email (individual), OR the current gh login is a member of the team
439
+ # (needs read:org). seeded_by is an audit record and is NOT an authorization gate.
440
+ is_authorized() {
441
+ local assigned="${1:-}"
442
+ [[ -z "$assigned" || "$assigned" == "~" ]] && return 0
443
+ local email; email=$(git config user.email 2>/dev/null || echo "")
444
+ [[ -n "$email" && "$assigned" == "$email" ]] && return 0
445
+ if [[ "$assigned" == @* || "$assigned" != *"@"* ]]; then # team: leading '@' or bare slug
446
+ local login team
447
+ login=$(gh api user --jq .login 2>/dev/null || echo "")
448
+ [[ -z "$login" ]] && return 1
449
+ team="${assigned#@}"; team="${team##*/}" # strip leading @ and any org/ prefix
450
+ gh api "orgs/$GITHUB_ORG/teams/$team/members" --jq '.[].login' 2>/dev/null \
451
+ | grep -qx "$login" && return 0
452
+ fi
453
+ return 1
454
+ }
455
+
456
+ # ── GitHub Project access — ADR-0001 Phase 3 authorization source of truth ───
457
+ # Authorization moves from YAML assigned_to to "does the current GitHub user
458
+ # have write access to the linked GitHub Project v2" (viewerCanUpdate). YAML
459
+ # assigned_to becomes a display/audit cache. When GitHub is unreachable these
460
+ # signal (rc 2) so callers fall back to the legacy YAML check.
461
+
462
+ # Parse "<scope> <owner> <number>" from a Project v2 URL (scope = orgs|users).
463
+ gh_project_owner_number() {
464
+ if [[ "$1" =~ /(orgs|users)/([^/]+)/projects/([0-9]+) ]]; then
465
+ printf '%s %s %s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}"
466
+ return 0
467
+ fi
468
+ return 1
469
+ }
470
+ _gh_owner_field() { [[ "$1" == "orgs" ]] && echo organization || echo user; }
471
+
472
+ # Close the GitHub Project board for a project URL so a completed/cancelled
473
+ # project stops showing as active in board-driven views (#56 Facet A). The
474
+ # board's open/closed state is what `prj manage list` keys on (open-only), so
475
+ # closing it both fixes the status display AND drops the project out of the
476
+ # active-management view (registry-backed `prj list` still shows it). Idempotent
477
+ # and non-fatal: a missing URL or already-closed board only warns.
478
+ close_project_board() {
479
+ local url="$1" scope owner num
480
+ if ! read -r scope owner num < <(gh_project_owner_number "$url"); then
481
+ warn "Could not derive project number from '$url' — close the board manually."
482
+ return 0
483
+ fi
484
+ if gh project close "$num" --owner "$owner" >/dev/null 2>&1; then
485
+ info "Closed GitHub Project board #$num (owner $owner)"
486
+ else
487
+ warn "Could not close GitHub Project board #$num — close manually: gh project close $num --owner $owner"
488
+ fi
489
+ return 0
490
+ }
491
+
492
+ # Echo the ProjectV2 node id for a project URL (empty + non-zero on failure).
493
+ gh_project_node_id() {
494
+ local scope owner num field id
495
+ read -r scope owner num < <(gh_project_owner_number "$1") || return 2
496
+ field=$(_gh_owner_field "$scope")
497
+ id=$(gh api graphql -f query="query{ $field(login:\"$owner\"){ projectV2(number:$num){ id } } }" \
498
+ --jq ".data.$field.projectV2.id" 2>/dev/null) || return 1
499
+ [[ -n "$id" && "$id" != "null" ]] && { echo "$id"; return 0; }
500
+ return 1
501
+ }
502
+
503
+ # rc 0 = current gh user can write the Project (authorized); rc 1 = cannot;
504
+ # rc 2 = GitHub unreachable (caller should fall back to the YAML check).
505
+ gh_viewer_can_update_project() {
506
+ local scope owner num field v
507
+ read -r scope owner num < <(gh_project_owner_number "$1") || return 2
508
+ field=$(_gh_owner_field "$scope")
509
+ v=$(gh api graphql -f query="query{ $field(login:\"$owner\"){ projectV2(number:$num){ viewerCanUpdate } } }" \
510
+ --jq ".data.$field.projectV2.viewerCanUpdate" 2>/dev/null) || return 2
511
+ [[ -z "$v" || "$v" == "null" ]] && return 2
512
+ [[ "$v" == "true" ]]
513
+ }
514
+
515
+ # Authorization gate (Phase 3). Args: <project_url> [yaml_assigned_to_for_fallback].
516
+ is_authorized_for_project() {
517
+ local url="$1" yaml_assigned="${2:-}"
518
+ gh_viewer_can_update_project "$url"
519
+ case "$?" in
520
+ 0) return 0 ;;
521
+ 1) return 1 ;;
522
+ 2) warn "Could not reach GitHub to check Project access — using cached assignment."
523
+ is_authorized "$yaml_assigned"; return $? ;;
524
+ esac
525
+ return 1
526
+ }
527
+
528
+ # Resolve an assignee token to "user <nodeId>" or "team <nodeId>".
529
+ # Convention: '@slug' = a GitHub team; a bare 'login' = a GitHub user.
530
+ # An email-style value (contains '@' not at the start) is legacy and unsupported.
531
+ gh_resolve_actor() {
532
+ local who="$1" id
533
+ if [[ "$who" == @* ]]; then
534
+ local slug="${who#@}"; slug="${slug##*/}"
535
+ id=$(gh api graphql -f query="query{ organization(login:\"$GITHUB_ORG\"){ team(slug:\"$slug\"){ id } } }" --jq '.data.organization.team.id' 2>/dev/null)
536
+ [[ -n "$id" && "$id" != "null" ]] && { echo "team $id"; return 0; }
537
+ return 1
538
+ elif [[ "$who" != *"@"* ]]; then
539
+ id=$(gh api graphql -f query="query{ user(login:\"$who\"){ id } }" --jq '.data.user.id' 2>/dev/null)
540
+ [[ -n "$id" && "$id" != "null" ]] && { echo "user $id"; return 0; }
541
+ return 1
542
+ fi
543
+ return 1
544
+ }
545
+
546
+ # Grant/revoke Project access. role: WRITER (assign) | NONE (unassign).
547
+ # kind: user|team. MUTATES real GitHub Project access. Returns gh's exit status.
548
+ gh_project_set_access() {
549
+ local project_id="$1" kind="$2" actor_id="$3" role="$4" idfield
550
+ [[ "$kind" == "team" ]] && idfield="teamId" || idfield="userId"
551
+ gh api graphql -f query="mutation(\$p:ID!,\$a:ID!){ updateProjectV2Collaborators(input:{projectId:\$p, collaborators:[{$idfield:\$a, role:$role}]}){ clientMutationId } }" \
552
+ -f p="$project_id" -f a="$actor_id" >/dev/null 2>&1
553
+ }
554
+
555
+ # Copy the developer's git identity from the workspace a lifecycle command runs
556
+ # in ($REPO_ROOT, where setup.sh configured user.name/user.email) into a freshly
557
+ # created per-project clone. Without this, the per-project workspace inherits
558
+ # only the ambient global identity, so commits and C01 authorization there would
559
+ # not reflect the developer who seeded/joined the project.
560
+ set_clone_identity() {
561
+ local dir="$1" name email
562
+ name=$(git -C "$REPO_ROOT" config user.name 2>/dev/null || echo "")
563
+ email=$(git -C "$REPO_ROOT" config user.email 2>/dev/null || echo "")
564
+ [[ -n "$name" ]] && git -C "$dir" config user.name "$name"
565
+ [[ -n "$email" ]] && git -C "$dir" config user.email "$email"
566
+ return 0
567
+ }
568
+
569
+ # ── Registry-on-default-branch (Option 2 global index) ──────────────────────
570
+ # registry.yaml is the authoritative index and lives on $DEFAULT_BRANCH so
571
+ # management/read commands see all projects without checking out a project
572
+ # branch. This sets a project's status there and pushes. Safe to call from a
573
+ # standalone clone on any branch when the working tree is clean (callers commit
574
+ # their own changes first); it switches to the default branch and back.
575
+ # Internal: rewrite a project's status in registry.yaml atomically. Run under
576
+ # _with_lock by registry_set_status_on_main. Writes to stdout and lets
577
+ # atomic_write handle the temp+rename, never truncating the file in place.
578
+ _registry_set_status_impl() {
579
+ python3 - "$1" "$2" "$3" <<'PY' | atomic_write "$1"
580
+ import sys, yaml
581
+ reg, pid, status = sys.argv[1:]
582
+ c = yaml.safe_load(open(reg)) or {}
583
+ for p in (c.get('projects') or []):
584
+ if p and p.get('id') == pid:
585
+ p['status'] = status
586
+ break
587
+ yaml.dump(c, sys.stdout, default_flow_style=False, allow_unicode=True, sort_keys=False)
588
+ PY
589
+ }
590
+
591
+ registry_set_status_on_main() {
592
+ local pid="$1" status="$2"
593
+ local cur; cur=$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
594
+ if [[ -n "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null)" ]]; then
595
+ warn "Working tree not clean — skipping registry status update on $DEFAULT_BRANCH for $pid."
596
+ return 0
597
+ fi
598
+ git -C "$REPO_ROOT" fetch origin "$DEFAULT_BRANCH" 2>/dev/null || true
599
+ git -C "$REPO_ROOT" checkout "$DEFAULT_BRANCH" 2>/dev/null \
600
+ || { warn "Could not switch to $DEFAULT_BRANCH to update registry for $pid."; return 0; }
601
+ git -C "$REPO_ROOT" pull --ff-only origin "$DEFAULT_BRANCH" 2>/dev/null || true
602
+ # Audit C7: serialize concurrent seed/status writers and write atomically
603
+ # (temp+rename) so a crash mid-write can't truncate the shared registry.
604
+ _with_lock "$REGISTRY.lock" _registry_set_status_impl "$REGISTRY" "$pid" "$status"
605
+ if [[ -n "$(git -C "$REPO_ROOT" status --porcelain registry.yaml 2>/dev/null)" ]]; then
606
+ git -C "$REPO_ROOT" add registry.yaml
607
+ git -C "$REPO_ROOT" commit -m "registry: $pid status=$status" >/dev/null 2>&1 || true
608
+ git -C "$REPO_ROOT" push origin "$DEFAULT_BRANCH" 2>/dev/null \
609
+ || warn "Could not push registry status=$status for $pid to $DEFAULT_BRANCH."
610
+ fi
611
+ [[ -n "$cur" ]] && git -C "$REPO_ROOT" checkout "$cur" 2>/dev/null || true
612
+ return 0
613
+ }
614
+
615
+ # Best-effort: mirror a read-only governance summary into the GitHub Project
616
+ # README. Needs the 'project' (write) scope; on any failure it warns and
617
+ # returns 0 so it can never break a lifecycle op. git stays authoritative.
618
+ project_readme_mirror() {
619
+ local pid="$1" gh_url="$2" status="$3" assigned="$4" seeded="$5" branch="$6"
620
+ [[ -z "$gh_url" ]] && return 0
621
+ command -v gh >/dev/null 2>&1 || return 0
622
+ local num owner field
623
+ num=$(echo "$gh_url" | grep -oE '/projects/[0-9]+' | grep -oE '[0-9]+' || echo "")
624
+ [[ -z "$num" ]] && return 0
625
+ if echo "$gh_url" | grep -q '/orgs/'; then
626
+ owner=$(echo "$gh_url" | sed 's|.*/orgs/\([^/]*\)/.*|\1|'); field="organization"
627
+ else
628
+ owner=$(echo "$gh_url" | sed 's|.*/users/\([^/]*\)/.*|\1|'); field="user"
629
+ fi
630
+ local node_id
631
+ node_id=$(gh api graphql -f query="query{ ${field}(login: \"$owner\"){ projectV2(number: $num){ id } } }" \
632
+ --jq ".data.${field}.projectV2.id" 2>/dev/null || echo "")
633
+ if [[ -z "$node_id" || "$node_id" == "null" ]]; then
634
+ warn "README mirror skipped for $pid (could not resolve project — needs 'project' scope)."
635
+ return 0
636
+ fi
637
+ local readme
638
+ readme=$(cat <<MD
639
+ <!-- Managed by the agentic-dev framework — do not edit. Mirrored from registry.yaml. -->
640
+ ## Governance — $pid
641
+
642
+ | Field | Value |
643
+ |---|---|
644
+ | Project ID | \`$pid\` |
645
+ | Status | $status |
646
+ | Assigned to | $assigned |
647
+ | Seeded by | $seeded |
648
+ | Branch | \`$branch\` |
649
+
650
+ Authoritative record: \`registry.yaml\` + \`projects/$pid/\` in the governance repo.
651
+ MD
652
+ )
653
+ gh api graphql \
654
+ -f query='mutation($id:ID!,$r:String!){ updateProjectV2(input:{projectId:$id, readme:$r}){ projectV2 { id } } }' \
655
+ -f id="$node_id" -f r="$readme" >/dev/null 2>&1 \
656
+ || warn "README mirror skipped for $pid (write failed — needs 'project' scope)."
657
+ return 0
658
+ }
659
+
660
+ # Print active task IDs from project.yaml tasks[]
661
+ # Active tasks = OPEN/non-Done issues on the project board (tasks-on-board model;
662
+ # the board is the source of truth for task state, not project.yaml). Echoes one
663
+ # issue URL per line. Reads the board via gh (needs 'project' scope). Arg: project.yaml path.
664
+ get_project_tasks() {
665
+ local pf="$1"
666
+ local url; url=$(yaml_get "$pf" "github_project")
667
+ [[ -z "$url" || "$url" == "~" ]] && return 0
668
+ command -v gh >/dev/null 2>&1 || return 0
669
+ local num owner
670
+ num=$(echo "$url" | grep -oE '/projects/[0-9]+' | grep -oE '[0-9]+' || echo "")
671
+ [[ -z "$num" ]] && return 0
672
+ if echo "$url" | grep -q '/orgs/'; then
673
+ owner=$(echo "$url" | sed 's|.*/orgs/\([^/]*\)/.*|\1|')
674
+ else
675
+ owner=$(echo "$url" | sed 's|.*/users/\([^/]*\)/.*|\1|')
676
+ fi
677
+ gh project item-list "$num" --owner "$owner" --format json --limit 200 2>/dev/null | python3 -c "
678
+ import sys, json
679
+ try: d = json.load(sys.stdin)
680
+ except Exception: sys.exit(0)
681
+ for i in d.get('items', []):
682
+ c = i.get('content') or {}
683
+ if c.get('type') == 'Issue' and str(i.get('status','')).strip().lower() != 'done':
684
+ u = c.get('url')
685
+ if u: print(u)
686
+ " 2>/dev/null
687
+ }
688
+
689
+ # Best-effort: set a GitHub Project 'Status' single-select for an issue's item.
690
+ # Needs the 'project' (write) scope; warns + returns 0 on any failure so it can
691
+ # never break a task op. Args: project_url, issue_url, status_option_name.
692
+ board_set_status() {
693
+ local url="$1" issue="$2" want="$3"
694
+ [[ -z "$url" || -z "$issue" ]] && return 0
695
+ command -v gh >/dev/null 2>&1 || return 0
696
+ local num owner
697
+ num=$(echo "$url" | grep -oE '/projects/[0-9]+' | grep -oE '[0-9]+' || echo "")
698
+ [[ -z "$num" ]] && return 0
699
+ if echo "$url" | grep -q '/orgs/'; then
700
+ owner=$(echo "$url" | sed 's|.*/orgs/\([^/]*\)/.*|\1|')
701
+ else
702
+ owner=$(echo "$url" | sed 's|.*/users/\([^/]*\)/.*|\1|')
703
+ fi
704
+ local pid fid oid iid
705
+ pid=$(gh project view "$num" --owner "$owner" --format json 2>/dev/null \
706
+ | python3 -c "import sys,json; print((json.load(sys.stdin) or {}).get('id',''))" 2>/dev/null)
707
+ read -r fid oid <<EOF2
708
+ $(gh project field-list "$num" --owner "$owner" --format json 2>/dev/null | WANT="$want" python3 -c "
709
+ import sys, json, os
710
+ want = os.environ.get('WANT','').strip().lower()
711
+ d = json.load(sys.stdin)
712
+ for f in d.get('fields', []):
713
+ if f.get('name') == 'Status':
714
+ oid = ''
715
+ for o in (f.get('options') or []):
716
+ if o.get('name','').strip().lower() == want: oid = o.get('id','')
717
+ print(f.get('id',''), oid); break
718
+ " 2>/dev/null)
719
+ EOF2
720
+ iid=$(gh project item-list "$num" --owner "$owner" --format json --limit 200 2>/dev/null \
721
+ | ISSUE="$issue" python3 -c "
722
+ import sys, json, os
723
+ iss = os.environ.get('ISSUE','')
724
+ d = json.load(sys.stdin)
725
+ for i in d.get('items', []):
726
+ if (i.get('content') or {}).get('url') == iss: print(i.get('id','')); break
727
+ " 2>/dev/null)
728
+ if [[ -z "$pid" || -z "$fid" || -z "$oid" || -z "$iid" ]]; then
729
+ warn "Board Status not set for $issue (need 'project' scope + a '$want' option)."
730
+ return 0
731
+ fi
732
+ gh project item-edit --id "$iid" --project-id "$pid" --field-id "$fid" \
733
+ --single-select-option-id "$oid" >/dev/null 2>&1 \
734
+ || warn "Board Status update to '$want' failed for $issue."
735
+ return 0
736
+ }
737
+ # ── Git helpers ───────────────────────────────────────────────────────────────
738
+
739
+ check_clean() {
740
+ local path="${1:-$REPO_ROOT}"
741
+ if [[ -n "$(git -C "$path" status --porcelain 2>/dev/null)" ]]; then
742
+ hard_stop "Uncommitted changes in $path — commit or stash first."
743
+ fi
744
+ }
745
+
746
+ # Create branch from a base branch and push it
747
+ create_and_push_branch() {
748
+ local path="$1" branch="$2" from="$3"
749
+ info "Creating branch '$branch' from '$from' in $(basename "$path")..."
750
+ git -C "$path" fetch origin "$from" 2>/dev/null || true
751
+ git -C "$path" checkout "$from"
752
+ git -C "$path" pull origin "$from" 2>/dev/null || true
753
+ if git -C "$path" rev-parse --verify "$branch" &>/dev/null; then
754
+ hard_stop "Branch '$branch' already exists in $path — investigate before proceeding."
755
+ fi
756
+ git -C "$path" checkout -b "$branch"
757
+ git -C "$path" push -u origin "$branch"
758
+ }
759
+
760
+ # Archive (tag) and delete a branch in a repo
761
+ archive_branch() {
762
+ local path="$1" branch="$2"
763
+ local tag="archive/$branch"
764
+ info "Archiving '$branch' → '$tag' in $(basename "$path")..."
765
+ git -C "$path" tag "$tag" \
766
+ || hard_stop "Failed to create archive tag '$tag' in $path — branch NOT deleted."
767
+ git -C "$path" push origin "$tag" \
768
+ || hard_stop "Failed to push archive tag '$tag' — branch NOT deleted."
769
+ git -C "$path" push origin --delete "$branch" 2>/dev/null \
770
+ && info "Deleted remote branch '$branch'" \
771
+ || warn "Remote branch '$branch' not found (may already be deleted)"
772
+ git -C "$path" branch -D "$branch" 2>/dev/null || true
773
+ }
774
+
775
+ # Merge one branch into another; exit 2 on conflict so caller can handle
776
+ merge_branch() {
777
+ local path="$1" from="$2" into="$3"
778
+ info "Merging '$from' → '$into' in $(basename "$path")..."
779
+ git -C "$path" checkout "$into"
780
+ if ! git -C "$path" merge --no-edit "$from" 2>/dev/null; then
781
+ echo ""
782
+ echo "MERGE CONFLICT: $from → $into in $path"
783
+ echo "Resolve conflicts manually, then re-run this script to continue."
784
+ exit 2
785
+ fi
786
+ }
787
+
788
+ # ── Validation ────────────────────────────────────────────────────────────────
789
+
790
+ # Run validators against the current working tree of the workspace repo.
791
+ # Used by scripts that commit DIRECTLY to $DEFAULT_BRANCH (cancel, close-knowledge
792
+ # project.yaml status update). Call AFTER making the commit, BEFORE pushing.
793
+ # On validation failure: rolls back the most recent commit and hard_stops.
794
+ # On success: returns silently.
795
+ #
796
+ # Usage: validate_or_revert
797
+ # (Operates on $REPO_ROOT; reverts HEAD~1 on failure)
798
+ validate_or_revert() {
799
+ local validator="$REPO_ROOT/scripts/validate/run.py"
800
+ if [[ ! -x "$validator" ]]; then
801
+ warn "Validator not found at $validator — skipping pre-push validation."
802
+ return 0
803
+ fi
804
+ echo ""
805
+ info "Running validators on local tree before push..."
806
+ echo ""
807
+ if ! python3 "$validator" "$REPO_ROOT"; then
808
+ echo ""
809
+ warn "Validation FAILED — rolling back last commit."
810
+ git -C "$REPO_ROOT" reset --hard HEAD~1
811
+ hard_stop "Local commit rolled back. Remote $DEFAULT_BRANCH is unchanged."
812
+ fi
813
+ info "✓ Validation passed."
814
+ }
815
+
816
+ # ── Framework version guard ───────────────────────────────────────────────────
817
+ # No lower framework version may silently overwrite a higher one. Compare two
818
+ # semver-ish versions (strip leading v + any -suffix). Echoes -1 / 0 / 1.
819
+ version_cmp() {
820
+ local a="${1#v}" b="${2#v}"; a="${a%%-*}"; b="${b%%-*}"
821
+ [[ "$a" == "$b" ]] && { echo 0; return; }
822
+ local lo; lo="$(printf '%s\n%s\n' "$a" "$b" | sort -V | head -n1)"
823
+ [[ "$lo" == "$a" ]] && echo -1 || echo 1
824
+ }
825
+
826
+ # Hard-stop if INCOMING framework version < CURRENT (a downgrade that would
827
+ # overwrite newer framework code with older). Override only with the deliberate
828
+ # ALLOW_DOWNGRADE=true escape hatch ("stop and take a careful look").
829
+ assert_no_framework_downgrade() {
830
+ local incoming="$1" current="$2" context="${3:-framework sync}"
831
+ [[ -z "$incoming" || -z "$current" ]] && return 0
832
+ if [[ "$(version_cmp "$incoming" "$current")" == "-1" ]]; then
833
+ if [[ "${ALLOW_DOWNGRADE:-false}" == "true" ]]; then
834
+ warn "DOWNGRADE OVERRIDE ($context): applying v$incoming over v$current — proceeding (--allow-downgrade)."
835
+ else
836
+ hard_stop "Refusing $context: incoming framework v$incoming is LOWER than current v$current.
837
+ This would overwrite newer framework code with an older version — stopped for a careful look.
838
+ If this is genuinely intended, re-run with --allow-downgrade (ALLOW_DOWNGRADE=true)."
839
+ fi
840
+ fi
841
+ }