@svayam-opensource/prj 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,774 @@
1
+ #!/usr/bin/env bash
2
+ # Script: seed
3
+ # Purpose: Initialize a per-project workspace under $AGENT_WORK_ROOT/<PROJECT_ID>/.
4
+ # Clones the ORG GOVERNANCE repo and each impacted code repo into that
5
+ # workspace on the project branch. The HOME workspace stays on the
6
+ # default branch throughout — never switches.
7
+ # Usage: bash seed.sh [--non-interactive] <github_project_url> <assignee>
8
+ # Compliance: C01 for all validation gates (POL-056 to POL-075)
9
+ #
10
+ # Flags:
11
+ # --non-interactive Skip all interactive prompts. Uses $DEFAULT_CODE_BRANCH
12
+ # as the base branch for every linked repo, and aborts
13
+ # (instead of prompting) if leftover state is detected.
14
+ #
15
+ # Lifecycle invariants (Direction A):
16
+ # - Home workspace stays on $DEFAULT_BRANCH. No `git checkout` of any
17
+ # project branch happens here.
18
+ # - All project branch work lives in $AGENT_WORK_ROOT/<PROJECT_ID>/<workspace_repo>/
19
+ # (a separate clone of ORG GOVERNANCE).
20
+ # - Home's default branch gets a minimal projects/<PROJECT_ID>/.gitkeep stub
21
+ # so the registry entry has a folder for the validator. Full scaffolding
22
+ # (project.yaml, agent.md, knowledge/) lives on the project branch in the
23
+ # per-project workspace, and arrives in default via the close-project merge.
24
+ #
25
+ # Resilience:
26
+ # - Pre-conditions: home is on default, clean, no leftover state.
27
+ # - Tracked side effects roll back on error: created paths removed, pushed
28
+ # remote branches deleted, local registry commit reset.
29
+
30
+ set -euo pipefail
31
+ source "$(dirname "$0")/lib.sh"
32
+ load_config
33
+
34
+ # ── Inputs ────────────────────────────────────────────────────────────────────
35
+
36
+ NON_INTERACTIVE=false
37
+ ARGS=()
38
+ for arg in "$@"; do
39
+ case "$arg" in
40
+ --non-interactive) NON_INTERACTIVE=true ;;
41
+ *) ARGS+=("$arg") ;;
42
+ esac
43
+ done
44
+
45
+ GITHUB_PROJECT_URL="${ARGS[0]:-}"
46
+ ASSIGNEE="${ARGS[1]:-}"
47
+
48
+ [[ -n "$GITHUB_PROJECT_URL" ]] || hard_stop "Usage: $0 [--non-interactive] <github_project_url> <assignee>"
49
+ [[ -n "$ASSIGNEE" ]] || hard_stop "Usage: $0 [--non-interactive] <github_project_url> <assignee>"
50
+
51
+ [[ -n "$ORG_REPO_URL" ]] \
52
+ || hard_stop "org_repo_url not set in org-config.yaml. Run ./setup.sh first."
53
+
54
+ echo "=== seed: $GITHUB_PROJECT_URL"
55
+ echo " Assignee: $ASSIGNEE"
56
+ echo " Agent work root: $AGENT_WORK_ROOT"
57
+ echo ""
58
+
59
+ # ── Rollback machinery ────────────────────────────────────────────────────────
60
+ # Track artifacts created during this run so they can be reversed on failure.
61
+ # Each list entry uses '<path>|<value>' to avoid bash3 associative arrays.
62
+
63
+ CREATED_LOCAL_BRANCHES=()
64
+ PUSHED_REMOTE_BRANCHES=()
65
+ CREATED_WORKTREES=()
66
+ CREATED_PATHS=()
67
+ HOME_PRE_SEED_SHA=""
68
+ SEED_OK=0
69
+
70
+ run_rollback() {
71
+ local exit_code=$?
72
+ if [[ "$SEED_OK" == "1" ]]; then return 0; fi
73
+ if [[ ${#CREATED_LOCAL_BRANCHES[@]} -eq 0 \
74
+ && ${#PUSHED_REMOTE_BRANCHES[@]} -eq 0 \
75
+ && ${#CREATED_WORKTREES[@]} -eq 0 \
76
+ && ${#CREATED_PATHS[@]} -eq 0 \
77
+ && -z "$HOME_PRE_SEED_SHA" ]]; then
78
+ return 0
79
+ fi
80
+ echo ""
81
+ warn "seed.sh failed (exit $exit_code). Rolling back partial state..."
82
+
83
+ # Delete pushed remote branches (these may exist if Phase B/C succeeded
84
+ # before a later phase failed).
85
+ for ((i=${#PUSHED_REMOTE_BRANCHES[@]}-1; i>=0; i--)); do
86
+ local entry="${PUSHED_REMOTE_BRANCHES[$i]}"
87
+ local path="${entry%%|*}"
88
+ local branch="${entry#*|}"
89
+ git -C "$path" push origin --delete "$branch" 2>/dev/null || true
90
+ done
91
+
92
+ # Reset HOME workspace if we made a local commit but haven't pushed it.
93
+ if [[ -n "$HOME_PRE_SEED_SHA" ]]; then
94
+ local current_home_sha
95
+ current_home_sha=$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo "")
96
+ if [[ "$current_home_sha" != "$HOME_PRE_SEED_SHA" ]]; then
97
+ info "Reverting home workspace commit ($current_home_sha → $HOME_PRE_SEED_SHA)"
98
+ git -C "$REPO_ROOT" reset --hard "$HOME_PRE_SEED_SHA" 2>/dev/null || true
99
+ git -C "$REPO_ROOT" clean -fd projects 2>/dev/null || true
100
+ fi
101
+ fi
102
+
103
+ # Local branches in clones (mostly cosmetic — the clones themselves are
104
+ # deleted below, but switch off first to keep git happy).
105
+ for ((i=${#CREATED_LOCAL_BRANCHES[@]}-1; i>=0; i--)); do
106
+ local entry="${CREATED_LOCAL_BRANCHES[$i]}"
107
+ local path="${entry%%|*}"
108
+ local branch="${entry#*|}"
109
+ if [[ -d "$path/.git" ]]; then
110
+ local current=$(git -C "$path" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
111
+ if [[ "$current" == "$branch" ]]; then
112
+ git -C "$path" checkout - 2>/dev/null \
113
+ || git -C "$path" checkout "$DEFAULT_BRANCH" 2>/dev/null \
114
+ || git -C "$path" checkout main 2>/dev/null || true
115
+ fi
116
+ git -C "$path" branch -D "$branch" 2>/dev/null || true
117
+ fi
118
+ done
119
+
120
+ # Worktrees (ADR-0001 Phase 2): remove each worktree from its base repo,
121
+ # then delete the now-unchecked-out branch. Must run BEFORE the path rm
122
+ # below so the base's worktree registry stays consistent. rm + prune is the
123
+ # fallback if `worktree remove` refuses.
124
+ for ((i=${#CREATED_WORKTREES[@]}-1; i>=0; i--)); do
125
+ local wentry="${CREATED_WORKTREES[$i]}"
126
+ local wbase="${wentry%%|*}"; local wrest="${wentry#*|}"
127
+ local wpath="${wrest%%|*}"; local wbranch="${wrest#*|}"
128
+ git -C "$wbase" worktree remove --force "$wpath" 2>/dev/null \
129
+ || { rm -rf "$wpath"; git -C "$wbase" worktree prune 2>/dev/null || true; }
130
+ git -C "$wbase" branch -D "$wbranch" 2>/dev/null || true
131
+ done
132
+
133
+ # Created filesystem paths (reverse order — innermost first)
134
+ for ((i=${#CREATED_PATHS[@]}-1; i>=0; i--)); do
135
+ [[ -n "${CREATED_PATHS[$i]}" ]] && rm -rf "${CREATED_PATHS[$i]}"
136
+ done
137
+
138
+ warn "Rollback complete. Home workspace restored, remote branches deleted."
139
+ }
140
+
141
+ trap 'run_rollback' EXIT
142
+
143
+ # ── Pre-conditions on the HOME workspace ─────────────────────────────────────
144
+
145
+ cd "$REPO_ROOT"
146
+
147
+ CURRENT_HOME_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
148
+ if [[ "$CURRENT_HOME_BRANCH" != "$DEFAULT_BRANCH" ]]; then
149
+ hard_stop "Home workspace is on '$CURRENT_HOME_BRANCH', expected '$DEFAULT_BRANCH'.
150
+ The home workspace must stay on $DEFAULT_BRANCH (project branches live only in
151
+ per-project workspaces under \$AGENT_WORK_ROOT). Switch back first:
152
+ git checkout $DEFAULT_BRANCH"
153
+ fi
154
+
155
+ if [[ -n "$(git status --porcelain)" ]]; then
156
+ hard_stop "Home workspace has uncommitted changes. Commit or stash first."
157
+ fi
158
+
159
+ # Fetch + ff-pull default to ensure we're current with origin.
160
+ info "Fetching latest $DEFAULT_BRANCH from origin..."
161
+ git fetch origin "$DEFAULT_BRANCH" >/dev/null 2>&1 || true
162
+ if ! git pull --ff-only origin "$DEFAULT_BRANCH" >/dev/null 2>&1; then
163
+ warn "Could not fast-forward home workspace. Proceeding with local state."
164
+ fi
165
+
166
+ # ── C01 Validation Gates ──────────────────────────────────────────────────────
167
+
168
+ echo "[ C01 ] Validating GitHub Project..."
169
+
170
+ PROJECT_NUMBER=$(echo "$GITHUB_PROJECT_URL" | grep -oE '/projects/[0-9]+' | grep -oE '[0-9]+') \
171
+ || hard_stop "Cannot extract project number from: $GITHUB_PROJECT_URL"
172
+ [[ -n "$PROJECT_NUMBER" ]] || hard_stop "Cannot extract project number from: $GITHUB_PROJECT_URL"
173
+
174
+ if echo "$GITHUB_PROJECT_URL" | grep -q '/orgs/'; then
175
+ PROJECT_OWNER=$(echo "$GITHUB_PROJECT_URL" | sed 's|.*/orgs/\([^/]*\)/.*|\1|')
176
+ OWNER_FIELD="organization"
177
+ else
178
+ PROJECT_OWNER=$(echo "$GITHUB_PROJECT_URL" | sed 's|.*/users/\([^/]*\)/.*|\1|')
179
+ OWNER_FIELD="user"
180
+ fi
181
+
182
+ info "Owner: $PROJECT_OWNER ($OWNER_FIELD), Project #$PROJECT_NUMBER"
183
+
184
+ PROJECT_DATA=$(gh api graphql -f query="
185
+ query {
186
+ ${OWNER_FIELD}(login: \"$PROJECT_OWNER\") {
187
+ projectV2(number: $PROJECT_NUMBER) {
188
+ id
189
+ title
190
+ shortDescription
191
+ items(first: 50) {
192
+ nodes {
193
+ content {
194
+ ... on Issue { url repository { url } }
195
+ ... on PullRequest { url repository { url } }
196
+ }
197
+ }
198
+ }
199
+ }
200
+ }
201
+ }") || hard_stop "GitHub Project not found or not accessible. Check URL and permissions."
202
+
203
+ PROJECT_TITLE=$(echo "$PROJECT_DATA" | python3 -c "
204
+ import sys, json
205
+ d = json.load(sys.stdin)
206
+ p = list(d['data'].values())[0]['projectV2']
207
+ print(p.get('title') or '')
208
+ ")
209
+ [[ -n "$PROJECT_TITLE" ]] || hard_stop "GitHub Project has no name."
210
+ info "Project title: $PROJECT_TITLE"
211
+
212
+ ITEM_COUNT=$(echo "$PROJECT_DATA" | python3 -c "
213
+ import sys, json
214
+ d = json.load(sys.stdin)
215
+ p = list(d['data'].values())[0]['projectV2']
216
+ print(sum(1 for i in p['items']['nodes'] if i.get('content')))
217
+ ")
218
+ [[ "$ITEM_COUNT" -gt 0 ]] || hard_stop "GitHub Project has no linked Issues or PRs."
219
+ info "Linked items: $ITEM_COUNT"
220
+
221
+ PROJECT_DESC=$(echo "$PROJECT_DATA" | python3 -c "
222
+ import sys, json
223
+ d = json.load(sys.stdin)
224
+ p = list(d['data'].values())[0]['projectV2']
225
+ print(p.get('shortDescription') or '')
226
+ " 2>/dev/null || echo "")
227
+ [[ -n "$PROJECT_DESC" ]] || warn "Project has no description"
228
+
229
+ echo "[ C01 ] Validation passed."
230
+ echo ""
231
+
232
+ # ── Compute project ID ────────────────────────────────────────────────────────
233
+
234
+ SHORT_SLUG=$(slugify "$PROJECT_TITLE")
235
+ # A title that is all-punctuation / non-ASCII / '..' slugifies to empty, which
236
+ # would compose a malformed 'PRJ-NNN-' / 'brnch-NNN-' (§5 slug-empty finding).
237
+ [[ -n "$SHORT_SLUG" ]] \
238
+ || hard_stop "Project title '$PROJECT_TITLE' produced an empty slug. Rename the GitHub Project to include ASCII alphanumerics."
239
+ LAST_ISSUED=$(yaml_get "$REGISTRY" "last_issued")
240
+ NNN=$(printf "%03d" $((LAST_ISSUED + 1)))
241
+ PROJECT_ID="PRJ-${NNN}-${SHORT_SLUG}"
242
+ # POL-069 (scheme B): the project branch is keyed on the GitHub project NUMBER
243
+ # (not the registry NNN) — e.g. PRJ-27-<slug> for project PRJ-013-<slug>. seed
244
+ # stores this in registry/project.yaml so project_branch_for_id reads it
245
+ # everywhere (existing brnch-NNN projects keep their stored name).
246
+ BRANCH="PRJ-${PROJECT_NUMBER}-${SHORT_SLUG}"
247
+ TODAY=$(today)
248
+ NEW_LAST_ISSUED=$((LAST_ISSUED + 1))
249
+
250
+ PROJECT_WORK_ROOT="$AGENT_WORK_ROOT/$PROJECT_ID"
251
+ ORG_GOV_CLONE="$PROJECT_WORK_ROOT/$WORKSPACE_REPO"
252
+
253
+ echo "Project ID : $PROJECT_ID"
254
+ echo "Branch : $BRANCH"
255
+ echo "Per-project root : $PROJECT_WORK_ROOT"
256
+ echo "ORG GOV clone : $ORG_GOV_CLONE"
257
+ echo ""
258
+
259
+ # ── Leftover-state detection ──────────────────────────────────────────────────
260
+
261
+ LEFTOVER=()
262
+
263
+ if git -C "$REPO_ROOT" rev-parse --verify "$BRANCH" &>/dev/null; then
264
+ LEFTOVER+=("local branch '$BRANCH' in home workspace")
265
+ fi
266
+ if git ls-remote --exit-code --heads origin "$BRANCH" &>/dev/null; then
267
+ LEFTOVER+=("remote branch 'origin/$BRANCH' on $ORG_REPO_URL")
268
+ fi
269
+ if [[ -d "$PROJECT_WORK_ROOT" ]]; then
270
+ LEFTOVER+=("per-project workspace at '$PROJECT_WORK_ROOT'")
271
+ fi
272
+ if [[ -d "$REPO_ROOT/projects/$PROJECT_ID" ]]; then
273
+ LEFTOVER+=("home stub folder 'projects/$PROJECT_ID/' on $DEFAULT_BRANCH")
274
+ fi
275
+ if python3 - "$REGISTRY" "$PROJECT_ID" <<'PY'
276
+ import sys, yaml
277
+ c = yaml.safe_load(open(sys.argv[1])) or {}
278
+ ids = [p.get('id') for p in (c.get('projects') or []) if p]
279
+ sys.exit(0 if sys.argv[2] in ids else 1)
280
+ PY
281
+ then
282
+ LEFTOVER+=("registry entry for '$PROJECT_ID'")
283
+ fi
284
+
285
+ if [[ ${#LEFTOVER[@]} -gt 0 ]]; then
286
+ echo ""
287
+ warn "Detected leftover state from a previous failed run:"
288
+ for item in "${LEFTOVER[@]}"; do echo " - $item"; done
289
+ echo ""
290
+ if $NON_INTERACTIVE; then
291
+ hard_stop "Leftover state detected and --non-interactive is set.
292
+ Clean up manually, then re-run."
293
+ fi
294
+
295
+ echo "Options:"
296
+ echo " (a) Clean up these artifacts and start fresh"
297
+ echo " (b) Abort — inspect manually"
298
+ echo ""
299
+ printf "Choose [a/b]: "
300
+ read -r choice </dev/tty
301
+ case "$choice" in
302
+ a|A)
303
+ info "Cleaning up partial state..."
304
+ # Remote branch on workspace repo
305
+ git push origin --delete "$BRANCH" 2>/dev/null || true
306
+ # Local branch (should not exist on home in the new model — we never
307
+ # create one there — but clean defensively)
308
+ git branch -D "$BRANCH" 2>/dev/null || true
309
+ # Per-project workspace
310
+ rm -rf "$PROJECT_WORK_ROOT"
311
+ # Home stub folder (was created by a prior partial seed)
312
+ rm -rf "$REPO_ROOT/projects/$PROJECT_ID"
313
+ # Stray registry entry
314
+ python3 - "$REGISTRY" "$PROJECT_ID" <<'PY' 2>/dev/null || true
315
+ import sys, yaml
316
+ registry, pid = sys.argv[1:]
317
+ with open(registry) as f: c = yaml.safe_load(f) or {}
318
+ c['projects'] = [p for p in (c.get('projects') or []) if p and p.get('id') != pid]
319
+ with open(registry, 'w') as f:
320
+ yaml.dump(c, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
321
+ PY
322
+ git checkout -- registry.yaml 2>/dev/null || true
323
+ info "Cleanup complete. Continuing seed..."
324
+ echo ""
325
+ ;;
326
+ *)
327
+ hard_stop "Aborted at user request."
328
+ ;;
329
+ esac
330
+ fi
331
+
332
+ # ── Discover linked repos + prompt for base branches ─────────────────────────
333
+
334
+ # The workspace repo is an implicit participant in every project and must
335
+ # never be processed as a code repo (POL-057); otherwise its code-repo clone
336
+ # path collides with the gov clone and seed false-positives on "branch already
337
+ # exists" (line ~645). Filter it out of discovered repos here.
338
+ REPO_URLS=$(PROJECT_DATA="$PROJECT_DATA" python3 <<'PY'
339
+ import os, json, re
340
+ org = (os.environ.get('GITHUB_ORG') or '').lower()
341
+ wsrepo = (os.environ.get('WORKSPACE_REPO') or '').lower()
342
+ orgurl = (os.environ.get('ORG_REPO_URL') or '').lower()
343
+
344
+ def slug(u):
345
+ if not u: return ''
346
+ u = re.sub(r'\.git$', '', u.strip().lower()).rstrip('/')
347
+ m = re.search(r'[:/]([^/:]+)/([^/]+)$', u) # owner/name from ssh or https
348
+ return f'{m.group(1)}/{m.group(2)}' if m else u
349
+
350
+ ws = set()
351
+ if org and wsrepo: ws.add(f'{org}/{wsrepo}')
352
+ if orgurl: ws.add(slug(orgurl))
353
+
354
+ d = json.loads(os.environ.get('PROJECT_DATA') or '{}')
355
+ p = list(d['data'].values())[0]['projectV2']
356
+ seen = set()
357
+ for i in p['items']['nodes']:
358
+ c = i.get('content') or {}
359
+ r = (c.get('repository') or {}).get('url')
360
+ if not r or r in seen:
361
+ continue
362
+ if slug(r) in ws: # workspace repo is implicit (POL-057) — skip
363
+ continue
364
+ seen.add(r)
365
+ print(r)
366
+ PY
367
+ )
368
+
369
+ REPO_URL_LIST=()
370
+ REPO_BASE_LIST=()
371
+
372
+ if [[ -n "$REPO_URLS" ]]; then
373
+ while IFS= read -r line; do
374
+ [[ -z "$line" ]] && continue
375
+ REPO_URL_LIST+=("$line")
376
+ done <<< "$REPO_URLS"
377
+
378
+ for repo_url in "${REPO_URL_LIST[@]}"; do
379
+ # Pre-flight (ADR-0001): read the repo's real branches up front so a wrong
380
+ # repo or base is obvious HERE — before any registry commit or worktree —
381
+ # instead of failing mid-Phase-C and rolling back. Default to the repo's
382
+ # actual default branch, not the org-wide one.
383
+ _heads=$(git ls-remote --heads "$repo_url" 2>/dev/null | sed -E 's#.*refs/heads/##')
384
+ [[ -n "$_heads" ]] || hard_stop "Could not read branches of '$repo_url' (wrong repo URL, or no access?)."
385
+ _default=$(git ls-remote --symref "$repo_url" HEAD 2>/dev/null \
386
+ | sed -nE 's#^ref:[[:space:]]+refs/heads/([^[:space:]]+)[[:space:]]+HEAD#\1#p')
387
+ [[ -n "$_default" ]] || _default="$DEFAULT_CODE_BRANCH"
388
+ if $NON_INTERACTIVE; then
389
+ base="$_default"
390
+ echo " Base branch for '$repo_url': $base (--non-interactive)"
391
+ else
392
+ echo " branches in $repo_url: $(echo "$_heads" | tr '\n' ' ')"
393
+ printf " Base branch for '%s' [%s]: " "$repo_url" "$_default"
394
+ read -r input_base </dev/tty
395
+ base="${input_base:-$_default}"
396
+ fi
397
+ grep -qx "$base" <<< "$_heads" \
398
+ || hard_stop "Base branch '$base' not found in '$repo_url'. Available: $(echo "$_heads" | tr '\n' ' '). (Wrong repo or base?)"
399
+ REPO_BASE_LIST+=("$base")
400
+ done
401
+ else
402
+ warn "No repos detected from linked items — project.yaml repos[] will be a placeholder."
403
+ fi
404
+
405
+ get_repo_base() {
406
+ local target="$1" i
407
+ for ((i=0; i<${#REPO_URL_LIST[@]}; i++)); do
408
+ if [[ "${REPO_URL_LIST[$i]}" == "$target" ]]; then
409
+ echo "${REPO_BASE_LIST[$i]}"
410
+ return 0
411
+ fi
412
+ done
413
+ echo "$DEFAULT_CODE_BRANCH"
414
+ }
415
+
416
+ CURRENT_USER=$(git config user.email 2>/dev/null || echo "$ASSIGNEE")
417
+
418
+ is_authorized_for_project "$GITHUB_PROJECT_URL" "$ASSIGNEE" \
419
+ || hard_stop "Not authorized: '$CURRENT_USER' needs write access to the GitHub Project ($GITHUB_PROJECT_URL) to seed it."
420
+
421
+ # ── Phase A: HOME workspace, default branch — registry stub + folder stub ──
422
+ # We commit locally but do NOT push yet — pushing happens at the very end
423
+ # once every other phase has succeeded. If something fails in B/C, we just
424
+ # git reset --hard back to the pre-seed SHA (recorded below).
425
+
426
+ HOME_PRE_SEED_SHA=$(git -C "$REPO_ROOT" rev-parse HEAD)
427
+
428
+ info "Phase A: updating home registry + creating projects/$PROJECT_ID/ stub..."
429
+
430
+ python3 - "$REGISTRY" "$PROJECT_ID" "$BRANCH" "$ASSIGNEE" "$TODAY" "$GITHUB_PROJECT_URL" "$NEW_LAST_ISSUED" "$PROJECT_OWNER" "$CURRENT_USER" <<'PY'
431
+ import sys, yaml
432
+ registry, pid, branch, assignee, today, gh_url, new_last, owner, seeded = sys.argv[1:]
433
+ with open(registry) as f: c = yaml.safe_load(f) or {}
434
+ c['last_issued'] = int(new_last)
435
+ if not c.get('projects'): c['projects'] = []
436
+ c['projects'].append({
437
+ 'id': pid,
438
+ 'branch': branch,
439
+ 'github_project': gh_url,
440
+ 'github_owner': owner,
441
+ 'assigned_to': assignee,
442
+ 'seeded_by': seeded,
443
+ 'created_at': today,
444
+ 'status': 'active',
445
+ })
446
+ # Drop any matching pre_assignment now that we have a real registry entry.
447
+ c['pre_assignments'] = [a for a in (c.get('pre_assignments') or [])
448
+ if a and a.get('github_project') != gh_url]
449
+ with open(registry, 'w') as f:
450
+ yaml.dump(c, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
451
+ PY
452
+
453
+ mkdir -p "$REPO_ROOT/projects/$PROJECT_ID"
454
+ cat > "$REPO_ROOT/projects/$PROJECT_ID/.gitkeep" <<EOF
455
+ # Active project — full content lives on branch '$BRANCH'.
456
+ #
457
+ # This folder is a stub on $DEFAULT_BRANCH so the registry entry has a folder
458
+ # to point at (validator requirement). The full project content (project.yaml,
459
+ # agent.md, knowledge/, etc.) lives on branch '$BRANCH' inside the per-project
460
+ # workspace at:
461
+ #
462
+ # $AGENT_WORK_ROOT/$PROJECT_ID/$WORKSPACE_REPO/projects/$PROJECT_ID/
463
+ #
464
+ # On close-project, the project branch merges back to $DEFAULT_BRANCH and the
465
+ # full content arrives here, overwriting this stub.
466
+ EOF
467
+
468
+ git -C "$REPO_ROOT" add registry.yaml "projects/$PROJECT_ID/.gitkeep"
469
+ git -C "$REPO_ROOT" commit -m "seed: register project $PROJECT_ID (assigned to $ASSIGNEE)" >/dev/null
470
+ info " ✓ home commit recorded locally (will push after all phases succeed)"
471
+
472
+ # ── Phase B: per-project workspace — clone ORG GOVERNANCE on project branch ──
473
+
474
+ info "Phase B: cloning ORG GOVERNANCE into per-project workspace..."
475
+
476
+ mkdir -p "$PROJECT_WORK_ROOT"
477
+ CREATED_PATHS+=("$PROJECT_WORK_ROOT")
478
+
479
+ # ADR-0001 Phase 2: materialize the per-project governance workspace as a
480
+ # WORKTREE of the home clone (REPO_ROOT), not a fresh local clone. A worktree
481
+ # already carries REPO_ROOT's just-committed registry stub and shares its
482
+ # origin remote, so the old clone + remote re-point is unnecessary. The new
483
+ # project branch is created off $DEFAULT_BRANCH (the home branch).
484
+ git -C "$REPO_ROOT" worktree add -b "$BRANCH" "$ORG_GOV_CLONE" "$DEFAULT_BRANCH" >/dev/null 2>&1 \
485
+ || hard_stop "Failed to create governance worktree at $ORG_GOV_CLONE"
486
+ CREATED_WORKTREES+=("$REPO_ROOT|$ORG_GOV_CLONE|$BRANCH")
487
+
488
+ # Carry the developer's git identity into the per-project workspace so commits
489
+ # and C01 authorization here reflect the seeder, not the ambient global.
490
+ set_clone_identity "$ORG_GOV_CLONE"
491
+ info " ✓ created branch '$BRANCH' in ORG GOV worktree"
492
+
493
+ # ── Phase B.1: scaffold projects/<PID>/* inside the clone ────────────────────
494
+
495
+ PROJECT_DIR="$ORG_GOV_CLONE/projects/$PROJECT_ID"
496
+ rm -f "$PROJECT_DIR/.gitkeep" # we're about to write real content
497
+ mkdir -p "$PROJECT_DIR"/{requirements,environment,knowledge}
498
+
499
+ # todo.md from template. The template header placeholder is the full
500
+ # project-id pattern 'PRJ-NNN-<slug>'; substitute the concrete PROJECT_ID.
501
+ TODO_TEMPLATE="$ORG_GOV_CLONE/framework/knowledge/guidance/todo-template.md"
502
+ if [[ -f "$TODO_TEMPLATE" ]]; then
503
+ sed "s/PRJ-NNN-<slug>/$PROJECT_ID/g" "$TODO_TEMPLATE" > "$PROJECT_DIR/knowledge/todo.md"
504
+ fi
505
+
506
+ # Build repos[] YAML fragment
507
+ REPOS_BLOCK=""
508
+ if [[ ${#REPO_URL_LIST[@]} -gt 0 ]]; then
509
+ for repo_url in "${REPO_URL_LIST[@]}"; do
510
+ base=$(get_repo_base "$repo_url")
511
+ REPOS_BLOCK+=" - url: $repo_url"$'\n'
512
+ REPOS_BLOCK+=" role: primary"$'\n'
513
+ REPOS_BLOCK+=" base_branch: $base"$'\n'
514
+ REPOS_BLOCK+=" added_at: $TODAY"$'\n'
515
+ REPOS_BLOCK+=" added_reason: ~"$'\n'
516
+ done
517
+ else
518
+ REPOS_BLOCK=" - url: ~"$'\n'
519
+ REPOS_BLOCK+=" role: primary"$'\n'
520
+ REPOS_BLOCK+=" base_branch: $DEFAULT_CODE_BRANCH"$'\n'
521
+ REPOS_BLOCK+=" added_at: ~"$'\n'
522
+ REPOS_BLOCK+=" added_reason: ~"$'\n'
523
+ fi
524
+
525
+ # Quote string scalars for YAML safety.
526
+ # Escape backslash FIRST, then the double-quote, so an untrusted value ending
527
+ # in '\' (e.g. a GitHub Project title) cannot escape the closing quote and
528
+ # inject YAML (C10). Order matters: backslash before quote.
529
+ yaml_quote() {
530
+ printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g')"
531
+ }
532
+ Q_PROJECT_ID=$(yaml_quote "$PROJECT_ID")
533
+ Q_SHORT_SLUG=$(yaml_quote "$SHORT_SLUG")
534
+ Q_GITHUB_PROJECT_URL=$(yaml_quote "$GITHUB_PROJECT_URL")
535
+ Q_PROJECT_TITLE=$(yaml_quote "$PROJECT_TITLE")
536
+ Q_ASSIGNEE=$(yaml_quote "$ASSIGNEE")
537
+ Q_CURRENT_USER=$(yaml_quote "$CURRENT_USER")
538
+ Q_BRANCH=$(yaml_quote "$BRANCH")
539
+
540
+ cat > "$PROJECT_DIR/project.yaml" <<YAML
541
+ id: $Q_PROJECT_ID
542
+ slug: $Q_SHORT_SLUG
543
+ branch: $Q_BRANCH
544
+ description: ~
545
+ github_project: $Q_GITHUB_PROJECT_URL
546
+ github_project_name: $Q_PROJECT_TITLE
547
+ assigned_to: $Q_ASSIGNEE
548
+ seeded_by: $Q_CURRENT_USER
549
+ status: active
550
+ created_at: $TODAY
551
+ started_at: $TODAY
552
+ completed_at: ~
553
+ paused_at: ~
554
+ cancelled_at: ~
555
+ cancellation_reason: ~
556
+ repos:
557
+ $REPOS_BLOCK
558
+ knowledge_status: ~
559
+ knowledge_pr: ~
560
+ agent_config:
561
+ model: auto
562
+ provider: cursor
563
+ YAML
564
+
565
+ cat > "$PROJECT_DIR/agent.md" <<MD
566
+ # $PROJECT_TITLE — Project Agent Entry Point
567
+ # Project: $PROJECT_ID | Branch: $BRANCH
568
+
569
+ This file is the project-specific entrypoint. Combined with the framework's
570
+ universal session-start protocol (CLAUDE.md / AGENTS.md / etc. at repo root),
571
+ it tells you everything you need to start work on $PROJECT_ID.
572
+
573
+ ## Working Directory
574
+
575
+ Your per-project workspace lives at:
576
+
577
+ $PROJECT_WORK_ROOT/
578
+
579
+ Inside it:
580
+
581
+ - \`$WORKSPACE_REPO/\` — clone of ORG GOVERNANCE on branch \`$BRANCH\`. This is where
582
+ you are right now. \`projects/$PROJECT_ID/\` here is your project metadata workspace.
583
+ $([[ ${#REPO_URL_LIST[@]} -gt 0 ]] && for u in "${REPO_URL_LIST[@]}"; do
584
+ rn=$(get_repo_name "$u"); echo "- \`$rn/\` — clone of $u on branch \`$BRANCH\`. Code changes go here.";
585
+ done)
586
+
587
+ ## Knowledge Layer Priority
588
+
589
+ 1. **Org-wide knowledge** → \`$WORKSPACE_REPO/knowledge/\` (read-only this project)
590
+ 2. **This project** → \`$WORKSPACE_REPO/projects/$PROJECT_ID/knowledge/\`
591
+ 3. **Repo-local** → \`<repo>/knowledge/\` in each cloned code repo
592
+ 4. **Your developer preferences** → \`$AGENT_WORK_ROOT/preferences/<your-gh-login>.md\`
593
+ - At session start, run \`gh api user --jq .login\` to determine your handle.
594
+ - Load only the file matching your handle.
595
+
596
+ ## Session Start Checklist (C01)
597
+
598
+ 1. Verify you are authorized: you have **write access to this project's linked
599
+ GitHub Project** (the authorization source of truth; an owner grants it via
600
+ \`./prj manage assign\`). \`assigned_to\` in \`project.yaml\` is a display
601
+ cache, not the gate. On a task sub-branch, confirm its assignee is you.
602
+ 2. Verify \`status: active\` in \`project.yaml\`.
603
+ 3. Read \`projects/$PROJECT_ID/knowledge/todo.md\` and surface \`## Open\`
604
+ items before planning new work.
605
+ 4. Load all four knowledge layers fresh.
606
+
607
+ ## Operational Workflow
608
+
609
+ 1. Pick an issue from the GitHub Project board: $GITHUB_PROJECT_URL
610
+ 2. Start a task sub-branch: \`./prj task <issue-url>\`
611
+ (creates \`$BRANCH/<task-slug>\` in this clone + each code repo clone)
612
+ 3. Do code work in the cloned code repos on the task sub-branch.
613
+ Capture decisions, exceptions, and policy notes in
614
+ \`projects/$PROJECT_ID/knowledge/\` as you go (not at session end).
615
+ Capture intermediate to-dos in \`projects/$PROJECT_ID/knowledge/todo.md\`
616
+ under \`## Open\` as they arise.
617
+ 4. When the task is complete: \`./prj merge\` (merges sub-branch into \`$BRANCH\`).
618
+ 5. When the whole project is complete: \`./prj close\` (merges \`$BRANCH\` back to
619
+ $DEFAULT_BRANCH in ORG GOVERNANCE, archives, fires knowledge-proposal PR).
620
+
621
+ ## Do Not
622
+
623
+ - Never hand-manage task state — tasks are GitHub Issues on the board; use \`./prj task\` / \`./prj merge\`.
624
+ - Create GitHub Issues unilaterally — those are humans-only.
625
+ - Touch \`$WORKSPACE_REPO/knowledge/\` — read-only this project.
626
+ - Push the project branch from the home ORG GOVERNANCE checkout — that
627
+ checkout stays on $DEFAULT_BRANCH. All project-branch work happens here.
628
+ MD
629
+
630
+ # Per-tool agent rule files: copy framework-level files into the project
631
+ # workspace, substituting org values + project ID. The per-project copies have
632
+ # baked-in values (no <ORG_NAME> tokens) so the agent has full context.
633
+ TOOL_FILES=(
634
+ "AGENTS.md"
635
+ "CONVENTIONS.md"
636
+ ".cursor/rules/agent.mdc"
637
+ ".clinerules/agent.md"
638
+ ".windsurf/rules/agent.md"
639
+ ".github/copilot-instructions.md"
640
+ ".gemini/styleguide.md"
641
+ ".continue/rules.md"
642
+ "CLAUDE.md"
643
+ )
644
+
645
+ for rel in "${TOOL_FILES[@]}"; do
646
+ src="$ORG_GOV_CLONE/framework/$rel"
647
+ dst="$PROJECT_DIR/$rel"
648
+ [[ -f "$src" ]] || continue
649
+ mkdir -p "$(dirname "$dst")"
650
+ # Substitute org values + the per-project ID/branch/paths.
651
+ ORG_NAME_V="$ORG_NAME" ORG_SHORT_NAME_V="$ORG_SHORT_NAME" \
652
+ ORG_SLUG_V="$ORG_SLUG" ORG_SLUG_LOWER_V="$ORG_SLUG_LOWER" \
653
+ GITHUB_ORG_V="$GITHUB_ORG" WORKSPACE_REPO_V="$WORKSPACE_REPO" \
654
+ DEFAULT_BRANCH_V="$DEFAULT_BRANCH" DEFAULT_CODE_BRANCH_V="$DEFAULT_CODE_BRANCH" \
655
+ AGENT_WORK_ROOT_V="$AGENT_WORK_ROOT" \
656
+ POLICY_OWNER_EMAIL_V="$POLICY_OWNER_EMAIL" \
657
+ PROJECT_ID_V="$PROJECT_ID" BRANCH_V="$BRANCH" \
658
+ perl -pe '
659
+ s|<ORG_NAME>|$ENV{ORG_NAME_V}|g;
660
+ s|<ORG_SHORT_NAME>|$ENV{ORG_SHORT_NAME_V}|g;
661
+ s|<ORG_SLUG>|$ENV{ORG_SLUG_V}|g;
662
+ s|<org_slug>|$ENV{ORG_SLUG_LOWER_V}|g;
663
+ s|<GITHUB_ORG>|$ENV{GITHUB_ORG_V}|g;
664
+ s|<WORKSPACE_REPO>|$ENV{WORKSPACE_REPO_V}|g;
665
+ s|<DEFAULT_BRANCH>|$ENV{DEFAULT_BRANCH_V}|g;
666
+ s|<DEFAULT_CODE_BRANCH>|$ENV{DEFAULT_CODE_BRANCH_V}|g;
667
+ s|<AGENT_WORK_ROOT>|$ENV{AGENT_WORK_ROOT_V}|g;
668
+ s|<POLICY_OWNER_EMAIL>|$ENV{POLICY_OWNER_EMAIL_V}|g;
669
+ s|<PROJECT_ID>|$ENV{PROJECT_ID_V}|g;
670
+ ' "$src" > "$dst"
671
+ done
672
+
673
+ info " ✓ scaffolded $PROJECT_DIR"
674
+
675
+ # Commit the scaffold on the project branch
676
+ git -C "$ORG_GOV_CLONE" add "projects/$PROJECT_ID"
677
+ git -C "$ORG_GOV_CLONE" commit -m "seed: scaffold project content for $PROJECT_ID" >/dev/null
678
+
679
+ # ── Phase C: clone code repos into per-project workspace ─────────────────────
680
+
681
+ if [[ ${#REPO_URL_LIST[@]} -gt 0 ]]; then
682
+ info "Phase C: cloning code repos into $PROJECT_WORK_ROOT/..."
683
+ for repo_url in "${REPO_URL_LIST[@]}"; do
684
+ REPO_NAME=$(get_repo_name "$repo_url")
685
+ REPO_BASE=$(get_repo_base "$repo_url")
686
+ REPO_DIR="$PROJECT_WORK_ROOT/$REPO_NAME"
687
+
688
+ info " setting up $repo_url → $REPO_DIR (worktree, base: $REPO_BASE)..."
689
+ # ADR-0001 Phase 2: one shared base clone per repo, project branch as a
690
+ # worktree off the base branch.
691
+ REPO_BASE_CLONE="$(base_clone_dir "$repo_url")"
692
+ if [[ ! -e "$REPO_BASE_CLONE/.git" ]]; then
693
+ mkdir -p "$(dirname "$REPO_BASE_CLONE")"
694
+ git_clone_retry "$repo_url" "$REPO_BASE_CLONE" \
695
+ || hard_stop "Clone failed for $repo_url (after retries — check network/repo size)"
696
+ fi
697
+ git -C "$REPO_BASE_CLONE" fetch origin "$REPO_BASE" >/dev/null 2>&1 \
698
+ || git -C "$REPO_BASE_CLONE" fetch origin >/dev/null 2>&1 || true
699
+ git -C "$REPO_BASE_CLONE" show-ref --verify --quiet "refs/remotes/origin/$REPO_BASE" \
700
+ || hard_stop "Base branch '$REPO_BASE' not found in $repo_url"
701
+ if git -C "$REPO_BASE_CLONE" show-ref --verify --quiet "refs/heads/$BRANCH" \
702
+ || git -C "$REPO_BASE_CLONE" show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
703
+ hard_stop "Branch '$BRANCH' already exists in $repo_url — investigate."
704
+ fi
705
+ git -C "$REPO_BASE_CLONE" worktree add -b "$BRANCH" "$REPO_DIR" "origin/$REPO_BASE" >/dev/null 2>&1 \
706
+ || hard_stop "Failed to create worktree for $repo_url on '$BRANCH'"
707
+ CREATED_WORKTREES+=("$REPO_BASE_CLONE|$REPO_DIR|$BRANCH")
708
+ set_clone_identity "$REPO_DIR"
709
+ git -C "$REPO_DIR" push -u origin "$BRANCH" >/dev/null 2>&1 \
710
+ || hard_stop "Failed to push '$BRANCH' to $repo_url"
711
+ PUSHED_REMOTE_BRANCHES+=("$REPO_DIR|$BRANCH")
712
+ info " ✓ branch '$BRANCH' pushed"
713
+ done
714
+ fi
715
+
716
+ # ── Phase D: push everything ─────────────────────────────────────────────────
717
+
718
+ info "Phase D: pushing project branch and home registry update..."
719
+
720
+ # Push project branch from ORG GOV clone first (so origin has the entry)
721
+ git -C "$ORG_GOV_CLONE" push -u origin "$BRANCH" >/dev/null 2>&1 \
722
+ || hard_stop "Failed to push '$BRANCH' to $ORG_REPO_URL"
723
+ PUSHED_REMOTE_BRANCHES+=("$ORG_GOV_CLONE|$BRANCH")
724
+ info " ✓ pushed $BRANCH to $ORG_REPO_URL"
725
+
726
+ # Push home's default branch (the registry update + stub folder commit)
727
+ git -C "$REPO_ROOT" push origin "$DEFAULT_BRANCH" >/dev/null 2>&1 \
728
+ || hard_stop "Failed to push $DEFAULT_BRANCH from home workspace"
729
+ info " ✓ pushed $DEFAULT_BRANCH (registry update) to $ORG_REPO_URL"
730
+
731
+ # ── Done — disarm rollback ───────────────────────────────────────────────────
732
+
733
+ SEED_OK=1
734
+
735
+ # ── First-session prompt ─────────────────────────────────────────────────────
736
+
737
+ FIRST_PROMPT="Start project $PROJECT_ID. I'm working in $ORG_GOV_CLONE on branch $BRANCH. Follow your session-start protocol: read org-config.yaml, then read projects/$PROJECT_ID/agent.md, then knowledge/policies/agentic-development-policy.md, then surface any \\\`## Open\\\` items from projects/$PROJECT_ID/knowledge/todo.md before planning work."
738
+
739
+ cat <<EOF
740
+
741
+ === Project $PROJECT_ID initialized.
742
+
743
+ ID : $PROJECT_ID
744
+ Branch : $BRANCH
745
+ Assignee : $ASSIGNEE
746
+ GitHub : $GITHUB_PROJECT_URL
747
+
748
+ Workspace layout:
749
+ $PROJECT_WORK_ROOT/
750
+ └── $WORKSPACE_REPO/ ← ORG GOVERNANCE clone (you cd here)
751
+ EOF
752
+ if [[ ${#REPO_URL_LIST[@]} -gt 0 ]]; then
753
+ for repo_url in "${REPO_URL_LIST[@]}"; do
754
+ REPO_NAME=$(get_repo_name "$repo_url")
755
+ echo " └── $REPO_NAME/ ← code repo clone (code changes here)"
756
+ done
757
+ fi
758
+
759
+ cat <<EOF
760
+
761
+ The home workspace stayed on '$DEFAULT_BRANCH' throughout. All project-branch
762
+ work happens inside the per-project workspace above.
763
+
764
+ ────────────────────────────────────────────────────────────────────────
765
+ Next step — paste this in your shell:
766
+
767
+ cd $ORG_GOV_CLONE
768
+
769
+ Then start your agent session with this prompt:
770
+
771
+ $FIRST_PROMPT
772
+
773
+ ────────────────────────────────────────────────────────────────────────
774
+ EOF