From ce42b30b340c1275903c4100e9cf3c9b3c8b491c Mon Sep 17 00:00:00 2001 From: wyattb Date: Sun, 18 Oct 2026 13:06:03 -0400 Subject: [PATCH] Delphi 0.8.0: workspaces are named by their folders' short names and leaf, joined by dots (ws/s.app.argos.argos-dev); linked/ and parked/ are flat; sync carries ws/ over; migration renames clones --- .delphi/manifest.yml | 3 +- .delphi/migrations/0.8.0-dotted-names.sh | 52 ++++++ .delphi/names.sh | 27 +++ .delphi/park.sh | 14 +- .delphi/paths.sh | 2 +- .delphi/setup.sh | 23 +-- .delphi/switch.sh | 34 ++-- .delphi/versions | 1 + CLAUDE.md | 13 +- README.md | 33 ++-- ci/check.sh | 38 +++-- ci/plugins.sh | 11 +- ci/sync.sh | 56 ++++--- docs/design.md | 54 +++--- .../application-software/argos/AUTHORING.md | 2 +- software/application-software/argos/README.md | 2 +- .../argos/workspace-defaults/workspace.yml | 2 +- .../argos/workspaces/argos-dev/CLAUDE.md | 2 +- .../argos/workspaces/argos-dev/workspace.yml | 4 +- .../argos/workspaces/experimental/CLAUDE.md | 2 +- .../workspaces/experimental/workspace.yml | 4 +- .../workspaces/nero-experimental/CLAUDE.md | 2 +- .../nero-experimental/workspace.yml | 4 +- .../{plugins/scope => short-name} | 0 .../workspaces/hub/CLAUDE.md | 4 +- .../workspaces/hub/workspace.yml | 10 +- .../calypso/workspaces/calypso-dev/CLAUDE.md | 2 +- .../workspaces/calypso-dev/workspace.yml | 4 +- .../plugins/claude/default/hooks/register.tsx | 11 +- .../plugins/claude/default/tests/ws.test.ts | 21 +-- .../plugins/claude/default/web/index.html | 8 +- software/plugins/claude/default/web/server.py | 4 +- software/short-name | 1 + .../.claude/skills/setup-workspace/SKILL.md | 6 +- .../.claude/skills/switch-workspace/SKILL.md | 5 +- templates/workspace/CLAUDE.md | 2 +- templates/workspace/workspace.yml | 4 +- tests/e2e.sh | 157 +++++++++--------- tools/new-workspace.sh | 44 ++--- 39 files changed, 394 insertions(+), 274 deletions(-) create mode 100644 .delphi/migrations/0.8.0-dotted-names.sh create mode 100644 .delphi/names.sh rename software/application-software/{plugins/scope => short-name} (100%) create mode 100644 software/short-name diff --git a/.delphi/manifest.yml b/.delphi/manifest.yml index aa8eb88..5fe7475 100644 --- a/.delphi/manifest.yml +++ b/.delphi/manifest.yml @@ -6,7 +6,7 @@ # MINOR something new that existing clones need nothing for # MAJOR clones need cleanup (MINOR while 0.x): add .delphi/migrations/-.sh, which # setup.sh runs once per workspace in each clone (rules in Delphi's CLAUDE.md) -version: 0.7.0 +version: 0.8.0 files: # repo-relative paths; a trailing / means everything under it - .delphi/manifest.yml - .delphi/.gitattributes @@ -18,5 +18,6 @@ files: # repo-relative paths; a trailing / means everythi - .delphi/plugins.sh - .delphi/switch.sh - .delphi/park.sh + - .delphi/names.sh - .delphi/migrations/ - .github/workflows/delphi.yml diff --git a/.delphi/migrations/0.8.0-dotted-names.sh b/.delphi/migrations/0.8.0-dotted-names.sh new file mode 100644 index 0000000..2f9781d --- /dev/null +++ b/.delphi/migrations/0.8.0-dotted-names.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# 0.8.0: a workspace's name is its folders' short names and its own, joined by dots (.delphi/names.sh: +# s.app.argos.argos-dev, branch ws/s.app.argos.argos-dev; was its folder's path without workspaces/, +# software/application-software/argos/argos-dev), and `workspaces:` entries (`- ` or `- main`) +# are copied to linked/. Moves what this clone keeps under old names to the new ones, for every +# workspace on main: parked slots (.delphi/parked/ to .delphi/parked/), read-only copies +# (linked/ws/ to linked/, here and in each parked slot), and version records +# (delphi..* in git config). Then, if this clone is on ws/, renames that branch ws/ +# tracking origin/ws/ (sync pushed both to the same commit); a local ws/ already there is +# replaced only if this branch has all its commits. Only renames; a name taken at both places is +# reported and left. +set -euo pipefail +git fetch -q origin 2>/dev/null || echo "migrate: could not fetch; using the last fetched" >&2 +git rev-parse -q --verify refs/remotes/origin/main >/dev/null || + { echo "migrate: no origin/main here; git fetch origin main, then re-run setup" >&2 && exit 1; } +pairs=$(bash .delphi/names.sh refs/remotes/origin/main | awk '{ sub("/workspaces/", "/", $2); print $2 "|" $1 }') # | +move() { # + [ -e "$1" ] || return 0 + if [ -e "$2" ]; then echo "migrate: $1 and $2 both exist; left $1 (move what you need by hand)" >&2 && return 0; fi + mkdir -p "$(dirname "$2")" && mv "$1" "$2" && echo "migrate: moved $1 to $2" +} +for p in $pairs; do + old=${p%|*} n=${p#*|} + move ".delphi/parked/$old" ".delphi/parked/$n" + (cd .delphi/parked 2>/dev/null && rmdir -p "${old%/*}" 2>/dev/null) || true # the old name's emptied folders + if git config --get-regexp "^delphi\\.$old\\." >/dev/null; then + if git config "delphi.$n.version" >/dev/null; then git config --remove-section "delphi.$old" + else git config --rename-section "delphi.$old" "delphi.$n"; fi + fi +done +for slot in . $(for p in $pairs; do [ ! -d ".delphi/parked/${p#*|}" ] || echo ".delphi/parked/${p#*|}"; done); do + for p in $pairs; do + old=${p%|*} + move "$slot/linked/ws/$old" "$slot/linked/${p#*|}" + (cd "$slot/linked" 2>/dev/null && rmdir -p "ws/${old%/*}" 2>/dev/null) || true + done +done + +cur=$(git branch --show-current) +for p in $pairs; do + n=${p#*|} + [ "$cur" = "ws/${p%|*}" ] || continue + if git rev-parse -q --verify "refs/heads/ws/$n" >/dev/null; then + git merge-base --is-ancestor "ws/$n" HEAD || + { echo "migrate: local ws/$n has commits $cur doesn't; merge or delete it (git branch -D ws/$n), then re-run setup" >&2 && exit 1; } + fi + git branch -M "$cur" "ws/$n" + if git rev-parse -q --verify "refs/remotes/origin/ws/$n" >/dev/null; then + git branch -q -u "origin/ws/$n" "ws/$n" + echo "migrate: renamed this branch $cur to ws/$n, tracking origin/ws/$n" + else echo "migrate: renamed this branch $cur to ws/$n; once CI creates origin/ws/$n: git branch -u origin/ws/$n"; fi +done diff --git a/.delphi/names.sh b/.delphi/names.sh new file mode 100644 index 0000000..426ea7d --- /dev/null +++ b/.delphi/names.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# .delphi/names.sh []: print ` ` for each workspace folder software/**/workspaces// +# in (default: the working tree here, a main checkout), sorted by folder. A workspace's name is +# the short name of each folder from software/ down to its project folder, then , joined by +# dots (software/application-software/argos/workspaces/argos-dev -> s.app.argos.argos-dev; branch +# ws/). A folder's short name is the one line in its `short-name` file, else its own name. +# Prints nothing where there's no software/ (a ws/* branch). Bash 3.2 and Git Bash safe. +set -euo pipefail +rev=${1:-} +folders() { + if [ -n "$rev" ]; then git ls-tree -r --name-only "$rev" -- software + else find software -type f -name workspace.yml 2>/dev/null; fi | sed -nE 's#^(software/(.+/)?workspaces/[^/]+)/workspace\.yml$#\1#p' | LC_ALL=C sort +} +short() { # : its short name + local s + if [ -n "$rev" ]; then s=$(git show "$rev:$1/short-name" 2>/dev/null || true); else s=$(cat "$1/short-name" 2>/dev/null || true); fi + s=$(printf '%s' "$s" | tr -d '[:space:]') + echo "${s:-${1##*/}}" +} +for f in $(folders); do + n="" p="" + for part in $(echo "${f%/workspaces/*}" | tr / ' '); do + p=${p:+$p/}$part + n="$n$(short "$p")." + done + echo "$n${f##*/} $f" +done diff --git a/.delphi/park.sh b/.delphi/park.sh index cafb387..cb405c0 100755 --- a/.delphi/park.sh +++ b/.delphi/park.sh @@ -2,20 +2,22 @@ # .delphi/park.sh : run by the post-checkout hook .delphi/setup.sh writes, # from the worktree that checked out. .delphi/ is on every branch, main included, so the hook always # finds it, the version just checked out. When a checkout moves this clone to another workspace -# (the `name:` in workspace.yml, its place in the tree; none on main), it parks the old workspace's +# (the `name:` in workspace.yml; none on main), it parks the old workspace's # root folders (the ones .delphi/paths.sh names, .journal/, and those under `park:` in the workspace.yml # of the branch it leaves) at .delphi/parked// (git-ignored by .delphi/.gitignore), and brings -# back everything parked for the new one. A name from before workspaces were named by -# path (argos-dev) is the same workspace as the path ending in it, so moving onto the renamed branch -# moves nothing. Moves are renames, so every path is the same again once its workspace is checked +# back everything parked for the new one. A workspace's dotted name and its old one, its folder's +# path (s.app.argos.argos-dev, software/application-software/argos/argos-dev), are taken to be the +# same workspace when their last parts match, so moving onto the renamed branch moves nothing. Moves are renames, so every path is the same again once its workspace is checked # out. Runs on bash 3.2 and Git Bash. set -euo pipefail [ "${3:-0}" = 1 ] || exit 0 name() { git show "$1:workspace.yml" 2>/dev/null | awk '/^name:/ { print $2; exit }' || true; } old=$(name "$1") new=$(name "$2") [ "$old" != "$new" ] || exit 0 -case "$old" in */*) ;; ?*) [ "$old" != "${new##*/}" ] || exit 0 ;; esac # renamed by path: still the same workspace -case "$new" in */*) ;; ?*) [ "$new" != "${old##*/}" ] || exit 0 ;; esac +last() { local n=${1##*/}; echo "${n##*.}"; } # a name's last part, after its last / or . +slashed() { case "$1" in */*) echo 1 ;; esac; } # an old name, its folder's path +[ -z "$old" ] || [ -z "$new" ] || [ "$(slashed "$old")" = "$(slashed "$new")" ] || [ "$(last "$old")" != "$(last "$new")" ] || + exit 0 # its old name and its new one: still the same workspace top=$(git rev-parse --show-toplevel) park=$top/.delphi/parked parks() { # : the root folders its workspace parks diff --git a/.delphi/paths.sh b/.delphi/paths.sh index 1c0c27e..e763ad3 100644 --- a/.delphi/paths.sh +++ b/.delphi/paths.sh @@ -9,7 +9,7 @@ DELPHI_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd) DELPHI_REPOS=$DELPHI_ROOT/repos # bare stores: repos/ DELPHI_WORKTREES=$DELPHI_ROOT/worktrees # checkouts: worktrees// DELPHI_REFERENCES=$DELPHI_ROOT/references # read-only dependency sources: references/ -DELPHI_LINKED=$DELPHI_ROOT/linked # read-only Delphi copies: linked/ +DELPHI_LINKED=$DELPHI_ROOT/linked # read-only Delphi copies: linked/, linked/main export DELPHI_ROOT DELPHI_REPOS DELPHI_WORKTREES DELPHI_REFERENCES DELPHI_LINKED [ "${BASH_SOURCE[0]:-}" != "$0" ] || printf 'export DELPHI_ROOT=%q DELPHI_REPOS=%q DELPHI_WORKTREES=%q DELPHI_REFERENCES=%q DELPHI_LINKED=%q\n' \ "$DELPHI_ROOT" "$DELPHI_REPOS" "$DELPHI_WORKTREES" "$DELPHI_REFERENCES" "$DELPHI_LINKED" diff --git a/.delphi/setup.sh b/.delphi/setup.sh index ecb2a62..185ef31 100755 --- a/.delphi/setup.sh +++ b/.delphi/setup.sh @@ -5,14 +5,15 @@ # out its default branch as the first worktree, worktrees//. For each repo under # `references-git:` (read-only dependency sources, `: []`), checks out a shallow, # detached copy of (default: origin's default branch) at references/, refreshed on every -# run, with pushes disabled. Each entry under `workspaces:` (`- ws/` or `- main`, a workspace -# being named by its place in the tree) gets the same kind of copy of that branch of this clone's -# origin (Delphi), at linked/; to edit one, switch this clone to it (.delphi/switch.sh). Adds the +# run, with pushes disabled. Each entry under `workspaces:` (`- `, a workspace's dotted name +# from .delphi/names.sh, or `- main`) gets the same kind of copy of that branch of this clone's +# origin (Delphi, ws/ or main), at linked/; to edit one, switch this clone to it (.delphi/switch.sh). Adds the # folders park.sh parks and macOS's .DS_Store to this clone's .git/info/exclude, and writes its post-checkout hook, which runs .delphi/park.sh (on # every branch) as just checked out in the worktree that checked out (unless another hook is there), so switching # to another workspace or main parks those folders. First # it runs the .delphi/migrations/-*.sh this workspace hasn't (oldest first; the last one done -# is `git config delphi..version`, 0.0.0 if unset), stopping at one that fails, and warns if +# is `git config delphi..version`, else the record under its old name, its folder's path without +# workspaces/, from origin/main; 0.0.0 if neither), stopping at one that fails, and warns if # .delphi/ isn't exactly the version it says (.delphi/version.sh). Last, it brings this machine's Delphi plugins in # step with the workspace (.delphi/plugins.sh: installs what it enables, uninstalls local ones it doesn't and what the marketplace dropped). Safe to re-run. Keep it bash 3.2 # (macOS) and Git Bash safe: no bash-4 features, POSIX awk only. @@ -20,7 +21,7 @@ set -euo pipefail cd "$(dirname "$0")/.." [ -f workspace.yml ] || { echo "setup: no workspace.yml here (on main?); nothing to set up" >&2 && exit 0; } self=$(awk '/^name:/ { print $2; exit }' workspace.yml) -case "$self" in "" | [-/]* | */ | *//* | *[!a-z0-9/-]*) echo "setup: workspace.yml needs a valid name:" >&2 && exit 1 ;; esac +case "$self" in "" | [-.]* | *. | *..* | *[!a-z0-9.-]*) echo "setup: workspace.yml needs a valid name:" >&2 && exit 1 ;; esac older() { # : a is a lower MAJOR.MINOR.PATCH than b [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -t. -k1,1n -k2,2n -k3,3n | head -n 1)" = "$1" ] @@ -28,7 +29,8 @@ older() { # : a is a lower MAJOR.MINOR.PATCH than b here=$(bash .delphi/version.sh) released=$(grep -v '^#' .delphi/versions | grep . | tail -n 1 | cut -d' ' -f1,2 || true) [ "$here" = "$released" ] || echo "setup: .delphi/ is not Delphi ${released%% *} as released (edited here? change it only through main)" >&2 -have=$(git config "delphi.$self.version" || echo 0.0.0) +old=$(bash .delphi/names.sh refs/remotes/origin/main 2>/dev/null | awk -v n="$self" '$1 == n { sub("/workspaces/", "/", $2); print $2 }' || true) +have=$(git config "delphi.$self.version" || { [ -n "$old" ] && git config "delphi.$old.version"; } || echo 0.0.0) pending=$(for f in .delphi/migrations/*.sh; do [ ! -e "$f" ] || echo "${f##*/}"; done | sort -t. -k1,1n -k2,2n -k3,3n) while IFS= read -r f; do v=${f%%-*} @@ -66,10 +68,11 @@ readonly_copy() { # []: shallow, detached, push-disabled checko fi } -link() { # ws/|main: a read-only copy of that branch of Delphi at linked/ - case "$1" in main | ws/[a-z0-9]*) ;; *) echo "setup: skipping bad workspaces: entry '$1'" >&2 && return ;; esac - case "$1" in "ws/$self") return ;; */ | *//* | *[!a-z0-9/-]*) echo "setup: skipping bad workspaces: entry '$1'" >&2 && return ;; esac - readonly_copy "linked/$1" "$(git remote get-url origin)" "$1" +link() { # |main: a read-only copy of that workspace's branch of Delphi (ws/), or main, at linked/ + case "$1" in "$self") return ;; "" | [-.]* | *. | *..* | *[!a-z0-9.-]*) + echo "setup: skipping bad workspaces: entry '$1' (want a workspace's name, like s.app.argos.argos-dev, or main)" >&2 && return ;; + esac + readonly_copy "linked/$1" "$(git remote get-url origin)" "$([ "$1" = main ] && echo main || echo "ws/$1")" } while read -r kind name url ref; do diff --git a/.delphi/switch.sh b/.delphi/switch.sh index 0e6afb7..72aa889 100755 --- a/.delphi/switch.sh +++ b/.delphi/switch.sh @@ -1,33 +1,27 @@ #!/usr/bin/env bash # .delphi/switch.sh |main: move this clone to Delphi's ws/ (or main). A workspace -# is named by its place in the tree (software/application-software/argos/argos-dev; ws/ in front is -# fine), or by its last part alone (argos-dev) if only one workspace ends in it. Refuses if -# tracked files have changes (they would follow the switch), fetches the branch, switches to it (the -# local branch, else a new one tracking origin), fast-forwards it to origin, then runs the new -# workspace's .delphi/setup.sh (none for main). The post-checkout hook (.delphi/park.sh) parks this workspace's -# repos/, worktrees/, references/ and linked/ and restores the new one's. Runs on bash 3.2 and Git Bash. -# .delphi/switch.sh --list: fetch the ws/* branches, then print main and each workspace's name, one a -# line, `* ` before this clone's and two spaces before the others (the delphi mod's /ws-delphi pane reads it). +# is named by .delphi/names.sh (s.app.argos.argos-dev; ws/ in front is fine). Refuses if tracked files have changes (they would follow the +# switch), fetches the branch, switches to it (the local branch, else a new one tracking origin), +# fast-forwards it to origin, then runs the new workspace's .delphi/setup.sh (none for main). The +# post-checkout hook (.delphi/park.sh) parks this workspace's repos/, worktrees/, references/ and +# linked/ and restores the new one's. Runs on bash 3.2 and Git Bash. +# .delphi/switch.sh --list: fetch main and the ws/* branches, then print main and each workspace on +# main that has its branch, one a line, as ` `, `* ` before this clone's +# and two spaces before the others (the delphi mod's /ws-delphi pane reads it). set -euo pipefail { # one block, so bash has read all of it before the switch replaces this file cd "$(dirname "$0")/.." if [ "${1:-}" = --list ]; then - git fetch -q --prune origin '+refs/heads/ws/*:refs/remotes/origin/ws/*' || - echo "switch: could not fetch the ws/* branches; listing the ones last fetched" >&2 - { echo main && git for-each-ref --format='%(refname:lstrip=3)' refs/remotes/origin/ws/; } | - awk -v here="$(git branch --show-current)" '$0 == "main" || /^ws\/.*\// { # not ws/: an old name - n = $0; sub(/^ws\//, "", n); print ($0 == here ? "* " : " ") n }' + git fetch -q --prune origin '+refs/heads/main:refs/remotes/origin/main' '+refs/heads/ws/*:refs/remotes/origin/ws/*' || + echo "switch: could not fetch main and the ws/* branches; listing the ones last fetched" >&2 + { echo main && bash .delphi/names.sh refs/remotes/origin/main | while read -r n f; do + ! git rev-parse -q --verify "refs/remotes/origin/ws/$n" >/dev/null || echo "$n ${f%/workspaces/*}" + done; } | awk -v here="$(git branch --show-current)" '{ print (($1 == "main" ? "main" : "ws/" $1) == here ? "* " : " ") $0 }' exit fi name=${1:?usage: switch.sh |main} name=${name#ws/} ref=ws/${name#ws/} - case "$name" in main) ref=main ;; "" | [-/]* | */ | *//* | *[!a-z0-9/-]*) echo "switch: bad workspace name '$1'" >&2 && exit 1 ;; esac - if [ "$ref" != main ] && [ "$name" = "${name##*/}" ]; then # its last part alone: find the one workspace it names - git fetch -q origin '+refs/heads/ws/*:refs/remotes/origin/ws/*' || { echo "switch: could not fetch the ws/* branches" >&2 && exit 1; } - ref=$(git for-each-ref --format='%(refname:lstrip=3)' refs/remotes/origin/ws/ | grep "^ws/.*/$name\$" || true) # not ws/: an old name - [ -n "$ref" ] && [ "$(echo "$ref" | wc -l)" -eq 1 ] || - { echo "switch: no one workspace ends in '$name' (found: $(echo "${ref:-none}" | tr '\n' ' ')); give its full name" >&2 && exit 1; } - fi + case "$name" in main) ref=main ;; "" | [-.]* | *. | *..* | *[!a-z0-9.-]*) echo "switch: bad workspace name '$1'" >&2 && exit 1 ;; esac git diff --quiet HEAD -- || { echo "switch: commit or set aside your changes first (git status)" >&2 && exit 1; } git fetch -q origin "+refs/heads/$ref:refs/remotes/origin/$ref" || { echo "switch: could not fetch $ref" >&2 && exit 1; } git switch -q "$ref" diff --git a/.delphi/versions b/.delphi/versions index cab1212..4f5c8a5 100644 --- a/.delphi/versions +++ b/.delphi/versions @@ -12,3 +12,4 @@ 0.5.1 3fd96f222b77 the delphi mod's command is /ws-delphi (was /ws); switch.sh's comment says so 0.6.0 c65ffc578bfa setup.sh (.delphi/plugins.sh) makes each clone's @delphi plugins what its workspace enables (installs them, uninstalls local ones it doesn't) and uninstalls ones the marketplace dropped, instead of warning; plugin changes need no migration 0.7.0 84df5ce802db park.sh also parks .journal/ and the root folders under a workspace's park: in workspace.yml (each workspace its own), restores everything parked for a workspace, and skips bad entries; setup.sh excludes them +0.8.0 05d4c954a946 workspaces are named by their folders' short names and leaf, joined by dots (.delphi/names.sh, short-name files; ws/s.app.argos.argos-dev, was ws/software/…/argos-dev); workspaces: entries are - | - main, copied to linked/; sync carries ws/ over once (migration) diff --git a/CLAUDE.md b/CLAUDE.md index 0968781..4b5131d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,8 @@ # Delphi — developing it Delphi stores NER's AI-harness context. Each workspace is a folder `software/**/workspaces//` -on `main`, named by its place in the tree (the path without `workspaces/`: -`software/application-software/argos/argos-dev`); branch `ws/` has that folder as its repo root. `ci/sync.sh` keeps them in sync with +on `main`, named by the short names of its folders and its leaf, joined by dots (`.delphi/names.sh`; +a folder's short name is its `short-name` file, else its name: `s.app.argos.argos-dev`); branch `ws/` has that folder as its repo root. `ci/sync.sh` keeps them in sync with subtree merges: **refresh** (main → `ws/`) and **propose** (`ws/` → PR to main via `propose/`). There is no CLI, just a few shell scripts run by CI. `docs/design.md` is the source of truth; `docs/goals.md` lists what any change must keep. @@ -16,14 +16,15 @@ source of truth; `docs/goals.md` lists what any change must keep. sync's propose PRs fill `.github/pull_request_template.md`'s sections (keep them in step) and are labelled `by: ai` (new-workspace: `--by ai-assisted|human` when the user says so). - `ci/plugins.sh`: writes `.claude-plugin/marketplace.json` from the plugin sets - (`software/**/plugins///`, named `[.]`; a folder's scope is its name or - its `plugins/scope`), each workspace's `workspace.yml` `plugins:` defaults (the + (`software/**/plugins///`, named `[.]`; a folder's scope is its + `plugins/scope`, else its `short-name`, else its name), each workspace's `workspace.yml` `plugins:` defaults (the `default` set of every folder above), and its `.claude/settings.json` (enabled sets, and the SessionStart hook that sets `$DELPHI_ROOT`; never edit it by hand). Run it after changing a set or a `workspace.yml`'s `plugins:`. - `.delphi/{setup,new-worktree,switch,park,plugins}.sh`: the scripts every workspace runs (setup.sh runs `plugins.sh`, which makes this clone's local `@delphi` installs what the workspace enables and uninstalls ones the marketplace dropped, so plugin changes need no migration); `.delphi/paths.sh` - is the layout (`DELPHI_ROOT`, `DELPHI_WORKTREES`, …) that they and skill scripts source; park.sh also moves + is the layout (`DELPHI_ROOT`, `DELPHI_WORKTREES`, …) that they and skill scripts source; `.delphi/names.sh` names every + workspace (dotted short names; setup, switch, sync, check and the migration all use it); park.sh also moves the root folders under a workspace's `park:` in `workspace.yml` on a switch, and setup.sh excludes them. Only here: sync puts `.delphi/` at the root of every `ws/*` branch and strips it from proposals. - `.delphi/manifest.yml` (Delphi's files and their `version:`), `.delphi/versions` (one line per @@ -35,7 +36,7 @@ source of truth; `docs/goals.md` lists what any change must keep. - `software/plugins/claude/default/`: the `delphi` plugin set, a mod whose `/ws-delphi` pane switches workspaces (`hooks/register.tsx`, tests in `tests/ws.test.ts`, run by `claude plugin test`); `/ws-delphi --web` serves the same cards in a browser tab (`web/server.py`, `web/index.html`). -- `software/…/workspaces//`: the workspaces. Org structure is plain directories. A project +- `software/…/workspaces//`: the workspaces. Org structure is plain directories. A project folder (the one holding `workspaces/`) may add `README.md`, `AUTHORING.md`, and `workspace-defaults/` (copied once into a new workspace there). Any folder may add `workspace-inherit/` (written onto the `ws/*` root of every workspace below on each refresh, never proposed; `opt-out:` in a diff --git a/README.md b/README.md index 75a1605..9cd19f4 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,10 @@ NER's AI-harness context (instructions, skills, docs, settings), organized by the org chart under `software/`. A **workspace** is a folder `software/**/workspaces//` with a `workspace.yml`, -named by its place in the tree: the folder's path without `workspaces/` -(`software/application-software/argos/argos-dev`). For each one, CI keeps a branch **`ws/`** +named by the **short name** of each folder above it, from `software/` down, then its own folder's +name, joined by dots: `software/application-software/argos/workspaces/argos-dev` is +`s.app.argos.argos-dev` (a folder's short name is its `short-name` file, else its name; +`.delphi/names.sh` prints them all). For each one, CI keeps a branch **`ws/`** whose repo root *is* that folder, so people and agents work on it with plain git. Design: `docs/design.md`. Goals: `docs/goals.md`. ## How it works @@ -40,12 +42,12 @@ histories stay joined and edits on either side meet in normal three-way merges. ## Using a workspace ```sh -git clone -b ws/software/application-software/argos/argos-dev https://github.com/Northeastern-Electric-Racing/Delphi.git delphi +git clone -b ws/s.app.argos.argos-dev https://github.com/Northeastern-Electric-Racing/Delphi.git delphi cd delphi && .delphi/setup.sh # repos/ stores + default-branch worktrees in worktrees/ (git-ignored) -.delphi/switch.sh nero-experimental # switch freely (or to main; the full name works too): the hook parks them (and workspace.yml's park: folders) in .delphi/parked/ +.delphi/switch.sh s.app.nero.nero-experimental # switch freely (or to main; --list lists them): the hook parks them (and workspace.yml's park: folders) in .delphi/parked/ claude plugin install delphi@delphi --scope user # then /ws-delphi in Claude Code: a pane to keep open that switches workspaces (/ws-delphi --web: in a browser) -git switch -c my-change # edit, commit, push, open a PR into ws/software/application-software/argos/argos-dev -git fetch origin && git merge origin/ws/software/application-software/argos/argos-dev # update your branch any time +git switch -c my-change # edit, commit, push, open a PR into ws/s.app.argos.argos-dev +git fetch origin && git merge origin/ws/s.app.argos.argos-dev # update your branch any time ``` After your PR merges into `ws/`, CI proposes it: a PR `ws/ → main` from @@ -60,13 +62,13 @@ workspace. Fix it in a PR into `ws/`: on a branch cut from `ws/`, ru ## workspace.yml ```yaml -name: software/application-software/argos/argos-dev # its place in the tree +name: s.app.argos.argos-dev # its dotted name harness: claude-code repos: # stored in repos/, checked out in worktrees// by .delphi/setup.sh argos: https://github.com/Northeastern-Electric-Racing/Argos.git references-git: # read-only dependency sources, shallow-cloned into references// socketioxide: https://github.com/Totodore/socketioxide.git v0.18.7 # optional tag or branch -workspaces: # Delphi workspaces (ws/) or main, read-only, shallow-cloned into linked// +workspaces: # Delphi workspaces (by name) or main, read-only, shallow-cloned into linked// - main ``` @@ -74,14 +76,13 @@ workspaces: # Delphi workspaces (ws/) or main, read-only, shal context: `setup.sh` checks each out shallow and detached at its pin (or the default branch), with pushes disabled, and refreshes them on every run. They get no store and no worktrees. Names are unique across both maps. The `-git` suffix leaves room for other kinds of references later. -`workspaces:` are other workspaces (`- ws/`) or `main` to read: the same kind of read-only copy, of Delphi itself. +`workspaces:` are other workspaces (`- `) or `main` to read: the same kind of read-only copy, of Delphi itself. To change one, switch to it instead. -The workspace's name is its place in the tree, repeated in `name:` so a checkout knows which -workspace it is. Workspaces in different places may end in the same name; but since git can't hold -both `ws/a` and `ws/a/b`, no workspace may be named like a project folder beside it. Every other file in the folder is the workspace's own, at its normal harness +The workspace's dotted name is repeated in `name:` so a checkout knows which workspace it is; names +are unique (give a folder a `short-name` that no sibling's name or short name clashes with). Every other file in the folder is the workspace's own, at its normal harness path, except `.github/workflows/delphi.yml` (a copy of the template's). The scripts, `.delphi/*.sh` -(`setup.sh`, `new-worktree.sh`, `switch.sh`, `park.sh`, `version.sh`), live once at main's root: sync puts +(listed in `.delphi/manifest.yml`), live once at main's root: sync puts `.delphi/` on every `ws/*` branch, so change them only through PRs to main. ## Versions @@ -94,10 +95,10 @@ a version needs cleanup in people's clones, it ships `.delphi/migrations/ ` copies `templates/workspace/` into -`software//workspaces//`, adds the project's `workspace-defaults/` if it has one, and +`tools/new-workspace.sh ` copies `templates/workspace/` into +`software//workspaces//`, adds the project's `workspace-defaults/` if it has one, and opens a PR to `main`, labelled `by: ai` (put `--by ai-assisted` or `--by human` first to change it). -Once it merges, CI creates `ws/software//`. +Once it merges, CI creates `ws/` (its dotted name). **Project folders.** The folder above `workspaces/` (e.g. `software/application-software/argos/`) can hold shared context (`README.md`), a guide to its workspaces (`AUTHORING.md`), and diff --git a/ci/check.sh b/ci/check.sh index fb66980..ebdcdfe 100755 --- a/ci/check.sh +++ b/ci/check.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # ci/check.sh [ []]: validate Delphi (CI runs it on PRs to main, against main; sync.sh on # each proposal). Rules: a workspace is software/**/workspaces// with a workspace.yml; its name -# is its folder's path without `workspaces/` (software/x/workspaces/y -> software/x/y; branch -# ws/), in lowercase letters, digits, '-' and '/', and no name is a folder of another's (git -# can't have both ws/a and ws/a/b); workspaces never nest; no symlinks under +# is the short names of its folders and , joined by dots (.delphi/names.sh; branch ws/), +# and unique across the tree; a folder's `short-name` file is one line of lowercase letters, digits +# and '-'; so is each ; workspaces never nest; no symlinks under # software/; no workspace (nor the template) has its own .delphi/ (sync puts main's on every ws/* # branch); .delphi/manifest.yml lists itself, every file in .delphi/ but `versions`, and only files # git tracks; its `version:` and the hash of those files (.delphi/version.sh) are the last line of @@ -13,7 +13,7 @@ # outside .delphi/ (the workflow) is the same in templates/workspace/ and every workspace; workspace.yml is `name: `, # `harness: `, an optional `repos:` map of `: `, and an optional # `references-git:` map of `: []`; names are unique across both; and an optional -# `workspaces:` list of `- ws/` (a workspace on main) or `- main`, without duplicates or the workspace itself, and an +# `workspaces:` list of `- ` (a workspace on main) or `- main`, without duplicates or the workspace itself, and an # optional `plugins:` list of `- [.]` without duplicates, and an optional `opt-out:` list # of `- ` it inherits, each matching something. A project's workspace-defaults/workspace.yml is # the same without `name:` (and a project has no old defaults/). A workspace-inherit/ folder sits in @@ -95,13 +95,13 @@ manifest() { # []: without (a workspace-defaults/ m while IFS= read -r msg; do bad "$1: $msg"; done < <(awk -v want="${2:-}" ' /^[[:space:]]*#/ || /^[[:space:]]*$/ { next } want != "" && /^name:/ { v = $0; sub(/^name:[[:space:]]*/, "", v); sub(/[[:space:]]*#.*$/, "", v) - if (v != want) print "name is \"" v "\", want \"" want "\" (the folder path without workspaces/)" + if (v != want) print "name is \"" v "\", want \"" want "\" (.delphi/names.sh)" if (nm++) print "duplicate name"; r = 0; next } /^harness:/ { v = $0; sub(/^harness:[[:space:]]*/, "", v); sub(/[[:space:]]*#.*$/, "", v) if (v == "") print "harness is empty"; if (h++) print "duplicate harness"; r = 0; next } /^repos:[[:space:]]*(#.*)?$/ { r = 1; next } /^references-git:[[:space:]]*(#.*)?$/ { r = 2; next } - /^links:/ { print "links: is now workspaces: (entries ` - ws/` or ` - main`)"; r = 3; next } + /^links:/ { print "links: is now workspaces: (entries ` - ` or ` - main`)"; r = 3; next } /^workspaces:[[:space:]]*(#.*)?$/ { r = 3; next } /^plugins:[[:space:]]*(#.*)?$/ { r = 4; next } /^opt-out:[[:space:]]*(#.*)?$/ { r = 5; next } @@ -125,10 +125,10 @@ manifest() { # []: without (a workspace-defaults/ m if (sets[v]++) print "duplicate plugin set: " v next } r == 3 && /^[[:space:]]/ { - if ($0 !~ /^[[:space:]]+-[[:space:]]+(main|ws\/[a-z0-9][a-z0-9\/-]*)[[:space:]]*(#.*)?$/ || $0 ~ /\/\/|\/[[:space:]]*(#.*)?$/) { - print "bad workspaces entry (want ` - ws/` or ` - main`): " $0; next } + if ($0 !~ /^[[:space:]]+-[[:space:]]+[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*[[:space:]]*(#.*)?$/) { + print "bad workspaces entry (want ` - `, e.g. s.app.argos.argos-dev, or ` - main`): " $0; next } v = $0; sub(/^[[:space:]]+-[[:space:]]+/, "", v); sub(/[[:space:]]*#.*$/, "", v) - if (v == "ws/" want) print "lists itself: " v + if (v == want) print "lists itself: " v if (listed[v]++) print "duplicate workspace: " v next } r == 1 && /^[[:space:]]/ && $0 !~ /^[[:space:]]+[A-Za-z0-9._-]+:[[:space:]]+[^[:space:]"#]+[[:space:]]*(#.*)?$/ { @@ -142,8 +142,8 @@ manifest() { # []: without (a workspace-defaults/ m next } { print "unexpected line: " $0 } END { if (!h) print "harness is missing"; if (want != "" && !nm) print "name is missing" }' "$1") - while IFS= read -r v; do listed="$listed $1|${v#ws/}"; done < <(awk '/^workspaces:/ { s = 1; next } /^[^[:space:]#]/ { s = 0 } - s && /^[[:space:]]+-[[:space:]]+ws\// { sub(/^[[:space:]]+-[[:space:]]+/, ""); sub(/[[:space:]]*#.*$/, ""); print }' "$1") + while IFS= read -r v; do listed="$listed $1|$v"; done < <(awk '/^workspaces:/ { s = 1; next } /^[^[:space:]#]/ { s = 0 } + s && /^[[:space:]]+-[[:space:]]+/ { sub(/^[[:space:]]+-[[:space:]]+/, ""); sub(/[[:space:]]*#.*$/, ""); if ($0 != "main") print }' "$1") } levels() { # : software/ and each folder down to its project folder, top first @@ -174,18 +174,22 @@ while IFS= read -r d; do [ ! -d "${d%/workspaces}/defaults" ] || bad "${d%/workspaces}/defaults: renamed workspace-defaults/" done < <(find software -type d -name workspaces 2>/dev/null) +while IFS= read -r f; do + s=$(cat "$f") + case "$s" in "" | -* | *[!a-z0-9-]*) bad "$f: want one line of lowercase letters, digits and '-' (the folder's short name)" ;; esac +done < <(find software -name short-name -type f 2>/dev/null | sort) +all=$(bash .delphi/names.sh 2>/dev/null || true) # folders="" names="" listed="" while IFS= read -r f; do folder=${f%/workspace.yml} - name=${folder%/workspaces/*}/${folder##*/} case "$(dirname "$folder")" in */workspaces) ;; *) - bad "$f: must be at software/**/workspaces//workspace.yml" && continue ;; + bad "$f: must be at software/**/workspaces//workspace.yml" && continue ;; esac case "$folder" in *[!A-Za-z0-9._/-]*) bad "$folder: path has unsafe characters" && continue ;; esac - case "$name" in */-* | *[!a-z0-9/-]*) bad "$folder: name $name must be lowercase letters, digits, '-' and '/'" ;; esac + case "${folder##*/}" in -* | *[!a-z0-9-]*) bad "$folder: its folder name must be lowercase letters, digits and '-'" ;; esac + name=$(echo "$all" | awk -v f="$folder" '$2 == f { print $1 }') for other in $names; do - case "$name/" in "$other"/*) bad "$folder: ws/$name can't be a branch while ws/$other is (git can't hold both); rename one" ;; esac - case "$other/" in "$name"/*) bad "$folder: ws/$name can't be a branch while ws/$other is (git can't hold both); rename one" ;; esac + [ "$other" != "$name" ] || bad "$folder: another workspace is named $name too (names are unique); change a short-name or rename a folder" done names="$names $name" for other in $folders; do @@ -201,7 +205,7 @@ done < <(find software -name workspace.yml -type f ! -path '*/workspace-inherit/ while IFS= read -r f; do manifest "$f"; done \ < <(find software -path '*/workspace-defaults/workspace.yml' ! -path '*/workspaces/workspace-defaults/*' -type f 2>/dev/null) for l in $listed; do - case " $names " in *" ${l#*|} "*) ;; *) bad "${l%%|*}: workspaces: lists ws/${l#*|}, which isn't a workspace on main" ;; esac + case " $names " in *" ${l#*|} "*) ;; *) bad "${l%%|*}: workspaces: lists ${l#*|}, which isn't a workspace on main" ;; esac done [ "$problems" = 0 ] || { echo "check: $problems problem(s)" >&2 && exit 1; } diff --git a/ci/plugins.sh b/ci/plugins.sh index 0a5a349..0c73b3c 100755 --- a/ci/plugins.sh +++ b/ci/plugins.sh @@ -2,8 +2,9 @@ # ci/plugins.sh [--check] []: write the generated harness files of a main checkout (default .): # .claude-plugin/marketplace.json: marketplace `delphi`, one plugin per set folder # software/**/plugins/claude// outside workspaces, named , or . unless the -# set is `default`. A folder's scope is the one line in its plugins/scope, else its own name -# (software/application-software/plugins/scope says `app`: app, app.testing); scopes are +# set is `default`. A folder's scope is the one line in its plugins/scope, else its short-name +# file (as in workspace names, .delphi/names.sh), else its own name (software/application-software/ +# short-name says `app`: app, app.testing); scopes are # lowercase letters, digits and '-', and unique. Each plugin.json's description is copied, and its # name must be that name. # each `harness: claude-code` workspace's workspace.yml `plugins:`: the `default` set of every @@ -24,7 +25,9 @@ sets() { # every set folder, sorted } scope_of() { # : its scope - if [ -f "$1/plugins/scope" ]; then tr -d '[:space:]' <"$1/plugins/scope" && echo; else echo "${1##*/}"; fi + if [ -f "$1/plugins/scope" ]; then tr -d '[:space:]' <"$1/plugins/scope" && echo + elif [ -f "$1/short-name" ]; then tr -d '[:space:]' <"$1/short-name" && echo + else echo "${1##*/}"; fi } name_of() { # : the plugin's name in the marketplace @@ -131,7 +134,7 @@ put() { # : write 's output to folders=$(for s in $(sets); do echo "${s%/plugins/claude/*}"; done | uniq) for f in $folders; do case "$(scope_of "$f")" in "" | -* | *[!a-z0-9-]*) - echo "plugins: $f: scope '$(scope_of "$f")' must be lowercase letters, digits and '-' (set it in $f/plugins/scope)" >&2 + echo "plugins: $f: scope '$(scope_of "$f")' must be lowercase letters, digits and '-' (set it in $f/plugins/scope or $f/short-name)" >&2 problems=$((problems + 1)) ;; esac done diff --git a/ci/sync.sh b/ci/sync.sh index c60098f..f8cb282 100755 --- a/ci/sync.sh +++ b/ci/sync.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # ci/sync.sh [refresh|propose] []: sync each workspace folder software/**/workspaces// on # origin/main (or just ) with its branch ws/, whose root is the folder. A workspace's -# is its place in the tree: the folder's path without `workspaces/` -# (software/application-software/argos/workspaces/argos-dev -> software/application-software/argos/argos-dev). -# CI runs it with no direction (both) on every push to main or ws/**. +# is .delphi/names.sh's: its folders' short names and , joined by dots +# (software/application-software/argos/workspaces/argos-dev -> s.app.argos.argos-dev). CI runs it +# with no direction (both) on every push to main or ws/**. # refresh: merge main into ws/ (-Xsubtree=), then make its .delphi/ main's root # .delphi/ (a "delphi: sync" commit if that changed it), write over it the files it inherits # from main (every workspace-inherit/ folder from software/ down to its project folder, @@ -11,22 +11,24 @@ # last merged but no longer does (a "delphi: sync inherited" commit), list main's default plugin sets # above it under its workspace.yml's plugins: and regenerate its .claude/settings.json from # that (ci/plugins.sh; a "delphi: generate" commit if that changed either), and push if the -# tree changed. A -# missing ws/ starts from ws/, the branch's name before workspaces were named -# by path, if that exists (so its history and unproposed edits carry over), else is created -# as one commit: tree = the folder plus main's .delphi/ and inherited files, parent = main. +# tree changed. While ws/ (the branch's name when workspaces were named by their +# folder's path, software/application-software/argos/argos-dev) is ahead of ws/ or +# ws/ is missing, it starts from ws/ (so its history and unproposed edits carry +# over) and pushes the result to both, so clones still on ws/ get the version that +# moves them. A missing ws/ with no ws/ is created as one commit: tree = the +# folder plus main's .delphi/ and inherited files, parent = main. # propose: build propose/ = main + a subtree merge of ws/ without /.delphi/ # or its inherited files (both managed on main); if that changes main and isn't already on propose/, check it (against main), # force-push it, and open or update its PR. # A conflict is reported with its files and that workspace skipped; exits 1 if any failed. ws/* -# branches without a folder get a notice, never deleted. Merges run in a temporary worktree. +# branches without a folder, old names included, get a notice, never deleted. Merges run in a temporary worktree. set -euo pipefail here=$(cd "$(dirname "$0")" && pwd) dir=both case "${1:-}" in refresh | propose) dir=$1 && shift ;; esac only=${1:-} -case "$only" in ws/*) only=${only#ws/} ;; esac -case "$only" in *[!a-z0-9/-]*) echo "usage: ci/sync.sh [refresh|propose] []" >&2 && exit 2 ;; esac +only=${only#ws/} +case "$only" in *[!a-z0-9.-]*) echo "usage: ci/sync.sh [refresh|propose] []" >&2 && exit 2 ;; esac git fetch --quiet --prune origin main=$(git rev-parse origin/main) @@ -96,9 +98,12 @@ conflict() { # : report the conflicted files, abort the merge, skip thi failed=1 ok=0 } -refresh() { # : leave the worktree at ws/ with main merged in - local ws=refs/remotes/origin/ws/$1 from=refs/remotes/origin/ws/$1 - [ -n "$(tree "$ws")" ] || [ -z "$(tree "refs/remotes/origin/ws/${1##*/}")" ] || from=refs/remotes/origin/ws/${1##*/} +refresh() { # : leave the worktree at ws/ with main merged in + local ws=refs/remotes/origin/ws/$1 from=refs/remotes/origin/ws/$1 was=refs/remotes/origin/ws/$3 + if [ -n "$(tree "$was")" ] && { [ -z "$(tree "$ws")" ] || + { [ "$(git rev-parse "$ws")" != "$(git rev-parse "$was")" ] && git merge-base --is-ancestor "$ws" "$was"; }; }; then + from=$was # its old name is ahead: carry that over + fi if [ -z "$(tree "$from")" ]; then git -C "$wt" checkout --quiet --detach \ "$(git commit-tree -p "$main" -m "delphi: create ws/$1 from $2" "$(with_inherited "$(with_delphi "$main:$2")" "$2" "" "$main:$2/workspace.yml")")" @@ -135,9 +140,12 @@ refresh() { # : leave the worktree at ws/ with main merged git -C "$wt" commit --quiet -m "delphi: generate plugins: defaults and .claude/settings.json" fi fi - if [ "$(tree HEAD)" != "$(tree "$ws")" ]; then + if [ "$from" = "$was" ]; then + git -C "$wt" push --quiet origin "HEAD:refs/heads/ws/$1" "HEAD:refs/heads/ws/$3" + echo "ws/$1: from ws/$3, its old name (pushed to both)" + elif [ "$(tree HEAD)" != "$(tree "$ws")" ]; then git -C "$wt" push --quiet origin "HEAD:refs/heads/ws/$1" - if [ "$from" = "$ws" ]; then echo "ws/$1: refreshed"; else echo "ws/$1: created from ws/${1##*/}, its old name"; fi + echo "ws/$1: refreshed" fi } @@ -181,23 +189,27 @@ $(git log --no-merges --invert-grep --grep='^delphi: ' --format='- %s (%an)' "$m echo "ws/$1: proposed (PR from propose/$1)" } -for folder in $(git ls-tree -r --name-only "$main" -- software | - sed -nE 's#^(software/(.+/)?workspaces/[^/]+)/workspace\.yml$#\1#p'); do - name=${folder%/workspaces/*}/${folder##*/} ok=1 - names="$names$name " +olds=" " # | of each workspace +while read -r name folder; do + ok=1 + names="$names$name " olds="$olds${folder%/workspaces/*}/${folder##*/}|$name " [ -z "$only" ] || [ "$only" = "$name" ] || continue if [ "$dir" != propose ]; then - refresh "$name" "$folder" + refresh "$name" "$folder" "${folder%/workspaces/*}/${folder##*/}" elif [ -n "$(tree "refs/remotes/origin/ws/$name")" ]; then git -C "$wt" checkout --quiet --detach "refs/remotes/origin/ws/$name" # propose ws/ as is else ok=0; fi if [ "$ok" = 1 ] && [ "$dir" != refresh ]; then propose "$name" "$folder"; fi -done +done < <(bash "$here/../.delphi/names.sh" "$main") [ -z "$only" ] || case "$names" in *" $only "*) ;; *) echo "no workspace '$only' on main" >&2 && exit 1 ;; esac for b in $(git for-each-ref --format='%(refname:lstrip=3)' refs/remotes/origin/ws/); do case "$names" in *" ${b#ws/} "*) continue ;; esac - case "$names" in *"/${b#ws/} "*) echo "notice: $b is a workspace's old name; it's now ws/$(echo "$names" | tr ' ' '\n' | grep "/${b#ws/}\$" | head -n 1) (kept)" ;; + n=${olds#*" ${b#ws/}|"} n=${n%% *} + case "$olds" in *" ${b#ws/}|"*) + if git merge-base --is-ancestor "refs/remotes/origin/$b" "refs/remotes/origin/ws/$n" 2>/dev/null; then + echo "notice: $b is ws/$n's old name (kept; delete it once no clone is on it)" + else echo "notice: $b, ws/$n's old name, has commits ws/$n lacks: merge it into ws/$n in a PR (kept)"; fi ;; *) echo "notice: $b has no workspace folder on main (kept)" ;; esac done exit "$failed" diff --git a/docs/design.md b/docs/design.md index ae67d62..e769297 100644 --- a/docs/design.md +++ b/docs/design.md @@ -5,9 +5,13 @@ Goals: `docs/goals.md`. No CLI: plain git plus a few shell scripts run by GitHub ## 1. Model `main` holds every workspace as a folder, `software/**/workspaces//`, with its files at their -normal harness paths (`CLAUDE.md`, `.claude/…`, `docs/…`). A workspace's `` is its place in -the tree: the folder's path without `workspaces/` (`software/application-software/argos/argos-dev` -for `software/application-software/argos/workspaces/argos-dev`). For each workspace, branch +normal harness paths (`CLAUDE.md`, `.claude/…`, `docs/…`). A workspace's `` is the short name +of each folder from `software/` down to its project folder, then ``, joined by dots +(`s.app.argos.argos-dev` for `software/application-software/argos/workspaces/argos-dev`). A folder's +short name is the one line in its `short-name` file, else its own name; `.delphi/names.sh []` +prints every workspace's name and folder, and every script that names workspaces uses it. Short +names and leaves are lowercase letters, digits and `-`, so a name is always a valid ref and a flat +folder name (`linked/`, `.delphi/parked/`), and no branch can be a folder of another. For each workspace, branch **`ws/`** has **that folder as its repo root**. Two directions keep them in sync, both subtree merges (`git merge -Xsubtree=`), so the histories stay joined and edits on either side meet in normal three-way merges. The one thing shared is main's root `.delphi/` (the scripts): sync @@ -50,9 +54,10 @@ harness plugins (section 2) carry what workspaces opt into. The one generated wo software//…// README.md AUTHORING.md workspace-defaults/ (optional, main only) software/[/…/]workspace-inherit/ files every workspace below gets on its ws/* root (optional, main only) software//…/plugins/// a plugin set for the workspaces under that folder (optional, main only) -software//…/plugins/scope the folder's short name in plugin names (optional; default: its name) +software//…/short-name the folder's short name in workspace names (optional; default: its name) +software//…/plugins/scope its scope in plugin names (optional; default: its short name) .claude-plugin/marketplace.json every Claude Code plugin set under software/ (generated) -software//…/workspaces// workspace.yml CLAUDE.md .claude/… docs/… +software//…/workspaces// workspace.yml CLAUDE.md .claude/… docs/… .github/workflows/delphi.yml .gitattributes .delphi/ {setup,new-worktree,switch,park,plugins,version}.sh manifest.yml versions migrations/ .gitattributes .gitignore: on every ws/* root @@ -68,9 +73,8 @@ Org folders under `software/` are plain directories. `workspace.yml` is tiny YAM workspace's name, so a checkout of `ws/` knows which workspace it is), `harness: `, an optional `repos:` map of `: `, and an optional `references-git:` map of `: []` (names unique across both maps), and an optional `workspaces:` list of -`- ws/` (a workspace on main) or `- main` (no duplicates, not itself). Names are lowercase -letters, digits, `-` and `/`. Workspaces in different places may end in the same leaf, but no name -may be a folder of another's (git can't hold both `ws/a` and `ws/a/b`). Workspaces never nest; no symlinks under +`- ` (a workspace on main) or `- main` (no duplicates, not itself). `name:` must be the one +`names.sh` gives, and names are unique (a clash is fixed with a `short-name`). Workspaces never nest; no symlinks under `software/`. The root `.delphi/` holds the scripts and the version files (section 5b), and no workspace folder (nor the template) has a `.delphi/` of its own. Each manifest file outside `.delphi/` (the workflow) is the same at main's root, in the template, and in every workspace. `ci/check.sh` enforces all of this and lists every problem. @@ -111,8 +115,9 @@ rest, and `ci/check.sh` fails if the list or either file is out of date: - `.claude-plugin/marketplace.json` at main's root: marketplace `delphi`, one plugin per set, named `` for a folder's `default` set and `.` for the others (`argos`, - `argos.testing`), so names stay short in Claude Code's `/` menu. A folder's scope is its own name, - or the one line in its `plugins/scope` (`application-software` has `app`). Scopes are lowercase + `argos.testing`), so names stay short in Claude Code's `/` menu. A folder's scope is the one line in + its `plugins/scope`, else its short name (`application-software`'s `short-name` is `app`), else its + own name. Scopes are lowercase letters, digits and `-`, unique across the tree, and each `plugin.json` uses its name. (Claude Code refuses `/` in plugin names; `.` works, though Claude.ai's marketplace sync wants kebab-case.) - Each `harness: claude-code` workspace's `.claude/settings.json`: the shared settings in @@ -146,10 +151,11 @@ that touches a set is an update. For every workspace on `origin/main` (or just ``): -1. **Refresh** (main → `ws/`). If `ws/` is missing and `ws/` (its name before - workspaces were named by path) exists, start from that, so its history and unproposed edits - carry over, and refresh as below ("created from ws/, its old name"; the old branch is kept, - with a notice). Otherwise, if `ws/` is missing, create it as one commit +1. **Refresh** (main → `ws/`). If `ws/` (its name before dotted names, the folder's + path without `workspaces/`) exists and `ws/` is missing or behind it, start from `ws/`, + so its history and unproposed edits carry over, refresh as below, and push the result to both + (so clones still on `ws/` pull the 0.8.0 migration that renames their branch; the old branch + is kept, with a notice to delete it once no clone is on it). Otherwise, if `ws/` is missing, create it as one commit (`git commit-tree
: -p
`, "delphi: create ws/ from "): root = folder plus main's `.delphi/`, parent = main, so no unrelated histories. Otherwise merge `origin/main` into `ws/` with `--no-ff -Xsubtree=`, then, if its `.delphi/` differs @@ -197,9 +203,9 @@ and `.DS_Store` to the clone's `.git/info/exclude` once. For each repo under `re sources), it fetches `` (default: origin's `HEAD`) with `--depth 1` into `references//`, checks it out detached, disables pushes, and does this again on every run, so the copy tracks its pin; a failed fetch is reported and the old copy kept. Adds `/references/` to the exclude too; references get -no store and no worktrees (`new-worktree.sh` refuses them). Each entry under `workspaces:` (`- ws/` -or `- main`; the workspace itself is skipped) gets the same kind of copy of that branch of the clone's -own origin, at `linked//`, also refreshed every run; `/linked/` is excluded too. +no store and no worktrees (`new-worktree.sh` refuses them). Each entry under `workspaces:` (`- ` +or `- main`; the workspace itself is skipped) gets the same kind of copy of that branch (`ws/` +or `main`) of the clone's own origin, at `linked//`, also refreshed every run; `/linked/` is excluded too. Links are separate clones, not worktrees of the Delphi clone, so two workspaces can both link `main` and each copy parks with its workspace. Writes the clone's `post-checkout` hook, a 3-line script that runs `.delphi/park.sh` from the worktree that checked out (replacing an older link to or copy of a @@ -214,8 +220,8 @@ workspace's root folders to `.delphi/parked//`: the ones `.delphi/paths.sh and those under `park:` in the `workspace.yml` of the branch it leaves, so each workspace chooses its own (a project's `workspace-defaults/` can start the list). It moves back everything parked for the new one, whatever its list says now. Entries are folder names at the root, never `.git` or `.delphi`: -`ci/check.sh` checks them and `park.sh` skips a bad one. A name from before workspaces were named by path (`argos-dev`) is the same -workspace as the path ending in it, so moving a clone onto its renamed branch moves nothing. +`ci/check.sh` checks them and `park.sh` skips a bad one. An old name (a folder path, from before dotted names) is the same +workspace as the dotted name ending in its last part, so moving a clone onto its renamed branch moves nothing. `.delphi/.gitignore` ignores `parked/`; since `.delphi/` is on every branch, so is that rule. Parked work is ignored, not hidden: `git clean -X` or `git stash --all` at the root would take it. A move is a rename, so it is instant and every absolute path (worktree links, build caches) is the same again whenever its workspace is checked @@ -240,7 +246,7 @@ variable, so another harness needs only its own settings to set `DELPHI_ROOT`. `.delphi/switch.sh |main` moves the clone to `ws/` (or `main`): it refuses while tracked files have changes, fetches the branch, switches (the hook parks and restores), fast-forwards -to origin, and runs the new workspace's `setup.sh`. `switch.sh --list` prints `main` and the workspaces, `*` before this clone's. The `delphi` mod +to origin, and runs the new workspace's `setup.sh`. `switch.sh --list` prints `main` and each workspace with a branch, as ` `, `*` before this clone's. The `delphi` mod (`software/plugins/claude/default/`) puts that in a pane to keep open: `/ws-delphi` shows them as cards grouped by project, this clone's marked current, with a warning when tracked files have changed; pressing a card selects it, and **Switch workspace** runs `switch.sh` (`/ws-delphi ` skips the pane). No restart: Claude Code @@ -285,12 +291,12 @@ it's switched back to (`switch.sh` runs setup). Migrations must be safe to re-ru fresh clone, and delete only what nothing is lost from, stopping with what to do otherwise. `setup.sh` also warns when `.delphi/` isn't exactly the version it says (edited in the clone). -## 6. New workspaces (`tools/new-workspace.sh [--by ] `) +## 6. New workspaces (`tools/new-workspace.sh [--by ] `) -Validates the name (format, not on main, no leftover `ws/` or `ws/`), copies `templates/workspace/` +Validates the leaf and the dotted name it gets (format, unique, no leftover `ws/` or `ws/`), copies `templates/workspace/` and then `software//workspace-defaults/` if present (its `CLAUDE.md` appended to the template's) -into `software//workspaces//` (filling `{{name}}`/`{{folder}}` in `CLAUDE.md`) in a -temporary worktree, sets `name: software//`, generates its `.claude/settings.json`, runs `ci/check.sh`, pushes branch +into `software//workspaces//` (filling `{{name}}`/`{{folder}}` in `CLAUDE.md`) in a +temporary worktree, sets `name: `, generates its `.claude/settings.json`, runs `ci/check.sh`, pushes branch `new-workspace//`, and opens a PR in the PR template's sections, labelled `by: ` (`ai`, the default, `ai-assisted` or `human`). Sync creates `ws/` once it merges. diff --git a/software/application-software/argos/AUTHORING.md b/software/application-software/argos/AUTHORING.md index 1fc0eb6..5bd2f2e 100644 --- a/software/application-software/argos/AUTHORING.md +++ b/software/application-software/argos/AUTHORING.md @@ -5,7 +5,7 @@ full model; this is the Argos-specific short version. ## Change an existing workspace -1. Check out `ws/software/application-software/argos/` (from another workspace: `bash .delphi/switch.sh `, the +1. Check out `ws/s.app.argos.` (from another workspace: `bash .delphi/switch.sh s.app.argos.`, the `switch-workspace` skill), then `git switch -c `. 2. Edit at normal harness paths: `CLAUDE.md`, `.claude/skills//SKILL.md`, `docs/`. 3. Push the branch and open a PR into `ws/`. After it merges, CI proposes it to `main`. diff --git a/software/application-software/argos/README.md b/software/application-software/argos/README.md index 1d5fcad..0135297 100644 --- a/software/application-software/argos/README.md +++ b/software/application-software/argos/README.md @@ -9,7 +9,7 @@ backend (`scylla-server/`), with schema tooling in `charybdis/` and MQTT broker | Path | What it is | |---|---| -| `workspaces//` | One workspace per purpose; branch `ws/software/application-software/argos/` is its root | +| `workspaces//` | One workspace per purpose; branch `ws/s.app.argos.` is its root | | `workspaces/argos-dev/` | Day-to-day Argos development: tickets, specs, PRs | | `workspace-defaults/` | What a new Argos workspace starts with (see `AUTHORING.md`) | | `workspace-inherit/` | Skills every Argos workspace gets on each sync: ticket and PR conventions, `implement`, `run-local`, `verify-graph`, `verify-telemetry` | diff --git a/software/application-software/argos/workspace-defaults/workspace.yml b/software/application-software/argos/workspace-defaults/workspace.yml index d4a70f2..86d5603 100644 --- a/software/application-software/argos/workspace-defaults/workspace.yml +++ b/software/application-software/argos/workspace-defaults/workspace.yml @@ -9,5 +9,5 @@ park: # `- `: root folders that park with this works repos: # `: `: code you change; bare store at repos/, default branch at worktrees// argos: https://github.com/Northeastern-Electric-Racing/Argos.git odyssey-definitions: https://github.com/Northeastern-Electric-Racing/Odyssey-Definitions.git # CAN definitions for verify-telemetry -workspaces: # `- ws/` or `- main`: Delphi workspaces to read, never edit; shallow copy at linked/ +workspaces: # `- ` (dotted) or `- main`: Delphi workspaces to read, never edit; shallow copy at linked/ - main diff --git a/software/application-software/argos/workspaces/argos-dev/CLAUDE.md b/software/application-software/argos/workspaces/argos-dev/CLAUDE.md index 32c95ed..a4ca48d 100644 --- a/software/application-software/argos/workspaces/argos-dev/CLAUDE.md +++ b/software/application-software/argos/workspaces/argos-dev/CLAUDE.md @@ -1,6 +1,6 @@ # Delphi workspace -You're in Delphi workspace `software/application-software/argos/argos-dev` (branch `ws/software/application-software/argos/argos-dev`); code repos are checked out as worktrees under `worktrees//` (`.delphi/setup.sh` makes the default branch's). Make new branches as worktrees (`new-worktree` skill) unless the user says not to. Delphi workspaces (or `main`) under `workspaces:` in `workspace.yml` (`- ws/`, a workspace's place in the tree, or `- main`) are read-only copies at `linked//` for reading their context; change what's checked out with the `setup-workspace` skill. To work in another workspace, switch this clone to it (`switch-workspace` skill). Park rough notes with the `journal` skill. Work here unless the task clearly belongs to another project. +You're in Delphi workspace `s.app.argos.argos-dev` (branch `ws/s.app.argos.argos-dev`); code repos are checked out as worktrees under `worktrees//` (`.delphi/setup.sh` makes the default branch's). Make new branches as worktrees (`new-worktree` skill) unless the user says not to. Delphi workspaces (or `main`) under `workspaces:` in `workspace.yml` (`- `, a workspace's dotted name like `s.app.argos.argos-dev`, or `- main`) are read-only copies at `linked//` for reading their context; change what's checked out with the `setup-workspace` skill. To work in another workspace, switch this clone to it (`switch-workspace` skill). Park rough notes with the `journal` skill. Work here unless the task clearly belongs to another project. Always work from the workspace root (this clone's top, where `workspace.yml` is); the layout exists so one session can reach every worktree from there. Name worktrees by path: `git -C worktrees/argos/ …` runs git there without moving, and a one-off subshell like `(cd worktrees/argos//angular-client && npx ng lint)` runs anything else. If you `cd` into a worktree to look closely at something, `cd` back to the root as soon as you're done. Skill scripts take only names (repo, branch) and find the rest from `$DELPHI_ROOT`, which a session hook sets in every shell (`$DELPHI_WORKTREES` and the other `.delphi/paths.sh` folders too). diff --git a/software/application-software/argos/workspaces/argos-dev/workspace.yml b/software/application-software/argos/workspaces/argos-dev/workspace.yml index 7358ba7..6a90f7a 100644 --- a/software/application-software/argos/workspaces/argos-dev/workspace.yml +++ b/software/application-software/argos/workspaces/argos-dev/workspace.yml @@ -1,4 +1,4 @@ -name: software/application-software/argos/argos-dev +name: s.app.argos.argos-dev harness: claude-code plugins: # `- [.]`: plugin sets to enable; CI lists the `default` set of every folder above first and generates .claude/settings.json - delphi # default, from software/ @@ -16,5 +16,5 @@ references-git: # `: []`: dependency so socketioxide: https://github.com/Totodore/socketioxide.git socketioxide-v0.18.7 socket.io: https://github.com/socketio/socket.io.git socket.io-client@4.8.1 # monorepo: client, server, engine.io, parser zenoh: https://github.com/eclipse-zenoh/zenoh.git 1.10.1 -workspaces: # `- ws/` or `- main`: Delphi workspaces to read, never edit; shallow copy at linked/ +workspaces: # `- ` (dotted) or `- main`: Delphi workspaces to read, never edit; shallow copy at linked/ - main diff --git a/software/application-software/argos/workspaces/experimental/CLAUDE.md b/software/application-software/argos/workspaces/experimental/CLAUDE.md index 81a78e5..a7b6b24 100644 --- a/software/application-software/argos/workspaces/experimental/CLAUDE.md +++ b/software/application-software/argos/workspaces/experimental/CLAUDE.md @@ -1,6 +1,6 @@ # Delphi workspace -You're in Delphi workspace `software/application-software/argos/experimental` (branch `ws/software/application-software/argos/experimental`); code repos are checked out as worktrees under `worktrees//` (`.delphi/setup.sh` makes the default branch's). Make new branches as worktrees (`new-worktree` skill) unless the user says not to. Delphi workspaces (or `main`) under `workspaces:` in `workspace.yml` (`- ws/`, a workspace's place in the tree, or `- main`) are read-only copies at `linked//` for reading their context; change what's checked out with the `setup-workspace` skill. To work in another workspace, switch this clone to it (`switch-workspace` skill). Park rough notes with the `journal` skill. Work here unless the task clearly belongs to another project. +You're in Delphi workspace `s.app.argos.experimental` (branch `ws/s.app.argos.experimental`); code repos are checked out as worktrees under `worktrees//` (`.delphi/setup.sh` makes the default branch's). Make new branches as worktrees (`new-worktree` skill) unless the user says not to. Delphi workspaces (or `main`) under `workspaces:` in `workspace.yml` (`- `, a workspace's dotted name like `s.app.argos.argos-dev`, or `- main`) are read-only copies at `linked//` for reading their context; change what's checked out with the `setup-workspace` skill. To work in another workspace, switch this clone to it (`switch-workspace` skill). Park rough notes with the `journal` skill. Work here unless the task clearly belongs to another project. # NER Software conventions diff --git a/software/application-software/argos/workspaces/experimental/workspace.yml b/software/application-software/argos/workspaces/experimental/workspace.yml index 08d1e3e..c5de52f 100644 --- a/software/application-software/argos/workspaces/experimental/workspace.yml +++ b/software/application-software/argos/workspaces/experimental/workspace.yml @@ -1,4 +1,4 @@ -name: software/application-software/argos/experimental +name: s.app.argos.experimental harness: claude-code plugins: # `- [.]`: plugin sets to enable; CI lists the `default` set of every folder above first and generates .claude/settings.json - delphi # default, from software/ @@ -11,5 +11,5 @@ park: # `- `: root folders that park with this works repos: # `: `: code you change; bare store at repos/, default branch at worktrees// argos: https://github.com/Northeastern-Electric-Racing/Argos.git odyssey-definitions: https://github.com/Northeastern-Electric-Racing/Odyssey-Definitions.git -workspaces: # `- ws/` or `- main`: Delphi workspaces to read, never edit; shallow copy at linked/ +workspaces: # `- ` (dotted) or `- main`: Delphi workspaces to read, never edit; shallow copy at linked/ - main diff --git a/software/application-software/nero/workspaces/nero-experimental/CLAUDE.md b/software/application-software/nero/workspaces/nero-experimental/CLAUDE.md index adb838a..1b11166 100644 --- a/software/application-software/nero/workspaces/nero-experimental/CLAUDE.md +++ b/software/application-software/nero/workspaces/nero-experimental/CLAUDE.md @@ -1,6 +1,6 @@ # Delphi workspace -You're in Delphi workspace `software/application-software/nero/nero-experimental` (branch `ws/software/application-software/nero/nero-experimental`); code repos are checked out as worktrees under `worktrees//` (`.delphi/setup.sh` makes the default branch's). Make new branches as worktrees (`new-worktree` skill) unless the user says not to. Delphi workspaces (or `main`) under `workspaces:` in `workspace.yml` (`- ws/`, a workspace's place in the tree, or `- main`) are read-only copies at `linked//` for reading their context; change what's checked out with the `setup-workspace` skill. To work in another workspace, switch this clone to it (`switch-workspace` skill). Park rough notes with the `journal` skill. Work here unless the task clearly belongs to another project. +You're in Delphi workspace `s.app.nero.nero-experimental` (branch `ws/s.app.nero.nero-experimental`); code repos are checked out as worktrees under `worktrees//` (`.delphi/setup.sh` makes the default branch's). Make new branches as worktrees (`new-worktree` skill) unless the user says not to. Delphi workspaces (or `main`) under `workspaces:` in `workspace.yml` (`- `, a workspace's dotted name like `s.app.argos.argos-dev`, or `- main`) are read-only copies at `linked//` for reading their context; change what's checked out with the `setup-workspace` skill. To work in another workspace, switch this clone to it (`switch-workspace` skill). Park rough notes with the `journal` skill. Work here unless the task clearly belongs to another project. # NER Software conventions diff --git a/software/application-software/nero/workspaces/nero-experimental/workspace.yml b/software/application-software/nero/workspaces/nero-experimental/workspace.yml index 6c57f66..d12ffe0 100644 --- a/software/application-software/nero/workspaces/nero-experimental/workspace.yml +++ b/software/application-software/nero/workspaces/nero-experimental/workspace.yml @@ -1,4 +1,4 @@ -name: software/application-software/nero/nero-experimental +name: s.app.nero.nero-experimental harness: claude-code plugins: # `- [.]`: plugin sets to enable; CI lists the `default` set of every folder above first and generates .claude/settings.json - delphi # default, from software/ @@ -8,5 +8,5 @@ park: # `- `: root folders that park with this works # .delphi/setup.sh checks out everything below (git-ignored); re-run it after any change. repos: # `: `: code you change; bare store at repos/, default branch at worktrees// nero: https://github.com/Northeastern-Electric-Racing/Nero-2.0.git -workspaces: # `- ws/` or `- main`: Delphi workspaces to read, never edit; shallow copy at linked/ +workspaces: # `- ` (dotted) or `- main`: Delphi workspaces to read, never edit; shallow copy at linked/ # - main diff --git a/software/application-software/plugins/scope b/software/application-software/short-name similarity index 100% rename from software/application-software/plugins/scope rename to software/application-software/short-name diff --git a/software/application-software/workspaces/hub/CLAUDE.md b/software/application-software/workspaces/hub/CLAUDE.md index d2f0139..78b91b8 100644 --- a/software/application-software/workspaces/hub/CLAUDE.md +++ b/software/application-software/workspaces/hub/CLAUDE.md @@ -1,12 +1,12 @@ # Hub: application-software overview -You're in Delphi workspace `software/application-software/hub` (branch `ws/software/application-software/hub`): +You're in Delphi workspace `s.app.hub` (branch `ws/s.app.hub`): the head of application software's overview of the workspaces they run. It has no code repos by default; add one under `repos:` in `workspace.yml` to make worktrees here (`new-worktree` skill). Each workspace listed under `workspaces:` in `workspace.yml` is also cloned separately on this machine, on its own `ws/` branch, with its own Claude Code session. The user works in those in parallel. Here you -only see their read-only copies under `linked//` (refresh with `bash .delphi/setup.sh`; change which are +only see their read-only copies under `linked//` (refresh with `bash .delphi/setup.sh`; change which are listed with the `setup-workspace` skill). Park rough notes with the `journal` skill. ## What you do here diff --git a/software/application-software/workspaces/hub/workspace.yml b/software/application-software/workspaces/hub/workspace.yml index 14a5794..e907a5c 100644 --- a/software/application-software/workspaces/hub/workspace.yml +++ b/software/application-software/workspaces/hub/workspace.yml @@ -1,4 +1,4 @@ -name: software/application-software/hub +name: s.app.hub harness: claude-code plugins: # `- [.]`: plugin sets to enable; CI lists the `default` set of every folder above first and generates .claude/settings.json - delphi # default, from software/ @@ -10,8 +10,8 @@ repos: # `: `: code you change; bare store at # name: https://github.com/Northeastern-Electric-Racing/.git references-git: # `: []`: dependency sources to read, never edit; shallow copy at references/ # name: https://github.com//.git v1.2.3 # no tag or branch: its default branch -workspaces: # `- ws/` or `- main`: Delphi workspaces to read, never edit; shallow copy at linked/ +workspaces: # `- ` (dotted) or `- main`: Delphi workspaces to read, never edit; shallow copy at linked/ - main - - ws/software/application-software/argos/argos-dev - - ws/software/firmware/calypso/calypso-dev - - ws/software/application-software/nero/nero-experimental + - s.app.argos.argos-dev + - s.firmware.calypso.calypso-dev + - s.app.nero.nero-experimental diff --git a/software/firmware/calypso/workspaces/calypso-dev/CLAUDE.md b/software/firmware/calypso/workspaces/calypso-dev/CLAUDE.md index f8af449..f84877e 100644 --- a/software/firmware/calypso/workspaces/calypso-dev/CLAUDE.md +++ b/software/firmware/calypso/workspaces/calypso-dev/CLAUDE.md @@ -1,6 +1,6 @@ # Delphi workspace -You're in Delphi workspace `software/firmware/calypso/calypso-dev` (branch `ws/software/firmware/calypso/calypso-dev`); code repos are checked out as worktrees under `worktrees//` (`.delphi/setup.sh` makes the default branch's). Make new branches as worktrees (`new-worktree` skill) unless the user says not to. Read-only dependency sources from `references-git:` are checked out under `references//` at the version the workspace pins: read them to learn how a dependency behaves (prefer them over web docs), and never edit, branch, or push them. Delphi workspaces (or `main`) under `workspaces:` in `workspace.yml` (`- ws/`, a workspace's place in the tree, or `- main`) are read-only copies at `linked//` for reading their context; change what's checked out with the `setup-workspace` skill. To work in another workspace, switch this clone to it (`switch-workspace` skill). Park rough notes with the `journal` skill. Work here unless the task clearly belongs to another project. +You're in Delphi workspace `s.firmware.calypso.calypso-dev` (branch `ws/s.firmware.calypso.calypso-dev`); code repos are checked out as worktrees under `worktrees//` (`.delphi/setup.sh` makes the default branch's). Make new branches as worktrees (`new-worktree` skill) unless the user says not to. Read-only dependency sources from `references-git:` are checked out under `references//` at the version the workspace pins: read them to learn how a dependency behaves (prefer them over web docs), and never edit, branch, or push them. Delphi workspaces (or `main`) under `workspaces:` in `workspace.yml` (`- `, a workspace's dotted name like `s.app.argos.argos-dev`, or `- main`) are read-only copies at `linked//` for reading their context; change what's checked out with the `setup-workspace` skill. To work in another workspace, switch this clone to it (`switch-workspace` skill). Park rough notes with the `journal` skill. Work here unless the task clearly belongs to another project. # Calypso diff --git a/software/firmware/calypso/workspaces/calypso-dev/workspace.yml b/software/firmware/calypso/workspaces/calypso-dev/workspace.yml index ac4b871..089b30b 100644 --- a/software/firmware/calypso/workspaces/calypso-dev/workspace.yml +++ b/software/firmware/calypso/workspaces/calypso-dev/workspace.yml @@ -1,4 +1,4 @@ -name: software/firmware/calypso/calypso-dev # its place in the tree, the folder path without workspaces/ (branch ws/); checkouts park and restore repos/ by it +name: s.firmware.calypso.calypso-dev # its dotted name (.delphi/names.sh, like s.app.argos.argos-dev; branch ws/); checkouts park and restore repos/ by it harness: claude-code plugins: # `- [.]`: plugin sets to enable; CI lists the `default` set of every folder above first and generates .claude/settings.json - delphi # default, from software/ @@ -7,5 +7,5 @@ opt-out: # `- `: files from the workspace-inherit/ folder # .delphi/setup.sh checks out everything below (git-ignored); re-run it after any change. repos: # `: `: code you change; bare store at repos/, default branch at worktrees// calypso: https://github.com/Northeastern-Electric-Racing/Calypso.git -workspaces: # `- ws/` or `- main`: Delphi workspaces to read, never edit; shallow copy at linked/ +workspaces: # `- ` (dotted) or `- main`: Delphi workspaces to read, never edit; shallow copy at linked/ - main diff --git a/software/plugins/claude/default/hooks/register.tsx b/software/plugins/claude/default/hooks/register.tsx index 19e3883..488b36b 100644 --- a/software/plugins/claude/default/hooks/register.tsx +++ b/software/plugins/claude/default/hooks/register.tsx @@ -34,10 +34,11 @@ let webUrl = '' // the browser view's address while web/server.py runs let webError = '' // why it isn't running, when it failed to let webStart: Promise | null = null -function toWs(name: string): Ws { +// A switch.sh --list line without its mark: `main`, or ` ` +function toWs(line: string): Ws { + const [name = '', folder = ''] = line.trim().split(' ') if (name === 'main') return { name, leaf: 'main', group: 'project folders, templates, CI' } - const parts = name.split('/') - return { name, leaf: parts[parts.length - 1] ?? name, group: parts.slice(1, -1).join(' / ') } + return { name, leaf: name.split('.').pop() ?? name, group: folder.split('/').slice(1).join(' / ') } } function leafOf(name: string): string { @@ -76,7 +77,7 @@ async function load($: EngineInterface) { isRefreshing = false if (list.exitCode === 0 || all.length === 0) { // a failed refresh keeps the last list all = lines.map(l => toWs(l.slice(2))) - here = lines.find(l => l.startsWith('* '))?.slice(2) ?? '' + here = toWs(lines.find(l => l.startsWith('* '))?.slice(2) ?? '').name } isDirty = (await run($, ['git', 'diff', '--quiet', 'HEAD', '--'], 10_000)).exitCode !== 0 if (!all.some(w => w.name === selected) || (selected === here && phase === 'pick')) { @@ -259,7 +260,7 @@ export const register: Register = on => { on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => { const { Box, Text, Button, Link } = $.ui.resolve(e) - const cur = toWs(here) + const cur = all.find(w => w.name === here) ?? toWs(here) const sel = all.find(w => w.name === selected) const groups = [...new Set(all.map(w => w.group))] const redraw = () => $.ui.invalidate('ui.render') diff --git a/software/plugins/claude/default/tests/ws.test.ts b/software/plugins/claude/default/tests/ws.test.ts index 09ea5c1..c5247e6 100644 --- a/software/plugins/claude/default/tests/ws.test.ts +++ b/software/plugins/claude/default/tests/ws.test.ts @@ -7,8 +7,9 @@ import { expect, test } from 'claude-code/testing' const ROOT = '/work/delphi' const SWITCH = ROOT + '/.delphi/switch.sh' -const ARGOS = 'software/application-software/argos/argos-dev' -const BMS = 'software/electrical/bms-dev' +const ARGOS = 's.app.argos.argos-dev' +const BMS = 's.electrical.bms-dev' +const FOLDER: Record = { main: '', [ARGOS]: ' software/application-software/argos', [BMS]: ' software/electrical' } const PANE = { plugin: 'delphi', component: 'Pane', @@ -82,7 +83,7 @@ function fakeClone(on: On, here = ARGOS, isDirty = false, isDelphi = true, hasPy if (cmd === 'bash' && a1 === SWITCH && a2 === '--list') { c.lists++ if (c.isOffline) return done(128, '', 'fatal: unable to access origin\n') - const out = ['main', ARGOS, BMS].map((n) => (n === c.here ? '* ' : ' ') + n).join('\n') + '\n' + const out = ['main', ARGOS, BMS].map((n) => (n === c.here ? '* ' : ' ') + n + FOLDER[n]).join('\n') + '\n' return done(0, out) } if (cmd === 'python3' && a1 === '-m' && a2 === 'webbrowser') { @@ -97,7 +98,7 @@ function fakeClone(on: On, here = ARGOS, isDirty = false, isDelphi = true, hasPy c.behind = 0 return done(0, 'switch: on ws/' + c.here + '\n') } - c.here = a2.split('/').length > 1 || a2 === 'main' ? a2 : BMS + c.here = a2 in FOLDER ? a2 : BMS return done(0, 'switch: on ws/' + c.here + '\n') } return { deny: 'unexpected process: ' + e.argv.join(' ') } @@ -170,9 +171,9 @@ test('with tracked changes the pane warns, a refused switch says why, and Back r test('/ws-delphi switches without the pane', async ($, on) => { const c = fakeClone(on) - const answer = await $.command.run(ws('bms-dev')) - expect(c.switches.map(s => s.name)).toEqual(['bms-dev']) - expect(answer.text).toMatch(/^On bms-dev: Claude reads its CLAUDE.md with your next message/) + const answer = await $.command.run(ws(BMS)) + expect(c.switches.map(s => s.name)).toEqual([BMS]) + expect(answer.text).toMatch(/^On s\.electrical\.bms-dev: Claude reads its CLAUDE.md with your next message/) }) test('outside a Delphi clone, /ws-delphi says so and runs nothing', async ($, on) => { @@ -268,12 +269,12 @@ test('the first prompt after a switch carries the new CLAUDE.md, once, saying it const c = fakeClone(on) await $.session.start({ surface: 'terminal', isInteractive: true, cwd: ROOT }) expect((await $.prompt.submit(typed('hi'))).context ?? []).toEqual([]) - await $.command.run(ws('bms-dev')) + await $.command.run(ws(BMS)) expect(c.here).toBe(BMS) const after = (await $.prompt.submit(typed('what now?'))).context ?? [] expect(after.length).toBe(1) - expect(after[0]).toMatch(/^Delphi: since this session started, this clone moved to ws\/software\/electrical\/bms-dev /) + expect(after[0]).toMatch(/^Delphi: since this session started, this clone moved to ws\/s\.electrical\.bms-dev /) expect(after[0]).toMatch(/It replaces the CLAUDE\.md you were given at the start/) - expect(after[0]).toMatch(/# Workspace software\/electrical\/bms-dev\n$/) + expect(after[0]).toMatch(/# Workspace s\.electrical\.bms-dev\n$/) expect((await $.prompt.submit(typed('and then?'))).context ?? []).toEqual([]) }) diff --git a/software/plugins/claude/default/web/index.html b/software/plugins/claude/default/web/index.html index 7bf85b9..5455b82 100644 --- a/software/plugins/claude/default/web/index.html +++ b/software/plugins/claude/default/web/index.html @@ -74,10 +74,10 @@

Delphi workspaces

let isPulling = false, pullNote = '', isPullOk = true // a pull is running; what the last one did const MAIN_GROUP = 'project folders, templates, CI' -function toWs(name) { +function toWs(line) { // a switch.sh --list line without its mark: `main`, or ` ` + const [name = '', folder = ''] = line.trim().split(' ') if (name === 'main') return { name, leaf: 'main', group: MAIN_GROUP } - const parts = name.split('/') - return { name, leaf: parts[parts.length - 1], group: parts.slice(1, -1).join(' / ') } + return { name, leaf: name.split('.').pop(), group: folder.split('/').slice(1).join(' / ') } } // el('div', { class: 'x', onclick }, child, ...): children are elements or text (never parsed as HTML) @@ -166,7 +166,7 @@

Delphi workspaces

} function render() { - const cur = toWs(here) + const cur = all.find(w => w.name === here) || toWs(here) document.getElementById('here').replaceChildren( el('span', { class: 'dot' }, '●'), el('strong', {}, cur.leaf || 'not on a workspace'), el('span', { class: 'dim' }, cur.group)) diff --git a/software/plugins/claude/default/web/server.py b/software/plugins/claude/default/web/server.py index 2f05a46..f255868 100644 --- a/software/plugins/claude/default/web/server.py +++ b/software/plugins/claude/default/web/server.py @@ -45,7 +45,7 @@ def state(): listed = run(['bash', str(switch), '--list'], 60) lines = [line for line in listed.stdout.splitlines() if line.strip()] return { - 'here': next((line[2:] for line in lines if line.startswith('* ')), ''), + 'here': next((line[2:].split(' ')[0] for line in lines if line.startswith('* ')), ''), 'workspaces': [line[2:] for line in lines], 'isDirty': run(['git', 'diff', '--quiet', 'HEAD', '--'], 10).returncode != 0, 'switching': switching, @@ -76,7 +76,7 @@ def do_pull(): return 400, {'ok': False, 'message': 'this clone is on no workspace'} if not busy.acquire(blocking=False): return 409, {'ok': False, 'message': 'a switch to ' + switching + ' is running'} - leaf = here.split('/')[-1] + leaf = here.split('.')[-1] try: switching = here before = run(['git', 'rev-parse', 'HEAD'], 10).stdout.strip() diff --git a/software/short-name b/software/short-name new file mode 100644 index 0000000..b478595 --- /dev/null +++ b/software/short-name @@ -0,0 +1 @@ +s diff --git a/software/workspace-inherit/.claude/skills/setup-workspace/SKILL.md b/software/workspace-inherit/.claude/skills/setup-workspace/SKILL.md index 6e73fb3..5343a5a 100644 --- a/software/workspace-inherit/.claude/skills/setup-workspace/SKILL.md +++ b/software/workspace-inherit/.claude/skills/setup-workspace/SKILL.md @@ -3,7 +3,7 @@ name: setup-workspace description: Change what this workspace checks out and re-run its setup. workspace.yml lists code repos (repos:), read-only dependency sources (references-git:), and read-only copies of other Delphi workspaces or main (workspaces:). Use when the user wants to read or compare another workspace's or main's instructions, skills, or docs (link it), add or remove a repo or dependency reference, or run setup after cloning or pulling. --- -`workspace.yml` at the workspace root lists what `.delphi/setup.sh` checks out, all git-ignored: code repos (`repos:`), dependency sources (`references-git:`), and other Delphi workspaces or main (`workspaces:`, entries `- ws/` or `- main`; a workspace's name is its place in the tree, like `software/application-software/argos/argos-dev`). The comment on each section gives its entry format and where it lands; read it, then edit the file. +`workspace.yml` at the workspace root lists what `.delphi/setup.sh` checks out, all git-ignored: code repos (`repos:`), dependency sources (`references-git:`), and other Delphi workspaces or main (`workspaces:`, entries `- ` or `- main`; a workspace's name is the short names of its folders (each folder's `short-name` file, else its name) and its own folder name, joined by dots, like `s.app.argos.argos-dev`; `bash .delphi/switch.sh --list` lists them). The comment on each section gives its entry format and where it lands; read it, then edit the file. Run from the workspace root after any change, and to refresh references and workspace copies to their latest: @@ -12,7 +12,7 @@ bash .delphi/setup.sh ``` - The copies under `linked/` are for reading: never edit, branch, or push them. To change one of those workspaces or main, switch to it (`switch-workspace` skill). -- This workspace's project folder (shared context, `AUTHORING.md`, `workspace-defaults/`) is `linked/main/` plus this workspace's name without its last part: for `software/application-software/argos/argos-dev`, `linked/main/software/application-software/argos/`. List workspaces: `git branch -r --list 'origin/ws/*'`. +- This workspace's project folder (shared context, `AUTHORING.md`, `workspace-defaults/`) is the folder above `workspaces//` in `linked/main/` (`bash .delphi/switch.sh --list` prints it): for `s.app.argos.argos-dev`, `linked/main/software/application-software/argos/`. List workspaces and their project folders: `bash .delphi/switch.sh --list`. - `workspace.yml` is shared: change it on a branch and open a PR into `ws/` (the `name:` in `workspace.yml`). For a one-off look, add the entry, run setup, then `git restore workspace.yml`. -- Setup never deletes what you'd lose. A removed entry's `linked//`, `references//`, or repo store and worktrees stay until you delete them (ask first). +- Setup never deletes what you'd lose. A removed entry's `linked//`, `references//`, or repo store and worktrees stay until you delete them (ask first). - When Delphi releases a version that needs cleanup in clones, setup runs its migration first (`.delphi/migrations/`), once per workspace. A migration removes only what nothing is lost from, and otherwise stops setup with what to do: tell the user, do it with their go-ahead, and re-run. diff --git a/software/workspace-inherit/.claude/skills/switch-workspace/SKILL.md b/software/workspace-inherit/.claude/skills/switch-workspace/SKILL.md index 4ca2ab9..a29a5df 100644 --- a/software/workspace-inherit/.claude/skills/switch-workspace/SKILL.md +++ b/software/workspace-inherit/.claude/skills/switch-workspace/SKILL.md @@ -6,13 +6,12 @@ description: Switch this clone to another Delphi workspace, or to Delphi's main, Run from the workspace root: ``` -bash .delphi/switch.sh # ws/: its place in the tree, e.g. software/application-software/argos/argos-dev -bash .delphi/switch.sh argos-dev # its last part alone, if only one workspace ends in it +bash .delphi/switch.sh # ws/: its dotted name, e.g. s.app.argos.argos-dev bash .delphi/switch.sh main # Delphi's main: project folders, templates, CI ``` - The user can also switch from the `/ws-delphi` pane (the `delphi` plugin's mod), which shows them as cards (`bash .delphi/switch.sh --list`): press one, then Switch workspace; `/ws-delphi ` switches straight away, and `/ws-delphi --web` opens the same cards in a browser tab. -- List workspaces: `git branch -r --list 'origin/ws/*'`. A workspace's name is its folder path on main without `workspaces/`. +- List workspaces and their project folders: `bash .delphi/switch.sh --list`. - It refuses while tracked files have changes: commit them on a branch (and PR them) first. Then it fetches, switches, fast-forwards, and runs the new workspace's `.delphi/setup.sh`. - The post-checkout hook parks this workspace's `repos/`, `worktrees/`, `references/` and `linked/` in `.delphi/parked//` (git-ignored; `` as in its `workspace.yml`) and brings the other's back at the same paths, so worktrees and the work in them return as they were. Switch back the same way. Never run `git clean -X` or `git stash --all` at the workspace root: they take ignored files, parked work included. - Before switching, stop anything running from `worktrees/` (dev servers, Docker builds): its folder moves. diff --git a/templates/workspace/CLAUDE.md b/templates/workspace/CLAUDE.md index 3194730..39aacda 100644 --- a/templates/workspace/CLAUDE.md +++ b/templates/workspace/CLAUDE.md @@ -1,3 +1,3 @@ # Delphi workspace -You're in Delphi workspace `{{name}}` (branch `ws/{{name}}`); code repos are checked out as worktrees under `worktrees//` (`.delphi/setup.sh` makes the default branch's). Make new branches as worktrees (`new-worktree` skill) unless the user says not to. Read-only dependency sources from `references-git:` are checked out under `references//` at the version the workspace pins: read them to learn how a dependency behaves (prefer them over web docs), and never edit, branch, or push them. Delphi workspaces (or `main`) under `workspaces:` in `workspace.yml` (`- ws/`, a workspace's place in the tree, or `- main`) are read-only copies at `linked//` for reading their context; change what's checked out with the `setup-workspace` skill. To work in another workspace, switch this clone to it (`switch-workspace` skill). Park rough notes with the `journal` skill. Work here unless the task clearly belongs to another project. +You're in Delphi workspace `{{name}}` (branch `ws/{{name}}`); code repos are checked out as worktrees under `worktrees//` (`.delphi/setup.sh` makes the default branch's). Make new branches as worktrees (`new-worktree` skill) unless the user says not to. Read-only dependency sources from `references-git:` are checked out under `references//` at the version the workspace pins: read them to learn how a dependency behaves (prefer them over web docs), and never edit, branch, or push them. Delphi workspaces (or `main`) under `workspaces:` in `workspace.yml` (`- `, a workspace's dotted name like `s.app.argos.argos-dev`, or `- main`) are read-only copies at `linked//` for reading their context; change what's checked out with the `setup-workspace` skill. To work in another workspace, switch this clone to it (`switch-workspace` skill). Park rough notes with the `journal` skill. Work here unless the task clearly belongs to another project. diff --git a/templates/workspace/workspace.yml b/templates/workspace/workspace.yml index 183e2fc..3f29181 100644 --- a/templates/workspace/workspace.yml +++ b/templates/workspace/workspace.yml @@ -1,4 +1,4 @@ -name: {{name}} # its place in the tree, the folder path without workspaces/ (branch ws/); checkouts park and restore repos/ by it +name: {{name}} # its dotted name (.delphi/names.sh, like s.app.argos.argos-dev; branch ws/); checkouts park and restore repos/ by it harness: claude-code plugins: # `- [.]`: plugin sets to enable; CI lists the `default` set of every folder above first and generates .claude/settings.json # - argos.testing # the set at software/application-software/argos/plugins//testing/ @@ -11,5 +11,5 @@ repos: # `: `: code you change; bare store at # name: https://github.com/Northeastern-Electric-Racing/.git references-git: # `: []`: dependency sources to read, never edit; shallow copy at references/ # name: https://github.com//.git v1.2.3 # no tag or branch: its default branch -workspaces: # `- ws/` or `- main`: Delphi workspaces to read, never edit; shallow copy at linked/ +workspaces: # `- ` (dotted) or `- main`: Delphi workspaces to read, never edit; shallow copy at linked/ # - main # recommended: this project's shared context, AUTHORING.md and workspace-defaults/ diff --git a/tests/e2e.sh b/tests/e2e.sh index bf09936..88335ed 100644 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -34,7 +34,8 @@ ok() { echo "ok - $*" && pass=$((pass + 1)); } fail() { echo "FAIL - $*" >&2 && exit 1; } A=software/application-software/argos/workspaces/argos-dev B=software/electrical/workspaces/bms-dev -AN=software/application-software/argos/argos-dev BN=software/electrical/bms-dev # their names: folder path without workspaces/ +AN=s.app.argos.argos-dev BN=s.electrical.bms-dev # their names (.delphi/names.sh): software/ is s, application-software/ app +AO=software/application-software/argos/argos-dev # A's old name (before 0.8.0): its folder's path without workspaces/ V=$(awk '/^version:/ { print $2 }' "$src/.delphi/manifest.yml") # the current version; VN, the patch after it, is what the test releases VN=${V%.*}.$((${V##*.} + 1)) @@ -42,6 +43,7 @@ VN=${V%.*}.$((${V##*.} + 1)) git init -q "$t/seed" cp -R "$src/ci" "$src/tools" "$src/templates" "$src/.github" "$src/.delphi" "$src/README.md" "$t/seed/" mkdir -p "$t/seed/$A" "$t/seed/$B" +cp "$src/software/short-name" "$t/seed/software/" && cp "$src/software/application-software/short-name" "$t/seed/software/application-software/" cp -R "$src/$A/." "$t/seed/$A/" cp -R "$src/templates/workspace/." "$t/seed/$B/" sed -i.bak "s|{{name}}|$BN|" "$t/seed/$B/workspace.yml" && rm "$t/seed/$B/workspace.yml.bak" @@ -53,7 +55,7 @@ echo "fw skill" >"$t/seed/$FD/.claude/skills/fw/SKILL.md" set_json() { # : a minimal plugin set mkdir -p "$1/plugins/claude/$2/.claude-plugin" && printf '{\n "name": "%s"\n}\n' "$3" >"$1/plugins/claude/$2/.claude-plugin/plugin.json" } -set_json "$t/seed/software/application-software" default app && echo app >"$t/seed/software/application-software/plugins/scope" +set_json "$t/seed/software/application-software" default app # its scope: its short-name set_json "$t/seed/software/application-software/argos" default argos # default sets above argos-dev TS=software/application-software/argos/plugins/claude/testing # an extra set a workspace can list under plugins: mkdir -p "$t/seed/$TS/.claude-plugin" && printf '{\n "name": "argos.testing"\n}\n' >"$t/seed/$TS/.claude-plugin/plugin.json" @@ -75,7 +77,7 @@ commit_on() { sync >"$t/out" main=$(o rev-parse main) for w in "$A" "$B"; do - n=${w%/workspaces/*}/${w##*/} + n=$BN && [ "$w" != "$A" ] || n=$AN o diff --quiet "main:$w" "ws/$n" -- ':!.delphi' || fail "ws/$n root != $w" [ "$(o rev-parse "ws/$n:.delphi")" = "$(o rev-parse main:.delphi)" ] || fail "ws/$n lacks main's .delphi/" [ "$(o rev-parse "ws/$n^")" = "$main" ] || fail "ws/$n parent is not main" @@ -268,17 +270,17 @@ expect_bad bad-repo "bad repos entry" sh -c "awk '{ print } /^repos:/ { print \" expect_bad bad-reference "bad references-git entry" sh -c "printf 'references-git:\n x: url ref extra\n' >>$A/workspace.yml" expect_bad reference-repo-clash "duplicate repo: argos" sh -c "printf 'references-git:\n argos: https://example.com/a.git\n' >>$A/workspace.yml" expect_bad bad-key "unexpected line" sh -c "printf 'color: x\n' >>$A/workspace.yml" -expect_bad wrong-name "the folder path without workspaces/" sh -c "sed -i.bak 's/^name:.*/name: other/' $A/workspace.yml && rm $A/workspace.yml.bak" +expect_bad wrong-name "(.delphi/names.sh)" sh -c "sed -i.bak 's/^name:.*/name: other/' $A/workspace.yml && rm $A/workspace.yml.bak" expect_bad no-name "name is missing" sh -c "sed -i.bak '/^name:/d' $A/workspace.yml && rm $A/workspace.yml.bak" expect_bad defaults-name "workspace-defaults/workspace.yml: unexpected line: name:" sh -c "printf 'name: x\n' >>${A%/workspaces/*}/workspace-defaults/workspace.yml" expect_bad park-path "bad park entry" sh -c "printf 'park:\n - a/b\n' >>$A/workspace.yml" expect_bad park-git "bad park entry" sh -c "printf 'park:\n - .git\n' >>$A/workspace.yml" expect_bad park-duplicate "duplicate park entry: pictures" sh -c "printf 'park:\n - pictures\n - pictures\n' >>$A/workspace.yml" expect_bad folder-delphi "$A/.delphi: managed in the root" sh -c "mkdir $A/.delphi && cp .delphi/park.sh $A/.delphi/" -expect_bad ref-clash "can't be a branch while ws/$AN is" sh -c "mkdir -p software/application-software/workspaces && cp -R $A software/application-software/workspaces/argos && - sed -i.bak 's|^name:.*|name: software/application-software/argos|' software/application-software/workspaces/argos/workspace.yml" +expect_bad same-name "is named $AN too (names are unique)" sh -c "mkdir -p software/x/argos/workspaces && cp -R $A software/x/argos/workspaces/ && echo app >software/x/short-name" +expect_bad bad-short-name "software/application-software/short-name: want one line of lowercase" sh -c "echo A_pp >software/application-software/short-name" expect_bad nested "nested inside" sh -c "mkdir -p $A/docs/workspaces/inner && cp $A/workspace.yml $A/docs/workspaces/inner/" -expect_bad bad-name "must be lowercase letters, digits, '-' and '/'" sh -c "mkdir -p software/x/workspaces && cp -R $A software/x/workspaces/Bad_Name" +expect_bad bad-name "must be lowercase letters, digits and '-'" sh -c "mkdir -p software/x/workspaces && cp -R $A software/x/workspaces/Bad_Name" expect_bad symlink "symlinks are not allowed" ln -s ../CLAUDE.md "$A/docs/link.md" expect_bad settings-hand-edit "$A/.claude/settings.json is generated" sh -c "echo x >>$A/.claude/settings.json" expect_bad marketplace-stale ".claude-plugin/marketplace.json is generated" sh -c "mkdir -p software/x/plugins/claude/default/.claude-plugin && echo '{}' >software/x/plugins/claude/default/.claude-plugin/plugin.json" @@ -322,18 +324,12 @@ d=$t/chk-opt-out && rm -rf "$d" && mkdir "$d" && cp -R "$src/software" "$src/tem "$src/ci/check.sh" "$d" >/dev/null 2>&1 || fail "check fails on a workspace keeping an inherited path it opts out of" ok "a workspace may keep its own copy of an inherited path it lists under opt-out:" expect_bad links-renamed "links: is now workspaces:" sh -c "sed -i.bak 's/^workspaces:/links:/' $A/workspace.yml && rm $A/workspace.yml.bak" -expect_bad bad-workspaces-entry "bad workspaces entry" sh -c "printf ' - argos-dev\n' >>$A/workspace.yml" +expect_bad bad-workspaces-entry "bad workspaces entry" sh -c "printf ' - ws/argos-dev\n' >>$A/workspace.yml" expect_bad duplicate-workspace "duplicate workspace: main" sh -c "printf ' - main\n' >>$A/workspace.yml" -expect_bad lists-itself "lists itself: ws/$AN" sh -c "printf ' - ws/$AN\n' >>$A/workspace.yml" -expect_bad unknown-workspace "lists ws/software/nope/x, which isn't a workspace on main" sh -c "printf ' - ws/software/nope/x\n' >>$A/workspace.yml" +expect_bad lists-itself "lists itself: $AN" sh -c "printf ' - $AN\n' >>$A/workspace.yml" +expect_bad unknown-workspace "lists nope, which isn't a workspace on main" sh -c "printf ' - nope\n' >>$A/workspace.yml" expect_bad template-delphi "templates/workspace/.delphi: scripts live only" mkdir templates/workspace/.delphi expect_bad template-workflow-drift "differs from .github/workflows/delphi.yml" sh -c "echo x >>.github/workflows/delphi.yml" -d=$t/chk-same-leaf && rm -rf "$d" && mkdir "$d" && cp -R "$src/software" "$src/templates" "$src/.github" "$src/.delphi" "$src/.claude-plugin" "$d/" -mkdir -p "$d/software/x/workspaces" && cp -R "$d/$A" "$d/software/x/workspaces/" -sed -i.bak 's|^name:.*|name: software/x/argos-dev|' "$d/software/x/workspaces/argos-dev/workspace.yml" && rm "$d/software/x/workspaces/argos-dev/workspace.yml.bak" -bash "$src/ci/plugins.sh" "$d" >/dev/null # its settings.json: no plugin sets above software/x -{ (cd "$d" && git init -q && git add -A) && "$src/ci/check.sh" "$d" >/dev/null; } || fail "two workspaces ending in the same name failed check" -ok "workspaces in different places may end in the same name (software/x/argos-dev beside $AN)" # --- 8. setup.sh fetches workspace.yml repos into repos/, checks out the default branch under worktrees/, and is idempotent for r in lib1 lib2; do @@ -341,7 +337,7 @@ for r in lib1 lib2; do done def=$(git -C "$t/lib1" branch --show-current) git clone -q -b ws/$AN "$t/origin.git" "$t/ws" -printf 'name: software/application-software/argos/argos-dev\nharness: claude-code\nplugins:\n - x.y\npark:\n - pictures # screenshots\n - .playwright-mcp\nrepos:\n # a comment\n lib1: %s # trailing\n lib2: %s\n' \ +printf 'name: s.app.argos.argos-dev\nharness: claude-code\nplugins:\n - x.y\npark:\n - pictures # screenshots\n - .playwright-mcp\nrepos:\n # a comment\n lib1: %s # trailing\n lib2: %s\n' \ "$t/lib1.git" "$t/lib2.git" >"$t/ws/workspace.yml" /bin/bash "$t/ws/.delphi/setup.sh" >/dev/null 2>&1 || fail "setup.sh failed" /bin/bash "$t/ws/.delphi/setup.sh" >"$t/out" 2>&1 || fail "setup.sh re-run failed" @@ -371,7 +367,7 @@ ok "setup.sh runs in a core.autocrlf=true clone (Git Bash default), thanks to .g git -C "$t/lib2" tag lib2@v1 && git -C "$t/lib2" commit -q --allow-empty -m "after v1" && git -C "$t/lib2" push -q "$t/lib2.git" --all && git -C "$t/lib2" push -q "$t/lib2.git" --tags git -C "$t/lib1" commit -q --allow-empty -m "lib1 two" && git -C "$t/lib1" push -q "$t/lib1.git" --all w=$t/refs && git clone -q -b ws/$AN "$t/origin.git" "$w" -printf 'name: software/application-software/argos/argos-dev\nharness: claude-code\nreferences-git:\n lib1: file://%s\n lib2: file://%s lib2@v1 # pinned, monorepo-style tag\n' "$t/lib1.git" "$t/lib2.git" >"$w/workspace.yml" +printf 'name: s.app.argos.argos-dev\nharness: claude-code\nreferences-git:\n lib1: file://%s\n lib2: file://%s lib2@v1 # pinned, monorepo-style tag\n' "$t/lib1.git" "$t/lib2.git" >"$w/workspace.yml" /bin/bash "$w/.delphi/setup.sh" >"$t/out" 2>&1 || fail "setup.sh failed with references-git: $(cat "$t/out")" [ "$(git -C "$w/references/lib1" rev-parse HEAD)" = "$(git -C "$t/lib1" rev-parse HEAD)" ] || fail "lib1 reference not at default branch" [ "$(git -C "$w/references/lib2" rev-parse HEAD)" = "$(git -C "$t/lib2" rev-parse 'lib2@v1^{commit}')" ] || fail "lib2 reference not at its pin" @@ -432,7 +428,7 @@ git -C "$w" checkout -q main && mkdir "$w/worktrees" && git -C "$w" checkout -q fail "park: a folder in the way was not reported and kept: $(cat "$t/err")" { rmdir "$w/worktrees" && git -C "$w" checkout -q main && git -C "$w" checkout -q a1 && live lib1; } || fail "park: no recovery once cleared" ok "park.sh parks a workspace's clones on switching to another workspace or main and restores them at the same paths" -ok "parked clones sit git-ignored in .delphi/parked//, the workspace's place in the tree" +ok "parked clones sit git-ignored in .delphi/parked//, the workspace's name" ok "park.sh also parks .journal/ and the folders under the leaving workspace's park:, and restores all a workspace parked" ok "park.sh leaves same-workspace branches alone and reports, never overwrites, a folder in the way" git -C "$w" checkout -q -b a3 a1 && echo 'echo "park.sh from a3" >&2' >>"$w/.delphi/park.sh" && git -C "$w" commit -qam a3 @@ -459,22 +455,23 @@ ok "setup.sh keeps someone else's post-checkout hook and says so" git -C "$t/dev" branch -q new-workspace/electrical/firmware/fw-dev origin/main # left by an earlier failed run (cd "$t/dev" && git checkout -q -B main origin/main && tools/new-workspace.sh electrical/firmware fw-dev >/dev/null) || fail "new-workspace.sh failed (blocked by a leftover local branch?)" -F=software/electrical/firmware/workspaces/fw-dev +F=software/electrical/firmware/workspaces/fw-dev FN=s.electrical.firmware.fw-dev o cat-file -e "new-workspace/electrical/firmware/fw-dev:$F/workspace.yml" || fail "no workspace.yml on new-workspace/electrical/firmware/fw-dev" -o show "new-workspace/electrical/firmware/fw-dev:$F/CLAUDE.md" | grep -q "branch \`ws/software/electrical/firmware/fw-dev\`" || fail "CLAUDE.md not filled in" +o show "new-workspace/electrical/firmware/fw-dev:$F/CLAUDE.md" | grep -q "branch \`ws/$FN\`" || fail "CLAUDE.md not filled in" ! o show "new-workspace/electrical/firmware/fw-dev:$F/CLAUDE.md" | grep -q "{{" || fail "placeholder left" -o show "new-workspace/electrical/firmware/fw-dev:$F/CLAUDE.md" | grep -qx "# Firmware for software/electrical/firmware/fw-dev" || fail "defaults CLAUDE.md not appended" -[ "$(o show "new-workspace/electrical/firmware/fw-dev:$F/workspace.yml")" = "$(printf 'name: software/electrical/firmware/fw-dev\n'; o show "main:$FD/workspace.yml")" ] || +o show "new-workspace/electrical/firmware/fw-dev:$F/CLAUDE.md" | grep -qx "# Firmware for $FN" || fail "defaults CLAUDE.md not appended" +[ "$(o show "new-workspace/electrical/firmware/fw-dev:$F/workspace.yml")" = "$(printf 'name: %s\n' "$FN"; o show "main:$FD/workspace.yml")" ] || fail "defaults workspace.yml not used, or name: not set" o cat-file -e "new-workspace/electrical/firmware/fw-dev:$F/.claude/skills/fw/SKILL.md" || fail "defaults skill not copied" o cat-file -e "new-workspace/electrical/firmware/fw-dev:$F/.claude/settings.json" || fail "new workspace has no generated settings.json" ! o cat-file -e "new-workspace/electrical/firmware/fw-dev:$F/.claude/skills/top/SKILL.md" 2>/dev/null || fail "an inherited skill was copied into the folder" -grep -q "gh pr create --base main --head new-workspace/electrical/firmware/fw-dev --title Add workspace software/electrical/firmware/fw-dev --label by: ai --body ## Changes" "$t/gh.log" || +grep -q "gh pr create --base main --head new-workspace/electrical/firmware/fw-dev --title Add workspace $FN --label by: ai --body ## Changes" "$t/gh.log" || fail "no PR for new workspace, or not labelled by: ai with the template's sections: $(cat "$t/gh.log")" (cd "$t/dev" && tools/new-workspace.sh --by ai-assisted electrical/firmware fw-two >/dev/null) || fail "new-workspace.sh --by failed" -grep -q "fw-two --label by: ai-assisted " "$t/gh.log" || fail "--by ai-assisted not used for the label" +grep -q "s.electrical.firmware.fw-two --label by: ai-assisted " "$t/gh.log" || fail "--by ai-assisted not used for the label" (cd "$t/dev" && ! tools/new-workspace.sh --by bot electrical/firmware fw-three 2>/dev/null) || fail "bad --by accepted" (cd "$t/dev" && ! tools/new-workspace.sh application-software/argos argos-dev 2>/dev/null) || fail "duplicate workspace accepted" +(cd "$t/dev" && ! tools/new-workspace.sh app/argos argos-dev 2>"$t/err" && grep -q "is already named $AN (names are unique)" "$t/err") || fail "a name taken elsewhere in the tree accepted" (cd "$t/dev" && ! tools/new-workspace.sh x Bad 2>/dev/null) || fail "bad name accepted" (cd "$t/dev" && ! tools/new-workspace.sh ../x ok 2>/dev/null) || fail "bad org path accepted" [ -z "$(git -C "$t/dev" status --porcelain)" ] || fail "new-workspace touched the checkout" @@ -510,7 +507,7 @@ git init -q "$t/code" && git -C "$t/code" commit -q --allow-empty -m one && git git -C "$t/code" branch develop && git -C "$t/code" commit -q --allow-empty -m two def=$(git -C "$t/code" branch --show-current) w=$t/nw && git clone -q -b ws/$BN "$t/origin.git" "$w" -printf 'name: software/electrical/bms-dev\nharness: claude-code\nrepos:\n code: %s\n' "$t/code" >"$w/workspace.yml" +printf 'name: s.electrical.bms-dev\nharness: claude-code\nrepos:\n code: %s\n' "$t/code" >"$w/workspace.yml" bash "$w/.delphi/setup.sh" >/dev/null 2>&1 || fail "setup.sh failed for new-worktree test" nw() { bash "$w/.delphi/new-worktree.sh" "$@"; } [ "$(nw code 1-new)" = "$w/worktrees/code/1-new" ] || fail "new-worktree path" @@ -579,20 +576,21 @@ pl app@delphi user - 0 argos@delphi local "$w" 0 x@other user - 0 >"$t/plugins.j { [ -z "$(plugins)" ] && [ ! -s "$t/claude.log" ]; } || fail "plugins.sh did something with every plugin in order: $(cat "$t/claude.log")" ok "setup.sh (plugins.sh) makes this clone's @delphi plugins what the workspace enables and uninstalls ones the marketplace dropped; warns offline or for other marketplaces; quiet when all is in order" -# --- 12. workspaces: setup.sh checks out listed workspaces and main read-only at linked/, refreshed on re-run; +# --- 12. workspaces: setup.sh checks out listed workspaces and main read-only at linked/, refreshed on re-run; # migrations: setup.sh runs the pending ones (0.0.1 replaces old link.sh worktrees), stopping where one can't go on safely -printf 'name: software/electrical/bms-dev\nharness: claude-code\nrepos:\n code: %s\nworkspaces:\n - ws/software/application-software/argos/argos-dev # a comment\n - main\n - ws/software/electrical/bms-dev\n - Bad\n' \ +printf 'name: s.electrical.bms-dev\nharness: claude-code\nrepos:\n code: %s\nworkspaces:\n - s.app.argos.argos-dev # a comment\n - main\n - s.electrical.bms-dev\n - Bad\n - ws/argos-dev\n' \ "$t/code" >"$w/workspace.yml" bash "$w/.delphi/setup.sh" >/dev/null 2>"$t/err" || fail "setup.sh failed with links: $(cat "$t/err")" -[ "$(git -C "$w/linked/ws/$AN" rev-parse HEAD)" = "$(o rev-parse "ws/$AN")" ] || fail "copy not at ws/$AN" +[ "$(git -C "$w/linked/$AN" rev-parse HEAD)" = "$(o rev-parse "ws/$AN")" ] || fail "copy not at ws/$AN" [ -f "$w/linked/main/ci/sync.sh" ] || fail "link main" -{ [ ! -e "$w/linked/ws/$BN" ] && [ ! -e "$w/linked/Bad" ] && grep -q "skipping bad workspaces: entry 'Bad'" "$t/err"; } || fail "copied itself or a bad entry" -! git -C "$w/linked/ws/$AN" push -q origin HEAD:refs/heads/x 2>/dev/null || fail "push from a link succeeded" +{ [ ! -e "$w/linked/$BN" ] && [ ! -e "$w/linked/Bad" ] && [ ! -e "$w/linked/ws" ] && grep -q "skipping bad workspaces: entry 'Bad'" "$t/err" && + grep -q "skipping bad workspaces: entry 'ws/argos-dev'" "$t/err"; } || fail "copied itself or a bad entry" +! git -C "$w/linked/$AN" push -q origin HEAD:refs/heads/x 2>/dev/null || fail "push from a link succeeded" commit_on ws/$AN docs/CONTEXT.md "linked update" -{ bash "$w/.delphi/setup.sh" >/dev/null 2>&1 && grep -qx "linked update" "$w/linked/ws/$AN/docs/CONTEXT.md"; } || fail "re-run did not refresh a link" +{ bash "$w/.delphi/setup.sh" >/dev/null 2>&1 && grep -qx "linked update" "$w/linked/$AN/docs/CONTEXT.md"; } || fail "re-run did not refresh a link" [ "$(grep -cx /linked/ "$w/.git/info/exclude")" = 1 ] || fail "/linked/ not excluded exactly once" ! git -C "$w" status --porcelain | grep -q linked || fail "linked/ shows in status" -ok "setup.sh checks out workspaces: read-only at linked/, skips itself and bad entries, refreshes on re-run" +ok "setup.sh checks out workspaces: read-only at linked/, skips itself and bad entries (old ws/ ones too), refreshes on re-run" [ "$(git -C "$w" config delphi.$BN.version)" = "$VN" ] || fail "setup.sh did not record the version" old_link() { rm -rf "$w/linked/main" && git -C "$w" worktree add -q --detach "$w/linked/main" origin/main && git -C "$w" config --unset delphi.$BN.version; } old_link && echo x >>"$w/linked/main/README.md" @@ -620,16 +618,16 @@ ok "setup.sh warns when .delphi/ isn't the version it says" # --- 12b. switch.sh: refuses a dirty root; switches workspaces (parking and restoring), fast-forwards, sets up; main too (cd "$t/dev" && git fetch -q && git checkout -q -B sw origin/ws/$AN && - printf 'name: software/application-software/argos/argos-dev\nharness: claude-code\nrepos:\n lib1: %s\nworkspaces:\n - main\n' "$t/lib1.git" >workspace.yml && + printf 'name: s.app.argos.argos-dev\nharness: claude-code\nrepos:\n lib1: %s\nworkspaces:\n - main\n' "$t/lib1.git" >workspace.yml && git commit -qam "local repos" && git push -q origin HEAD:ws/$AN) w=$t/sw && git clone -q -b ws/$AN "$t/origin.git" "$w" /bin/bash "$w/.delphi/setup.sh" >/dev/null 2>&1 || fail "switch: setup failed" sw() { /bin/bash "$w/.delphi/switch.sh" "$@"; } echo x >>"$w/CLAUDE.md" -{ ! sw bms-dev 2>"$t/err" >/dev/null && grep -q "commit or set aside" "$t/err" && [ "$(git -C "$w" branch --show-current)" = ws/$AN ]; } || +{ ! sw $BN 2>"$t/err" >/dev/null && grep -q "commit or set aside" "$t/err" && [ "$(git -C "$w" branch --show-current)" = ws/$AN ]; } || fail "switch: dirty root not refused" git -C "$w" checkout -q CLAUDE.md -sw bms-dev >"$t/out" 2>&1 || fail "switch to bms-dev (by its last part) failed: $(cat "$t/out")" +sw $BN >"$t/out" 2>&1 || fail "switch to bms-dev failed: $(cat "$t/out")" { [ "$(git -C "$w" branch --show-current)" = ws/$BN ] && [ ! -e "$w/worktrees" ] && [ ! -e "$w/linked" ] && [ -d "$w/.delphi/parked/$AN/linked/main" ]; } || fail "switch: argos-dev not parked on bms-dev" commit_on ws/$AN docs/CONTEXT.md "while away" @@ -641,11 +639,11 @@ sw "ws/$AN" >"$t/out" 2>&1 || fail "switch back (by branch) failed: $(cat "$t/ou git -C "$w" switch -q ws/$AN { ! sw ../x 2>/dev/null && ! sw nope 2>/dev/null && ! sw software/nope/x 2>/dev/null && [ "$(git -C "$w" branch --show-current)" = ws/$AN ]; } || fail "switch accepted a bad or missing workspace" ok "switch.sh refuses a dirty root, parks and restores workspaces, fast-forwards, runs setup, and switches to main" -ok "switch.sh takes a workspace's name, its branch, or its last part alone" -git -C "$w" branch -q -f ws/old-leaf origin/ws/$AN && git -C "$w" push -q origin ws/old-leaf # a workspace's old ws/ name -[ "$(sw --list)" = "$(printf ' main\n* %s\n %s' "$AN" "$BN")" ] || fail "switch.sh --list: $(sw --list 2>&1)" -git -C "$w" push -q origin :ws/old-leaf && git -C "$w" branch -q -D ws/old-leaf -ok "switch.sh --list prints main and each workspace (not old ws/ names), marking this clone's" +ok "switch.sh takes a workspace's name or its branch" +git -C "$w" push -q origin "origin/ws/$AN:refs/heads/ws/$AO" # its old name, and bms-dev (gone from main): not listed +[ "$(sw --list)" = "$(printf ' main\n* %s software/application-software/argos' "$AN")" ] || fail "switch.sh --list: $(sw --list 2>&1)" +git -C "$w" push -q origin ":ws/$AO" +ok "switch.sh --list prints main and each workspace on main with its project folder (not old names), marking this clone's" # --- 12c. the delphi mod (software/plugins/claude/default): /ws-delphi, a pane that switches through switch.sh. # Its own tests stub every process; they need a real claude, which CI doesn't have. @@ -671,7 +669,7 @@ if command -v python3 >/dev/null && command -v curl >/dev/null; then { [ "$(get "$url")" = 200 ] && curl -s "$url" | grep -q "Delphi workspaces" && [ "$(get "http://127.0.0.1:$port/")" = 404 ] && [ "$(get "http://127.0.0.1:$port/wrong/api/state")" = 404 ] && [ "$(get -H "Host: evil.example:$port" "${url}api/state")" = 403 ]; } || fail "web/server.py let a request without its token or a local Host through" - curl -s "${url}api/state" | python3 -c 'import json, sys; s = json.load(sys.stdin); sys.exit(not (s["here"] == sys.argv[1] and s["workspaces"] == ["main", sys.argv[1], sys.argv[2]] and not s["isDirty"]))' "$AN" "$BN" || + curl -s "${url}api/state" | python3 -c 'import json, sys; s = json.load(sys.stdin); sys.exit(not (s["here"] == sys.argv[1] and s["workspaces"] == ["main", sys.argv[1] + " software/application-software/argos"] and not s["isDirty"]))' "$AN" || fail "web/server.py api/state: $(curl -s "${url}api/state")" commit_on ws/$AN docs/CONTEXT.md "for the browser's pull" { [ "$(get -X POST -d '{}' "${url}api/pull")" = 415 ] && @@ -679,10 +677,10 @@ if command -v python3 >/dev/null && command -v curl >/dev/null; then [ "$(git -C "$w" rev-parse HEAD)" = "$(git -C "$t/origin.git" rev-parse "ws/$AN")" ] && grep -Eqx "pulled Pulled [0-9]+ commits? into argos-dev" "$t/web.out" && curl -s -X POST -H 'Content-Type: application/json' -d '{}' "${url}api/pull" | grep -q '"argos-dev is up to date with origin"'; } || fail "web/server.py pull: $(cat "$t/web.out" "$t/web.err")" - { [ "$(get -X POST -d '{"name":"bms-dev"}' "${url}api/switch")" = 415 ] && [ "$(git -C "$w" branch --show-current)" = "ws/$AN" ]; } || + { [ "$(get -X POST -d '{"name":"s.electrical.bms-dev"}' "${url}api/switch")" = 415 ] && [ "$(git -C "$w" branch --show-current)" = "ws/$AN" ]; } || fail "web/server.py switched without a JSON body" - { curl -s -X POST -H 'Content-Type: application/json' -d '{"name":"bms-dev"}' "${url}api/switch" | grep -q '"ok": true' && - [ "$(git -C "$w" branch --show-current)" = "ws/$BN" ] && grep -qx "switched bms-dev" "$t/web.out"; } || + { curl -s -X POST -H 'Content-Type: application/json' -d '{"name":"s.electrical.bms-dev"}' "${url}api/switch" | grep -q '"ok": true' && + [ "$(git -C "$w" branch --show-current)" = "ws/$BN" ] && grep -qx "switched $BN" "$t/web.out"; } || fail "web/server.py switch to bms-dev: $(cat "$t/web.out" "$t/web.err")" kill "$web" && wait "$web" 2>/dev/null || true git -C "$w" switch -q "ws/$AN" @@ -691,37 +689,48 @@ else echo "skip - the delphi mod's browser view: no python3 or curl" fi -# --- 14. named by path: a workspace's old ws/ branch carries over into ws/; moving a clone onto the -# renamed branch moves nothing; 0.1.0 renames what the clone keeps under old names -C=software/electrical/workspaces/vcu-dev CN=software/electrical/vcu-dev D=software/electrical/workspaces/hv-dev DN=software/electrical/hv-dev -(cd "$t/dev" && git fetch -q && git checkout -q -B main origin/main && mkdir -p "$C" "$D" && cp -R templates/workspace/. "$C/" && - cp -R templates/workspace/. "$D/" && sed -i.bak "s|{{name}}|vcu-dev|" "$C/workspace.yml" && sed -i.bak "s|{{name}}|$DN|" "$D/workspace.yml" && - rm "$C/workspace.yml.bak" "$D/workspace.yml.bak" && bash ci/plugins.sh >/dev/null && git add -A && git commit -qm "vcu-dev, still named the old way" && git push -q origin main && +# --- 14. dotted names: a workspace's old path-named ws/ branch carries over into ws/ (pushed to both once); +# moving a clone between the old and new names moves nothing; 0.8.0 renames what a clone keeps under old names +C=software/electrical/workspaces/vcu-dev CN=s.electrical.vcu-dev CO=software/electrical/vcu-dev +(cd "$t/dev" && git fetch -q && git checkout -q -B main origin/main && mkdir -p "$C" && cp -R templates/workspace/. "$C/" && + sed -i.bak "s|{{name}}|$CO|" "$C/workspace.yml" && rm "$C/workspace.yml.bak" && bash ci/plugins.sh >/dev/null && + git add -A && git commit -qm "vcu-dev, still named the old way" && git push -q origin main && i=$t/legacy.index && GIT_INDEX_FILE=$i git read-tree "main:$C" && GIT_INDEX_FILE=$i git read-tree --prefix=.delphi/ main:.delphi && - git push -q origin "$(git commit-tree -p main -m "delphi: create ws/vcu-dev" "$(GIT_INDEX_FILE=$i git write-tree)"):refs/heads/ws/vcu-dev" && - rm "$i" && sed -i.bak "s|^name:.*|name: $CN|" "$C/workspace.yml" && rm "$C/workspace.yml.bak" && git commit -qam "vcu-dev named by path" && git push -q origin main) -commit_on ws/vcu-dev CLAUDE.md "on the old branch" -legacy=$(o rev-parse ws/vcu-dev) + git push -q origin "$(git commit-tree -p main -m "delphi: create ws/$CO" "$(GIT_INDEX_FILE=$i git write-tree)"):refs/heads/ws/$CO" && + git push -q origin "origin/main:refs/heads/ws/$CN" && # a stale ws/ behind it + rm "$i" && sed -i.bak "s|^name:.*|name: $CN|" "$C/workspace.yml" && rm "$C/workspace.yml.bak" && git commit -qam "vcu-dev named with dots" && git push -q origin main) +o merge-base --is-ancestor "ws/$CN" "ws/$CO" || fail "sandbox: ws/$CN is not behind ws/$CO" +commit_on "ws/$CO" CLAUDE.md "on the old branch" +legacy=$(o rev-parse "ws/$CO") sync >"$t/out" 2>&1 || fail "sync after a rename failed: $(cat "$t/out")" -{ grep -q "ws/$CN: created from ws/vcu-dev, its old name" "$t/out" && o merge-base --is-ancestor "$legacy" "ws/$CN" && - o show "ws/$CN:CLAUDE.md" | grep -qx "on the old branch" && [ "$(o show "ws/$CN:workspace.yml" | sed -n 1p)" = "name: $CN" ] && - grep -q "notice: ws/vcu-dev is a workspace's old name; it's now ws/$CN" "$t/out" && o show "propose/$CN:$C/CLAUDE.md" | grep -qx "on the old branch"; } || - fail "ws/$CN did not carry over ws/vcu-dev: $(cat "$t/out")" -ok "a workspace renamed by path starts its ws/ from its old ws/, history and unproposed edits included" -w=$t/rename && git clone -q -b ws/vcu-dev "$t/origin.git" "$w" && /bin/bash "$w/.delphi/setup.sh" >/dev/null 2>&1 && mkdir -p "$w/repos/keep" -{ git -C "$w" checkout -q "ws/$CN" && [ -d "$w/repos/keep" ] && [ ! -e "$w/.delphi/parked" ]; } || fail "moving onto the renamed branch parked the workspace" -ok "checking out a workspace's renamed branch over its old one moves nothing" -mkdir -p "$w/.delphi/parked/argos-dev/linked/vcu-dev" "$w/.git/delphi/hv-dev/repos/r" "$w/linked/argos-dev" && git -C "$w" config delphi.argos-dev.version 0.0.1 -/bin/bash "$w/.delphi/setup.sh" >"$t/out" 2>&1 || fail "setup.sh failed running 0.1.0: $(cat "$t/out")" -{ [ -d "$w/.delphi/parked/$AN/linked/ws/$CN" ] && [ -d "$w/.delphi/parked/$DN/repos/r" ] && [ ! -e "$w/.git/delphi" ] && - [ -d "$w/linked/ws/$AN" ] && [ ! -e "$w/linked/argos-dev" ] && ! git -C "$w" config delphi.argos-dev.version >/dev/null && - [ "$(git -C "$w" config "delphi.$CN.version")" = "$VN" ]; } || fail "0.1.0 did not rename by path: $(cat "$t/out"); $(find "$w/.delphi/parked" "$w/linked")" -{ /bin/bash "$w/.delphi/setup.sh" >"$t/out" 2>&1 && ! grep -q moved "$t/out"; } || fail "setup.sh re-ran 0.1.0" -{ git -C "$w" config --unset "delphi.$CN.version" && /bin/bash "$w/.delphi/setup.sh" >"$t/out" 2>&1 && ! grep -q moved "$t/out"; } || fail "0.1.0 is not safe to re-run" -ok "0.1.0 moves parked slots (.delphi/parked/, .git/delphi/), old copies in linked/ and old version records to path names, once" -git -C "$w" checkout -q ws/vcu-dev && /bin/bash "$w/.delphi/switch.sh" vcu-dev >"$t/out" 2>&1 && [ "$(git -C "$w" branch --show-current)" = "ws/$CN" ] || - fail "switch.sh by last part picked the old ws/vcu-dev: $(cat "$t/out")" -ok "switch.sh by a workspace's last part ignores its old ws/ branch" +{ grep -q "ws/$CN: from ws/$CO, its old name (pushed to both)" "$t/out" && o merge-base --is-ancestor "$legacy" "ws/$CN" && + [ "$(o rev-parse "ws/$CO")" = "$(o rev-parse "ws/$CN")" ] && o show "ws/$CN:CLAUDE.md" | grep -qx "on the old branch" && + [ "$(o show "ws/$CN:workspace.yml" | sed -n 1p)" = "name: $CN" ] && o show "propose/$CN:$C/CLAUDE.md" | grep -qx "on the old branch"; } || + fail "ws/$CN did not carry over ws/$CO: $(cat "$t/out")" +ok "a workspace's ws/ starts from its path-named ws/ while that's ahead, history and unproposed edits included, and both get the result" +{ sync >"$t/out" 2>&1 && grep -q "notice: ws/$CO is ws/$CN's old name (kept" "$t/out" && ! grep -q "ws/$CN: from" "$t/out"; } || + fail "a caught-up old name was carried over again, or no notice: $(cat "$t/out")" +ok "once ws/ has it, ws/ is left alone with a notice" +w=$t/rename && git clone -q -b "ws/$CO" "$t/origin.git" "$w" +mkdir -p "$w/.delphi/parked/$AO/linked/ws/$CO" "$w/linked/ws/$AO" && git -C "$w" config "delphi.$CO.version" 0.7.0 && git -C "$w" config "delphi.$AO.version" 0.7.0 +/bin/bash "$w/.delphi/setup.sh" >"$t/out" 2>&1 || fail "setup.sh failed running 0.8.0: $(cat "$t/out")" +{ grep -q "migrating $CN to 0.8.0" "$t/out" && ! grep -q "to 0.1.0" "$t/out" && [ -d "$w/.delphi/parked/$AN/linked/$CN" ] && + [ ! -e "$w/.delphi/parked/software" ] && [ -d "$w/linked/$AN" ] && [ ! -e "$w/linked/ws" ] && + [ "$(git -C "$w" config "delphi.$AN.version")" = 0.7.0 ] && ! git -C "$w" config "delphi.$AO.version" >/dev/null && + ! git -C "$w" config "delphi.$CO.version" >/dev/null && [ "$(git -C "$w" config "delphi.$CN.version")" = "$VN" ] && + [ "$(git -C "$w" branch --show-current)" = "ws/$CN" ] && [ "$(git -C "$w" rev-parse --abbrev-ref '@{u}')" = "origin/ws/$CN" ]; } || + fail "0.8.0 did not rename to dotted names: $(cat "$t/out"); $(find "$w/.delphi/parked" "$w/linked"); $(git -C "$w" config --get-regexp '^delphi\.')" +{ /bin/bash "$w/.delphi/setup.sh" >"$t/out" 2>&1 && ! grep -q "moved\|renamed" "$t/out"; } || fail "setup.sh re-ran 0.8.0: $(cat "$t/out")" +{ git -C "$w" config "delphi.$CN.version" 0.7.0 && /bin/bash "$w/.delphi/setup.sh" >"$t/out" 2>&1 && ! grep -q "moved\|renamed" "$t/out"; } || fail "0.8.0 is not safe to re-run: $(cat "$t/out")" +ok "0.8.0 moves parked slots, linked/ws/ copies and version records to dotted names, and renames a clone's ws/ branch to ws/, once" +mkdir -p "$w/repos/keep" +{ git -C "$w" checkout -q "$legacy" && [ -d "$w/repos/keep" ] && [ ! -e "$w/.delphi/parked/$CN" ] && + git -C "$w" checkout -q "ws/$CN" && [ -d "$w/repos/keep" ]; } || fail "moving between a workspace's old and new names parked it" +ok "checking out a workspace's old-named commit or its renamed branch moves nothing" +commit_on "ws/$CO" CLAUDE.md "old side" && commit_on "ws/$CN" .gitattributes "# new side" +{ sync >"$t/out" 2>&1 && grep -q "notice: ws/$CO, ws/$CN's old name, has commits ws/$CN lacks" "$t/out" && + ! o show "ws/$CN:CLAUDE.md" | grep -qx "old side"; } || fail "a diverged old name was merged or not reported: $(cat "$t/out")" +ok "an old name with commits of its own is reported, never merged" # --- 13. shellcheck, if installed if command -v shellcheck >/dev/null; then diff --git a/tools/new-workspace.sh b/tools/new-workspace.sh index f5698da..085fe70 100755 --- a/tools/new-workspace.sh +++ b/tools/new-workspace.sh @@ -1,38 +1,34 @@ #!/usr/bin/env bash -# tools/new-workspace.sh [--by ai|ai-assisted|human] : propose a new +# tools/new-workspace.sh [--by ai|ai-assisted|human] : propose a new # workspace as a PR to main, labelled `by: ` (default ai: who wrote it, as the PR template asks). # In a temporary worktree of origin/main, copies templates/workspace/ and then, if present, the # project's software//workspace-defaults/ (its CLAUDE.md appended to the template's) to -# software//workspaces//, sets `name:` in its workspace.yml to its place in the tree -# (software//; the workspace-inherit/ folders above it reach ws/ on each sync), lists its default plugin sets and generates its .claude/settings.json (ci/plugins.sh), checks it, pushes branch new-workspace//, and opens -# the PR with gh. Your checkout is untouched. After merge, ci/sync.sh creates ws/software//. +# software//workspaces//, sets `name: ` in its workspace.yml (its dotted name from +# .delphi/names.sh, like s.app.argos.argos-dev; the workspace-inherit/ folders above it reach ws/ +# on each sync), lists its default plugin sets and generates its .claude/settings.json (ci/plugins.sh), +# checks it, pushes branch new-workspace//, and opens the PR with gh. Your checkout is +# untouched. After merge, ci/sync.sh creates ws/. set -euo pipefail die() { echo "new-workspace: $*" >&2 && exit 1; } by=ai [ "${1:-}" != --by ] || { by=${2:-} && shift 2; } case "$by" in ai | ai-assisted | human) ;; *) die "--by must be ai, ai-assisted or human" ;; esac -[ $# -eq 2 ] || die "usage: tools/new-workspace.sh [--by ai|ai-assisted|human] " +[ $# -eq 2 ] || die "usage: tools/new-workspace.sh [--by ai|ai-assisted|human] " org=${1%/} org=${org#software/} -name=$2 -case "$name" in '' | -* | *[!a-z0-9-]*) die "name must be lowercase letters, digits, and '-'" ;; esac +leaf=$2 +case "$leaf" in '' | -* | *[!a-z0-9-]*) die "leaf must be lowercase letters, digits, and '-'" ;; esac case "/$org/" in // | *//* | */./* | */../* | */workspaces/* | *[!a-z0-9/-]*) die "bad org path '$1' (lowercase letters, digits, '-' and '/')" ;; esac cd "$(dirname "$0")/.." +folder=software/$org/workspaces/$leaf +branch=new-workspace/$org/$leaf git fetch --quiet --prune origin -id=software/$org/$name -! git cat-file -e "origin/main:software/$org/workspaces/$name" 2>/dev/null || - die "workspace $id already exists on main" -for b in "ws/$id" "ws/$name"; do # ws/: a workspace's branch before they were named by path, which sync would carry over - ! git rev-parse --quiet --verify "refs/remotes/origin/$b" >/dev/null || die "branch $b already exists (from a removed workspace?); pick another name" -done - -folder=software/$org/workspaces/$name -branch=new-workspace/$org/$name wt=$(mktemp -d) git worktree add --quiet --detach "$wt" origin/main trap 'git worktree remove --force "$wt"' EXIT defaults=$wt/software/$org/workspace-defaults +[ ! -e "$wt/$folder" ] || die "$folder already exists on main" mkdir -p "$wt/$folder" cp -R "$wt/templates/workspace/." "$wt/$folder/" if [ -d "$defaults" ]; then @@ -40,17 +36,23 @@ if [ -d "$defaults" ]; then cp "$wt/templates/workspace/CLAUDE.md" "$wt/$folder/CLAUDE.md" [ ! -f "$defaults/CLAUDE.md" ] || { echo && cat "$defaults/CLAUDE.md"; } >>"$wt/$folder/CLAUDE.md" fi -sed -i.bak -e "s|{{name}}|$id|g" -e "s|{{folder}}|$folder|g" "$wt/$folder/CLAUDE.md" && rm "$wt/$folder/CLAUDE.md.bak" +name=$(cd "$wt" && bash .delphi/names.sh | awk -v f="$folder" '$2 == f { print $1 }') +taken=$(cd "$wt" && bash .delphi/names.sh | awk -v n="$name" -v f="$folder" '$1 == n && $2 != f { print $2 }') +[ -z "$taken" ] || die "$taken is already named $name (names are unique); pick another leaf" +for b in "ws/$name" "ws/software/$org/$leaf"; do # ws/software/…: a workspace's branch when they were named by path, which sync would carry over + ! git rev-parse --quiet --verify "refs/remotes/origin/$b" >/dev/null || die "branch $b already exists (from a removed workspace?); pick another leaf" +done +sed -i.bak -e "s|{{name}}|$name|g" -e "s|{{folder}}|$folder|g" "$wt/$folder/CLAUDE.md" && rm "$wt/$folder/CLAUDE.md.bak" y=$wt/$folder/workspace.yml -{ echo "name: $id"; grep -v '^name:' "$y"; } >"$y.new" && mv "$y.new" "$y" +{ echo "name: $name"; grep -v '^name:' "$y"; } >"$y.new" && mv "$y.new" "$y" bash "$wt/ci/plugins.sh" "$wt" >/dev/null # its plugins: defaults and .claude/settings.json "$wt/ci/check.sh" "$wt" git -C "$wt" add -A "$folder" -git -C "$wt" commit --quiet -m "New workspace $id" +git -C "$wt" commit --quiet -m "New workspace $name" git -C "$wt" push --quiet origin "HEAD:refs/heads/$branch" -gh pr create --base main --head "$branch" --title "Add workspace $id" --label "by: $by" --body "## Changes +gh pr create --base main --head "$branch" --title "Add workspace $name" --label "by: $by" --body "## Changes -Adds \`$folder/\` from templates/workspace (plus software/$org/workspace-defaults if present). After merge, CI creates branch ws/$id. +Adds \`$folder/\` from templates/workspace (plus software/$org/workspace-defaults if present). After merge, CI creates branch ws/$name. ## Testing