@sassywares/skills 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sassywares/skills",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "Lean, explicit-invocation Agent Skills for shipping fast: plan, work, review, validate, ship.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@ Treat the reads as a snapshot; re-read branch and staged set right before commit
30
30
 
31
31
  **Config.** Resolve `.sassywares/config.yaml` (nearest, walking up) per the `sassy-setup` skill's `references/config.md`. Precedence: explicit token > session instruction > config > skill default. `commit.branch` supplies the default.
32
32
 
33
- **Branch option.** `branch:current|new [<name>]`. `current` commits on the checked-out branch. `new` cuts a branch from `origin/<base>` — `<base>` is `repos[<dir>]` in a workspace config, else `git.default_branch`, else the probed base **confirmed with the user** — named `<name>` when given, else from the change. `sassy-commit-push-pr` forwards its own `branch` choice here.
33
+ **Branch option.** `branch:current|new [<name>]`. `current` commits on the checked-out branch. `new` cuts a branch from `origin/<base>` — `<base>` is `repos[<dir>]` in a workspace config, else `base_map[<current head>]` (falling back to `base_map["*"]`), else the probed base **confirmed with the user** — named `<name>` when given, else from the change. `sassy-commit-push-pr` forwards its own `branch` choice here.
34
34
 
35
35
  **Not in a workspace root, not outside git.** With `git.repo: workspace` there is no local commit to make: refuse in one line and name the snapshot instead. With `git.repo: none`, refuse and say the project is deliberately outside git. Inside a nested repo, a normal commit is correct — the nearest work tree is the repo.
36
36
 
@@ -37,7 +37,7 @@ Only an exit-0 empty list means "no open PR". A non-zero exit means **unknown**
37
37
 
38
38
  **Branch policy.** Default: if on the base branch or detached HEAD, create a feature branch named from the work and say so. `branch:current` or a plain request keeps the checked-out branch (that run pushes and skips the PR). Never ask whether to branch. Forward the resolved choice to `sassy-commit`: `branch:new <name>` when this run creates a branch, else `branch:current`.
39
39
 
40
- **Base branch.** `<base>` is `repos[<repo dir>]` from a workspace config, else `git.default_branch`, else the probed remote HEAD, **confirmed with the user before use**. An explicit `base:<branch>` token wins over all of them — use it when this PR targets something other than the day-to-day base, such as a release PR aimed at `main` while work lands on `staging`. **Never use `defaultBranchRef` as the PR base when the config names one.** Pass it explicitly: `gh pr create --base <base>`.
40
+ **Base branch.** `<base>` is `repos[<repo dir>]` from a workspace config, else `base_map[<current head>]` falling back to `base_map["*"]`, else the probed remote HEAD **confirmed with the user before use**. The map is the reason a release PR targets `main` while the work sits on `staging`, with no token passed. An explicit `base:<branch>` token wins over all of them — use it when this PR targets something other than the day-to-day base, such as a release PR aimed at `main` while work lands on `staging`. **Never use `defaultBranchRef` as the PR base when the config names one.** Pass it explicitly: `gh pr create --base <base>`.
41
41
 
42
42
  **Refused topologies.** From a `workspace` root there is no single PR to open — refuse in one line and point at `sassy-ship`, which owns the per-repo fan-out. With `git.repo: none`, refuse and say why. Neither ever pushes directly to a base branch.
43
43
 
@@ -26,9 +26,9 @@ disable-model-invocation: true
26
26
  - **`none`** — confirm the intent: this folder is deliberately outside git, so commit, PR, and babysit refuse here.
27
27
  - **Artifacts** — offer the detected paths as the default; take overrides.
28
28
  - **Knowledge** — `knowledge.read` defaults to the solutions dir plus the concepts file. Ask whether there are extra folders to read (a hand-written `notes/learnings/`, a `docs/adr/`).
29
- - **Base branch** — if detection printed no `default_branch`, ask; never write `main` as a guess. In `workspace` mode, show the `repos` map and get a yes on every row — a probed base is only the remote's default, so a repo that targets `dev` or `staging` reads `main` until a human corrects it.
29
+ - **Base map** — detection proposes `"*"` from the probed remote default. Ask where ordinary work branches from, and whether the default branch itself releases to another branch (a `staging: main` rung). Never write `main` as a guess. In `workspace` mode, show the `repos` map and get a yes on every row — a probed base is only the remote's default.
30
30
  - **`pr.*`** — default `babysit: true`, `merge: false`, `delete_branch: false`. Say plainly that `merge: true` authorizes merging every babysat PR in this project without asking.
31
- - **Hooks** — offer, do not impose. Ask whether the project wants steps injected at `before_create`, `after_create`, `before_merge`, or `after_merge`, and write the skills they name. Leave them `[]` when they have nothing to add. **Never write a hook skill yourself** — hooks are project skills, and copying one of ours into that role is the fork this mechanism exists to prevent.
31
+ - **Hooks** — offer, do not impose. Ask whether the project wants steps injected at `before_create`, `after_create`, `before_merge`, or `after_merge`, and under which head branch (`"*"` for all). Entries may carry args (`<skill> <args…>`); a hook skill's args are its own vocabulary. Leave them `[]` when there is nothing to add. **Never write a hook skill yourself** — hooks are project skills, and copying one of ours into that role is the fork this mechanism exists to prevent.
32
32
  4. **Write the config.** Build the file from the proposal plus the answers. It is written **fully commented**: every key keeps a description and its allowed values, exactly as the detector emits them. Never strip those comments to save lines — for a teammate opening the file, the comments are the documentation. If a config already exists, show what changes and ask before writing — never silently overwrite. Keep every key; an unset key falls back to its documented default, so a short file is legal when nothing deviates.
33
33
  5. **Wire the skills.** The suite is installed from upstream with the open `skills` CLI, which vendors the skill files into the project and pins them. Never copy skill files in by hand, never add the suite as a dependency.
34
34
 
@@ -120,7 +120,7 @@ resolve_branch() {
120
120
  fi
121
121
  fi
122
122
  local configured
123
- configured="$(config_value default_branch || true)"
123
+ configured="$(config_value mirror_branch || true)"
124
124
  BRANCH="${configured:-main}"
125
125
  }
126
126
 
@@ -42,11 +42,13 @@ knowledge:
42
42
  write: true
43
43
  git:
44
44
  repo: origin # <remote name> | workspace | none
45
- remote: thekayshawn/knit # workspace only: the snapshot mirror
46
- default_branch: main # repo base; workspace: the mirror branch
45
+ base_map: # head branch -> the base it targets
46
+ "*": staging # ordinary work branches from and targets staging
47
+ staging: main # staging releases to main
48
+ remote: owner/backup # workspace only: the snapshot mirror
49
+ mirror_branch: main # workspace only: the mirror's branch
47
50
  repos: # workspace only: sibling dir -> PR base
48
51
  fe: dev
49
- knit-ai-pipeline: master
50
52
  commit:
51
53
  branch: current # current | new
52
54
  pr:
@@ -55,10 +57,10 @@ pr:
55
57
  delete_branch: false
56
58
  hooks:
57
59
  pr:
58
- before_create: [] # after the commit, before the PR opens
59
- after_create: [] # the moment the PR exists
60
- before_merge: [] # this run is about to merge
61
- after_merge: [] # this run observed the merge
60
+ "*":
61
+ before_create: [tap2-version mode:auto]
62
+ staging:
63
+ before_create: []
62
64
  ```
63
65
 
64
66
  | Key | Default | Meaning |
@@ -70,14 +72,15 @@ hooks:
70
72
  | `knowledge.read` | `[docs/solutions, CONCEPTS.md]` | Paths scouted for prior learnings |
71
73
  | `knowledge.write` | `true` | `false` = this project takes no learnings |
72
74
  | `git.repo` | `origin` | Remote name, or a reserved value (below) |
75
+ | `git.base_map` | probed `"*"` | Head branch → the base it targets. `"*"` is required |
73
76
  | `git.remote` | mirror of `git.repo` | Workspace only: mirror remote (URL or `owner/name`) |
74
- | `git.default_branch` | probed | Repo base branch; workspace: the mirror branch |
77
+ | `git.mirror_branch` | `main` | Workspace only: the branch the mirror pushes to |
75
78
  | `repos` | `{}` | Workspace only: repo dir name → the base its PRs target |
76
79
  | `commit.branch` | `current` | `current` commits where you are; `new` cuts a branch |
77
80
  | `pr.babysit` | `true` | Whether close-out babysits the PR it opened |
78
81
  | `pr.merge` | `false` | `true` authorizes merging a single babysat PR |
79
82
  | `pr.delete_branch` | `false` | Delete the head branch once merged |
80
- | `hooks.pr.*` | `[]` | Project steps injected into the PR pipeline |
83
+ | `hooks.pr.<pattern>.<point>` | `[]` | Project steps, keyed by the PR's head branch |
81
84
 
82
85
  Paths are relative to the config's own directory, never to the cwd.
83
86
 
@@ -91,8 +94,9 @@ Paths are relative to the config's own directory, never to the cwd.
91
94
 
92
95
  `workspace` and `none` are reserved words; a remote cannot be named either.
93
96
 
94
- `remote` and `default_branch` are conditional. In a repo config, `remote` is
95
- omitted (the repo name is `git.repo`) and `default_branch` is the repo's base.
97
+ `base_map` is repo-only. `remote` and `mirror_branch` are workspace-only and
98
+ describe the snapshot mirror — in a repo config the remote is simply
99
+ the value of `git.repo`.
96
100
  In a workspace config, both describe **the snapshot mirror**: where it pushes,
97
101
  and its branch.
98
102
 
@@ -145,21 +149,30 @@ dropped; the refusal names why and what to run instead.
145
149
  even when the resolved config says `workspace`. Ran from `fe/`, work commits in
146
150
  `fe/`. Ran from the workspace root (no repo), the workspace rules above apply.
147
151
 
148
- **Base branch**, per affected repo:
152
+ **Base branch of a head.** `base_map` answers one question — *given this head,
153
+ what does a PR target?* — so there is no free-floating "default branch":
149
154
 
150
- 1. `repos[<dir name>]` in a workspace config; `git.default_branch` in a repo config
151
- 2. `git -C <repo> symbolic-ref --short refs/remotes/origin/HEAD`
152
- 3. `gh repo view <repo> --json defaultBranchRef -q .defaultBranchRef.name`
153
- 4. stop and ask — **never guess `main`**
155
+ ```
156
+ feature/x --base_map["*"]--> staging --base_map[staging]--> main
157
+ ```
158
+
159
+ 1. `base_map[<head>]` — the exact rung
160
+ 2. `base_map["*"]` — ordinary work
161
+ 3. stop and ask; never guess
154
162
 
155
- Steps 2 and 3 can only learn the **remote's default**, which is not necessarily the team's base: a repo whose PRs target `dev` or `staging` still reports `main`. So a probed base is reported as probed, and confirmed by a human before it is written. A wrong base aims PRs at the wrong branch — the exact failure a hand-kept integration table exists to prevent.
163
+ A rung is a release edge: standing on `staging`, a PR targets `main` without
164
+ anyone passing `base:main`. Add a rung for any branch that releases elsewhere; a
165
+ three-rung ladder is just one more line. The map must be acyclic, and `"*"` must
166
+ exist — that is the rung everything falls back to.
156
167
 
157
- Because of that, a declared base differing from the remote default is **not**
158
- drift: `sassy-setup --check` reports the override and moves on. Only a missing
159
- base, or a repo with no `repos` entry, is drift.
168
+ The probed remote default is only useful as a hint for `"*"`: it tells you where
169
+ the repository considers work to land, not what every branch targets. Confirm it
170
+ before writing.
160
171
 
161
- A repo on disk but absent from `repos` is a question, not a default. A `repos`
162
- entry with no directory on disk is ignored with one line.
172
+ In a `workspace`, `base_map` does not apply — there are no branches at the
173
+ workspace level. Nested repos carry their own bases in `repos`, and a nested repo
174
+ that needs a ladder gets its own `.sassywares/config.yaml` rather than a nested
175
+ map here.
163
176
 
164
177
  **Branch name**, one per run. Derived once from the plan slug or topic before the
165
178
  first commit, then passed to every repo. Multi-repo runs pass
@@ -180,7 +193,7 @@ recreate it. Never leave a PR aimed at the wrong base.
180
193
  `origin` → `git.remote` → stop and say setup is incomplete.
181
194
 
182
195
  **Mirror branch**: `SASSY_WORKSPACE_BRANCH` → the mirror's own
183
- `refs/remotes/origin/HEAD` → `git.default_branch` → `main`.
196
+ `refs/remotes/origin/HEAD` → `git.mirror_branch` → `main`.
184
197
 
185
198
  **Knowledge read.** Every path in `knowledge.read` that exists is searched for
186
199
  prior learnings; a path that does not exist is skipped with one line. Absence of
@@ -188,13 +201,18 @@ matches is a fact worth stating, never evidence that no similar problem exists.
188
201
 
189
202
  ## Hooks
190
203
 
191
- A project injects its own steps into our pipeline by declaring them here. They are
192
- run by the skill that owns the moment, so they fire no matter which pipeline the
193
- user started. Nothing declared means nothing runs.
204
+ A project injects its own steps into the PR pipeline. They run by the skill that
205
+ owns the moment, so they fire whichever pipeline the user started. Nothing
206
+ declared means nothing runs.
194
207
 
195
- Only the PR has hook points. Planning, phases, review, and commit do not: their
196
- extension point is `AGENTS.md` for rules, or a project skill the user invokes
197
- directly.
208
+ **Keys are the head branch** — the branch the PR comes from, never the one it
209
+ targets. `hooks.pr.staging` means *PRs whose source is staging*.
210
+
211
+ | Key form | Matches |
212
+ |----------|---------|
213
+ | `staging` | that exact head |
214
+ | `release/*` | a family; only one trailing `*` is allowed |
215
+ | `"*"` | every other head |
198
216
 
199
217
  | Point | Owner | Fires |
200
218
  |-------|-------|-------|
@@ -203,25 +221,44 @@ directly.
203
221
  | `before_merge` | `sassy-babysit` | the PR looks ready **and** this run will merge it |
204
222
  | `after_merge` | `sassy-babysit` | this run observed the PR merged |
205
223
 
224
+ ### Resolution
225
+
226
+ Per point, independently:
227
+
228
+ 1. the exact head
229
+ 2. the longest matching family, if the head starts with its prefix
230
+ 3. `"*"`
231
+
232
+ The first rule that declares a value for that point wins and the rest are not
233
+ merged. So a `staging` entry that sets only `before_create` still inherits
234
+ `after_merge` from `"*"`. Declaring the same point in two families that both
235
+ match one head is a configuration error, not a precedence puzzle.
236
+
237
+ ### Entries carry arguments
238
+
239
+ An entry is `<skill>` or `<skill> <args…>`, split on the first whitespace. Args
240
+ are passed to the hook verbatim — they are that skill's vocabulary, not ours. A
241
+ hook skill documents the args it accepts and must not silently ignore one.
242
+
243
+ Args are how a project pre-authorizes a step: `tap2-version mode:auto` runs
244
+ versioning without its approval prompt. Treat it like `pr.merge: true` — visible
245
+ in review, and commented when it is deliberate.
246
+
206
247
  ### Contract
207
248
 
208
249
  - **Resolution**: `<repo>/.agents/skills/<name>/SKILL.md`, nearest walking up.
209
- - **Names**: a skill the project owns, never one of ours. A `tap2-`/`knit-`
210
- prefix is the convention when the name could be confused with a general
211
- workflow; `version` is fine when it is plainly the project's.
250
+ - **Names**: a skill the project owns, never one of ours.
212
251
  - **Order**: declared order, one worker each, sequential.
213
252
  - **Brief**: plan path, changed files, artifact paths, base, branch, commit hash,
214
253
  and the PR URL where one exists.
215
- - **Result**: `done`, `blocked`, or `needs-decision`. A decision comes back to the
216
- orchestrator, which holds the gate — a worker never blocks on a prompt.
217
- - **Fail closed**: a missing file or a `blocked` result stops the pipeline and
218
- names the hook. A mandatory step must never evaporate.
219
- - **Writes**: a hook may write files. Only `before_create` may also **commit**
220
- them, because it runs after the pipeline's own commit; every other point must
221
- leave the tree clean.
254
+ - **Result**: `done`, `blocked`, or `needs-decision`; a decision comes back to the
255
+ orchestrator, which holds the gate.
256
+ - **Fail closed**: a missing skill or a `blocked` result stops the pipeline.
257
+ - **Writes**: only `before_create` may also commit, because it runs after the
258
+ pipeline's commit; every other point leaves the tree clean.
222
259
  - **Never**: push, open a PR, change `mode`, `branch`, or `merge`.
223
- - **Idempotent**: `after_merge` can be reached by a later run observing an already
224
- merged PR, and `before_create` can run again on a re-ship.
260
+ - **Idempotent**: `after_merge` can be reached by a later run observing an
261
+ already merged PR, and `before_create` can run again on a re-ship.
225
262
  - **Per PR**: in a workspace fan-out the hooks run once per PR.
226
263
 
227
264
  ### When they do not fire
@@ -10,12 +10,15 @@
10
10
  set -euo pipefail
11
11
 
12
12
  ROOT_ARG=""
13
+ HEAD_ARG=""
13
14
  CHECK=0
14
15
 
15
16
  while (( $# )); do
16
17
  case "$1" in
17
18
  --root) shift; [[ $# -ge 1 ]] || { echo "missing DIR after --root" >&2; exit 2; }; ROOT_ARG="$1" ;;
18
19
  --root=*) ROOT_ARG="${1#*=}" ;;
20
+ --head) shift; [[ $# -ge 1 ]] || { echo "missing BRANCH after --head" >&2; exit 2; }; HEAD_ARG="$1" ;;
21
+ --head=*) HEAD_ARG="${1#*=}" ;;
19
22
  --check) CHECK=1 ;;
20
23
  -h|--help) sed -n '2,10p' "${BASH_SOURCE[0]}" | sed 's|^# \{0,1\}||'; exit 0 ;;
21
24
  *) echo "unknown argument: $1" >&2; exit 2 ;;
@@ -102,6 +105,91 @@ hook_skill_path() { # $1 hook skill name — nearest .agents/skills/<name>/SKILL
102
105
  done
103
106
  }
104
107
 
108
+ # --- config readers -------------------------------------------------------
109
+ # The file is flattened to `path<TAB>value` with paths joined by unit separators,
110
+ # which is enough to read the two nested maps we own (git.base_map, hooks.pr.*).
111
+ US=$'\037'
112
+
113
+ yaml_flat() {
114
+ awk -v us="$(printf '\037')" '
115
+ function ind(s, m) { m = match(s, /^ */); return m ? RLENGTH : 0 }
116
+ {
117
+ line = $0; sub(/#.*$/, "", line)
118
+ if (line ~ /^[[:space:]]*$/) next
119
+ d = ind(line)
120
+ body = line; sub(/^[[:space:]]*/, "", body); sub(/[[:space:]]+$/, "", body)
121
+ if (body ~ /:$/) {
122
+ key = body; sub(/:$/, "", key); gsub(/"/, "", key)
123
+ while (sp > 0 && sd[sp] >= d) sp--
124
+ sd[++sp] = d; sk[sp] = key
125
+ next
126
+ }
127
+ if (body !~ /:/) next
128
+ key = body; sub(/:.*/, "", key); gsub(/"/, "", key)
129
+ val = body; sub(/^[^:]*:[[:space:]]*/, "", val); gsub(/^"|"$/, "", val)
130
+ path = ""
131
+ for (i = 1; i <= sp; i++) if (sd[i] < d) path = path (path == "" ? "" : us) sk[i]
132
+ print path (path == "" ? "" : us) key "\t" val
133
+ }' "$CONFIG"
134
+ }
135
+
136
+ yflat() { yaml_flat | awk -F'\t' -v p="$1" '$1 == p { print $2; exit }'; }
137
+
138
+ list_items() {
139
+ printf '%s' "$1" | sed -e 's/^\[//' -e 's/\]$//' | tr ',' '\n' \
140
+ | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//; s/^"//; s/"$//' | grep -v '^$' || true
141
+ }
142
+
143
+ # map_match SECTION HEAD — exact key, then the longest matching family, then "*".
144
+ # base_map uses it, and hooks use the same rule, so "which pattern applies" has
145
+ # exactly one answer in the whole config.
146
+ map_match() {
147
+ local section="$1" head="$2" pat stem best="" bestlen=0 v
148
+ v="$(yflat "git${US}${section}${US}${head}")"
149
+ [[ -n "$v" ]] && { printf '%s' "$v"; return; }
150
+ while IFS= read -r pat; do
151
+ case "$pat" in *"*") ;; *) continue ;; esac
152
+ [[ "$pat" == "*" ]] && continue
153
+ stem="${pat%\*}"
154
+ if [[ "$head" == "$stem"* ]] && (( ${#stem} > bestlen )); then
155
+ best="$pat"; bestlen=${#stem}
156
+ fi
157
+ done < <(yaml_flat | awk -F'\t' -v us="$US" -v sec="$section" \
158
+ '{n=split($1,a,us); if (n==3 && a[1]=="git" && a[2]==sec) print a[3]}' | sort -u)
159
+ if [[ -n "$best" ]]; then
160
+ v="$(yflat "git${US}${section}${US}${best}")"
161
+ [[ -n "$v" ]] && { printf '%s' "$v"; return; }
162
+ fi
163
+ yflat "git${US}${section}${US}*"
164
+ }
165
+
166
+ base_for_head() { map_match base_map "$1"; }
167
+
168
+ hook_patterns() {
169
+ yaml_flat | awk -F'\t' -v us="$US" '{n=split($1,a,us); if (n==4 && a[1]=="hooks" && a[2]=="pr") print a[3]}' | sort -u
170
+ }
171
+
172
+ # hooks_for_head_point HEAD POINT — exact head, then the longest matching
173
+ # family, then "*". A pattern that declares no entry for this point inherits.
174
+ hooks_for_head_point() {
175
+ local head="$1" point="$2" pat stem best="" bestlen=0 v
176
+ v="$(yflat "hooks${US}pr${US}${head}${US}${point}")"
177
+ [[ -n "$v" ]] && { printf '%s' "$v"; return; }
178
+ while IFS= read -r pat; do
179
+ case "$pat" in *"*") ;; *) continue ;; esac
180
+ [[ "$pat" == "*" ]] && continue
181
+ stem="${pat%\*}"
182
+ if [[ "$head" == "$stem"* ]] && (( ${#stem} > bestlen )); then
183
+ best="$pat"; bestlen=${#stem}
184
+ fi
185
+ done < <(hook_patterns)
186
+ if [[ -n "$best" ]]; then
187
+ v="$(yflat "hooks${US}pr${US}${best}${US}${point}")"
188
+ [[ -n "$v" ]] && { printf '%s' "$v"; return; }
189
+ fi
190
+ printf '%s' "$(yflat "hooks${US}pr${US}*${US}${point}")"
191
+ }
192
+
105
193
  detect_base() {
106
194
  local dir="$1" b
107
195
  b="$(git -C "$dir" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||' || true)"
@@ -156,7 +244,7 @@ case "$TOPOLOGY" in
156
244
  ;;
157
245
  workspace)
158
246
  MIRROR_REMOTE="$(config_value remote 2>/dev/null || true)"
159
- MIRROR_BRANCH="$(config_value default_branch 2>/dev/null || true)"
247
+ MIRROR_BRANCH="$(config_value mirror_branch 2>/dev/null || true)"
160
248
  MIRROR_BRANCH="${MIRROR_BRANCH:-main}"
161
249
  ;;
162
250
  esac
@@ -194,17 +282,35 @@ if (( CHECK )); then
194
282
  fi
195
283
 
196
284
  if [[ "$TOPOLOGY" == "repo" ]]; then
197
- configured_base="$(config_value default_branch 2>/dev/null || true)"
198
- [[ -n "$configured_base" ]] || DEFAULT_BRANCH="$(detect_base "$ROOT")"
199
- if [[ -z "$configured_base" ]]; then
200
- bad "git.default_branch is unset — name the branch new work is cut from"
201
- elif [[ -n "$DEFAULT_BRANCH" && "$configured_base" != "$DEFAULT_BRANCH" ]]; then
202
- # A team base is a policy choice: staging, dev, or the release branch can
203
- # all differ from the remote default. Report it, do not call it drift.
204
- note " default_branch '$configured_base' overrides the remote default '$DEFAULT_BRANCH' — expected when PRs target a team base"
285
+ if [[ -n "$(yflat "git${US}default_branch")" ]]; then
286
+ bad "git.default_branch is gone — use git.base_map with a \"*\" entry"
287
+ fi
288
+ if [[ -z "$(yflat "git${US}base_map${US}*")" ]]; then
289
+ bad "git.base_map has no \"*\" entry — it is the base for ordinary work"
290
+ else
291
+ while IFS= read -r pat; do
292
+ [[ -n "$pat" ]] || continue
293
+ case "$pat" in
294
+ "*") ;;
295
+ *"*") [[ "${pat%\*}" != *"*" ]] || bad "base_map key '$pat': only one trailing * is allowed" ;;
296
+ esac
297
+ done < <(yaml_flat | awk -F'\t' -v us="$US" '{n=split($1,a,us); if (n==3 && a[1]=="git" && a[2]=="base_map") print a[3]}' | sort -u)
298
+ head_ref="${HEAD_ARG:-$(git -C "$ROOT" branch --show-current 2>/dev/null || true)}"
299
+ if [[ -n "$head_ref" ]]; then
300
+ note " base for '${head_ref}': $(base_for_head "$head_ref")"
301
+ cur="$head_ref"; hops=0
302
+ while [[ -n "$cur" ]] && (( hops < 8 )); do
303
+ next="$(yflat "git${US}base_map${US}${cur}")"
304
+ [[ -z "$next" ]] && break
305
+ cur="$next"; hops=$((hops + 1))
306
+ if [[ "$cur" == "$head_ref" ]]; then
307
+ bad "git.base_map cycles through '$head_ref'"; break
308
+ fi
309
+ done
310
+ else
311
+ note " detached HEAD — pass --head <branch> to see the resolved base"
312
+ fi
205
313
  fi
206
- configured_remote="$(config_value remote 2>/dev/null || true)"
207
- [[ -z "$configured_remote" ]] || bad "git.remote is set but git.repo is not workspace"
208
314
  fi
209
315
 
210
316
  if [[ "$TOPOLOGY" == "workspace" ]]; then
@@ -235,34 +341,37 @@ if (( CHECK )); then
235
341
  done < <(nested_repos)
236
342
  fi
237
343
 
238
- # Hooks: every declared name resolves to a project skill, and none may be ours.
239
- if grep -q '^hooks:' "$CONFIG" 2>/dev/null; then
240
- while IFS= read -r hline; do
241
- [[ "$hline" == " "* ]] || continue # only nested lines belong here
242
- hkey="$(printf '%s' "$hline" | sed -nE 's/^[[:space:]]*([A-Za-z_]+):.*/\1/p')"
243
- case "$hkey" in
244
- "") continue ;;
245
- pr) continue ;;
246
- before_create|after_create|before_merge|after_merge) ;;
247
- *) bad "unknown hook point '$hkey' under hooks:"; continue ;;
248
- esac
249
- hlist="$(printf '%s' "$hline" | sed -nE 's/^[^:]+:[[:space:]]*\[([^]]*)\].*/\1/p')"
250
- [[ -n "$hlist" ]] || continue
251
- while IFS= read -r hraw; do
252
- hname="$(printf '%s' "$hraw" | sed -E 's/#.*$//; s/^[[:space:]]+//; s/[[:space:]]+$//; s/^["'\'']//; s/["'\'']$//')"
253
- [[ -n "$hname" ]] || continue
254
- case "$hname" in
255
- sassy-*) bad "hook $hkey: '$hname' is one of our skills — hooks are project skills only" ;;
256
- *)
257
- if hook_skill_path "$hname" >/dev/null; then
258
- note " hook $hkey → $hname"
259
- else
260
- bad "hook $hkey: '$hname' not found at .agents/skills/$hname/SKILL.md"
261
- fi
262
- ;;
344
+ # Hooks: head-scoped, so `staging` means "the PR is from staging".
345
+ if [[ -n "$(yflat "hooks${US}pr${US}before_create")" ]]; then
346
+ bad "hooks.pr.<point> is now nested by head branch — write hooks.pr.\"*\".<point>"
347
+ fi
348
+ while IFS= read -r pat; do
349
+ [[ -n "$pat" ]] || continue
350
+ case "$pat" in
351
+ "*") ;;
352
+ *"*") [[ "${pat%\*}" != *"*" ]] || bad "hook pattern '$pat': only one trailing * is allowed" ;;
353
+ esac
354
+ for point in before_create after_create before_merge after_merge; do
355
+ val="$(yflat "hooks${US}pr${US}${pat}${US}${point}")"
356
+ [[ -n "$val" ]] || continue
357
+ while IFS= read -r entry; do
358
+ [[ -n "$entry" ]] || continue
359
+ name="${entry%% *}"
360
+ case "$name" in
361
+ sassy-*) bad "hooks.$pat.$point: '$name' is one of our skills — hooks are project skills only" ;;
362
+ *) hook_skill_path "$name" >/dev/null 2>&1 \
363
+ || bad "hooks.$pat.$point: '$name' not found at .agents/skills/$name/SKILL.md" ;;
263
364
  esac
264
- done < <(printf '%s\n' "$hlist" | tr ',' '\n')
265
- done < <(sed -n '/^hooks:/,/^[a-z]/p' "$CONFIG")
365
+ done < <(list_items "$val")
366
+ done
367
+ done < <(hook_patterns)
368
+
369
+ # Resolve for one head, so nobody has to merge maps in their head.
370
+ if [[ -n "$HEAD_ARG" && "$TOPOLOGY" == "repo" ]]; then
371
+ for point in before_create after_create before_merge after_merge; do
372
+ resolved="$(hooks_for_head_point "$HEAD_ARG" "$point")"
373
+ note " head '$HEAD_ARG' → $point: $( [[ -n "$resolved" ]] && printf '%s' "$resolved" || printf 'none' )"
374
+ done
266
375
  fi
267
376
 
268
377
  for key in plans solutions handbooks concepts; do
@@ -325,15 +434,18 @@ case "$TOPOLOGY" in
325
434
  # --- Git ------------------------------------------------------------------
326
435
  # repo: a remote name (this folder is the repo) | workspace (repos live below,
327
436
  # this folder is not a repo) | none (no git anywhere here).
328
- # default_branch: what new branches are cut from, and what PRs target.
437
+ # base_map: the base for a HEAD branch, so a PR targets the right rung.
438
+ # "*" is ordinary work; add a rung for a branch that releases elsewhere.
329
439
  git:
330
440
  EOF
331
441
  kv repo "$REPO_NAME" 0
442
+ printf ' base_map:\n'
332
443
  if [[ -n "$DEFAULT_BRANCH" ]]; then
333
- kv default_branch "$DEFAULT_BRANCH" 34 '# probed — confirm it is the team base'
444
+ printf ' "*": %s # probed — confirm this is where ordinary work branches from\n' "$DEFAULT_BRANCH"
334
445
  else
335
- kv default_branch "<confirm by hand>" 34 '# base for branches and PRs; never guessed'
446
+ printf ' "*": %s # base for ordinary work; never guessed\n' "<confirm by hand>"
336
447
  fi
448
+ printf ' # staging: main # uncomment when a branch releases somewhere else\n'
337
449
  cat <<'EOF'
338
450
 
339
451
  # workspace only: each nested repo folder -> the base its PRs target
@@ -347,12 +459,13 @@ EOF
347
459
  # --- Git ------------------------------------------------------------------
348
460
  # repo: workspace means repos live below and this folder is not a repo.
349
461
  # remote: the snapshot mirror (GitHub owner/name or URL), required here.
350
- # default_branch: the mirror's branch, not any nested repo's base.
462
+ # mirror_branch: the branch the mirror pushes to. Not a PR base — nested repos
463
+ # carry their own, and a workspace has no branches of its own.
351
464
  git:
352
465
  EOF
353
466
  kv repo workspace 0
354
467
  kv remote "${MIRROR_REMOTE:-<set the snapshot mirror, owner/name or URL>}" 0
355
- kv default_branch "$MIRROR_BRANCH" 0
468
+ kv mirror_branch "$MIRROR_BRANCH" 0
356
469
  cat <<'EOF'
357
470
 
358
471
  # --- Repos ----------------------------------------------------------------
@@ -389,7 +502,7 @@ cat <<'EOF'
389
502
 
390
503
  # --- Commit ---------------------------------------------------------------
391
504
  # current: commit on the checked-out branch.
392
- # new: cut a branch from origin/<default_branch>; name it in multi-repo runs so
505
+ # new: cut a branch from the base map for the current head; name it in multi-repo runs so
393
506
  # every repo gets the same branch name.
394
507
  commit:
395
508
  EOF
@@ -405,17 +518,22 @@ kv delete_branch false 24 '# true | false — delete the head branch once merged
405
518
  cat <<'EOF'
406
519
 
407
520
  # --- Hooks ----------------------------------------------------------------
408
- # Project steps injected into the PR pipeline. Each entry is a skill name,
409
- # resolved at .agents/skills/<name>/SKILL.md, nearest walking up. Declared
410
- # order, one worker each, and a declared-but-missing skill fails closed.
411
- # before_create — after the commit, before the PR opens (may commit its output)
412
- # after_create — the PR was just created
413
- # before_merge — this run is about to merge the PR
414
- # after_merge — this run observed the PR merged
521
+ # Project steps injected into the PR pipeline, keyed by the HEAD branch — the
522
+ # branch the PR comes from, never the one it targets. `"*"` is the default;
523
+ # a name overrides per point, and `release/*` matches a family. Each entry is
524
+ # `<skill> <args…>`, resolved at .agents/skills/<name>/SKILL.md; args are passed
525
+ # to the skill verbatim. Declared order, one worker each, missing skill fails closed.
415
526
  hooks:
416
527
  pr:
528
+ "*":
529
+ EOF
530
+ kv before_create '[]' 30 '# after the commit, before the PR opens' ' '
531
+ kv after_create '[]' 30 '# the PR was just created' ' '
532
+ kv before_merge '[]' 30 '# this run is about to merge' ' '
533
+ kv after_merge '[]' 30 '# this run observed the merge' ' '
534
+ cat <<'EOF'
535
+ # staging: # per-point override for one head branch
536
+ # before_create: []
537
+ # release/*: # a family of heads
538
+ # after_merge: [notify mode:auto]
417
539
  EOF
418
- kv before_create '[]' 24 '# after the commit, before the PR opens' ' '
419
- kv after_create '[]' 24 '# the PR was just created' ' '
420
- kv before_merge '[]' 24 '# this run is about to merge' ' '
421
- kv after_merge '[]' 24 '# this run observed the merge' ' '
@@ -6,7 +6,7 @@ description: >-
6
6
  the rules and release steps that location requires. Use when asked to create a
7
7
  skill, turn a workflow into a skill, fix a description or triggering,
8
8
  restructure a bloated skill, or retune an existing one.
9
- argument-hint: "[create|retune] [name, idea, or skill path]"
9
+ argument-hint: "[mode:create|retune|verify] [name, idea, or skill path]"
10
10
  disable-model-invocation: true
11
11
  ---
12
12
 
@@ -14,6 +14,8 @@ disable-model-invocation: true
14
14
 
15
15
  **Outcome:** a well-formed Agent Skill at a location the user chose, or an existing skill improved and re-verified. The harness loads it with no diagnostics.
16
16
 
17
+ **Modes.** `mode:create` writes a new skill. `mode:retune` changes the smallest thing that fixes a named failure in an existing skill. `mode:verify` checks an existing skill against the checklist and reports — it edits nothing. Omitted, detect it from the argument: something that already resolves to a skill directory is `retune`, or `verify` when the user only asked whether it is any good; anything else is `create`. State the mode you picked and why; when both are plausible, ask instead of guessing.
18
+
17
19
  **Locations are not interchangeable** — they decide who gets the skill, how it ships, and what it may be called. Ask before writing.
18
20
 
19
21
  **Scope:** skills only — not extensions, packages, or prompt templates.
@@ -54,7 +56,7 @@ Paths in the table are the defaults. Harnesses also read `skills` arrays in sett
54
56
 
55
57
  ## Workflow
56
58
 
57
- 1. **Intake.** What the skill does, when it should trigger, and **where it lives** — internal, project, or user (see above). Ask; do not infer from the cwd. For a retune, read the existing skill and name the specific failure (won't trigger, too long, wrong steps, stale, wrong scope).
59
+ 1. **Intake.** Settle the mode (above), then what the skill does, when it should trigger, and **where it lives** — internal, project, or user (see above). Ask; do not infer from the cwd. In `mode:retune`, read the existing skill and name the specific failure (won't trigger, too long, wrong steps, stale, wrong scope). In `mode:verify`, read it, run the checklist and Verify, report, and stop without writing.
58
60
  2. **Choose the shape.** Single file, or `SKILL.md` + `references/` per the rule above.
59
61
  3. **Write the frontmatter.** A description that states both what and when.
60
62
  4. **Write the body.** Outcome, a hard-rules table, the workflow, and reference pointers. Plain English. Structured output where the skill returns a result. Match this setup's house style.
@@ -72,7 +74,7 @@ Paths in the table are the defaults. Harnesses also read `skills` arrays in sett
72
74
 
73
75
  ## Retune
74
76
 
75
- When asked to retune or improve a skill, change the **smallest** thing that fixes the named failure:
77
+ In `mode:retune`, change the **smallest** thing that fixes the named failure:
76
78
 
77
79
  - **Won't trigger** → rewrite the description (what + when, concrete verbs).
78
80
  - **Too long / bloated** → extract conditional content to `references/`, or delete what never gets used.
@@ -83,6 +85,8 @@ Re-verify after any retune.
83
85
 
84
86
  ## Verify
85
87
 
88
+ Run this at the end of `create` and `retune`, and as the whole job in `mode:verify`.
89
+
86
90
  - `/reload`.
87
91
  - Check pi's startup output for warnings about the skill.
88
92
  - Run `/skill:<name>` and confirm it loads and behaves.