@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/prj ADDED
@@ -0,0 +1,2381 @@
1
+ #!/usr/bin/env bash
2
+ # prj — Agentic Development Framework CLI
3
+ #
4
+ # Usage:
5
+ # ./prj interactive menu
6
+ # ./prj <command> run a specific command
7
+ #
8
+ # Commands:
9
+ # init Initialize a new project
10
+ # task Create a task (sub-branch) for parallel work
11
+ # merge Merge a completed task back to the project branch
12
+ # pause Pause an active project
13
+ # resume Resume a paused project
14
+ # sync Sync project branches with latest base branches
15
+ # add-repo Add a repository to an active project
16
+ # cancel Cancel a project
17
+ # close Close a completed project
18
+ # knowledge Propose org knowledge changes
19
+ # onboard Onboard a repository into the framework
20
+ # list List all projects
21
+ # status Show status of a project
22
+ # deps Install / verify dependencies
23
+
24
+ set -euo pipefail
25
+
26
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
27
+ SCRIPTS="$SCRIPT_DIR/scripts"
28
+ # ADR-0001 Phase 4: the CLI may be installed separately from the workspace
29
+ # data. $ADF_WORKSPACE (exported by the installed `prj` wrapper, or set by
30
+ # hand) points at the governance repo; default to the vendored layout (the
31
+ # CLI lives inside the workspace) when it is unset — i.e. unchanged behavior.
32
+ if [[ -n "${ADF_WORKSPACE:-}" && -f "$ADF_WORKSPACE/org-config.yaml" ]]; then
33
+ WORKSPACE_ROOT="$ADF_WORKSPACE"
34
+ else
35
+ WORKSPACE_ROOT="$SCRIPT_DIR"
36
+ fi
37
+ CONFIG="$WORKSPACE_ROOT/org-config.yaml"
38
+ REGISTRY="$WORKSPACE_ROOT/registry.yaml"
39
+
40
+ # ── Colours ───────────────────────────────────────────────────────────────────
41
+
42
+ BOLD='\033[1m'; DIM='\033[2m'; CYAN='\033[0;36m'; GREEN='\033[0;32m'
43
+ YELLOW='\033[1;33m'; RED='\033[0;31m'; MAGENTA='\033[0;35m'; NC='\033[0m'
44
+
45
+ # Sentinel returned by interactive helpers when the user chooses "go back".
46
+ # Every menu offers '0) ← back'; callers compare the result against $BACK and
47
+ # step backward (or return to the previous menu) instead of proceeding.
48
+ BACK='__back__'
49
+ # is_back <value> — true if the helper result is the back sentinel.
50
+ is_back() { [[ "$1" == "$BACK" ]]; }
51
+
52
+ header() { echo -e "\n${BOLD}${CYAN}$*${NC}"; }
53
+ label() { echo -e "${DIM}$*${NC}"; }
54
+ ok() { echo -e "${GREEN}✓${NC} $*"; }
55
+ warn() { echo -e "${YELLOW}!${NC} $*"; }
56
+ err() { echo -e "${RED}✗${NC} $*" >&2; }
57
+ info() { echo -e "${DIM}›${NC} $*"; }
58
+ divider(){ echo -e "${DIM}────────────────────────────────────────${NC}"; }
59
+
60
+ # ── Read org config ───────────────────────────────────────────────────────────
61
+
62
+ if command -v yq &>/dev/null; then
63
+ ORG_NAME=$(yq '.org_name' "$CONFIG" 2>/dev/null || echo "")
64
+ ORG_SLUG=$(yq '.org_slug' "$CONFIG" 2>/dev/null || echo "")
65
+ ORG_SLUG_LOWER=$(yq '.org_slug_lower' "$CONFIG" 2>/dev/null || echo "")
66
+ GITHUB_ORG=$(yq '.github_org' "$CONFIG" 2>/dev/null || echo "")
67
+ WORKSPACE_REPO=$(yq '.workspace_repo' "$CONFIG" 2>/dev/null || echo "")
68
+ AGENT_WORK_ROOT=$(yq '.agent_work_root' "$CONFIG" 2>/dev/null || echo "")
69
+ DEFAULT_BRANCH=$(yq '.default_branch' "$CONFIG" 2>/dev/null || echo "main")
70
+ POLICY_OWNER_EMAIL=$(yq '.policy_owner_email' "$CONFIG" 2>/dev/null || echo "")
71
+ else
72
+ ORG_NAME=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONFIG'))['org_name'])" 2>/dev/null || echo "")
73
+ ORG_SLUG=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONFIG'))['org_slug'])" 2>/dev/null || echo "")
74
+ ORG_SLUG_LOWER=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONFIG'))['org_slug_lower'])" 2>/dev/null || echo "")
75
+ GITHUB_ORG=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONFIG'))['github_org'])" 2>/dev/null || echo "")
76
+ WORKSPACE_REPO=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONFIG'))['workspace_repo'])" 2>/dev/null || echo "")
77
+ AGENT_WORK_ROOT=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONFIG')).get('agent_work_root',''))" 2>/dev/null || echo "")
78
+ DEFAULT_BRANCH=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONFIG'))['default_branch'])" 2>/dev/null || echo "main")
79
+ POLICY_OWNER_EMAIL=$(python3 -c "import yaml; print(yaml.safe_load(open('$CONFIG'))['policy_owner_email'])" 2>/dev/null || echo "")
80
+ fi
81
+
82
+ AGENT_WORK_ROOT="${AGENT_WORK_ROOT/#\~/$HOME}" # expand leading ~ for path use
83
+ WORKSPACE_REPO="${WORKSPACE_REPO:-svm-prj-work}"
84
+
85
+ CURRENT_USER=$(git config user.email 2>/dev/null || echo "")
86
+
87
+ # ── Navigation context + agent launch (#57) — prj-local (prj does not source lib.sh) ──
88
+ org_gov_clone() { echo "$AGENT_WORK_ROOT/$1/$WORKSPACE_REPO"; }
89
+
90
+ resolve_my_identities() {
91
+ git config user.email 2>/dev/null || true
92
+ gh api user --jq '.login' 2>/dev/null || true
93
+ gh api user/teams --jq '.[].slug' 2>/dev/null || true
94
+ gh api user/teams --jq '.[] | (.organization.login + "/" + .slug)' 2>/dev/null || true
95
+ }
96
+
97
+ # project_context_list <assignment:me|unassigned|any> <lifecycle:not-initiated|initiated|paused|ongoing|done|any>
98
+ # TSV: <id-or-(not seeded)>\t<github_url>\t<status>\t<assigned_to>. Registry-backed
99
+ # (board open/closed approximated from lifecycle, so no per-project GitHub calls).
100
+ project_context_list() {
101
+ local assignment="${1:-any}" lifecycle="${2:-any}" me owned=""
102
+ me="$(resolve_my_identities | paste -sd '|' -)"
103
+ # #57 Increment 5: ownership ("me") is GitHub-definitive — the boards whose
104
+ # anchor issue is assigned to me. Resolve once here and pass to the filter.
105
+ # If empty (no anchors labelled yet, or API hiccup), the python falls back to
106
+ # the registry assigned_to cache so nav never breaks during the transition.
107
+ if [[ "$assignment" == "me" ]]; then
108
+ owned="$(my_github_project_numbers | paste -sd ' ' -)"
109
+ fi
110
+ ME="$me" OWNED_NUMS="$owned" python3 - "$REGISTRY" "$assignment" "$lifecycle" <<'PY'
111
+ import sys, os, re, yaml
112
+ registry, assignment, lifecycle = sys.argv[1], sys.argv[2], sys.argv[3]
113
+ me = set(x for x in os.environ.get('ME', '').split('|') if x)
114
+ owned = set(x for x in re.split(r'[ ,]+', os.environ.get('OWNED_NUMS', '')) if x)
115
+ use_gh = bool(owned) # GitHub anchor ownership known → authoritative; else cache
116
+ doc = yaml.safe_load(open(registry)) or {}
117
+ projects = [p for p in (doc.get('projects') or []) if p]
118
+ preasn = [a for a in (doc.get('pre_assignments') or []) if a]
119
+ seeded = {p.get('github_project') for p in projects}
120
+ ONGOING, DONE = {'active', 'paused'}, {'completed', 'cancelled'}
121
+ def board_num(url):
122
+ m = re.search(r'/projects/(\d+)', url or ''); return m.group(1) if m else ''
123
+ def assign_ok(who, url):
124
+ if assignment == 'any': return True
125
+ if assignment == 'unassigned': return not who
126
+ if assignment == 'me':
127
+ if use_gh: return board_num(url) in owned
128
+ return bool(who) and who in me
129
+ return True
130
+ def nnn(pid):
131
+ m = re.search(r'-(\d+)-', pid or ''); return int(m.group(1)) if m else -1
132
+ rows = []
133
+ if lifecycle in ('initiated', 'paused', 'ongoing', 'done', 'any'):
134
+ for p in projects:
135
+ s = p.get('status', '')
136
+ url = p.get('github_project', '') or ''
137
+ ok = ((lifecycle == 'initiated' and s == 'active') or
138
+ (lifecycle == 'paused' and s == 'paused') or
139
+ (lifecycle == 'ongoing' and s in ONGOING) or
140
+ (lifecycle == 'done' and s in DONE) or
141
+ (lifecycle == 'any'))
142
+ if ok and assign_ok(p.get('assigned_to'), url):
143
+ rows.append((nnn(p.get('id', '')), p['id'], url, s, p.get('assigned_to') or ''))
144
+ if lifecycle in ('not-initiated', 'any'):
145
+ for a in preasn:
146
+ url = a.get('github_project', '') or ''
147
+ if url in seeded: continue
148
+ if assign_ok(a.get('assigned_to'), url):
149
+ rows.append((-1, '(not seeded)', url, 'not-initiated', a.get('assigned_to') or ''))
150
+ for _, pid, url, s, who in sorted(rows, key=lambda r: r[0]):
151
+ print('\t'.join((pid, url, s, who)))
152
+ PY
153
+ }
154
+
155
+ agent_session_start_prompt() {
156
+ printf 'Run the session-start protocol for %s now, before I send anything else: read org-config.yaml, projects/%s/agent.md, knowledge/policies/agentic-development-policy.md, and surface any "## Open" items from projects/%s/knowledge/todo.md; then post the context manifest and wait for my direction.' "$1" "$1" "$1"
157
+ }
158
+
159
+ SANCTIONED_AGENTS="claude cursor"
160
+ launch_agent() {
161
+ local agent="$1" dir="$2" pid="$3" inject
162
+ [[ -d "$dir" ]] || { warn "Project worktree not found: $dir"; return 0; }
163
+ case " $SANCTIONED_AGENTS " in
164
+ *" $agent "*) : ;;
165
+ *) warn "Agent '$agent' is not sanctioned (sanctioned: $SANCTIONED_AGENTS; see knowledge/policies/sanctioned_agents.md)."; return 0 ;;
166
+ esac
167
+ inject="$(agent_session_start_prompt "$pid")"
168
+ case "$agent" in
169
+ claude)
170
+ command -v claude >/dev/null 2>&1 || { warn "claude not on PATH. Start manually: cd \"$dir\" && claude \"<session-start>\""; return 0; }
171
+ ok "Launching Claude in $dir (session-start runs first)..."; cd "$dir" && exec claude "$inject" ;;
172
+ cursor)
173
+ command -v cursor-agent >/dev/null 2>&1 || { warn "cursor-agent not on PATH (install: curl https://cursor.com/install -fsS | bash). Start manually: cd \"$dir\" && cursor-agent \"<session-start>\""; return 0; }
174
+ ok "Launching cursor-agent in $dir (session-start runs first)..."; cd "$dir" && exec cursor-agent "$inject" ;;
175
+ esac
176
+ }
177
+
178
+ prompt_and_launch_agent() {
179
+ local dir="$1" pid="$2" choice
180
+ if [[ ! -t 0 ]]; then
181
+ label "Project ready at $dir. Start your agent: cd \"$dir\" && claude \"<session-start>\" (or cursor-agent)"
182
+ return 0
183
+ fi
184
+ echo ""
185
+ label "Start an agent in the project workspace (it runs the session-start protocol automatically):"
186
+ echo " 1) claude 2) cursor 0) none — I'll start it myself"
187
+ printf " Choose [1]: "
188
+ IFS= read -r choice || choice=""
189
+ case "${choice:-1}" in
190
+ 1|claude) launch_agent claude "$dir" "$pid" ;;
191
+ 2|cursor) launch_agent cursor "$dir" "$pid" ;;
192
+ 0|none|"") label "Skipped. Start later: cd \"$dir\" && claude \"<session-start>\"" ;;
193
+ *) warn "Unknown choice — skipped. Start later: cd \"$dir\" && claude \"<session-start>\"" ;;
194
+ esac
195
+ }
196
+
197
+ # ── GitHub-definitive ownership + repo helpers (manage/work redesign) ─────────
198
+ # No cache: ownership = the project's anchor-issue assignees; repos = the board's
199
+ # issues' repos. All read live from GitHub. See prj-manage-work-redesign-spec.md.
200
+ ANCHOR_LABEL="anchor"
201
+
202
+ # anchor_issue_ref <project_number> [owner] → "owner/repo#number" of the board's
203
+ # anchor issue (labelled 'anchor'), or empty. owner defaults to $GITHUB_ORG.
204
+ anchor_issue_ref() {
205
+ local num="$1" owner="${2:-$GITHUB_ORG}"
206
+ gh api graphql -f query='query($o:String!,$n:Int!){ organization(login:$o){ projectV2(number:$n){ items(first:100){ nodes{ content{ __typename ... on Issue { number repository{nameWithOwner} labels(first:30){nodes{name}} } } } } } } }' \
207
+ -F o="$owner" -F n="$num" --jq '
208
+ [ .data.organization.projectV2.items.nodes[].content
209
+ | select(.__typename=="Issue")
210
+ | select(([.labels.nodes[].name] | index("anchor")) != null)
211
+ | "\(.repository.nameWithOwner)#\(.number)" ] | (.[0] // "")' 2>/dev/null
212
+ }
213
+
214
+ # project_owners <project_number> [owner] → anchor-issue assignee logins (the
215
+ # definitive, readable owners — no cache).
216
+ project_owners() {
217
+ local ref; ref="$(anchor_issue_ref "$@")"
218
+ [[ -z "$ref" ]] && return 0
219
+ gh issue view "${ref##*#}" --repo "${ref%%#*}" --json assignees --jq '.assignees[].login' 2>/dev/null
220
+ }
221
+
222
+ # my_projects → anchor issues assigned to me (TSV: owner/repo#num \t title).
223
+ # "my projects" as one definitive query; map to a board via issue.projectItems.
224
+ my_projects() {
225
+ local login; login="$(gh api user --jq .login 2>/dev/null || echo "")"
226
+ [[ -z "$login" ]] && return 0
227
+ # Flag form, NOT the inline "label:.. assignee:.. org:.." query string — the
228
+ # inline multi-qualifier form silently returns empty for this combination
229
+ # (verified against issue #78); the flags are reliable.
230
+ gh search issues --owner "$GITHUB_ORG" --label "$ANCHOR_LABEL" --assignee "$login" \
231
+ --json number,title,repository \
232
+ --jq '.[] | "\(.repository.nameWithOwner)#\(.number)\t\(.title)"' 2>/dev/null
233
+ }
234
+
235
+ # my_github_project_numbers → the GitHub Project NUMBERS of boards whose anchor
236
+ # issue is assigned to me (ownership, GitHub-definitive). Resolves each anchor
237
+ # issue I'm assigned to → its board(s) via issue.projectItems. Empty if none
238
+ # (or on API error) — callers fall back to the registry assigned_to cache so
239
+ # nav never breaks before anchors are labelled. One number per line, deduped.
240
+ my_github_project_numbers() {
241
+ local login repo num
242
+ login="$(gh api user --jq .login 2>/dev/null || echo "")"
243
+ [[ -z "$login" ]] && return 0
244
+ while IFS=$'\t' read -r repo num; do
245
+ [[ -z "$repo" || -z "$num" ]] && continue
246
+ gh api graphql -f query='query($o:String!,$r:String!,$n:Int!){ repository(owner:$o,name:$r){ issue(number:$n){ projectItems(first:20){ nodes{ ... on ProjectV2Item { project{ number } } } } } } }' \
247
+ -F o="${repo%%/*}" -F r="${repo##*/}" -F n="$num" \
248
+ --jq '.data.repository.issue.projectItems.nodes[].project.number' 2>/dev/null
249
+ done < <(gh search issues --owner "$GITHUB_ORG" --label "$ANCHOR_LABEL" --assignee "$login" \
250
+ --json number,repository \
251
+ --jq '.[] | "\(.repository.nameWithOwner)\t\(.number)"' 2>/dev/null) | sort -un
252
+ }
253
+
254
+ # project_repos <project_number> [owner] → distinct repos of the board's items
255
+ # (the project's repo set, derived — no repos[]; workspace repo is implicit).
256
+ project_repos() {
257
+ local num="$1" owner="${2:-$GITHUB_ORG}"
258
+ gh api graphql -f query='query($o:String!,$n:Int!){ organization(login:$o){ projectV2(number:$n){ items(first:100){ nodes{ content{ ... on Issue { repository{nameWithOwner} } ... on PullRequest { repository{nameWithOwner} } } } } } } }' \
259
+ -F o="$owner" -F n="$num" --jq '
260
+ [ .data.organization.projectV2.items.nodes[].content | .repository.nameWithOwner | select(.) ] | unique | .[]' 2>/dev/null
261
+ }
262
+
263
+ # set_owner <add|remove> <project_number> <login> [owner]
264
+ # Atomic owner change: (1) anchor-issue assignee (readable owner) + (2) project
265
+ # write access (authorization gate, via project-access.sh). Fail-closed.
266
+ set_owner() {
267
+ local action="$1" num="$2" login="$3" owner="${4:-$GITHUB_ORG}"
268
+ local ref repo inum url aflag pa
269
+ ref="$(anchor_issue_ref "$num" "$owner")"
270
+ [[ -z "$ref" ]] && { warn "No '$ANCHOR_LABEL' issue on project #$num — designate one first (prj manage)."; return 1; }
271
+ repo="${ref%%#*}"; inum="${ref##*#}"
272
+ url="https://github.com/orgs/$owner/projects/$num"
273
+ case "$action" in
274
+ add) aflag="--add-assignee"; pa="grant" ;;
275
+ remove) aflag="--remove-assignee"; pa="revoke" ;;
276
+ *) warn "set_owner: action must be add|remove"; return 1 ;;
277
+ esac
278
+ gh issue edit "$inum" --repo "$repo" "$aflag" "$login" >/dev/null 2>&1 \
279
+ || { warn "Failed to $action anchor-issue assignee $login on $repo#$inum"; return 1; }
280
+ bash "$SCRIPTS/project-access.sh" "$pa" "$url" "$login" >/dev/null 2>&1 \
281
+ || warn "Assignee updated, but board write $pa failed — reconcile: bash scripts/project-access.sh $pa $url $login"
282
+ ok "${action}: owner $login on project #$num (anchor $repo#$inum + board $pa)"
283
+ }
284
+
285
+ # pid_for_project_number <num> → the registry project id whose github_project URL
286
+ # ends in /projects/<num>, or empty if the board isn't seeded locally yet.
287
+ pid_for_project_number() {
288
+ python3 - "$REGISTRY" "$1" <<'PY'
289
+ import sys, re, yaml
290
+ reg, num = sys.argv[1], sys.argv[2]
291
+ d = yaml.safe_load(open(reg)) or {}
292
+ for p in (d.get('projects') or []):
293
+ if not p: continue
294
+ m = re.search(r'/projects/(\d+)', p.get('github_project') or '')
295
+ if m and m.group(1) == num:
296
+ print(p.get('id', '')); break
297
+ PY
298
+ }
299
+
300
+ # i_can_work_project <project_number> [owner] → 0 if the current GitHub user is a
301
+ # project owner (anchor-issue assignee) OR is assigned to any of the board's issues
302
+ # (GitHub-definitive authorization for `work`, no cache).
303
+ i_can_work_project() {
304
+ local num="$1" owner="${2:-$GITHUB_ORG}" me o
305
+ me="$(gh api user --jq .login 2>/dev/null || echo "")"
306
+ [[ -z "$me" ]] && return 1
307
+ while IFS= read -r o; do [[ "$o" == "$me" ]] && return 0; done < <(project_owners "$num" "$owner")
308
+ gh api graphql -f query='query($o:String!,$n:Int!){ organization(login:$o){ projectV2(number:$n){ items(first:100){ nodes{ content{ ... on Issue { assignees(first:20){nodes{login}} } } } } } } }' \
309
+ -F o="$owner" -F n="$num" \
310
+ --jq '.data.organization.projectV2.items.nodes[].content.assignees.nodes[].login' 2>/dev/null \
311
+ | grep -qx "$me" && return 0
312
+ return 1
313
+ }
314
+
315
+ # i_can_write_board <project_number> [owner] → 0 if I have write access to the
316
+ # board (projectV2.viewerCanUpdate). Returns 0 (allow) when the check is
317
+ # indeterminate — e.g. a user-owned board where the org query yields null — so
318
+ # we never block on an unreadable signal; the downstream GitHub mutations
319
+ # (seed/create-task/merge/close, project-access grant) remain the hard gate.
320
+ i_can_write_board() {
321
+ local num="$1" owner="${2:-$GITHUB_ORG}" vcu
322
+ vcu=$(gh api graphql -f query='query($o:String!,$n:Int!){ organization(login:$o){ projectV2(number:$n){ viewerCanUpdate } } }' \
323
+ -F o="$owner" -F n="$num" --jq '.data.organization.projectV2.viewerCanUpdate' 2>/dev/null || echo "")
324
+ [[ "$vcu" == "false" ]] && return 1
325
+ return 0
326
+ }
327
+
328
+ # ensure_issue_repo <owner/repo> <project_id> <project_branch> [base]
329
+ # Repo-on-demand: if the issue's repo isn't in the project workspace, bring it in
330
+ # (clone/worktree on the project branch). No repos[]. base = DEFAULT_CODE_BRANCH.
331
+ ensure_issue_repo() {
332
+ local repo="$1" pid="$2" branch="$3" base="${4:-${DEFAULT_CODE_BRANCH:-dev}}"
333
+ local name dir
334
+ name="${repo##*/}"
335
+ dir="$AGENT_WORK_ROOT/$pid/$name"
336
+ if [[ -e "$dir/.git" ]]; then return 0; fi
337
+ info "Bringing in $repo on-demand (base $base) → $dir"
338
+ bash "$SCRIPTS/add-repo.sh" "$pid" "https://github.com/$repo" "dependency" \
339
+ "auto: brought in on-demand for an issue" "$base" 2>/dev/null \
340
+ || warn "Could not auto-add $repo (check access). It will be needed to work its issues."
341
+ }
342
+
343
+ # ── Registry-elimination derivation helpers (#57 / registry-elimination-spec) ──
344
+ # GitHub is the sole SoT; the registry survives only as a FROZEN legacy shim
345
+ # (grandfather: PRJ-001…013, whose ids/branches predate scheme B). Every helper
346
+ # below checks the shim FIRST (by board number → its stored field) and otherwise
347
+ # derives the value from GitHub. New projects are never in the shim → fully derived.
348
+ # These are pure/read-only and not yet wired into the live flows (Increment 1).
349
+
350
+ # slugify_str <text> — same rule as lib.sh slugify (prj does not source lib.sh).
351
+ slugify_str() {
352
+ echo "$1" | tr '[:upper:]' '[:lower:]' \
353
+ | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//'
354
+ }
355
+
356
+ # legacy_shim_field <board_number> <field> → the stored registry value for the
357
+ # legacy project on that board, or empty if the board isn't a legacy project.
358
+ # field ∈ id | branch | status | assigned_to (anything in the registry entry).
359
+ legacy_shim_field() {
360
+ python3 - "$REGISTRY" "$1" "$2" <<'PY'
361
+ import sys, re, yaml
362
+ reg, num, field = sys.argv[1], sys.argv[2], sys.argv[3]
363
+ d = yaml.safe_load(open(reg)) or {}
364
+ for p in (d.get('projects') or []):
365
+ if not p: continue
366
+ m = re.search(r'/projects/(\d+)', p.get('github_project') or '')
367
+ if m and m.group(1) == num:
368
+ v = p.get(field)
369
+ if v is not None: print(v)
370
+ break
371
+ PY
372
+ }
373
+
374
+ # gh_board_title <board_number> [owner] → the GitHub Project's title (for slug).
375
+ gh_board_title() {
376
+ local num="$1" owner="${2:-$GITHUB_ORG}"
377
+ gh api graphql -f query='query($o:String!,$n:Int!){ organization(login:$o){ projectV2(number:$n){ title } } }' \
378
+ -F o="$owner" -F n="$num" --jq '.data.organization.projectV2.title // ""' 2>/dev/null
379
+ }
380
+
381
+ # derive_project_id <board_number> [owner] → PRJ-<id>. Legacy → stored id;
382
+ # else PRJ-<number>-<slug(title)> (scheme B; id == branch for new projects).
383
+ derive_project_id() {
384
+ local num="$1" owner="${2:-$GITHUB_ORG}" id slug
385
+ id="$(legacy_shim_field "$num" id)"
386
+ [[ -n "$id" ]] && { echo "$id"; return; }
387
+ slug="$(slugify_str "$(gh_board_title "$num" "$owner")")"
388
+ [[ -z "$slug" ]] && return 1
389
+ echo "PRJ-${num}-${slug}"
390
+ }
391
+
392
+ # derive_project_branch <board_number> [owner] → branch. Legacy → stored branch;
393
+ # else the scheme-B branch, which for new projects equals the derived id.
394
+ derive_project_branch() {
395
+ local num="$1" owner="${2:-$GITHUB_ORG}" br
396
+ br="$(legacy_shim_field "$num" branch)"
397
+ [[ -n "$br" ]] && { echo "$br"; return; }
398
+ derive_project_id "$num" "$owner"
399
+ }
400
+
401
+ # anchor_has_label <board_number> <label> [owner] → 0 if the board's anchor issue
402
+ # carries <label>. Used by status derivation (paused / cancelled live as labels).
403
+ anchor_has_label() {
404
+ local num="$1" want="$2" owner="${3:-$GITHUB_ORG}" ref
405
+ ref="$(anchor_issue_ref "$num" "$owner")"
406
+ [[ -z "$ref" ]] && return 1
407
+ gh issue view "${ref##*#}" --repo "${ref%%#*}" --json labels \
408
+ --jq '.labels[].name' 2>/dev/null | grep -qx "$want"
409
+ }
410
+
411
+ # derive_project_status <board_number> [owner] → active|paused|completed|cancelled.
412
+ # Legacy → stored status (don't rewrite history). Else from GitHub:
413
+ # open board: anchor 'paused' → paused else active
414
+ # closed board: anchor 'cancelled'→ cancelled else completed
415
+ derive_project_status() {
416
+ local num="$1" owner="${2:-$GITHUB_ORG}" s closed
417
+ s="$(legacy_shim_field "$num" status)"
418
+ [[ -n "$s" ]] && { echo "$s"; return; }
419
+ closed="$(gh api graphql -f query='query($o:String!,$n:Int!){ organization(login:$o){ projectV2(number:$n){ closed } } }' \
420
+ -F o="$owner" -F n="$num" --jq '.data.organization.projectV2.closed' 2>/dev/null)"
421
+ if [[ "$closed" == "true" ]]; then
422
+ anchor_has_label "$num" cancelled "$owner" && echo "cancelled" || echo "completed"
423
+ else
424
+ anchor_has_label "$num" paused "$owner" && echo "paused" || echo "active"
425
+ fi
426
+ }
427
+
428
+ # derive_workspace_dir <board_number> [owner] → the project's local workspace dir.
429
+ # Legacy → $AGENT_WORK_ROOT/<stored-id> (dirs predate scheme B). Else the existing
430
+ # PRJ-<number>-* dir if one is present (rename-proof glob), else the derived path.
431
+ derive_workspace_dir() {
432
+ local num="$1" owner="${2:-$GITHUB_ORG}" id hit
433
+ id="$(legacy_shim_field "$num" id)"
434
+ [[ -n "$id" ]] && { echo "$AGENT_WORK_ROOT/$id"; return; }
435
+ for hit in "$AGENT_WORK_ROOT/PRJ-${num}-"*; do
436
+ [[ -d "$hit" ]] && { echo "$hit"; return; }
437
+ done
438
+ echo "$AGENT_WORK_ROOT/$(derive_project_id "$num" "$owner")"
439
+ }
440
+
441
+ # ensure_anchor_label <owner/repo> — create the 'anchor' label if it's missing
442
+ # (gh issue edit --add-label fails when the label doesn't exist in the repo).
443
+ ensure_anchor_label() {
444
+ gh label create "$ANCHOR_LABEL" --repo "$1" --color 5319e7 \
445
+ --description "Project scope/anchor issue — its assignees are the project owners" \
446
+ >/dev/null 2>&1 || true
447
+ }
448
+
449
+ # create_anchor_issue <board_number> <board_title> [owner] — create the project's
450
+ # scope/anchor issue in the workspace repo, label it 'anchor', assign the current
451
+ # user, and add it to the board. Echoes "owner/repo#number" (empty on failure).
452
+ create_anchor_issue() {
453
+ local num="$1" title="$2" owner="${3:-$GITHUB_ORG}"
454
+ local repo="$owner/$WORKSPACE_REPO" me url body
455
+ me="$(gh api user --jq .login 2>/dev/null || echo "")"
456
+ ensure_anchor_label "$repo"
457
+ body="Anchor issue for the project on GitHub Project #$num — *$title*.
458
+
459
+ Owners = this issue's assignees (managed via \`prj manage\` / \`prj work\`). Long-lived
460
+ scope marker for the project; closed at project close."
461
+ url="$(gh issue create --repo "$repo" --title "$title: project scope & anchor" \
462
+ --label "$ANCHOR_LABEL" ${me:+--assignee "$me"} --body "$body" 2>/dev/null | tail -1)"
463
+ [[ -z "$url" ]] && return 1
464
+ gh project item-add "$num" --owner "$owner" --url "$url" >/dev/null 2>&1 || true
465
+ echo "${repo}#${url##*/}"
466
+ }
467
+
468
+ # Verify git is configured with an author identity before any write op.
469
+ # Without both user.name and user.email, git commit fails deep in the
470
+ # flow with 'empty ident name' — confusing for users running ./prj
471
+ # interactively. Call this at the top of each write command.
472
+ require_git_identity() {
473
+ local missing=()
474
+ [[ -z "$(git config user.name 2>/dev/null)" ]] && missing+=("user.name")
475
+ [[ -z "$(git config user.email 2>/dev/null)" ]] && missing+=("user.email")
476
+ if [[ ${#missing[@]} -gt 0 ]]; then
477
+ local joined
478
+ joined=$(IFS='+'; echo "${missing[*]}")
479
+ joined=${joined//+/ + }
480
+ err "git $joined not set — required for commits this command will make."
481
+ echo ""
482
+ echo " Set them globally (one-time):"
483
+ for f in "${missing[@]}"; do
484
+ case "$f" in
485
+ user.name) echo " git config --global user.name 'Your Name'" ;;
486
+ user.email) echo " git config --global user.email 'you@example.com'" ;;
487
+ esac
488
+ done
489
+ echo ""
490
+ echo " Or for this repo only:"
491
+ for f in "${missing[@]}"; do
492
+ case "$f" in
493
+ user.name) echo " git -C $(pwd) config user.name 'Your Name'" ;;
494
+ user.email) echo " git -C $(pwd) config user.email 'you@example.com'" ;;
495
+ esac
496
+ done
497
+ echo ""
498
+ exit 1
499
+ fi
500
+ }
501
+
502
+ # ── Prompt helpers ────────────────────────────────────────────────────────────
503
+
504
+ # NOTE: helpers below use unique internal variable names (__rabort_val,
505
+ # __ask_val, __choice_val) to avoid shadowing the caller's __val via
506
+ # bash's dynamic scoping. printf -v writes to the closest local in scope —
507
+ # if both inner and outer scopes declare `local __val`, the inner wins
508
+ # and the read value never propagates back.
509
+
510
+ # Read a line from stdin or exit with a clear message on EOF (Ctrl-D, closed pipe).
511
+ # Sets the named variable. Returns 0 on success; calls exit on EOF.
512
+ _read_or_abort() {
513
+ local __varname="$1" __rabort_val
514
+ if ! IFS= read -r __rabort_val; then
515
+ echo ""
516
+ err "Aborted (no input)."
517
+ exit 1
518
+ fi
519
+ printf -v "$__varname" '%s' "$__rabort_val"
520
+ }
521
+
522
+ # ask <var_name> <prompt> [default]
523
+ ask() {
524
+ local __var="$1" __prompt="$2" __default="${3:-}" __ask_val
525
+ if [[ -n "$__default" ]]; then
526
+ printf "${BOLD}%s${NC} ${DIM}[%s]${NC}: " "$__prompt" "$__default"
527
+ else
528
+ printf "${BOLD}%s${NC}: " "$__prompt"
529
+ fi
530
+ _read_or_abort __ask_val
531
+ __ask_val="${__ask_val:-$__default}"
532
+ while [[ -z "$__ask_val" ]]; do
533
+ printf "${RED}Required.${NC} ${BOLD}%s${NC}: " "$__prompt"
534
+ _read_or_abort __ask_val
535
+ done
536
+ printf -v "$__var" '%s' "$__ask_val"
537
+ }
538
+
539
+ # ask_optional <var_name> <prompt> [default]
540
+ ask_optional() {
541
+ local __var="$1" __prompt="$2" __default="${3:-}" __ask_val
542
+ if [[ -n "$__default" ]]; then
543
+ printf "${BOLD}%s${NC} ${DIM}[%s]${NC}: " "$__prompt" "$__default"
544
+ else
545
+ printf "${BOLD}%s${NC} ${DIM}(optional)${NC}: " "$__prompt"
546
+ fi
547
+ _read_or_abort __ask_val
548
+ printf -v "$__var" '%s' "${__ask_val:-$__default}"
549
+ }
550
+
551
+ # ask_choice <var_name> <prompt> <option1> <option2> ...
552
+ # Always offers '0) ← back'; on 0/b/back the var is set to $BACK and the function
553
+ # returns 0 (callers must check is_back to step backward / return to the menu).
554
+ ask_choice() {
555
+ local __var="$1" __prompt="$2"; shift 2
556
+ local __opts=("$@") __i __choice_val
557
+ echo -e "${BOLD}$__prompt${NC}"
558
+ for __i in "${!__opts[@]}"; do
559
+ printf " ${CYAN}%d)${NC} %s\n" $((__i+1)) "${__opts[$__i]}"
560
+ done
561
+ printf " ${DIM}0) ← back${NC}\n"
562
+ while true; do
563
+ printf "Choose [1-%d, 0=back]: " "${#__opts[@]}"
564
+ _read_or_abort __choice_val
565
+ if [[ "$__choice_val" == "0" || "$__choice_val" == "b" || "$__choice_val" == "back" ]]; then
566
+ printf -v "$__var" '%s' "$BACK"; return
567
+ fi
568
+ if [[ "$__choice_val" =~ ^[0-9]+$ ]] && (( __choice_val >= 1 && __choice_val <= ${#__opts[@]} )); then
569
+ printf -v "$__var" '%s' "${__opts[$((__choice_val-1))]}"
570
+ return
571
+ fi
572
+ err "Invalid choice."
573
+ done
574
+ }
575
+
576
+ # confirm <prompt> — exits non-zero if user says anything other than yes,
577
+ # including EOF/empty input.
578
+ confirm() {
579
+ local _ans
580
+ printf "${YELLOW}%s${NC} ${DIM}[y/N]${NC}: " "$*"
581
+ if ! IFS= read -r _ans; then
582
+ echo ""
583
+ echo "Aborted (no input)."
584
+ exit 1
585
+ fi
586
+ if [[ "$_ans" != [yY] && "$_ans" != [yY][eE][sS] ]]; then
587
+ echo "Aborted."
588
+ exit 1
589
+ fi
590
+ }
591
+
592
+ # ── Project list helpers ──────────────────────────────────────────────────────
593
+
594
+ # select_project <var_name> [status_filter...] (no filter = all)
595
+ select_project() {
596
+ local __var="$1"; shift
597
+ local __filters=("$@")
598
+
599
+ local __projects
600
+ __projects=$(python3 - "$REGISTRY" "${__filters[@]:-}" <<'PY'
601
+ import sys, yaml
602
+ registry = sys.argv[1]
603
+ filters = sys.argv[2:]
604
+ c = yaml.safe_load(open(registry))
605
+ projects = c.get('projects') or []
606
+ for p in projects:
607
+ if not p:
608
+ continue
609
+ if not filters or p.get('status') in filters:
610
+ print(f"{p['id']}|{p.get('status','?')}")
611
+ PY
612
+ )
613
+
614
+ if [[ -z "$__projects" ]]; then
615
+ err "No matching projects found."
616
+ exit 1
617
+ fi
618
+
619
+ local __ids=()
620
+ while IFS='|' read -r id status; do
621
+ __ids+=("$id")
622
+ done <<< "$__projects"
623
+
624
+ echo -e "${BOLD}Select project:${NC}"
625
+ for i in "${!__ids[@]}"; do
626
+ printf " ${CYAN}%d)${NC} %s ${DIM}(%s)${NC}\n" $((i+1)) \
627
+ "${__ids[$i]}" \
628
+ "$(echo "$__projects" | sed -n "$((i+1))p" | cut -d'|' -f2)"
629
+ done
630
+ printf " ${DIM}0) ← back${NC}\n"
631
+
632
+ local __choice
633
+ while true; do
634
+ printf "Choose [1-%d, 0=back]: " "${#__ids[@]}"
635
+ read -r __choice
636
+ if [[ "$__choice" == "0" || "$__choice" == "b" || "$__choice" == "back" ]]; then
637
+ printf -v "$__var" '%s' "$BACK"; return
638
+ fi
639
+ if [[ "$__choice" =~ ^[0-9]+$ ]] && (( __choice >= 1 && __choice <= ${#__ids[@]} )); then
640
+ printf -v "$__var" '%s' "${__ids[$((__choice-1))]}"
641
+ return
642
+ fi
643
+ err "Invalid choice."
644
+ done
645
+ }
646
+
647
+ # select_project_ctx <var> <assignment> <lifecycle> — navigation-context picker (#57).
648
+ # Filters via project_context_list (lib.sh): assignment ∈ me|unassigned|any,
649
+ # lifecycle ∈ not-initiated|initiated|paused|ongoing|done|any. Sets <var> to the
650
+ # chosen project id (or, for not-initiated rows, the GitHub project URL).
651
+ select_project_ctx() {
652
+ local __var="$1" __assignment="$2" __lifecycle="$3"
653
+ local __rows __vals=() __labels=()
654
+ __rows=$(project_context_list "$__assignment" "$__lifecycle")
655
+ if [[ -z "$__rows" ]]; then
656
+ err "No projects match (assignment=$__assignment, lifecycle=$__lifecycle)."
657
+ exit 1
658
+ fi
659
+ local id url status who
660
+ while IFS=$'\t' read -r id url status who; do
661
+ [[ -z "$id$url" ]] && continue
662
+ if [[ "$id" == "(not seeded)" ]]; then
663
+ __vals+=("$url"); __labels+=("$url ${DIM}($status)${NC}")
664
+ else
665
+ __vals+=("$id"); __labels+=("$id ${DIM}($status${who:+, $who})${NC}")
666
+ fi
667
+ done <<< "$__rows"
668
+ echo -e "${BOLD}Select project:${NC}"
669
+ local i
670
+ for i in "${!__labels[@]}"; do
671
+ printf " ${CYAN}%d)${NC} %b\n" $((i+1)) "${__labels[$i]}"
672
+ done
673
+ printf " ${DIM}0) ← back${NC}\n"
674
+ local __choice
675
+ while true; do
676
+ printf "Choose [1-%d, 0=back]: " "${#__vals[@]}"
677
+ read -r __choice || { err "No input."; exit 1; }
678
+ if [[ "$__choice" == "0" || "$__choice" == "b" || "$__choice" == "back" ]]; then
679
+ printf -v "$__var" '%s' "$BACK"; return
680
+ fi
681
+ if [[ "$__choice" =~ ^[0-9]+$ ]] && (( __choice >= 1 && __choice <= ${#__vals[@]} )); then
682
+ printf -v "$__var" '%s' "${__vals[$((__choice-1))]}"
683
+ return
684
+ fi
685
+ err "Invalid choice."
686
+ done
687
+ }
688
+
689
+ # select_task <var_name> <project_id>
690
+ # Tasks-on-board: active tasks are OPEN (non-Done) issues on the project board.
691
+ # Sets <var_name> to the chosen issue URL (merge-task derives the sub-branch).
692
+ select_task() {
693
+ local __var="$1" __pid="$2"
694
+ local __pf="$SCRIPT_DIR/projects/$__pid/project.yaml"
695
+ local __url
696
+ __url=$(python3 -c "import yaml; print(yaml.safe_load(open('$__pf')).get('github_project') or '')" 2>/dev/null)
697
+ if [[ -z "$__url" || "$__url" == "~" ]]; then
698
+ err "No github_project on $__pid (run merge from the project's PRJ_GOV clone)."
699
+ exit 1
700
+ fi
701
+ local __num __owner
702
+ __num=$(echo "$__url" | grep -oE '/projects/[0-9]+' | grep -oE '[0-9]+')
703
+ if echo "$__url" | grep -q '/orgs/'; then
704
+ __owner=$(echo "$__url" | sed 's|.*/orgs/\([^/]*\)/.*|\1|')
705
+ else
706
+ __owner=$(echo "$__url" | sed 's|.*/users/\([^/]*\)/.*|\1|')
707
+ fi
708
+ local __rows
709
+ __rows=$(gh project item-list "$__num" --owner "$__owner" --format json --limit 200 2>/dev/null | python3 -c "
710
+ import sys, json
711
+ try: d = json.load(sys.stdin)
712
+ except Exception: sys.exit(0)
713
+ for i in d.get('items', []):
714
+ c = i.get('content') or {}
715
+ if c.get('type') == 'Issue' and str(i.get('status','')).strip().lower() != 'done':
716
+ u = c.get('url'); t = c.get('title','')
717
+ if u: print(u + '\t' + t)
718
+ ")
719
+ if [[ -z "$__rows" ]]; then
720
+ err "No active (open) task issues on the board for $__pid."
721
+ exit 1
722
+ fi
723
+
724
+ local __urls=() __titles=()
725
+ while IFS=$'\t' read -r u t; do
726
+ [[ -n "$u" ]] && __urls+=("$u") && __titles+=("$t")
727
+ done <<< "$__rows"
728
+
729
+ echo -e "${BOLD}Select task (open issue):${NC}"
730
+ for i in "${!__urls[@]}"; do
731
+ printf " ${CYAN}%d)${NC} %s ${DIM}%s${NC}\n" $((i+1)) "${__titles[$i]}" "${__urls[$i]}"
732
+ done
733
+ printf " ${DIM}0) ← back${NC}\n"
734
+
735
+ local __choice
736
+ while true; do
737
+ printf "Choose [1-%d, 0=back]: " "${#__urls[@]}"
738
+ read -r __choice
739
+ if [[ "$__choice" == "0" || "$__choice" == "b" || "$__choice" == "back" ]]; then
740
+ printf -v "$__var" '%s' "$BACK"; return
741
+ fi
742
+ if [[ "$__choice" =~ ^[0-9]+$ ]] && (( __choice >= 1 && __choice <= ${#__urls[@]} )); then
743
+ printf -v "$__var" '%s' "${__urls[$((__choice-1))]}"
744
+ return
745
+ fi
746
+ err "Invalid choice."
747
+ done
748
+ }
749
+
750
+ # ── Commands ──────────────────────────────────────────────────────────────────
751
+
752
+ cmd_init() {
753
+ header "Initialize New Project"
754
+ divider
755
+ echo ""
756
+
757
+ # Ask for GitHub owner, defaulting to config value
758
+ ask GH_OWNER "GitHub org or username" "$GITHUB_ORG"
759
+ echo ""
760
+ label "Fetching GitHub Projects for ${GH_OWNER}..."
761
+
762
+ # Fetch projects — gh project list works for both orgs and users
763
+ PROJECTS_JSON=$(gh project list --owner "$GH_OWNER" --format json 2>/dev/null) \
764
+ || PROJECTS_JSON=""
765
+
766
+ PROJECT_COUNT=$(echo "$PROJECTS_JSON" | python3 -c \
767
+ "import sys,json; print(len(json.load(sys.stdin).get('projects',[])))" 2>/dev/null || echo "0")
768
+
769
+ if [[ -z "$PROJECTS_JSON" ]] || [[ "$PROJECT_COUNT" == "0" ]]; then
770
+ err "No open GitHub Projects found for '$GH_OWNER'."
771
+ echo ""
772
+ label "Possible reasons:"
773
+ label " • Projects exist but are closed — check: gh project list --owner $GH_OWNER --closed"
774
+ label " • Wrong owner — try the org name instead of a username (or vice versa)"
775
+ label " • No projects created yet — create one at: https://github.com/orgs/${GH_OWNER}/projects/new"
776
+ echo ""
777
+ confirm "Try a different owner?"
778
+ cmd_init
779
+ return
780
+ fi
781
+
782
+ # Parse and display projects (bash 3.2 compatible — no mapfile)
783
+ local TITLES=() URLS=() NUMS=()
784
+ while IFS= read -r line; do TITLES+=("$line"); done < <(echo "$PROJECTS_JSON" | python3 -c "
785
+ import sys, json
786
+ ps = json.load(sys.stdin).get('projects', [])
787
+ for p in ps: print(p.get('title', '(untitled)'))
788
+ ")
789
+ while IFS= read -r line; do URLS+=("$line"); done < <(echo "$PROJECTS_JSON" | python3 -c "
790
+ import sys, json
791
+ ps = json.load(sys.stdin).get('projects', [])
792
+ for p in ps: print(p.get('url', ''))
793
+ ")
794
+ while IFS= read -r line; do NUMS+=("$line"); done < <(echo "$PROJECTS_JSON" | python3 -c "
795
+ import sys, json
796
+ ps = json.load(sys.stdin).get('projects', [])
797
+ for p in ps: print(p.get('number', ''))
798
+ ")
799
+
800
+ echo -e "${BOLD}Select a GitHub Project to initialize:${NC}"
801
+ for i in "${!TITLES[@]}"; do
802
+ printf " ${CYAN}%d)${NC} %s\n" $((i+1)) "${TITLES[$i]}"
803
+ done
804
+ echo ""
805
+
806
+ local CHOICE
807
+ while true; do
808
+ printf "Choose [1-%d]: " "${#TITLES[@]}"
809
+ read -r CHOICE
810
+ if [[ "$CHOICE" =~ ^[0-9]+$ ]] && (( CHOICE >= 1 && CHOICE <= ${#TITLES[@]} )); then
811
+ break
812
+ fi
813
+ err "Invalid choice."
814
+ done
815
+
816
+ local SELECTED_TITLE="${TITLES[$((CHOICE-1))]}"
817
+ local SELECTED_URL="${URLS[$((CHOICE-1))]}"
818
+
819
+ # ── Phase 3 (ADR-0001): authorization = GitHub Project access ──────────────
820
+ # The seeder must have write access to the project's GitHub Project board (an
821
+ # owner grants it via './prj manage assign'). seed.sh enforces this
822
+ # authoritatively; here we check early for a friendly message. We no longer
823
+ # consult YAML pre_assignments — GitHub access is the source of truth.
824
+ local PNUM VCU
825
+ PNUM=$(printf '%s' "$SELECTED_URL" | sed -nE 's#.*/projects/([0-9]+).*#\1#p')
826
+ VCU=$(gh api graphql -f query="query{ organization(login:\"$GH_OWNER\"){ projectV2(number:$PNUM){ viewerCanUpdate } } }" \
827
+ --jq '.data.organization.projectV2.viewerCanUpdate' 2>/dev/null || echo "")
828
+ if [[ "$VCU" == "false" ]]; then
829
+ err "You don't have write access to this project's GitHub Project board."
830
+ err "Ask an owner to grant access (./prj manage assign), then retry."
831
+ exit 1
832
+ fi # VCU empty = couldn't check (e.g. user project); seed.sh is the gate.
833
+
834
+ echo ""
835
+ echo -e " ${BOLD}Project:${NC} $SELECTED_TITLE"
836
+ echo -e " ${BOLD}URL:${NC} $SELECTED_URL"
837
+ echo ""
838
+
839
+ # The recorded assignee is a display/audit cache (authorization is GitHub
840
+ # access). Default to the seeder's GitHub login; accept a login or @team.
841
+ local CURRENT_LOGIN
842
+ CURRENT_LOGIN=$(gh api user --jq .login 2>/dev/null || echo "")
843
+ ask ASSIGNEE "Assignee (GitHub login or @team)" "${CURRENT_LOGIN:-$CURRENT_USER}"
844
+ echo ""
845
+ confirm "Initialize '$SELECTED_TITLE' assigned to '$ASSIGNEE'?"
846
+ echo ""
847
+ bash "$SCRIPTS/seed.sh" "$SELECTED_URL" "$ASSIGNEE" || return 1
848
+
849
+ # #57: resolve the just-seeded project, then offer to launch the chosen agent
850
+ # in its worktree with the session-start protocol pre-run.
851
+ local NEW_PID NEW_DIR
852
+ NEW_PID=$(python3 -c "import yaml; d=yaml.safe_load(open('$REGISTRY')) or {}; print(next((p['id'] for p in (d.get('projects') or []) if p and p.get('github_project')=='$SELECTED_URL'), ''))" 2>/dev/null)
853
+ if [[ -n "$NEW_PID" ]]; then
854
+ NEW_DIR="$(org_gov_clone "$NEW_PID")"
855
+ prompt_and_launch_agent "$NEW_DIR" "$NEW_PID"
856
+ fi
857
+ }
858
+
859
+ cmd_join() {
860
+ header "Join Project"
861
+ divider
862
+ label "Clone your own PRJ_GOV + code repos for an existing project (team member)."
863
+ echo ""
864
+ select_project_ctx PROJECT_ID me ongoing # join = my active/paused projects (#57)
865
+ is_back "$PROJECT_ID" && return
866
+ echo ""
867
+ confirm "Join '$PROJECT_ID' — clone gov + code repos on its branch?"
868
+ echo ""
869
+ bash "$SCRIPTS/join.sh" "$PROJECT_ID"
870
+ }
871
+
872
+ # ── Phase-1 developer verb facade (ADR-0001) ─────────────────────────────────
873
+ # A smaller, work-focused vocabulary layered over the existing lifecycle
874
+ # commands. The old verbs keep working unchanged; these just give developers
875
+ # one front door so they don't have to know join vs init vs task vs merge vs
876
+ # close. No topology change yet — worktrees / GitHub-as-source-of-truth land
877
+ # in later ADR-0001 phases.
878
+
879
+ cmd_start() {
880
+ header "Start Work"
881
+ divider
882
+ label "One front door to get to work — routes to the right lifecycle command."
883
+ echo ""
884
+ local __what
885
+ ask_choice __what "What do you want to start?" \
886
+ "Join a project I'm assigned to" \
887
+ "Start a task (parallel work on a GitHub issue)" \
888
+ "Initialize a brand-new project"
889
+ is_back "$__what" && return
890
+ echo ""
891
+ case "$__what" in
892
+ "Join a project I'm assigned to") cmd_join ;;
893
+ "Start a task (parallel work on a GitHub issue)") cmd_task ;;
894
+ "Initialize a brand-new project") cmd_init ;;
895
+ esac
896
+ }
897
+
898
+ # cmd_work — GitHub-definitive Work/Finish front door (#57 redesign §4).
899
+ # Work: select board → owner-or-assignee gate → ensure seeded+worktree →
900
+ # Existing branch (switch+sync) or New branch (pick issue[s], create
901
+ # task branch, repo-on-demand) → launch agent with session-start.
902
+ # Finish: delegated to cmd_finish (task submit / project close).
903
+ # cmd_work — project-first front door (redesigned per the user flow; work now
904
+ # absorbs init/join/task/finish):
905
+ # STEP 1 pick an open GitHub Project to work on.
906
+ # STEP 1.1 ensure it has an anchor issue WITH an owner (create/designate +
907
+ # claim ownership if not).
908
+ # STEP 1.2 initiated (seeded + cloned) ?
909
+ # 1.2.1 NO → seed/clone all the way → launch agent + session-start. END.
910
+ # 1.2.2 YES → pick a branch (project branch + task branches).
911
+ # 1.2.3 → Continue (open in agent) / Task (new task branch) /
912
+ # Finish (project branch → close, else → merge this task).
913
+ # Back-navigable: every step offers '0) ← back'; state persists in outer locals.
914
+ cmd_work() {
915
+ header "Work"
916
+ divider
917
+ echo ""
918
+ local PNUM PTITLE PID DIR BRANCH SEL ACT me yn
919
+ local step=pick
920
+ while true; do
921
+ case "$step" in
922
+
923
+ pick) # STEP 1 — choose a project (all open GitHub Projects)
924
+ _pick_open_project PNUM PTITLE
925
+ is_back "$PNUM" && return
926
+ # Early authorization: fail fast with a friendly message if you can't write
927
+ # the board, rather than deep in seed/anchor/merge. Indeterminate = allow.
928
+ if ! i_can_write_board "$PNUM"; then
929
+ err "You don't have write access to '$PTITLE' (its GitHub Project board)."
930
+ err "Ask an owner to grant access (./prj manage), then retry."
931
+ echo ""; step=pick; continue
932
+ fi
933
+ echo ""
934
+ step=anchor ;;
935
+
936
+ anchor) # STEP 1.1 — ensure an anchor issue WITH an owner
937
+ local ref owners how
938
+ ref="$(anchor_issue_ref "$PNUM")"
939
+ owners="$(project_owners "$PNUM")"
940
+ if [[ -n "$ref" && -n "$owners" ]]; then
941
+ info "Anchor: $ref · owner(s): $(echo "$owners" | paste -sd ',' - | sed 's/,/, /g')"
942
+ else
943
+ warn "'$PTITLE' has no anchor issue with an owner yet — let's set it up."
944
+ if [[ -z "$ref" ]]; then
945
+ ask_choice how "Set up its scope/anchor issue:" \
946
+ "Create a new scope/anchor issue" "Designate an existing board issue"
947
+ is_back "$how" && { echo ""; step=pick; continue; }
948
+ if [[ "$how" == Create* ]]; then
949
+ ref="$(create_anchor_issue "$PNUM" "$PTITLE")"
950
+ [[ -z "$ref" ]] && { err "Could not create the anchor issue (check access)."; return 1; }
951
+ ok "Anchor created: $ref"
952
+ else
953
+ _pick_board_issue ref "$PNUM"
954
+ is_back "$ref" && { echo ""; step=pick; continue; }
955
+ ensure_anchor_label "${ref%%#*}"
956
+ gh issue edit "${ref##*#}" --repo "${ref%%#*}" --add-label "$ANCHOR_LABEL" >/dev/null 2>&1 \
957
+ && ok "Designated $ref as the anchor." || { err "Could not label $ref."; return 1; }
958
+ fi
959
+ fi
960
+ owners="$(project_owners "$PNUM")"
961
+ if [[ -z "$owners" ]]; then
962
+ me="$(gh api user --jq .login 2>/dev/null || echo "")"
963
+ [[ -z "$me" ]] && { err "Could not resolve your GitHub login."; return 1; }
964
+ ask_choice yn "Make you ('$me') the owner of '$PTITLE'?" "Yes — claim ownership"
965
+ is_back "$yn" && { echo ""; step=pick; continue; }
966
+ set_owner add "$PNUM" "$me"
967
+ fi
968
+ fi
969
+ echo ""
970
+ step=route ;;
971
+
972
+ route) # STEP 1.2 — initiated (seeded + cloned locally) or not?
973
+ PID="$(pid_for_project_number "$PNUM")"
974
+ if [[ -z "$PID" ]]; then
975
+ # 1.2.1 NOT initiated → seed all the way to the agent + session-start.
976
+ require_git_identity
977
+ me="$(gh api user --jq .login 2>/dev/null || echo "$CURRENT_USER")"
978
+ info "Initializing '$PTITLE' (seed → branch → clone)…"
979
+ bash "$SCRIPTS/seed.sh" "https://github.com/orgs/$GITHUB_ORG/projects/$PNUM" "$me" || return 1
980
+ PID="$(pid_for_project_number "$PNUM")"
981
+ [[ -z "$PID" ]] && { err "Seed did not register the project."; return 1; }
982
+ DIR="$(org_gov_clone "$PID")"
983
+ prompt_and_launch_agent "$DIR" "$PID"
984
+ return
985
+ fi
986
+ DIR="$(org_gov_clone "$PID")"
987
+ BRANCH="$(project_branch "$PID")"
988
+ if [[ ! -e "$DIR/.git" ]]; then
989
+ # Seeded by someone, but no local clone yet → clone all the way + launch.
990
+ require_git_identity
991
+ info "Cloning your workspace for '$PID'…"
992
+ bash "$SCRIPTS/join.sh" "$PID" || return 1
993
+ prompt_and_launch_agent "$DIR" "$PID"
994
+ return
995
+ fi
996
+ echo ""
997
+ step=branch ;;
998
+
999
+ branch) # STEP 1.2.2 — pick a branch (project branch + task branches)
1000
+ local brs=() b i c
1001
+ while IFS= read -r b; do [[ -n "$b" ]] && brs+=("$b"); done < <(
1002
+ git -C "$DIR" for-each-ref --format='%(refname:short)' \
1003
+ "refs/heads/$BRANCH" "refs/heads/$BRANCH.*" 2>/dev/null)
1004
+ if [[ ${#brs[@]} -eq 0 ]]; then err "No local branches for '$PID'."; echo ""; step=pick; continue; fi
1005
+ echo -e "${BOLD}Select branch:${NC}"
1006
+ for i in "${!brs[@]}"; do
1007
+ if [[ "${brs[$i]}" == "$BRANCH" ]]; then
1008
+ printf " ${CYAN}%d)${NC} %s ${DIM}(project branch)${NC}\n" $((i+1)) "${brs[$i]}"
1009
+ else
1010
+ printf " ${CYAN}%d)${NC} %s\n" $((i+1)) "${brs[$i]}"
1011
+ fi
1012
+ done
1013
+ printf " ${DIM}0) ← back${NC}\n"
1014
+ SEL=""
1015
+ while true; do
1016
+ printf "Choose [1-%d, 0=back]: " "${#brs[@]}"; _read_or_abort c
1017
+ if [[ "$c" == "0" || "$c" == "b" || "$c" == "back" ]]; then SEL="$BACK"; break; fi
1018
+ if [[ "$c" =~ ^[0-9]+$ ]] && (( c>=1 && c<=${#brs[@]} )); then SEL="${brs[$((c-1))]}"; break; fi
1019
+ err "Invalid choice."
1020
+ done
1021
+ is_back "$SEL" && { echo ""; step=pick; continue; }
1022
+ echo ""
1023
+ step=action ;;
1024
+
1025
+ action) # STEP 1.2.3 — Continue / Task / Finish on the selected branch
1026
+ local fin_label
1027
+ if [[ "$SEL" == "$BRANCH" ]]; then fin_label="Finish — close the project"; else fin_label="Finish — merge this task into the project branch"; fi
1028
+ ask_choice ACT "On '$SEL' — what next?" \
1029
+ "Continue — open this branch in an agent" \
1030
+ "Task — start a new task branch (pick issue[s])" \
1031
+ "$fin_label"
1032
+ is_back "$ACT" && { echo ""; step=branch; continue; }
1033
+ echo ""
1034
+ case "$ACT" in
1035
+ Continue*)
1036
+ git -C "$DIR" checkout "$SEL" >/dev/null 2>&1 || { err "Could not switch to '$SEL'."; return 1; }
1037
+ ok "On branch '$SEL'."
1038
+ prompt_and_launch_agent "$DIR" "$PID"
1039
+ return ;;
1040
+ Task*)
1041
+ # New task branch — pick open board issue(s) without a local task branch.
1042
+ local refs=() titles=() ref title n sel picks=() tok urls=() bad=0 r joined
1043
+ while IFS=$'\t' read -r ref title; do
1044
+ [[ -z "$ref" ]] && continue
1045
+ n="${ref##*#}"
1046
+ git -C "$DIR" rev-parse --verify "refs/heads/$BRANCH.ISSUE-$n" &>/dev/null && continue
1047
+ refs+=("$ref"); titles+=("$title")
1048
+ done < <(
1049
+ gh api graphql -f query='query($o:String!,$n:Int!){ organization(login:$o){ projectV2(number:$n){ items(first:100){ nodes{ content{ ... on Issue { number title state repository{nameWithOwner} } } } } } } }' \
1050
+ -F o="$GITHUB_ORG" -F n="$PNUM" \
1051
+ --jq '.data.organization.projectV2.items.nodes[].content | select(.number) | select(.state=="OPEN") | "\(.repository.nameWithOwner)#\(.number)\t\(.title)"' 2>/dev/null)
1052
+ if [[ ${#refs[@]} -eq 0 ]]; then err "No open board issues without a local branch."; echo ""; step=action; continue; fi
1053
+ echo -e "${BOLD}Select issue(s)${NC} ${DIM}— one, or several (e.g. 1,3) for a combined branch:${NC}"
1054
+ for i in "${!refs[@]}"; do printf " ${CYAN}%d)${NC} %s ${DIM}%s${NC}\n" $((i+1)) "${titles[$i]}" "${refs[$i]}"; done
1055
+ printf " ${DIM}0) ← back${NC}\n"
1056
+ ask_optional sel "Choose [1-${#refs[@]}, 0=back]" ""
1057
+ if [[ -z "$sel" || "$sel" == "0" || "$sel" == "b" || "$sel" == "back" ]]; then echo ""; step=action; continue; fi
1058
+ IFS=', ' read -ra picks <<< "$sel"
1059
+ for tok in "${picks[@]}"; do
1060
+ [[ -z "$tok" ]] && continue
1061
+ if ! [[ "$tok" =~ ^[0-9]+$ ]] || (( tok<1 || tok>${#refs[@]} )); then err "Invalid selection: '$tok'."; bad=1; break; fi
1062
+ r="${refs[$((tok-1))]}"; urls+=("https://github.com/${r%%#*}/issues/${r##*#}")
1063
+ done
1064
+ (( bad )) && { echo ""; step=action; continue; }
1065
+ [[ ${#urls[@]} -eq 0 ]] && { err "Nothing selected."; echo ""; step=action; continue; }
1066
+ require_git_identity
1067
+ me="$(gh api user --jq .login 2>/dev/null || echo "$CURRENT_USER")"
1068
+ joined="$(IFS=,; echo "${urls[*]}")"
1069
+ ask_choice yn "Create a task branch for ${#urls[@]} issue(s) and assign to '$me'?" "Yes — create the branch"
1070
+ is_back "$yn" && { echo ""; step=action; continue; }
1071
+ echo ""
1072
+ bash "$SCRIPTS/create-task.sh" "$PID" "$joined" "$me" || return 1
1073
+ prompt_and_launch_agent "$DIR" "$PID"
1074
+ return ;;
1075
+ Finish*)
1076
+ if [[ "$SEL" == "$BRANCH" ]]; then
1077
+ ask_choice yn "Close project '$PID'? Merges all branches to base + triggers close-knowledge." "Yes — close the project"
1078
+ is_back "$yn" && { echo ""; step=action; continue; }
1079
+ echo ""
1080
+ bash "$SCRIPTS/close-project.sh" "$PID"
1081
+ else
1082
+ ask_choice yn "Merge task branch '$SEL' into '$BRANCH' and close its issue(s)?" "Yes — merge the task"
1083
+ is_back "$yn" && { echo ""; step=action; continue; }
1084
+ echo ""
1085
+ bash "$SCRIPTS/merge-task.sh" "$PID" "$SEL"
1086
+ fi
1087
+ return ;;
1088
+ esac ;;
1089
+
1090
+ esac
1091
+ done
1092
+ }
1093
+
1094
+ cmd_finish() {
1095
+ # 'finish' is context-aware: finishing a *task* hands it back to the project
1096
+ # branch (merge); finishing the *project* runs the governance close gate
1097
+ # (unchanged). The old 'submit'/'merge'/'close' verbs still work underneath.
1098
+ header "Finish"
1099
+ divider
1100
+ label "Finish a task (hand it back) or finish the whole project (close)."
1101
+ echo ""
1102
+ local __what
1103
+ ask_choice __what "What are you finishing?" \
1104
+ "A task — submit it back to the project branch" \
1105
+ "The whole project — close it (governance gate)"
1106
+ is_back "$__what" && return
1107
+ echo ""
1108
+ case "$__what" in
1109
+ "A task — submit it back to the project branch") cmd_merge ;;
1110
+ "The whole project — close it (governance gate)") cmd_close ;;
1111
+ esac
1112
+ }
1113
+
1114
+ cmd_task() {
1115
+ header "Create Task"
1116
+ divider
1117
+ label "Creates a sub-branch for parallel work on a specific GitHub Issue."
1118
+ echo ""
1119
+ select_project_ctx PROJECT_ID me initiated
1120
+ is_back "$PROJECT_ID" && return
1121
+ echo ""
1122
+ ask ISSUE_URL "GitHub Issue URL"
1123
+ ask ASSIGNEE "Assignee email" "$CURRENT_USER"
1124
+ echo ""
1125
+ confirm "Create task for issue '$ISSUE_URL' assigned to '$ASSIGNEE'?"
1126
+ echo ""
1127
+ bash "$SCRIPTS/create-task.sh" "$PROJECT_ID" "$ISSUE_URL" "$ASSIGNEE"
1128
+ }
1129
+
1130
+ cmd_merge() {
1131
+ header "Merge Task"
1132
+ divider
1133
+ label "Merges a completed task sub-branch back to the project branch and closes the issue."
1134
+ echo ""
1135
+ select_project_ctx PROJECT_ID me initiated
1136
+ is_back "$PROJECT_ID" && return
1137
+ echo ""
1138
+ select_task ISSUE_URL "$PROJECT_ID"
1139
+ is_back "$ISSUE_URL" && return
1140
+ echo ""
1141
+ confirm "Merge the task for issue '$ISSUE_URL' into the project branch?"
1142
+ echo ""
1143
+ bash "$SCRIPTS/merge-task.sh" "$PROJECT_ID" "$ISSUE_URL"
1144
+ }
1145
+
1146
+ cmd_pause() {
1147
+ header "Pause Project"
1148
+ divider
1149
+ label "Commits and pushes all changes, then marks the project as paused."
1150
+ echo ""
1151
+ select_project_ctx PROJECT_ID me initiated
1152
+ is_back "$PROJECT_ID" && return
1153
+ echo ""
1154
+ confirm "Pause '$PROJECT_ID'?"
1155
+ echo ""
1156
+ bash "$SCRIPTS/pause.sh" "$PROJECT_ID"
1157
+ }
1158
+
1159
+ cmd_resume() {
1160
+ header "Resume Project"
1161
+ divider
1162
+ label "Syncs all branches with their base, then marks the project as active."
1163
+ echo ""
1164
+ select_project_ctx PROJECT_ID me paused # resume = my paused projects (#57)
1165
+ is_back "$PROJECT_ID" && return
1166
+ echo ""
1167
+ confirm "Resume '$PROJECT_ID'? (base branches will be merged in)"
1168
+ echo ""
1169
+ bash "$SCRIPTS/resume.sh" "$PROJECT_ID"
1170
+ }
1171
+
1172
+ cmd_sync() {
1173
+ header "Sync Project"
1174
+ divider
1175
+ label "Merges latest base branches into all project branches mid-project."
1176
+ echo ""
1177
+ select_project_ctx PROJECT_ID me initiated
1178
+ is_back "$PROJECT_ID" && return
1179
+ echo ""
1180
+ confirm "Sync '$PROJECT_ID' with latest base branches?"
1181
+ echo ""
1182
+ bash "$SCRIPTS/sync.sh" "$PROJECT_ID"
1183
+ }
1184
+
1185
+ cmd_add_repo() {
1186
+ header "Add Repository to Project"
1187
+ divider
1188
+ label "Adds a new repo to an active project when scope expands."
1189
+ echo ""
1190
+ select_project_ctx PROJECT_ID me initiated
1191
+ is_back "$PROJECT_ID" && return
1192
+ echo ""
1193
+ ask REPO_URL "Repository URL"
1194
+ ask_choice ROLE "Role for this repo" "primary" "dependency" "read-only"
1195
+ is_back "$ROLE" && return
1196
+ ask ADDED_REASON "Why is this repo being added?"
1197
+ ask_optional BASE_BRANCH "Base branch" "dev"
1198
+ echo ""
1199
+ confirm "Add '$REPO_URL' ($ROLE) to '$PROJECT_ID'?"
1200
+ echo ""
1201
+ bash "$SCRIPTS/add-repo.sh" "$PROJECT_ID" "$REPO_URL" "$ROLE" "$ADDED_REASON" "${BASE_BRANCH:-dev}"
1202
+ }
1203
+
1204
+ cmd_cancel() {
1205
+ header "Cancel Project"
1206
+ divider
1207
+ warn "Cancellation archives all branches. No code is merged. This cannot be undone."
1208
+ echo ""
1209
+ select_project_ctx PROJECT_ID me ongoing # cancel = my active/paused projects (#57)
1210
+ is_back "$PROJECT_ID" && return
1211
+ echo ""
1212
+ ask REASON "Cancellation reason (required — C01)"
1213
+ echo ""
1214
+ confirm "Cancel '$PROJECT_ID'? All branches will be archived, not merged."
1215
+ echo ""
1216
+ bash "$SCRIPTS/cancel.sh" "$PROJECT_ID" "$REASON"
1217
+ }
1218
+
1219
+ cmd_close() {
1220
+ header "Close Project"
1221
+ divider
1222
+ label "Merges all branches to base, archives them, and triggers a knowledge close PR."
1223
+ echo ""
1224
+ select_project_ctx PROJECT_ID me initiated
1225
+ is_back "$PROJECT_ID" && return
1226
+ echo ""
1227
+ confirm "Close '$PROJECT_ID'? This will merge all branches and trigger close-knowledge."
1228
+ echo ""
1229
+ bash "$SCRIPTS/close-project.sh" "$PROJECT_ID"
1230
+ }
1231
+
1232
+ cmd_knowledge() {
1233
+ header "Propose Org Knowledge"
1234
+ divider
1235
+ label "Creates a knowledge branch for ad-hoc org knowledge proposals."
1236
+ echo ""
1237
+
1238
+ local SUB_CMD
1239
+ ask_choice SUB_CMD "What would you like to do?" \
1240
+ "Create a new knowledge proposal branch" \
1241
+ "Submit PR for an existing knowledge branch" \
1242
+ "Archive a merged knowledge branch"
1243
+ is_back "$SUB_CMD" && return
1244
+
1245
+ case "$SUB_CMD" in
1246
+ "Create a new knowledge proposal branch")
1247
+ ask SLUG "Branch slug (e.g. api-design-patterns)"
1248
+ ask DESCRIPTION "One-line description of what's being proposed"
1249
+ echo ""
1250
+ confirm "Create branch 'knowledge-$SLUG'?"
1251
+ echo ""
1252
+ bash "$SCRIPTS/propose-knowledge.sh" "$SLUG" "$DESCRIPTION"
1253
+ ;;
1254
+ "Submit PR for an existing knowledge branch")
1255
+ ask SLUG "Branch slug (the part after 'knowledge-')"
1256
+ ask DESCRIPTION "PR description"
1257
+ echo ""
1258
+ confirm "Create PR for 'knowledge-$SLUG'?"
1259
+ echo ""
1260
+ bash "$SCRIPTS/propose-knowledge.sh" "$SLUG" "$DESCRIPTION" --submit
1261
+ ;;
1262
+ "Archive a merged knowledge branch")
1263
+ ask SLUG "Branch slug (the part after 'knowledge-')"
1264
+ echo ""
1265
+ confirm "Archive 'knowledge-$SLUG'?"
1266
+ echo ""
1267
+ bash "$SCRIPTS/propose-knowledge.sh" "$SLUG" "" --archive
1268
+ ;;
1269
+ esac
1270
+ }
1271
+
1272
+ cmd_onboard() {
1273
+ header "Onboard Repository"
1274
+ divider
1275
+ label "Initializes a knowledge/ folder in an existing repo and raises a PR."
1276
+ echo ""
1277
+ ask REPO_URL "Repository URL"
1278
+ ask DESCRIPTION "One-line description of what this repo does"
1279
+ ask OWNER "Repo owner (team or individual)"
1280
+ echo ""
1281
+ confirm "Onboard '$REPO_URL'?"
1282
+ echo ""
1283
+ bash "$SCRIPTS/onboard-repo.sh" "$REPO_URL" "$DESCRIPTION" "$OWNER"
1284
+ }
1285
+
1286
+ cmd_list() {
1287
+ # Two registry-backed views (the registry is the SoT):
1288
+ # prj list → ongoing only (active/paused) — stays readable as the
1289
+ # registry grows; completed/cancelled are hidden.
1290
+ # prj list-all → every project, newest first (reverse chronological by NNN).
1291
+ local mode="open"
1292
+ case "${1:-}" in all|--all|-a) mode="all" ;; esac
1293
+ if [[ "$mode" == "all" ]]; then header "All Projects (newest first)"; else header "Ongoing Projects (active / paused)"; fi
1294
+ divider
1295
+ python3 - "$REGISTRY" "$mode" <<'PY'
1296
+ import sys, re, yaml
1297
+ registry, mode = sys.argv[1], sys.argv[2]
1298
+ doc = yaml.safe_load(open(registry)) or {}
1299
+ projects = [p for p in (doc.get('projects') or []) if p]
1300
+ status_colour = {
1301
+ 'active': '\033[0;32m',
1302
+ 'paused': '\033[1;33m',
1303
+ 'completed': '\033[0;36m',
1304
+ 'cancelled': '\033[0;31m',
1305
+ }
1306
+ NC = '\033[0m'; BOLD = '\033[1m'; DIM = '\033[2m'
1307
+ ONGOING = {'active', 'paused'}
1308
+ def nnn(p):
1309
+ m = re.search(r'PRJ-(\d+)', p.get('id', '') or '')
1310
+ return int(m.group(1)) if m else -1
1311
+ if mode == 'all':
1312
+ rows = sorted(projects, key=nnn, reverse=True) # newest first
1313
+ else:
1314
+ rows = sorted([p for p in projects if (p.get('status') or '') in ONGOING], key=nnn)
1315
+ if not rows:
1316
+ print(" No ongoing projects." if mode != 'all' else " No projects found.")
1317
+ else:
1318
+ for p in rows:
1319
+ s = p.get('status', '?')
1320
+ col = status_colour.get(s, '')
1321
+ a = p.get('assigned_to', '') or ''
1322
+ print(f" {BOLD}{p['id']}{NC} {col}{s}{NC} {DIM}{a}{NC}")
1323
+ if mode != 'all':
1324
+ hidden = sum(1 for p in projects if (p.get('status') or '') not in ONGOING)
1325
+ if hidden:
1326
+ print(f"\n {DIM}{hidden} completed/cancelled hidden — 'prj list-all' to show all.{NC}")
1327
+ PY
1328
+ echo ""
1329
+ }
1330
+
1331
+ cmd_status() {
1332
+ header "Project Status"
1333
+ divider
1334
+ echo ""
1335
+ select_project PROJECT_ID
1336
+ is_back "$PROJECT_ID" && return
1337
+ local PF="$SCRIPT_DIR/projects/$PROJECT_ID/project.yaml"
1338
+ echo ""
1339
+ divider
1340
+ if [[ ! -f "$PF" ]]; then
1341
+ # Active project: the manifest lives on the project branch, not on the
1342
+ # default branch where Gov.local rests. Show the registry index summary
1343
+ # (authoritative for status / assignee).
1344
+ python3 - "$REGISTRY" "$PROJECT_ID" <<'PY'
1345
+ import sys, yaml
1346
+ reg, pid = sys.argv[1], sys.argv[2]
1347
+ c = yaml.safe_load(open(reg)) or {}
1348
+ e = next((p for p in (c.get('projects') or []) if p and p.get('id') == pid), None)
1349
+ BOLD='\033[1m'; NC='\033[0m'; DIM='\033[2m'
1350
+ if not e:
1351
+ print(f" {pid}: not found in registry."); sys.exit(0)
1352
+ print(f" {BOLD}ID:{NC} {e.get('id','')}")
1353
+ print(f" {BOLD}Status:{NC} {e.get('status','')}")
1354
+ print(f" {BOLD}Assigned to:{NC} {e.get('assigned_to','')}")
1355
+ print(f" {BOLD}Seeded by:{NC} {e.get('seeded_by','')}")
1356
+ print(f" {BOLD}Branch:{NC} {e.get('branch','')}")
1357
+ print(f" {BOLD}GitHub:{NC} {e.get('github_project','')}")
1358
+ print(f" {DIM}Full manifest (repos / tasks) lives on the project branch — run from the project's PRJ_GOV clone for detail.{NC}")
1359
+ PY
1360
+ echo ""
1361
+ return
1362
+ fi
1363
+ python3 - "$PF" <<'PY'
1364
+ import sys, yaml
1365
+ BOLD='\033[1m'; NC='\033[0m'; DIM='\033[2m'; GREEN='\033[0;32m'
1366
+ YELLOW='\033[1;33m'; RED='\033[0;31m'; CYAN='\033[0;36m'
1367
+ c = yaml.safe_load(open(sys.argv[1]))
1368
+ status_colour = {'active': GREEN, 'paused': YELLOW, 'completed': CYAN, 'cancelled': RED}
1369
+ s = c.get('status','?')
1370
+ sc = status_colour.get(s,'')
1371
+ print(f" {BOLD}ID:{NC} {c.get('id','')}")
1372
+ print(f" {BOLD}Status:{NC} {sc}{s}{NC}")
1373
+ print(f" {BOLD}Assigned to:{NC} {c.get('assigned_to','')}")
1374
+ print(f" {BOLD}Started:{NC} {c.get('started_at') or '—'}")
1375
+ print(f" {BOLD}Paused:{NC} {c.get('paused_at') or '—'}")
1376
+ print(f" {BOLD}GitHub:{NC} {c.get('github_project','')}")
1377
+ print()
1378
+ repos = [r for r in (c.get('repos') or []) if r and r.get('url')]
1379
+ if repos:
1380
+ print(f" {BOLD}Repos ({len(repos)}):{NC}")
1381
+ for r in repos:
1382
+ print(f" {DIM}•{NC} {r['url']} {DIM}[{r.get('role','?')} / base: {r.get('base_branch','?')}]{NC}")
1383
+ tasks = [t for t in (c.get('tasks') or []) if t and t.get('id')]
1384
+ if tasks:
1385
+ print()
1386
+ print(f" {BOLD}Tasks ({len(tasks)}):{NC}")
1387
+ for t in tasks:
1388
+ ts = t.get('status','?')
1389
+ tc = GREEN if ts == 'completed' else (YELLOW if ts == 'active' else '')
1390
+ print(f" {DIM}•{NC} {t['id']} {tc}{ts}{NC}")
1391
+ PY
1392
+ echo ""
1393
+ }
1394
+
1395
+ cmd_deps() {
1396
+ header "Dependencies"
1397
+ divider
1398
+ echo ""
1399
+ bash "$SCRIPTS/install-deps.sh"
1400
+ }
1401
+
1402
+ cmd_upgrade() {
1403
+ header "Upgrade Framework"
1404
+ divider
1405
+ local target="${1:-}"
1406
+ echo ""
1407
+ label "Fetches a new framework version from the 'template' remote,"
1408
+ label "applies it via framework/bin/setup.sh, and leaves changes staged"
1409
+ label "for review + commit."
1410
+ echo ""
1411
+
1412
+ # Pre-conditions
1413
+ if [[ -n "$(git status --porcelain)" ]]; then
1414
+ err "Working tree has uncommitted changes. Commit or stash first."
1415
+ exit 1
1416
+ fi
1417
+ if ! git remote get-url template &>/dev/null; then
1418
+ err "'template' remote not configured."
1419
+ echo " Add it: git remote add template git@github.com:svayam-opensource/governed-agentic-dev-framework.git"
1420
+ exit 1
1421
+ fi
1422
+ local current_branch
1423
+ current_branch=$(git rev-parse --abbrev-ref HEAD)
1424
+ if [[ "$current_branch" != "$DEFAULT_BRANCH" ]]; then
1425
+ warn "You're on '$current_branch', not '$DEFAULT_BRANCH'. Framework upgrades should happen on the default branch."
1426
+ confirm "Continue anyway?"
1427
+ fi
1428
+
1429
+ # Resolve target version
1430
+ if [[ -z "$target" ]]; then
1431
+ target="template/$DEFAULT_BRANCH"
1432
+ label "No version specified — using $target (latest)"
1433
+ fi
1434
+
1435
+ echo ""
1436
+ label "Fetching from template remote..."
1437
+ git fetch template --tags 2>&1 | tail -3
1438
+
1439
+ echo ""
1440
+ label "Checking out framework/ at $target..."
1441
+ git checkout "$target" -- framework/ \
1442
+ || { err "git checkout $target failed. Is the tag/branch published?"; exit 1; }
1443
+
1444
+ if [[ ! -d "$SCRIPT_DIR/framework" ]]; then
1445
+ err "framework/ directory was not created by the checkout. Aborting."
1446
+ exit 1
1447
+ fi
1448
+
1449
+ echo ""
1450
+ label "Running framework/bin/setup.sh..."
1451
+ echo ""
1452
+ bash "$SCRIPT_DIR/framework/bin/setup.sh"
1453
+ echo ""
1454
+
1455
+ divider
1456
+ ok "Framework upgrade complete."
1457
+ echo ""
1458
+ echo " Review: git diff HEAD && git status"
1459
+ echo " Commit: git add -A && git commit -m 'framework upgrade'"
1460
+ echo " Push: git push origin $DEFAULT_BRANCH"
1461
+ }
1462
+
1463
+ # ── Manage: project assignment commands ──────────────────────────────────────
1464
+ #
1465
+ # Open to anyone with write access to the workspace repo. GitHub enforces
1466
+ # write access at git push time, so there is no need to gate on identity
1467
+ # here — and pretending we do creates a Policy Owner bottleneck where
1468
+ # none is required. The git audit trail records who assigned whom.
1469
+
1470
+ # Fetch GitHub org members (with email) and teams.
1471
+ # Outputs "value|display" per line.
1472
+ # value = email for members (if public) or @team-slug for teams; empty if email is private
1473
+ # display = "email (login)" | "login (no public email)" | "@team-slug"
1474
+ fetch_gh_assignees() {
1475
+ local owner="${1:-$GITHUB_ORG}"
1476
+ python3 - "$owner" 2>/dev/null <<'PY'
1477
+ import sys, subprocess, json
1478
+
1479
+ owner = sys.argv[1]
1480
+
1481
+ # One GraphQL call to get login + email for all org members
1482
+ gql = ('{ organization(login: "' + owner + '") { membersWithRole(first: 100) '
1483
+ '{ nodes { login email } } } }')
1484
+ gr = subprocess.run(['gh', 'api', 'graphql', '-f', f'query={gql}'],
1485
+ capture_output=True, text=True)
1486
+ members_done = False
1487
+ if gr.returncode == 0 and gr.stdout.strip():
1488
+ try:
1489
+ nodes = (json.loads(gr.stdout)
1490
+ .get('data', {}).get('organization', {})
1491
+ .get('membersWithRole', {}).get('nodes', []))
1492
+ for n in nodes:
1493
+ login = n.get('login', '')
1494
+ email = n.get('email') or ''
1495
+ if email:
1496
+ print(f"{email}|{email} ({login})")
1497
+ else:
1498
+ print(f"|{login} (no public email)")
1499
+ members_done = True
1500
+ except (json.JSONDecodeError, KeyError, TypeError):
1501
+ pass
1502
+
1503
+ if not members_done:
1504
+ # REST fallback — logins only
1505
+ r = subprocess.run(['gh', 'api', f'/orgs/{owner}/members', '--jq', '.[].login'],
1506
+ capture_output=True, text=True)
1507
+ if r.returncode == 0 and r.stdout.strip():
1508
+ for login in r.stdout.strip().splitlines():
1509
+ if login.strip():
1510
+ print(f"|{login.strip()} (no public email)")
1511
+
1512
+ # Org teams
1513
+ tr = subprocess.run(['gh', 'api', f'/orgs/{owner}/teams', '--jq', '.[].slug'],
1514
+ capture_output=True, text=True)
1515
+ if tr.returncode == 0 and tr.stdout.strip():
1516
+ for slug in tr.stdout.strip().splitlines():
1517
+ if slug.strip():
1518
+ print(f"@{slug.strip()}|@{slug.strip()}")
1519
+ PY
1520
+ return ${PIPESTATUS[0]}
1521
+ }
1522
+
1523
+ # Resolve the project branch name from a project_id. Prefers the canonical
1524
+ # value stored in registry.yaml's projects[<id>].branch (which seed.sh writes
1525
+ # at create time). Falls back to deriving from the ID, supporting both the
1526
+ # v0.2.0+ PRJ- prefix and legacy <ORG_SLUG>-NNN-slug projects.
1527
+ project_branch() {
1528
+ local pid="$1" branch
1529
+ branch=$(python3 - "$REGISTRY" "$pid" 2>/dev/null <<'PY'
1530
+ import sys, yaml
1531
+ c = yaml.safe_load(open(sys.argv[1])) or {}
1532
+ for p in (c.get('projects') or []):
1533
+ if p and p.get('id') == sys.argv[2]:
1534
+ b = p.get('branch')
1535
+ if b:
1536
+ print(b); sys.exit(0)
1537
+ sys.exit(1)
1538
+ PY
1539
+ )
1540
+ if [[ -n "$branch" && "$branch" != "null" ]]; then
1541
+ echo "$branch"
1542
+ elif [[ "$pid" == PRJ-* ]]; then
1543
+ # Defensive fallback only: every seeded project stores its branch above, so
1544
+ # this is never hit for a registered project. Scheme B (POL-069,
1545
+ # PRJ-<github_project_number>-<slug>) can't be derived from the registry id
1546
+ # alone (needs the GitHub project number), so the legacy brnch- shape stays
1547
+ # as a last resort for an unregistered/legacy id.
1548
+ echo "brnch-${pid#PRJ-}"
1549
+ else
1550
+ echo "$pid" | tr '[:upper:]' '[:lower:]'
1551
+ fi
1552
+ }
1553
+
1554
+ # Commit registry.yaml changes to the default branch and push.
1555
+ # Caller must already have modified registry.yaml in place. The home workspace
1556
+ # must be on $DEFAULT_BRANCH — project branches live only in per-project
1557
+ # workspaces under $AGENT_WORK_ROOT.
1558
+ commit_registry() {
1559
+ local msg="$1"
1560
+ cd "$SCRIPT_DIR"
1561
+ local current
1562
+ current=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
1563
+ if [[ "$current" != "$DEFAULT_BRANCH" ]]; then
1564
+ err "Home workspace is on '$current' but assignment changes must be made on '$DEFAULT_BRANCH'."
1565
+ err "Switch back: git checkout $DEFAULT_BRANCH"
1566
+ exit 1
1567
+ fi
1568
+ git pull --ff-only origin "$DEFAULT_BRANCH" 2>/dev/null || true
1569
+ git add registry.yaml
1570
+ git commit -m "$msg"
1571
+ git push origin "$DEFAULT_BRANCH"
1572
+ }
1573
+
1574
+ # select_from_owner_projects <id_var> <filter>
1575
+ # filter: "unassigned" | "assigned"
1576
+ # Shows ALL GitHub Projects for owner, filtered by assignment state.
1577
+ # Un-seeded projects use pre_assignments in registry.yaml; seeded projects use project.yaml.
1578
+ # Side-effects: sets GH_OWNER, MANAGE_SELECTED_URL, MANAGE_SELECTED_PID.
1579
+ MANAGE_SELECTED_URL=""
1580
+ MANAGE_SELECTED_PID=""
1581
+ select_from_owner_projects() {
1582
+ local __var="$1" __filter="${2:-unassigned}"
1583
+ MANAGE_SELECTED_URL=""
1584
+ MANAGE_SELECTED_PID=""
1585
+
1586
+ ask GH_OWNER "GitHub org or username" "$GITHUB_ORG"
1587
+ echo ""
1588
+ label "Fetching GitHub Projects for ${GH_OWNER}..."
1589
+ echo ""
1590
+
1591
+ local __raw
1592
+ __raw=$(python3 - "$REGISTRY" "$SCRIPT_DIR" "$GH_OWNER" "$__filter" <<'PY'
1593
+ import sys, os, json, subprocess, yaml
1594
+
1595
+ registry_path, proj_dir, owner, filt = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
1596
+
1597
+ result = subprocess.run(
1598
+ ['gh', 'project', 'list', '--owner', owner, '--format', 'json', '--limit', '100'],
1599
+ capture_output=True, text=True
1600
+ )
1601
+ if result.returncode != 0 or not result.stdout.strip():
1602
+ sys.exit(0)
1603
+
1604
+ gh_projects = json.loads(result.stdout).get('projects', [])
1605
+ c = yaml.safe_load(open(registry_path)) or {}
1606
+
1607
+ reg_by_url = {}
1608
+ for p in (c.get('projects') or []):
1609
+ if p and p.get('github_project'):
1610
+ reg_by_url[p['github_project']] = p
1611
+
1612
+ pre_by_url = {}
1613
+ for a in (c.get('pre_assignments') or []):
1614
+ if a and a.get('github_project'):
1615
+ pre_by_url[a['github_project']] = a
1616
+
1617
+ for gh in gh_projects:
1618
+ title = (gh.get('title') or '').replace('\t', ' ')
1619
+ url = gh.get('url', '')
1620
+ pid = ''
1621
+ s = ''
1622
+ assignee = ''
1623
+
1624
+ if url in reg_by_url:
1625
+ reg = reg_by_url[url]
1626
+ pid = reg.get('id', '')
1627
+ s = reg.get('status', '')
1628
+ pf = os.path.join(proj_dir, 'projects', pid, 'project.yaml')
1629
+ if os.path.exists(pf):
1630
+ assignee = yaml.safe_load(open(pf)).get('assigned_to') or ''
1631
+ elif url in pre_by_url:
1632
+ assignee = pre_by_url[url].get('assigned_to') or ''
1633
+
1634
+ if filt == 'unassigned' and assignee:
1635
+ continue
1636
+ if filt == 'assigned' and not assignee:
1637
+ continue
1638
+ print(f"{title}\x1f{url}\x1f{pid}\x1f{s}\x1f{assignee}")
1639
+ PY
1640
+ )
1641
+
1642
+ if [[ -z "$__raw" ]]; then
1643
+ case "$__filter" in
1644
+ unassigned) err "No unassigned GitHub Projects found for '${GH_OWNER}'." ;;
1645
+ assigned) err "No assigned projects found for '${GH_OWNER}'." ;;
1646
+ *) err "No matching projects found." ;;
1647
+ esac
1648
+ exit 1
1649
+ fi
1650
+
1651
+ local __titles=() __urls=() __pids=() __statuses=() __assignees=()
1652
+ while IFS=$'\x1f' read -r title url pid status assignee; do
1653
+ [[ -z "$url" ]] && continue
1654
+ __titles+=("$title"); __urls+=("$url"); __pids+=("$pid")
1655
+ __statuses+=("$status"); __assignees+=("$assignee")
1656
+ done <<< "$__raw"
1657
+
1658
+ if [[ ${#__urls[@]} -eq 0 ]]; then
1659
+ case "$__filter" in
1660
+ unassigned) err "No unassigned GitHub Projects found for '${GH_OWNER}'." ;;
1661
+ assigned) err "No assigned projects found for '${GH_OWNER}'." ;;
1662
+ *) err "No matching projects found." ;;
1663
+ esac
1664
+ exit 1
1665
+ fi
1666
+
1667
+ echo -e "${BOLD}Select project:${NC}"
1668
+ local i
1669
+ for i in "${!__urls[@]}"; do
1670
+ local _title="${__titles[$i]}"
1671
+ local _pid="${__pids[$i]}"
1672
+ local _asgn="${__assignees[$i]}"
1673
+ local _pd="${_pid:-(not seeded)}"
1674
+ local _ad="${_asgn:-(unassigned)}"
1675
+ if [[ -n "$_pid" && -n "$_asgn" ]]; then
1676
+ printf " ${CYAN}%d)${NC} %-38s %-24s %s\n" $((i+1)) "$_title" "$_pd" "$_ad"
1677
+ elif [[ -n "$_pid" ]]; then
1678
+ printf " ${CYAN}%d)${NC} %-38s %-24s ${DIM}%s${NC}\n" $((i+1)) "$_title" "$_pd" "$_ad"
1679
+ else
1680
+ printf " ${CYAN}%d)${NC} %-38s ${DIM}%-24s %s${NC}\n" $((i+1)) "$_title" "$_pd" "$_ad"
1681
+ fi
1682
+ done
1683
+
1684
+ local __choice
1685
+ while true; do
1686
+ printf "Choose [1-%d]: " "${#__urls[@]}"
1687
+ read -r __choice
1688
+ if [[ "$__choice" =~ ^[0-9]+$ ]] && (( __choice >= 1 && __choice <= ${#__urls[@]} )); then
1689
+ local __idx=$((__choice-1))
1690
+ printf -v "$__var" '%s' "${__pids[$__idx]}"
1691
+ MANAGE_SELECTED_URL="${__urls[$__idx]}"
1692
+ MANAGE_SELECTED_PID="${__pids[$__idx]}"
1693
+ return
1694
+ fi
1695
+ err "Invalid choice."
1696
+ done
1697
+ }
1698
+
1699
+ # Pick an assignee from org members/teams or enter manually.
1700
+ # Stores email (or @team-slug) in the named variable.
1701
+ select_assignee() {
1702
+ local __var="$1" __owner="${2:-$GITHUB_ORG}"
1703
+
1704
+ label "Fetching org members and teams from ${__owner}..."
1705
+
1706
+ local values=() displays=()
1707
+ while IFS='|' read -r val disp; do
1708
+ values+=("$val")
1709
+ displays+=("$disp")
1710
+ done < <(fetch_gh_assignees "$__owner")
1711
+
1712
+ if [[ ${#displays[@]} -eq 0 ]]; then
1713
+ warn "Could not fetch org members (may not be an org, or insufficient permissions)."
1714
+ ask "$__var" "Assignee email"
1715
+ return
1716
+ fi
1717
+
1718
+ values+=("")
1719
+ displays+=("— enter manually —")
1720
+
1721
+ echo ""
1722
+ echo -e "${BOLD}Select assignee:${NC}"
1723
+ local i
1724
+ for i in "${!displays[@]}"; do
1725
+ printf " ${CYAN}%d)${NC} %s\n" $((i+1)) "${displays[$i]}"
1726
+ done
1727
+
1728
+ local __choice
1729
+ while true; do
1730
+ printf "Choose [1-%d]: " "${#displays[@]}"
1731
+ read -r __choice
1732
+ if [[ "$__choice" =~ ^[0-9]+$ ]] && (( __choice >= 1 && __choice <= ${#displays[@]} )); then
1733
+ local _val="${values[$((__choice-1))]}"
1734
+ local _disp="${displays[$((__choice-1))]}"
1735
+ if [[ "$_disp" == "— enter manually —" || -z "$_val" ]]; then
1736
+ ask "$__var" "Assignee email"
1737
+ else
1738
+ printf -v "$__var" '%s' "$_val"
1739
+ fi
1740
+ return
1741
+ fi
1742
+ err "Invalid choice."
1743
+ done
1744
+ }
1745
+
1746
+ # _pick_open_project <numvar> <titlevar> [owner] — pick from open GitHub projects.
1747
+ _pick_open_project() {
1748
+ local __nv="$1" __tv="$2" owner="${3:-$GITHUB_ORG}" __json __titles=() __nums=()
1749
+ __json=$(gh project list --owner "$owner" --format json --limit 100 2>/dev/null) \
1750
+ || { err "Could not list GitHub Projects for '$owner'."; exit 1; }
1751
+ while IFS=$'\t' read -r t n; do __titles+=("$t"); __nums+=("$n"); done < <(echo "$__json" | python3 -c "
1752
+ import sys, json
1753
+ for p in json.load(sys.stdin).get('projects', []):
1754
+ print('%s\t%s' % (p.get('title','(untitled)'), p.get('number','')))
1755
+ ")
1756
+ [[ ${#__nums[@]} -eq 0 ]] && { err "No open GitHub Projects for '$owner'."; exit 1; }
1757
+ echo -e "${BOLD}Select project:${NC}"
1758
+ local i
1759
+ for i in "${!__titles[@]}"; do printf " ${CYAN}%d)${NC} %s ${DIM}(#%s)${NC}\n" $((i+1)) "${__titles[$i]}" "${__nums[$i]}"; done
1760
+ printf " ${DIM}0) ← back${NC}\n"
1761
+ local c
1762
+ while true; do
1763
+ printf "Choose [1-%d, 0=back]: " "${#__nums[@]}"; _read_or_abort c
1764
+ if [[ "$c" == "0" || "$c" == "b" || "$c" == "back" ]]; then
1765
+ printf -v "$__nv" '%s' "$BACK"; printf -v "$__tv" '%s' "$BACK"; return
1766
+ fi
1767
+ if [[ "$c" =~ ^[0-9]+$ ]] && (( c>=1 && c<=${#__nums[@]} )); then
1768
+ printf -v "$__nv" '%s' "${__nums[$((c-1))]}"; printf -v "$__tv" '%s' "${__titles[$((c-1))]}"; return
1769
+ fi
1770
+ err "Invalid choice."
1771
+ done
1772
+ }
1773
+
1774
+ # _pick_board_issue <refvar> <project_number> [owner] — sets refvar to "owner/repo#number".
1775
+ _pick_board_issue() {
1776
+ local __rv="$1" num="$2" owner="${3:-$GITHUB_ORG}" __refs=() __rows=()
1777
+ while IFS=$'\t' read -r ref title; do [[ -z "$ref" ]] && continue; __refs+=("$ref"); __rows+=("$ref $title"); done < <(
1778
+ gh api graphql -f query='query($o:String!,$n:Int!){ organization(login:$o){ projectV2(number:$n){ items(first:100){ nodes{ content{ ... on Issue { number title repository{nameWithOwner} } } } } } } }' \
1779
+ -F o="$owner" -F n="$num" --jq '.data.organization.projectV2.items.nodes[].content | select(.number) | "\(.repository.nameWithOwner)#\(.number)\t\(.title)"' 2>/dev/null)
1780
+ [[ ${#__refs[@]} -eq 0 ]] && { err "No issues on this board."; exit 1; }
1781
+ echo -e "${BOLD}Select issue:${NC}"
1782
+ local i
1783
+ for i in "${!__rows[@]}"; do printf " ${CYAN}%d)${NC} %s\n" $((i+1)) "${__rows[$i]}"; done
1784
+ printf " ${DIM}0) ← back${NC}\n"
1785
+ local c
1786
+ while true; do
1787
+ printf "Choose [1-%d, 0=back]: " "${#__refs[@]}"; _read_or_abort c
1788
+ if [[ "$c" == "0" || "$c" == "b" || "$c" == "back" ]]; then printf -v "$__rv" '%s' "$BACK"; return; fi
1789
+ if [[ "$c" =~ ^[0-9]+$ ]] && (( c>=1 && c<=${#__refs[@]} )); then printf -v "$__rv" '%s' "${__refs[$((c-1))]}"; return; fi
1790
+ err "Invalid choice."
1791
+ done
1792
+ }
1793
+
1794
+ # cmd_manage_owners — GitHub-definitive assign/reassign/unassign, combined (#57 §3).
1795
+ # Project → owners = anchor-issue assignees (+ board write, via set_owner).
1796
+ # Issue → assignees (native). No cache.
1797
+ cmd_manage_owners() {
1798
+ header "Manage Assignment"
1799
+ divider
1800
+ echo ""
1801
+ local PNUM PTITLE
1802
+ _pick_open_project PNUM PTITLE
1803
+ is_back "$PNUM" && return
1804
+ echo ""
1805
+ local SCOPE
1806
+ ask_choice SCOPE "Manage the Project (owners) or an Issue (assignees)?" "Project (owners)" "Issue (assignees)"
1807
+ is_back "$SCOPE" && return
1808
+ echo ""
1809
+ if [[ "$SCOPE" == "Project (owners)" ]]; then
1810
+ local ref; ref="$(anchor_issue_ref "$PNUM")"
1811
+ if [[ -z "$ref" ]]; then
1812
+ warn "No '$ANCHOR_LABEL' issue on '$PTITLE' — owners are read from the anchor issue."
1813
+ confirm "Designate an anchor issue now?"
1814
+ local AREF; _pick_board_issue AREF "$PNUM"
1815
+ is_back "$AREF" && return
1816
+ gh issue edit "${AREF##*#}" --repo "${AREF%%#*}" --add-label "$ANCHOR_LABEL" >/dev/null 2>&1 \
1817
+ && ok "Labelled $AREF as the anchor." || { err "Could not add '$ANCHOR_LABEL' label (does the label exist in ${AREF%%#*}?)."; exit 1; }
1818
+ ref="$AREF"
1819
+ fi
1820
+ local owners; owners="$(project_owners "$PNUM" | paste -sd ',' - | sed 's/,/, /g')"
1821
+ echo -e " ${BOLD}Anchor:${NC} $ref"
1822
+ echo -e " ${BOLD}Current owners:${NC} ${owners:-(none)}"
1823
+ echo ""
1824
+ local ACT; ask_choice ACT "Add or remove an owner?" "Add owner" "Remove owner"
1825
+ is_back "$ACT" && return
1826
+ echo ""
1827
+ if [[ "$ACT" == "Add owner" ]]; then
1828
+ local WHO; ask WHO "GitHub login to add as owner (@me for yourself)" ""
1829
+ [[ "$WHO" == "@me" ]] && WHO="$(gh api user --jq .login 2>/dev/null)"
1830
+ [[ -z "$WHO" ]] && { err "No login given."; exit 1; }
1831
+ confirm "Add '$WHO' as owner of '$PTITLE' (anchor assignee + board write)?"
1832
+ set_owner add "$PNUM" "$WHO"
1833
+ else
1834
+ local cur; cur="$(project_owners "$PNUM")"
1835
+ [[ -z "$cur" ]] && { err "No current owners to remove."; exit 1; }
1836
+ local WHO; ask_choice WHO "Remove which owner?" $cur
1837
+ is_back "$WHO" && return
1838
+ confirm "Remove '$WHO' as owner of '$PTITLE' (unassign + revoke board write)?"
1839
+ set_owner remove "$PNUM" "$WHO"
1840
+ fi
1841
+ else
1842
+ local IREF; _pick_board_issue IREF "$PNUM"
1843
+ is_back "$IREF" && return
1844
+ local irepo="${IREF%%#*}" inum="${IREF##*#}"
1845
+ local asn; asn="$(gh issue view "$inum" --repo "$irepo" --json assignees --jq '.assignees[].login' 2>/dev/null)"
1846
+ echo -e " ${BOLD}Issue:${NC} $IREF"
1847
+ echo -e " ${BOLD}Assignees:${NC} $(echo "$asn" | paste -sd ',' - | sed 's/,/, /g')"
1848
+ echo ""
1849
+ local ACT; ask_choice ACT "Add or remove an assignee?" "Add assignee" "Remove assignee"
1850
+ is_back "$ACT" && return
1851
+ echo ""
1852
+ if [[ "$ACT" == "Add assignee" ]]; then
1853
+ local WHO; ask WHO "GitHub login to assign (@me)" "@me"
1854
+ gh issue edit "$inum" --repo "$irepo" --add-assignee "$WHO" >/dev/null 2>&1 && ok "Assigned $WHO to $IREF." || err "Failed to assign $WHO."
1855
+ else
1856
+ [[ -z "$asn" ]] && { err "No assignees to remove."; exit 1; }
1857
+ local WHO; ask_choice WHO "Remove which assignee?" $asn
1858
+ is_back "$WHO" && return
1859
+ gh issue edit "$inum" --repo "$irepo" --remove-assignee "$WHO" >/dev/null 2>&1 && ok "Unassigned $WHO from $IREF." || err "Failed to unassign $WHO."
1860
+ fi
1861
+ fi
1862
+ }
1863
+
1864
+ cmd_manage_list() {
1865
+ # GitHub-definitive (registry-elimination Increment 2): the ONGOING project set
1866
+ # is the org's OPEN GitHub Project boards (open = active/paused; closed boards =
1867
+ # completed/cancelled, shown by 'manage list-all'). For each board, OWNERS are the
1868
+ # anchor-issue assignees and STATUS is derived (board open/closed + anchor labels)
1869
+ # via the derivation helpers — legacy projects (PRJ-001…013) resolve through the
1870
+ # frozen registry shim, everything else 100% from GitHub. No assigned_to cache.
1871
+ header "Ongoing Project Owners (live from GitHub)"
1872
+ divider
1873
+ label "Owners = anchor-issue assignees · status from the board + anchor labels · no cache."
1874
+ echo ""
1875
+ local owner="$GITHUB_ORG" json
1876
+ json=$(gh project list --owner "$owner" --format json --limit 100 2>/dev/null) \
1877
+ || { err "Could not list GitHub Projects for '$owner'."; return 1; }
1878
+ local nums=() titles=()
1879
+ while IFS=$'\t' read -r n t; do [[ -n "$n" ]] && nums+=("$n") && titles+=("$t"); done < <(
1880
+ echo "$json" | python3 -c "
1881
+ import sys, json
1882
+ for p in json.load(sys.stdin).get('projects', []):
1883
+ print('%s\t%s' % (p.get('number',''), (p.get('title') or '(untitled)')))
1884
+ ")
1885
+ if [[ ${#nums[@]} -eq 0 ]]; then echo " No open GitHub Projects for '$owner'."; echo ""; return; fi
1886
+ printf " ${BOLD}%-34s %-10s %s${NC}\n" "PROJECT" "STATUS" "OWNERS"
1887
+ printf " ${DIM}%-34s %-10s %s${NC}\n" "──────────────────────────────────" "──────────" "─────────────────────"
1888
+ local i id status owners scol
1889
+ for i in "${!nums[@]}"; do
1890
+ id="$(derive_project_id "${nums[$i]}" "$owner")"; [[ -z "$id" ]] && id="${titles[$i]}"
1891
+ status="$(derive_project_status "${nums[$i]}" "$owner")"
1892
+ owners="$(project_owners "${nums[$i]}" "$owner" | paste -sd ',' - | sed 's/,/, /g')"
1893
+ case "$status" in active) scol="$GREEN" ;; paused) scol="$YELLOW" ;; *) scol="$DIM" ;; esac
1894
+ if [[ -n "$owners" ]]; then
1895
+ printf " ${BOLD}%-34s${NC} ${scol}%-10s${NC} %s\n" "$id" "$status" "$owners"
1896
+ else
1897
+ printf " ${BOLD}%-34s${NC} ${scol}%-10s${NC} ${YELLOW}⚠ no anchor set up yet${NC}\n" "$id" "$status"
1898
+ fi
1899
+ done
1900
+ echo ""
1901
+ label "Closed boards (completed/cancelled) are hidden — 'prj manage list-all' for the full board universe."
1902
+ echo ""
1903
+ }
1904
+
1905
+ cmd_manage_list_all() {
1906
+ # Full board universe — sourced from GitHub (gh project list). Shows every
1907
+ # GitHub Project incl. pre-assigned / not-yet-seeded ones, enriched with
1908
+ # registry status/assignment. (#57: 'manage list-all' = from GitHub.)
1909
+ header "All GitHub Projects (board universe)"
1910
+ divider
1911
+ echo ""
1912
+
1913
+ ask GH_OWNER "GitHub org or username" "$GITHUB_ORG"
1914
+ echo ""
1915
+ label "Fetching GitHub Projects for ${GH_OWNER}..."
1916
+ echo ""
1917
+
1918
+ python3 - "$REGISTRY" "$SCRIPT_DIR" "$GH_OWNER" <<'PY'
1919
+ import sys, os, json, subprocess, yaml
1920
+
1921
+ registry_path, proj_dir, owner = sys.argv[1], sys.argv[2], sys.argv[3]
1922
+ BOLD='\033[1m'; NC='\033[0m'; DIM='\033[2m'
1923
+ GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; RED='\033[0;31m'
1924
+ status_colour = {'active': GREEN, 'paused': YELLOW, 'completed': CYAN, 'cancelled': RED}
1925
+
1926
+ result = subprocess.run(
1927
+ ['gh', 'project', 'list', '--owner', owner, '--format', 'json', '--limit', '100'],
1928
+ capture_output=True, text=True
1929
+ )
1930
+ if result.returncode != 0 or not result.stdout.strip():
1931
+ print(f" Could not fetch GitHub Projects for '{owner}'.")
1932
+ print(f" {DIM}Tip: gh project list --owner {owner}{NC}")
1933
+ sys.exit(0)
1934
+
1935
+ gh_projects = json.loads(result.stdout).get('projects', [])
1936
+ if not gh_projects:
1937
+ print(f" No open GitHub Projects found for '{owner}'.")
1938
+ sys.exit(0)
1939
+
1940
+ c = yaml.safe_load(open(registry_path)) or {}
1941
+ reg_by_url = {}
1942
+ for p in (c.get('projects') or []):
1943
+ if p and p.get('github_project'):
1944
+ reg_by_url[p['github_project']] = p
1945
+
1946
+ pre_by_url = {}
1947
+ for a in (c.get('pre_assignments') or []):
1948
+ if a and a.get('github_project'):
1949
+ pre_by_url[a['github_project']] = a
1950
+
1951
+ print(f" {BOLD}{'GITHUB PROJECT':<38} {'SEEDED AS':<22} {'STATUS':<12} ASSIGNED TO{NC}")
1952
+ print(f" {DIM}{'─'*38} {'─'*22} {'─'*12} {'─'*25}{NC}")
1953
+
1954
+ for gh in gh_projects:
1955
+ title = (gh.get('title') or '(untitled)')[:36]
1956
+ url = gh.get('url', '')
1957
+
1958
+ if url in reg_by_url:
1959
+ reg = reg_by_url[url]
1960
+ pid = reg.get('id', '—')
1961
+ s = reg.get('status', '?')
1962
+ sc = status_colour.get(s, '')
1963
+ assignee = reg.get('assigned_to') or ''
1964
+ if not assignee:
1965
+ pf = os.path.join(proj_dir, 'projects', pid, 'project.yaml')
1966
+ if os.path.exists(pf):
1967
+ assignee = (yaml.safe_load(open(pf)) or {}).get('assigned_to') or ''
1968
+ adisp = assignee if assignee else f'{DIM}(unassigned){NC}'
1969
+ print(f" {title:<38} {BOLD}{pid:<22}{NC} {sc}{s:<12}{NC} {adisp}")
1970
+ elif url in pre_by_url:
1971
+ assignee = pre_by_url[url].get('assigned_to') or ''
1972
+ adisp = assignee if assignee else f'{DIM}(unassigned){NC}'
1973
+ print(f" {title:<38} {DIM}{'(not seeded)':<22}{'pre-assigned':<12}{NC} {adisp}")
1974
+ else:
1975
+ print(f" {title:<38} {DIM}{'(not seeded)':<22}{'—':<12}(unassigned){NC}")
1976
+
1977
+ print()
1978
+ PY
1979
+ }
1980
+
1981
+ cmd_manage_assign() {
1982
+ header "Assign Project"
1983
+ divider
1984
+ label "Assign an unassigned project to a GitHub user or team."
1985
+ echo ""
1986
+ select_from_owner_projects PROJECT_ID "unassigned"
1987
+ echo ""
1988
+
1989
+ select_assignee ASSIGNEE "$GH_OWNER"
1990
+ echo ""
1991
+
1992
+ local label_proj="${MANAGE_SELECTED_PID:-$MANAGE_SELECTED_URL}"
1993
+ echo -e " ${BOLD}Project:${NC} $label_proj"
1994
+ echo -e " ${BOLD}Assignee:${NC} $ASSIGNEE"
1995
+ echo ""
1996
+ confirm "Assign this project to '$ASSIGNEE'?"
1997
+ echo ""
1998
+
1999
+ # Phase 3 (ADR-0001): the real assignment is WRITE access on the GitHub
2000
+ # Project. Grant it; the YAML written below is a display/audit cache.
2001
+ bash "$SCRIPTS/project-access.sh" grant "$MANAGE_SELECTED_URL" "$ASSIGNEE" \
2002
+ || warn "GitHub access not granted — recorded in cache only; grant it manually."
2003
+
2004
+ local today
2005
+ today=$(date +%Y-%m-%d)
2006
+
2007
+ if [[ -n "$MANAGE_SELECTED_PID" ]]; then
2008
+ # Seeded project — assignment lives on registry.yaml (default branch).
2009
+ # Project.yaml's locked_by is the per-session operational lock and is
2010
+ # managed inside the per-project workspace, not from here.
2011
+ python3 - "$REGISTRY" "$MANAGE_SELECTED_PID" "$ASSIGNEE" <<'PY'
2012
+ import sys, yaml
2013
+ registry_path, pid, assignee = sys.argv[1:]
2014
+ with open(registry_path) as f: c = yaml.safe_load(f) or {}
2015
+ for entry in (c.get('projects') or []):
2016
+ if entry and entry.get('id') == pid:
2017
+ entry['assigned_to'] = assignee
2018
+ break
2019
+ with open(registry_path, 'w') as f:
2020
+ yaml.dump(c, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
2021
+ PY
2022
+ commit_registry "manage: assign $MANAGE_SELECTED_PID → $ASSIGNEE"
2023
+ echo ""
2024
+ ok "'$MANAGE_SELECTED_PID' assigned to '$ASSIGNEE' (registry on $DEFAULT_BRANCH)."
2025
+ else
2026
+ # Unseeded project — pre_assignment in registry on default branch.
2027
+ python3 - "$REGISTRY" "$MANAGE_SELECTED_URL" "$ASSIGNEE" "$CURRENT_USER" "$today" <<'PY'
2028
+ import sys, yaml
2029
+ registry_path, url, assignee, assigned_by, today = sys.argv[1:]
2030
+ with open(registry_path) as f: c = yaml.safe_load(f) or {}
2031
+ pre = [a for a in (c.get('pre_assignments') or []) if a and a.get('github_project') != url]
2032
+ pre.append({'github_project': url, 'assigned_to': assignee, 'assigned_by': assigned_by, 'assigned_at': today})
2033
+ c['pre_assignments'] = pre
2034
+ with open(registry_path, 'w') as f:
2035
+ yaml.dump(c, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
2036
+ PY
2037
+ commit_registry "manage: pre-assign $MANAGE_SELECTED_URL → $ASSIGNEE"
2038
+ echo ""
2039
+ ok "Project assigned to '$ASSIGNEE'. They may now run './prj init' to seed it."
2040
+ fi
2041
+ }
2042
+
2043
+ cmd_manage_reassign() {
2044
+ header "Reassign Project"
2045
+ divider
2046
+ label "Reassign a project to a different user or team. Requires a reason (C02)."
2047
+ echo ""
2048
+ select_from_owner_projects PROJECT_ID "assigned"
2049
+ echo ""
2050
+
2051
+ local current_assignee=""
2052
+ if [[ -n "$MANAGE_SELECTED_PID" ]]; then
2053
+ current_assignee=$(python3 - "$REGISTRY" "$MANAGE_SELECTED_PID" <<'PY'
2054
+ import sys, yaml
2055
+ c = yaml.safe_load(open(sys.argv[1])) or {}
2056
+ for entry in (c.get('projects') or []):
2057
+ if entry and entry.get('id') == sys.argv[2]:
2058
+ print(entry.get('assigned_to') or ''); sys.exit(0)
2059
+ print('')
2060
+ PY
2061
+ )
2062
+ else
2063
+ current_assignee=$(python3 - "$REGISTRY" "$MANAGE_SELECTED_URL" <<'PY'
2064
+ import sys, yaml
2065
+ c = yaml.safe_load(open(sys.argv[1])) or {}
2066
+ for a in (c.get('pre_assignments') or []):
2067
+ if a and a.get('github_project') == sys.argv[2]:
2068
+ print(a.get('assigned_to') or ''); sys.exit(0)
2069
+ print('')
2070
+ PY
2071
+ )
2072
+ fi
2073
+
2074
+ echo -e " ${BOLD}Current assignee:${NC} ${current_assignee:-${DIM}(unassigned)${NC}}"
2075
+ echo ""
2076
+
2077
+ select_assignee NEW_ASSIGNEE "$GH_OWNER"
2078
+ echo ""
2079
+
2080
+ ask REASON "Reassignment reason (C02 — required)"
2081
+ echo ""
2082
+
2083
+ local today
2084
+ today=$(date +%Y-%m-%d)
2085
+ local label_proj="${MANAGE_SELECTED_PID:-$MANAGE_SELECTED_URL}"
2086
+
2087
+ echo -e " ${BOLD}Project:${NC} $label_proj"
2088
+ echo -e " ${BOLD}From:${NC} ${current_assignee:-unassigned}"
2089
+ echo -e " ${BOLD}To:${NC} $NEW_ASSIGNEE"
2090
+ echo -e " ${BOLD}Reason:${NC} $REASON"
2091
+ echo -e " ${BOLD}Approved by:${NC} $CURRENT_USER"
2092
+ echo ""
2093
+ confirm "Reassign this project?"
2094
+ echo ""
2095
+
2096
+ # Phase 3: move WRITE access on the GitHub Project from old to new assignee.
2097
+ [[ -n "$current_assignee" ]] && { bash "$SCRIPTS/project-access.sh" revoke "$MANAGE_SELECTED_URL" "$current_assignee" \
2098
+ || warn "Could not revoke access for '$current_assignee' — revoke manually."; }
2099
+ bash "$SCRIPTS/project-access.sh" grant "$MANAGE_SELECTED_URL" "$NEW_ASSIGNEE" \
2100
+ || warn "Could not grant access to '$NEW_ASSIGNEE' — grant manually."
2101
+
2102
+ if [[ -n "$MANAGE_SELECTED_PID" ]]; then
2103
+ # Seeded — append reassignment record to registry entry's history.
2104
+ python3 - "$REGISTRY" "$MANAGE_SELECTED_PID" "$current_assignee" "$NEW_ASSIGNEE" "$REASON" "$CURRENT_USER" "$today" <<'PY'
2105
+ import sys, yaml
2106
+ registry_path, pid, prev, new_assignee, reason, approved_by, today = sys.argv[1:]
2107
+ with open(registry_path) as f: c = yaml.safe_load(f) or {}
2108
+ for entry in (c.get('projects') or []):
2109
+ if entry and entry.get('id') == pid:
2110
+ entry['assigned_to'] = new_assignee
2111
+ history = entry.get('reassignment_history') or []
2112
+ history.append({'from': prev, 'to': new_assignee, 'reason': reason,
2113
+ 'approved_by': approved_by, 'at': today})
2114
+ entry['reassignment_history'] = history
2115
+ break
2116
+ with open(registry_path, 'w') as f:
2117
+ yaml.dump(c, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
2118
+ PY
2119
+ commit_registry "manage: reassign $MANAGE_SELECTED_PID → $NEW_ASSIGNEE (C02)"
2120
+ echo ""
2121
+ ok "'$MANAGE_SELECTED_PID' reassigned to '$NEW_ASSIGNEE'."
2122
+ else
2123
+ # Unseeded — update pre_assignment.
2124
+ python3 - "$REGISTRY" "$MANAGE_SELECTED_URL" "$NEW_ASSIGNEE" "$REASON" "$CURRENT_USER" "$today" <<'PY'
2125
+ import sys, yaml
2126
+ registry_path, url, new_assignee, reason, approved_by, today = sys.argv[1:]
2127
+ with open(registry_path) as f: c = yaml.safe_load(f) or {}
2128
+ for a in (c.get('pre_assignments') or []):
2129
+ if a and a.get('github_project') == url:
2130
+ a['assigned_to'] = new_assignee
2131
+ a['reassignment_reason'] = reason
2132
+ a['reassigned_at'] = today
2133
+ a['reassigned_approved_by'] = approved_by
2134
+ break
2135
+ with open(registry_path, 'w') as f:
2136
+ yaml.dump(c, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
2137
+ PY
2138
+ commit_registry "manage: reassign pre-assignment → $NEW_ASSIGNEE (C02)"
2139
+ echo ""
2140
+ ok "Project reassigned to '$NEW_ASSIGNEE'."
2141
+ fi
2142
+ ok "C02 fields recorded: reason, approver, timestamp."
2143
+ }
2144
+
2145
+ cmd_manage_unassign() {
2146
+ header "Unassign Project"
2147
+ divider
2148
+ label "Remove the current assignment, leaving the project unassigned."
2149
+ echo ""
2150
+ select_from_owner_projects PROJECT_ID "assigned"
2151
+ echo ""
2152
+
2153
+ local current_assignee=""
2154
+ if [[ -n "$MANAGE_SELECTED_PID" ]]; then
2155
+ current_assignee=$(python3 - "$REGISTRY" "$MANAGE_SELECTED_PID" <<'PY'
2156
+ import sys, yaml
2157
+ c = yaml.safe_load(open(sys.argv[1])) or {}
2158
+ for entry in (c.get('projects') or []):
2159
+ if entry and entry.get('id') == sys.argv[2]:
2160
+ print(entry.get('assigned_to') or ''); sys.exit(0)
2161
+ print('')
2162
+ PY
2163
+ )
2164
+ else
2165
+ current_assignee=$(python3 - "$REGISTRY" "$MANAGE_SELECTED_URL" <<'PY'
2166
+ import sys, yaml
2167
+ c = yaml.safe_load(open(sys.argv[1])) or {}
2168
+ for a in (c.get('pre_assignments') or []):
2169
+ if a and a.get('github_project') == sys.argv[2]:
2170
+ print(a.get('assigned_to') or ''); sys.exit(0)
2171
+ print('')
2172
+ PY
2173
+ )
2174
+ fi
2175
+
2176
+ echo -e " ${BOLD}Current assignee:${NC} $current_assignee"
2177
+ echo ""
2178
+ confirm "Remove assignment from this project?"
2179
+ echo ""
2180
+
2181
+ # Phase 3: revoke WRITE access on the GitHub Project (the real unassignment).
2182
+ [[ -n "$current_assignee" ]] && { bash "$SCRIPTS/project-access.sh" revoke "$MANAGE_SELECTED_URL" "$current_assignee" \
2183
+ || warn "Could not revoke access for '$current_assignee' — revoke manually."; }
2184
+
2185
+ if [[ -n "$MANAGE_SELECTED_PID" ]]; then
2186
+ # Seeded — clear registry entry's assigned_to.
2187
+ python3 - "$REGISTRY" "$MANAGE_SELECTED_PID" <<'PY'
2188
+ import sys, yaml
2189
+ registry_path, pid = sys.argv[1:]
2190
+ with open(registry_path) as f: c = yaml.safe_load(f) or {}
2191
+ for entry in (c.get('projects') or []):
2192
+ if entry and entry.get('id') == pid:
2193
+ entry['assigned_to'] = None
2194
+ break
2195
+ with open(registry_path, 'w') as f:
2196
+ yaml.dump(c, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
2197
+ PY
2198
+ commit_registry "manage: unassign $MANAGE_SELECTED_PID"
2199
+ echo ""
2200
+ ok "'$MANAGE_SELECTED_PID' is now unassigned."
2201
+ else
2202
+ # Unseeded — drop pre_assignment.
2203
+ python3 - "$REGISTRY" "$MANAGE_SELECTED_URL" <<'PY'
2204
+ import sys, yaml
2205
+ registry_path, url = sys.argv[1:]
2206
+ with open(registry_path) as f: c = yaml.safe_load(f) or {}
2207
+ c['pre_assignments'] = [a for a in (c.get('pre_assignments') or []) if a and a.get('github_project') != url]
2208
+ with open(registry_path, 'w') as f:
2209
+ yaml.dump(c, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
2210
+ PY
2211
+ commit_registry "manage: drop pre-assignment for $MANAGE_SELECTED_URL"
2212
+ echo ""
2213
+ ok "Assignment removed. Project is now unassigned."
2214
+ fi
2215
+ }
2216
+
2217
+ cmd_manage() {
2218
+ # Optional non-interactive subcommand: `prj manage list|list-all|assign|...`.
2219
+ local sub="${1:-}"
2220
+ if [[ -z "$sub" ]]; then
2221
+ header "Manage Assignments"
2222
+ divider
2223
+ echo ""
2224
+ echo -e " ${CYAN}1)${NC} list Ongoing assignments (registry cache, excl. completed)"
2225
+ echo -e " ${CYAN}2)${NC} list-all Full board universe (from GitHub)"
2226
+ echo -e " ${CYAN}3)${NC} manage Add/remove owners (project) or assignees (issue) — GitHub-definitive"
2227
+ echo -e " ${DIM}0) Back${NC}"
2228
+ echo ""
2229
+ printf " Choose: "
2230
+ read -r sub
2231
+ echo ""
2232
+ fi
2233
+ case "$sub" in
2234
+ 1|list) cmd_manage_list ;;
2235
+ 2|list-all) cmd_manage_list_all ;;
2236
+ 3|manage|assign|reassign|unassign) cmd_manage_owners ;; # collapsed (#57 §3); old verbs aliased
2237
+ 0|back|"") return ;;
2238
+ *) err "Unknown option '$sub'." ;;
2239
+ esac
2240
+ }
2241
+
2242
+ # ── Main menu ─────────────────────────────────────────────────────────────────
2243
+
2244
+ show_menu() {
2245
+ clear
2246
+ # Show the ACTUAL checked-out branch of the current repo (not the org default
2247
+ # branch) — this is what the developer is on. Fall back to the org default if
2248
+ # cwd isn't a git repo.
2249
+ local cur_branch
2250
+ cur_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "$DEFAULT_BRANCH")
2251
+ echo ""
2252
+ echo -e "${BOLD}${MAGENTA} ▸ ${ORG_NAME} — Agentic Development Framework${NC}"
2253
+ echo -e "${DIM} Org: ${ORG_SLUG} | Branch: ${cur_branch} | User: ${CURRENT_USER}${NC}"
2254
+ echo ""
2255
+ divider
2256
+ echo ""
2257
+ echo -e " ${GREEN}${BOLD}Work${NC} ${DIM}— developers start here${NC}"
2258
+ echo -e " ${GREEN}▸${NC} work Pick a project → init / continue / new task / finish (one front door)"
2259
+ echo ""
2260
+ echo -e " ${CYAN}${BOLD}Status${NC} ${CYAN}list${NC} · ${CYAN}status${NC}"
2261
+ echo ""
2262
+ echo -e " ${YELLOW}${BOLD}Admin${NC} ${DIM}(managers & maintenance)${NC}"
2263
+ echo -e " ${YELLOW}manage${NC} ${DIM}assign / reassign / unassign — grants GitHub Project access${NC}"
2264
+ echo -e " ${YELLOW}knowledge${NC} · ${YELLOW}onboard${NC} · ${YELLOW}upgrade${NC} · ${YELLOW}deps${NC}"
2265
+ echo ""
2266
+ echo -e " ${DIM}Direct lifecycle (normally reached via work):${NC}"
2267
+ echo -e " ${DIM} init · join · task · merge · pause · resume · sync · add-repo · cancel · close${NC}"
2268
+ echo ""
2269
+ echo -e " ${DIM}Type a verb name or number; 0 to exit.${NC}"
2270
+ echo ""
2271
+ divider
2272
+ printf " Choose: "
2273
+ read -r choice
2274
+ echo ""
2275
+ # Write ops need a configured git identity; check before dispatching.
2276
+ case "$choice" in
2277
+ 1|init|2|task|3|merge|4|pause|5|resume|6|sync|7|add-repo|8|cancel|9|close|10|knowledge|11|onboard|15|manage|16|upgrade|start|work|finish)
2278
+ require_git_identity
2279
+ ;;
2280
+ esac
2281
+
2282
+ case "$choice" in
2283
+ 1|init) cmd_init ;;
2284
+ 16|join) cmd_join ;;
2285
+ start) cmd_start ;;
2286
+ work) cmd_work ;;
2287
+ finish) cmd_finish ;;
2288
+ 2|task) cmd_task ;;
2289
+ 3|merge) cmd_merge ;;
2290
+ 4|pause) cmd_pause ;;
2291
+ 5|resume) cmd_resume ;;
2292
+ 6|sync) cmd_sync ;;
2293
+ 7|add-repo) cmd_add_repo ;;
2294
+ 8|cancel) cmd_cancel ;;
2295
+ 9|close) cmd_close ;;
2296
+ 10|knowledge) cmd_knowledge ;;
2297
+ 11|onboard) cmd_onboard ;;
2298
+ 12|list) cmd_list ;;
2299
+ 13|status) cmd_status ;;
2300
+ 14|deps) cmd_deps ;;
2301
+ 15|manage) cmd_manage ;;
2302
+ 16|upgrade) cmd_upgrade ;;
2303
+ 0|q|quit|exit) echo "Bye."; exit 0 ;;
2304
+ *) err "Unknown option '$choice'." ;;
2305
+ esac
2306
+ echo ""
2307
+ printf "${DIM}Press Enter to return to menu...${NC}"
2308
+ read -r
2309
+ show_menu
2310
+ }
2311
+
2312
+ # ── Entry point ───────────────────────────────────────────────────────────────
2313
+
2314
+ CMD="${1:-}"
2315
+
2316
+ # Write commands need a configured git identity (they call git commit
2317
+ # during the flow). Read-only commands don't.
2318
+ case "$CMD" in
2319
+ init|task|merge|pause|resume|sync|add-repo|cancel|close|knowledge|onboard|manage|upgrade|start|work|finish)
2320
+ require_git_identity
2321
+ ;;
2322
+ esac
2323
+
2324
+ case "$CMD" in
2325
+ "") show_menu ;;
2326
+ init) cmd_init ;;
2327
+ join) cmd_join ;;
2328
+ start) cmd_start ;;
2329
+ work) cmd_work ;;
2330
+ finish) cmd_finish ;;
2331
+ task) cmd_task ;;
2332
+ merge) cmd_merge ;;
2333
+ pause) cmd_pause ;;
2334
+ resume) cmd_resume ;;
2335
+ sync) cmd_sync ;;
2336
+ add-repo) cmd_add_repo ;;
2337
+ cancel) cmd_cancel ;;
2338
+ close) cmd_close ;;
2339
+ knowledge) cmd_knowledge ;;
2340
+ onboard) cmd_onboard ;;
2341
+ list) cmd_list ;;
2342
+ list-all) cmd_list all ;;
2343
+ status) cmd_status ;;
2344
+ deps) cmd_deps ;;
2345
+ manage) cmd_manage "${2:-}" ;;
2346
+ upgrade) cmd_upgrade "${2:-}" ;;
2347
+ help|--help|-h)
2348
+ echo ""
2349
+ echo -e "${BOLD}Usage:${NC} ./prj [command]"
2350
+ echo ""
2351
+ echo -e "${BOLD}Commands:${NC}"
2352
+ echo " start Begin work — join / new task / new project"
2353
+ echo " work Continue work — sync your branch and keep going"
2354
+ echo " finish Finish a task (submit) or the project (close)"
2355
+ echo " init Initialize a new project"
2356
+ echo " join Join an existing project (team member)"
2357
+ echo " task Create a task for parallel work"
2358
+ echo " merge Merge a completed task"
2359
+ echo " pause Pause a project"
2360
+ echo " resume Resume a paused project"
2361
+ echo " sync Sync with latest base branches"
2362
+ echo " add-repo Add a repository to a project"
2363
+ echo " cancel Cancel a project"
2364
+ echo " close Close a completed project"
2365
+ echo " knowledge Propose org knowledge changes"
2366
+ echo " onboard Onboard a repository"
2367
+ echo " list List ongoing projects (active/paused)"
2368
+ echo " list-all List all projects, newest first"
2369
+ echo " status Show project status"
2370
+ echo " deps Install / verify dependencies"
2371
+ echo " manage List, assign, reassign, unassign projects"
2372
+ echo " upgrade Pull a new framework version from the template remote"
2373
+ echo ""
2374
+ echo " Run without arguments for the interactive menu."
2375
+ echo ""
2376
+ ;;
2377
+ *)
2378
+ err "Unknown command '$CMD'. Run './prj help' for usage."
2379
+ exit 1
2380
+ ;;
2381
+ esac