@sassywares/skills 0.5.6 → 0.5.7

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/README.md CHANGED
@@ -205,7 +205,7 @@ flowchart LR
205
205
  I -->|changes| H
206
206
  I --> Q{Manual QA}
207
207
  Q -->|fails| H
208
- Q -->|passes, via sassy-work| J[Commit on current branch and stop]
208
+ Q -->|passes, via sassy-work| J[Commit on the run branch and stop]
209
209
  Q -->|passes, via sassy-lfg| K[sassy-ship]
210
210
  K --> L{Ship review}
211
211
  L -->|approve or mode:auto| M[Commit, PR, babysit]
@@ -215,7 +215,7 @@ Option 1 produces a durable plan with phased slices and manual QA tables. Option
215
215
 
216
216
  `sassy-work` handles one phase and hands off the next. `sassy-lfg` drains the whole plan in one session. Both delegate to `sassy-cycle` and stop at one gate carrying code approval plus the manual QA checklist; nothing is `Done`, committed, or shipped until both pass. With an in-context brief there are no QA rows, so the gate is code approval alone.
217
217
 
218
- Work always commits to the current branch; `sassy-ship` creates the shipping branch at close-out when the checked-out branch is the repo default.
218
+ The branch is cut once, before the first change, by `sassy-work` or `sassy-lfg` — never at commit time — and `sassy-ship` never creates it. `commit.branch` (default `new`) decides whether the run cuts one at all; see [Branching](#branching).
219
219
 
220
220
  `sassy-ship` is the close-out. It summarizes what is about to ship and waits, unless the caller passed `mode:auto`.
221
221
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sassywares/skills",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "Lean, explicit-invocation Agent Skills for shipping fast: plan, work, review, validate, ship.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,7 +40,12 @@ Treat the reads as a snapshot; re-read branch and staged set right before commit
40
40
 
41
41
  0. **Gather** the context above.
42
42
  1. **Nothing to commit** — if there are no staged, modified, or untracked files, say so and stop.
43
- 2. **Branch** — honor the `branch` option. With `branch:new`, cut from the base: `git fetch <remote> <base>`, then `git checkout -b <name> <remote>/<base>`, then confirm `git merge-base HEAD <remote>/<base>` is `<remote>/<base>`. If that ancestry check fails, the branch was cut from the wrong base — recreate it, never push it. If the name already exists, reuse it when it descends from the base; fork a suffix only when it has diverged, and never cut a second branch off the base for the same work. With `branch:current`, commit on the checked-out branch. With no token and no `commit.branch`, treat it as `new` and cut one. **Base means any `base_map` value, not just `origin/HEAD`**: `bash .agents/skills/sassy-setup/scripts/detect.sh --branch` answers it, and a branch it reports as `on-base` must be named in the summary you return — a warning nobody sees protects nothing.
43
+ 2. **Branch** — honor the `branch` option. With `branch:new`, cut from the base: `git fetch <remote> <base>`, then `git checkout -b <name> <remote>/<base>`, then confirm `git merge-base HEAD <remote>/<base>` is `<remote>/<base>`. If that ancestry check fails, the branch was cut from the wrong base — recreate it, never push it. If the name already exists, reuse it when it descends from the base; fork a suffix only when it has diverged, and never cut a second branch off the base for the same work.
44
+
45
+ With `branch:current`, commit on the checked-out branch — but check what it is first with the `sassy-setup` skill's `scripts/detect.sh --branch`. **Base means any `base_map` value, not just `origin/HEAD`.** Resolve the two facts before committing:
46
+ - `state: on-base` with `policy: new` means the caller skipped the branch decision. **Stop and report** — do not commit. A warning inside a subagent's summary has already failed to protect a shared branch once; this is a stop, not a notice.
47
+ - `commit-here` means the config or a token explicitly chose to commit here. Proceed, and name the branch in the summary you return.
48
+ - Any other state: commit on the checked-out branch.
44
49
  3. **Convention** — match the project's existing commit style; else the recent log pattern; else conventional commits (`type(scope): description`). When `fix`/`feat` both fit, default to `fix:`.
45
50
  4. **Logical commits** — if changed files clearly split into distinct concerns, make separate commits (file level, 2–3 max, never `git add -p`). If ambiguous, one commit.
46
51
  5. **Message** — imperative subject naming the outcome, not the file list. Body only when the motivation is not obvious.
@@ -18,6 +18,8 @@ disable-model-invocation: true
18
18
 
19
19
  **Config.** Resolve `.sassywares/config.yaml` (nearest, walking up) per the `sassy-setup` skill's `references/config.md`. `commit.branch` seeds the branch policy, `pr.*` seeds the babysit options, and `hooks.pr` supplies the `before_create` / `after_create` steps for this run's head. Precedence is explicit token > session instruction > config > skill default, and a token is explicit only when the **caller** supplied it; never pass this skill's own default as a token.
20
20
 
21
+ **Which branch is a base.** Use the `sassy-setup` skill's `scripts/detect.sh --branch`: a base is any `base_map` value, not `origin/HEAD`. Probing `origin/HEAD` alone calls `staging` "not a base" in a repo whose `base_map` routes `"*"` there, which is how a run once committed phase work straight onto a shared integration branch.
22
+
21
23
  **Keys are the head branch.** Resolve per head: the exact head, then the longest matching family (`release/*`), then `"*"`. Each point overrides independently. An entry is `<skill> <args…>` — split on the first whitespace and pass the args verbatim.
22
24
 
23
25
  ## Context
@@ -37,7 +39,7 @@ gh pr list --head <branch> --state open --json url,number
37
39
 
38
40
  Only an exit-0 empty list means "no open PR". A non-zero exit means **unknown** — resolve auth/connectivity first, never treat it as "none". Probe output is a snapshot; re-check branch/remote/PR state right before the push and the PR create.
39
41
 
40
- **Branch policy.** The run's branch is normally already decided: `sassy-work` and `sassy-lfg` cut it at the start via `sassy-setup`'s `references/branching.md`, and `sassy-ship` forwards `branch:current`. So the default here is to commit on the checked-out branch when it is not a base. Only when it *is* a base or a detached HEAD — a bare invocation with no orchestrator above it — 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>` only when this run creates a branch, else `branch:current`.
42
+ **Branch policy.** The run's branch is normally already decided: `sassy-work` and `sassy-lfg` cut it at the start via the `sassy-setup` skill's `references/branching.md`, and `sassy-ship` forwards `branch:current`. So the default here is to commit on the checked-out branch when `state` is not `on-base`. Only when it *is* `on-base` or `detached` — a bare invocation with no orchestrator above it — 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>` only when this run creates a branch, else `branch:current`.
41
43
 
42
44
  **Base branch.** `<base>` is `base_map[<current head>]` from the nearest config — a nested repo's own config wins over the workspace's `repos[<dir>]` — 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>`.
43
45
 
@@ -38,7 +38,7 @@ A brief has no QA table, so its gate is code approval alone. Never invent, enabl
38
38
  ## Steps
39
39
 
40
40
  1. **Load the plan.** Read the plan and its Phase tracking table. If phases are already `In progress` from an interrupted run, ask once whether to resume at the gate or rerun those cycles, then continue from the answer.
41
- 2. **Resolve the branch, before touching code.** Follow the `sassy-setup` skill's `references/branching.md` with this run's `branch:` token, if any. LFG runs every phase in one session, so the branch is cut once here and every phase commits onto it. Report the branch and its start point in the gate.
41
+ 2. **Resolve the branch, before touching code.** Follow the `sassy-setup` skill's `references/branching.md` with this run's `branch:` token, if any. LFG runs every phase in one session, so the branch is cut once here and every phase commits onto it. Report the branch and its start point in the gate. A `stop-and-ask` here stops the run before any phase starts.
42
42
  3. **Run every phase, in order.** For each phase not `Done`:
43
43
  - Set the phase `In progress`, then load the `sassy-cycle` skill and follow it, with work brief = the phase's goal/work/"Done when", and intent reference = the plan path and phase.
44
44
  - On `Status: done` and `Validator: Conforms`, leave the phase `In progress` — its QA rows are still owed, and only the gate closes them.
@@ -62,7 +62,7 @@ Every worker brief this run dispatches must include:
62
62
 
63
63
  When the argument is not a plan path, the brief is the one phase:
64
64
 
65
- 1. Resolve the branch exactly as the plan path does — `references/branching.md`, this run's `branch:` token — before the cycle runs.
65
+ 1. Resolve the branch exactly as the plan path does — the `sassy-setup` skill's `references/branching.md`, this run's `branch:` token — before the cycle runs.
66
66
  2. Run one `sassy-cycle` with the brief as the work brief and its decisions / done-when as the intent reference. Do not split it into multiple phases.
67
67
  3. Present the code approval gate with no QA checklist, and wait.
68
68
  4. On code approval, run `sassy-ship`. Do not run `sassy-plan` or write a plan file.
@@ -72,7 +72,7 @@ When the argument is not a plan path, the brief is the one phase:
72
72
  | Rule | Requirement |
73
73
  |------|-------------|
74
74
  | Orchestrator only | Delegate every phase to `sassy-cycle`. Never read source or edit code. |
75
- | Branch before code | Resolve the branch per `references/branching.md` before any phase runs, so no change lands on a base branch. |
75
+ | Branch before code | Resolve the branch per the `sassy-setup` skill's `references/branching.md` before any phase runs, so no change lands on a base branch. |
76
76
  | Phases in plan order | Phases run one after another, in plan order. |
77
77
  | Done needs both gates | Mark a phase `Done` only when the user approves the code and every QA row for it is `Done`. |
78
78
  | QA rows follow the user | Tick a QA row `Done` only from the user's explicit confirmation; keep unreported rows `Pending`; log a failed row as a finding for one more cycle. |
@@ -6,37 +6,49 @@ three cannot drift apart the way three hand-copied paragraphs would.
6
6
 
7
7
  ## Resolve
8
8
 
9
+ Run the `sassy-setup` skill's `scripts/detect.sh` — by skill name, never by an
10
+ absolute install path, because a consumer's copy may live in `.agents/skills/`
11
+ or anywhere else a harness puts it:
12
+
9
13
  ```bash
10
- bash .agents/skills/sassy-setup/scripts/detect.sh --branch [--policy current|new]
14
+ bash scripts/detect.sh --branch [--policy infer|current|new] [--name BRANCH]
11
15
  ```
12
16
 
13
17
  `--policy` is the `branch:` token: `infer` (the default), `current`, or `new`.
14
18
  Omit it to let `commit.branch` decide; `branch:infer` means exactly that and is
15
- passed only to be explicit.
19
+ passed only to be explicit. `--name` is optional and answers one question early:
20
+ which base a PR from the branch you are about to name will target.
21
+
22
+ The detector is read-only, but not network-free: when the config names no base it
23
+ falls back to the remote HEAD, which may shell out to `gh`.
16
24
 
17
- Output:
25
+ ## Output — repo topology
18
26
 
19
27
  | Key | Meaning |
20
28
  |---|---|
29
+ | `note` | present only when the config is unusable, e.g. a bad `commit.branch`; it names the fallback taken |
21
30
  | `topology` | `repo`, `workspace`, or `none` |
22
- | `configured` | `commit.branch` from the config, or `none` |
31
+ | `config` | the config file in force, or `none` |
32
+ | `configured` | `commit.branch` from the config (after validation), or `none` |
23
33
  | `policy` | what actually applies: the token, else the config, else `new` |
24
- | `head` | the checked-out branch, or `none` when detached |
34
+ | `checked-out` | printed only with `--head`, when you asked about a branch you are not on |
35
+ | `head` | the branch being decided for |
25
36
  | `state` | `on-base`, `on-branch`, `detached`, or `no-git` |
26
- | `action` | `create`, `reuse`, `commit-here`, or `refuse` |
27
- | `start-point` | where a new branch is cut from — `origin/<head>`, never local `HEAD` |
28
- | `unpublished` | commits on local `<head>` that are not on `origin/<head>` |
37
+ | `action` | `create`, `reuse`, `commit-here`, `stop-and-ask`, or `refuse` |
38
+ | `start-point` | where a new branch is cut from — `<remote>/<head>`, or `none` |
39
+ | `unpublished` | commits on local `<head>` missing upstream, `0`, `unknown`, or `n/a` |
29
40
  | `pr-base` | the base a PR from this run targets |
30
- | `pr-base-source` | `config`, `probed`, or `unknown` — a probed base is confirmed with the user before use |
41
+ | `pr-base-source` | `config`, `probed` (confirm it with the user), or `unknown` |
42
+ | `pr-base-final` | `yes` when `--name` resolved it for the real branch name, else `no` |
31
43
 
32
- A base branch is **any `base_map` value**, plus the remote default. That is why
33
- `staging` counts as a base in a repo whose `base_map` routes `"*"` to it: the
34
- remote default alone is not the base, it is only one of them.
44
+ A base branch is **any `base_map` value**, plus the `repos` rung, plus the remote
45
+ default. That is why `staging` counts as a base in a repo whose `base_map` routes
46
+ `"*"` to it: the remote default alone is not the base, it is only one of them.
35
47
 
36
48
  ## Token authorship
37
49
 
38
50
  Pass `--policy` only when the **caller** asked for it — the user typed
39
- `branch:new`, or an earlier gate of this same run authorized it.
51
+ `branch:new`, or the run's own gate summary already recorded the decision.
40
52
 
41
53
  Never pass a token that is merely this skill's own default. Doing that launders
42
54
  a default into "explicit token", which outranks the project's config and makes
@@ -47,16 +59,23 @@ config. This applies to every token these skills forward, not just `branch`.
47
59
 
48
60
  ### `create`
49
61
 
50
- 1. `unpublished` must be `0`. If not, **stop and ask**: cutting from local HEAD
51
- would carry commits stranded on the base into the new branch, and cutting
52
- from `origin/<head>` would abandon them. Name the count, the branch, and the
53
- choice.
54
- 2. Name it `<type>/<slug>` from the plan filename or the topic, deterministically,
62
+ 1. Run `git fetch <remote> <head>` **before** trusting the detector: a stale
63
+ upstream ref reads as "unpublished commits" and produces a false stop.
64
+ 2. `unpublished` must be `0` and `start-point` must not be `none`. Anything else
65
+ is a stop: cutting from local HEAD would carry commits stranded on the base
66
+ into the new branch, and cutting from a missing upstream ref silently does
67
+ exactly that. Name the count, the branch, and the choice.
68
+ 3. Name it `<type>/<slug>` from the plan filename or the topic, deterministically,
55
69
  so a retried run converges on the same name instead of forking `-2`.
56
- 3. If that name already exists: reuse it when it descends from `start-point`;
70
+ 4. Re-run with `--name <name>` and take `pr-base` from that answer: a name like
71
+ `release/1.2` or `hotfix/x` matches a `base_map` family and retargets the PR,
72
+ and it also changes which `hooks.pr` pattern applies — a plan slug containing
73
+ "release" is not a neutral name.
74
+ 5. If the name already exists: reuse it when it descends from `start-point`;
57
75
  stop and ask when it has diverged.
58
- 4. `git fetch <remote> <head>`, then `git checkout -b <name> <start-point>`.
59
- 5. Report the branch, the start point, and `pr-base`.
76
+ 6. `git checkout -b <name> <start-point>`. Uncommitted work comes along — say so
77
+ in the gate summary if the tree was dirty.
78
+ 7. Report the branch, the start point, and `pr-base`.
60
79
 
61
80
  ### `reuse`
62
81
 
@@ -72,18 +91,34 @@ committing on the base branch is intended. Proceed, and say in the run's gate
72
91
  summary that the commits are landing on `<head>` and that `pr-base` is
73
92
  `<pr-base>`. Never infer this from a skill default.
74
93
 
94
+ ### `stop-and-ask`
95
+
96
+ The base has commits that never reached the remote. Present the choice — publish
97
+ them, move them to the new branch, or drop them — and take the answer. Never
98
+ resolve it by cutting anyway.
99
+
75
100
  ### `refuse`
76
101
 
77
- Detached HEAD with `policy: current`, or `topology: none`. Report and stop;
78
- there is no branch to commit to.
102
+ Detached HEAD without a policy that permits it, `topology: none`, `git.repo:
103
+ none`, or no upstream ref to cut from. Report the `reason` line and stop; there
104
+ is no branch to commit to.
79
105
 
80
106
  ## Workspace
81
107
 
82
108
  Resolve once at the workspace root; the run uses **one branch name across every
83
- nested repo**. The detector prints each repo's head and state, so cut inside the
84
- repos whose state is `on-base` and reuse the rest. A nested repo carrying its own
85
- `commit.branch` that disagrees is printed as `drift` — that is a config bug to
86
- report, not a per-repo override, because one run cannot follow two policies.
109
+ nested repo**. The detector prints one line per repo:
110
+
111
+ ```
112
+ repo fe: head=staging state=on-base base=staging start-point=origin/staging unpublished=0 action=create
113
+ drift fe: commit.branch=current
114
+ ```
115
+
116
+ `base` is that repo's own `base_map["*"]` when it carries a config, else its
117
+ `repos[<dir>]` rung. Apply `action` per repo — cut where it is `create`, keep the
118
+ rest — and stop if any repo reports `stop-and-ask` or `refuse`. A nested repo
119
+ carrying its own `commit.branch` that disagrees is printed as `drift` — a config
120
+ bug to report, not a per-repo override, because one run cannot follow two
121
+ policies.
87
122
 
88
123
  ## After the branch exists
89
124
 
@@ -25,14 +25,20 @@ explicit token in the request > instruction in this session
25
25
  > .sassywares/config.yaml > skill default
26
26
  ```
27
27
 
28
- **A token is explicit only when the caller supplied it** — the user typed it, or
29
- an earlier gate of the same run authorized that side effect. A skill must never
30
- pass its own default as a token, to another skill or to itself: once a default
31
- sits in the token slot, nothing downstream can tell it apart from a user's
32
- choice, so it silently outranks the project's config. A skill defers to config
33
- by **omitting** the token, never by passing a value it invented. That is how
34
- `commit.branch` became unreachable while every skill still read as if it
35
- explained the rule correctly.
28
+ **A token is explicit only when the caller supplied it.** Three sources qualify:
29
+
30
+ 1. the user typed it in the request;
31
+ 2. an approval gate earlier in the same run authorized that side effect;
32
+ 3. the run's own orchestration decision, already recorded in its gate summary —
33
+ for example `sassy-ship` passing `babysit:off` because its step 6 owns the
34
+ babysit, or `sassy-work` forwarding `branch:current` because its step 2 cut
35
+ the branch and the gate showed it.
36
+
37
+ What never qualifies is **this skill's own default**. Once a default sits in the
38
+ slot, nothing downstream can tell it apart from the user's choice, so it
39
+ silently outranks the project's config. A skill defers to config by **omitting**
40
+ the token, never by passing a value it invented. That is how `commit.branch`
41
+ became unreachable while every skill still read as if it explained the rule.
36
42
 
37
43
  Read it once per run. An orchestrator resolves every config-backed value and
38
44
  passes the resolved values into subagent briefs; subagents never re-read the
@@ -59,7 +65,7 @@ git:
59
65
  repos: # workspace only: sibling dir -> PR base
60
66
  fe: dev
61
67
  commit:
62
- branch: current # current | new
68
+ branch: new # current | new
63
69
  pr:
64
70
  babysit: true
65
71
  merge: false
@@ -13,11 +13,14 @@
13
13
  # --policy takes infer|current|new: the skill-level token. infer (the default)
14
14
  # defers to commit.branch in the config, and new is the fallback when the config
15
15
  # is silent. --branch is read-only; it prints the decision, it never cuts.
16
+ # --name BRANCH resolves pr-base for the name a run is about to pick, because a
17
+ # new name can match a base_map family (release/*) and retarget the PR.
16
18
  set -euo pipefail
17
19
 
18
20
  ROOT_ARG=""
19
21
  HEAD_ARG=""
20
22
  POLICY_ARG=""
23
+ NAME_ARG=""
21
24
  CHECK=0
22
25
  BRANCH=0
23
26
 
@@ -29,9 +32,11 @@ while (( $# )); do
29
32
  --head=*) HEAD_ARG="${1#*=}" ;;
30
33
  --policy) shift; [[ $# -ge 1 ]] || { echo "missing POLICY after --policy" >&2; exit 2; }; POLICY_ARG="$1" ;;
31
34
  --policy=*) POLICY_ARG="${1#*=}" ;;
35
+ --name) shift; [[ $# -ge 1 ]] || { echo "missing BRANCH after --name" >&2; exit 2; }; NAME_ARG="$1" ;;
36
+ --name=*) NAME_ARG="${1#*=}" ;;
32
37
  --branch) BRANCH=1 ;;
33
38
  --check) CHECK=1 ;;
34
- -h|--help) sed -n '2,14p' "${BASH_SOURCE[0]}" | sed 's|^# \{0,1\}||'; exit 0 ;;
39
+ -h|--help) sed -n '2,15p' "${BASH_SOURCE[0]}" | sed 's|^# \{0,1\}||'; exit 0 ;;
35
40
  *) echo "unknown argument: $1" >&2; exit 2 ;;
36
41
  esac
37
42
  shift
@@ -39,6 +44,9 @@ done
39
44
 
40
45
  START="${ROOT_ARG:-$PWD}"
41
46
  [[ -d "$START" ]] || { echo "not a directory: $START" >&2; exit 2; }
47
+ # --branch prints a decision; --check validates a config. One at a time, or the
48
+ # second is silently ignored.
49
+ (( BRANCH && CHECK )) && { echo "--branch and --check are separate modes; run them one at a time" >&2; exit 2; }
42
50
  # Absolute, or the walk-up loop below re-reads "." forever.
43
51
  START="$(cd "$START" && pwd -P)"
44
52
 
@@ -419,6 +427,16 @@ if (( CHECK )); then
419
427
  [[ -n "$val" ]] && note " artifacts.$key: $val"
420
428
  done
421
429
 
430
+ # commit.branch steers whether a run may touch a base branch, so an invalid
431
+ # value is reported rather than silently ignored.
432
+ val="$(yflat "commit${US}branch" 2>/dev/null || true)"
433
+ if [[ -n "$val" ]]; then
434
+ case "$val" in
435
+ current|new) note " commit.branch: $val" ;;
436
+ *) bad "commit.branch '$val' is not current|new" ;;
437
+ esac
438
+ fi
439
+
422
440
  if (( drift )); then exit 1; fi
423
441
  note " config matches the tree"
424
442
  exit 0
@@ -439,64 +457,145 @@ if (( BRANCH )); then
439
457
  *) echo "unknown --policy: $policy (expected infer, current, or new)" >&2; exit 2 ;;
440
458
  esac
441
459
  configured="$(yflat "commit${US}branch" 2>/dev/null || true)"
442
- [[ "$policy" == "infer" ]] && policy="${configured:-new}"
443
-
444
- # base_heads prints every branch this config treats as a base: each base_map
445
- # value, plus the remote default it inherits from. With no config to speak,
446
- # the probed remote HEAD is the only base there is.
447
- base_heads() {
448
- local v out=""
449
- out="$(yaml_flat "$CONFIG" 2>/dev/null | awk -F'\t' -v us="$US" \
450
- '{n=split($1,a,us); if (n==3 && a[1]=="git" && a[2]=="base_map") print $2}' | sort -u || true)"
451
- if [[ "$TOPOLOGY" == "repo" ]]; then
452
- v="$(git -C "$ROOT" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||' || true)"
460
+ note=""
461
+ if [[ -n "$configured" && "$configured" != "current" && "$configured" != "new" ]]; then
462
+ # A soft config error must not become a silently wrong branch decision.
463
+ note="commit.branch '$configured' is not current|new; using new"
464
+ configured=""
465
+ fi
466
+ if [[ "$policy" == "infer" ]]; then
467
+ policy="${configured:-new}"
468
+ fi
469
+
470
+ # base_heads_in DIR — every branch DIR's config treats as a base: each
471
+ # base_map value, the repos[<dir>] rung, and the remote default. A workspace
472
+ # has no base_map of its own, so repos[] is what names its bases; reading
473
+ # base_map alone reported every nested repo as on-branch and let runs commit
474
+ # onto staging, which is the bug this whole mode exists to stop.
475
+ base_heads_in() {
476
+ local dir="$1" r v out=""
477
+ if [[ -f "$dir/.sassywares/config.yaml" ]]; then
478
+ out="$(yaml_flat "$dir/.sassywares/config.yaml" 2>/dev/null | awk -F'\t' -v us="$US" \
479
+ '{n=split($1,a,us); if (n==3 && a[1]=="git" && a[2]=="base_map") print $2}' | sort -u || true)"
480
+ fi
481
+ v="$(yflat "repos${US}$(basename "$dir")" 2>/dev/null || true)"
482
+ [[ -n "$v" ]] && out="$out"$'\n'"$v"
483
+ r="$(remote_name "$dir")"
484
+ if [[ -n "$r" ]]; then
485
+ v="$(git -C "$dir" symbolic-ref --short "refs/remotes/$r/HEAD" 2>/dev/null | sed "s|^$r/||" || true)"
453
486
  [[ -n "$v" ]] && out="$out"$'\n'"$v"
454
487
  fi
455
488
  out="$(printf '%s\n' "$out" | grep -v '^$' | sort -u || true)"
456
489
  # Probing through gh costs a network call, so only when nothing else said.
457
- [[ -n "$out" ]] || out="$(detect_base "$ROOT" 2>/dev/null || true)"
490
+ [[ -n "$out" ]] || out="$(detect_base "$dir" 2>/dev/null || true)"
458
491
  printf '%s\n' "$out" | grep -v '^$' || true
459
492
  }
460
493
 
461
- # in_base_heads BRANCH — membership without a pipe: `base_heads | grep -q`
462
- # trips pipefail, because grep exits on the first match and SIGPIPEs the
463
- # producer, so a branch that IS a base reported as if it were not.
494
+ # in_base_heads DIR BRANCH — membership without a pipe: `… | grep -q` trips
495
+ # pipefail, because grep exits on the first match and SIGPIPEs the producer,
496
+ # so a branch that IS a base reported as if it were not.
464
497
  in_base_heads() {
465
- local want="$1" b
498
+ local dir="$1" want="$2" b
466
499
  while IFS= read -r b; do
467
500
  [[ "$b" == "$want" ]] && return 0
468
- done < <(base_heads)
501
+ done < <(base_heads_in "$dir")
469
502
  return 1
470
503
  }
471
504
 
505
+ # repo_base DIR — the base a PR from DIR targets, for a brand-new branch name.
506
+ repo_base() {
507
+ local dir="$1" v="" r
508
+ if [[ -f "$dir/.sassywares/config.yaml" ]]; then
509
+ v="$(yflat_in "$dir/.sassywares/config.yaml" "git${US}base_map${US}*" 2>/dev/null || true)"
510
+ fi
511
+ [[ -n "$v" ]] || v="$(yflat "repos${US}$(basename "$dir")" 2>/dev/null || true)"
512
+ if [[ -z "$v" ]]; then
513
+ v="$(detect_base "$dir" 2>/dev/null || true)"
514
+ [[ -n "$v" ]] && printf '%s' "$v" && return 0
515
+ fi
516
+ printf '%s' "$v"
517
+ }
518
+
519
+ # classify DIR [HEAD] — on-base, on-branch, detached, or unknown.
520
+ classify() {
521
+ local dir="$1" h="$2"
522
+ if [[ -z "$h" ]]; then printf 'detached'; return 0; fi
523
+ if in_base_heads "$dir" "$h"; then printf 'on-base'; else printf 'on-branch'; fi
524
+ }
525
+
526
+ # start_point DIR HEAD — the upstream ref a new branch is cut from. Prints
527
+ # "none" when there is no upstream ref: cutting from local HEAD would carry
528
+ # commits stranded on the base, which is the failure this prevents.
529
+ start_point() {
530
+ local dir="$1" h="$2" r
531
+ r="$(remote_name "$dir")"
532
+ if [[ -n "$r" && -n "$h" ]] && git -C "$dir" rev-parse --verify --quiet "refs/remotes/$r/$h" >/dev/null 2>&1; then
533
+ printf '%s/%s' "$r" "$h"
534
+ else
535
+ printf 'none'
536
+ fi
537
+ }
538
+
539
+ # unpublished DIR HEAD — commits on the local base that are not upstream.
540
+ # Unknown on error, never 0: an unreadable count must not read as "clean".
541
+ unpublished() {
542
+ local dir="$1" h="$2" r n
543
+ r="$(remote_name "$dir")"
544
+ [[ -n "$r" && -n "$h" ]] || { printf 'unknown'; return 0; }
545
+ git -C "$dir" rev-parse --verify --quiet "refs/remotes/$r/$h" >/dev/null 2>&1 || { printf 'unknown'; return 0; }
546
+ n="$(git -C "$dir" rev-list --count "$r/$h..$h" 2>/dev/null || true)"
547
+ [[ -n "$n" ]] && printf '%s' "$n" || printf 'unknown'
548
+ }
549
+
550
+ # decide STATE POLICY START UNPUBLISHED — the action, fail closed.
551
+ decide() {
552
+ local st="$1" pol="$2" start="$3" unp="$4"
553
+ case "$st:$pol" in
554
+ on-branch:*) printf 'reuse'; return 0 ;;
555
+ on-base:new) ;;
556
+ on-base:current) printf 'commit-here'; return 0 ;;
557
+ detached:new) ;;
558
+ *) printf 'refuse'; return 0 ;;
559
+ esac
560
+ # Cutting needs a known-clean upstream base.
561
+ [[ "$start" == "none" ]] && { printf 'refuse'; return 0; }
562
+ [[ "$unp" == "0" ]] || { printf 'stop-and-ask'; return 0; }
563
+ printf 'create'
564
+ }
565
+
566
+ repo_value="$(yflat "git${US}repo" 2>/dev/null || true)"
567
+
568
+ [[ -n "$note" ]] && printf 'note: %s\n' "$note"
472
569
  printf 'topology: %s\n' "$TOPOLOGY"
473
570
  printf 'config: %s\n' "${CONFIG:-none}"
474
571
  printf 'configured: %s\n' "${configured:-none}"
475
572
  printf 'policy: %s\n' "$policy"
476
573
 
477
- if [[ "$TOPOLOGY" == "none" ]]; then
478
- printf 'state: no-git\naction: refuse\n'
574
+ if [[ "$TOPOLOGY" == "none" || "$repo_value" == "none" ]]; then
575
+ if [[ "$repo_value" == "none" ]]; then
576
+ printf 'state: no-git\naction: refuse\nreason: git.repo is none\n'
577
+ else
578
+ printf 'state: no-git\naction: refuse\nreason: not a git work tree\n'
579
+ fi
479
580
  exit 0
480
581
  fi
481
582
 
482
583
  if [[ "$TOPOLOGY" == "workspace" ]]; then
483
- # The run shares one branch name across every repo, so the decision is made
484
- # once, here, and applied inside each nested repo. A nested repo carrying
485
- # its own commit.branch that disagrees is drift, not an override: it would
486
- # give one run two branch policies.
584
+ # One policy for the whole run, resolved per repo: a run cannot follow two
585
+ # branch policies, so a nested repo that disagrees is reported as drift.
487
586
  while IFS= read -r dir; do
488
587
  [[ -n "$dir" ]] || continue
489
- head="$(git -C "$ROOT/$dir" branch --show-current 2>/dev/null || true)"
490
- if [[ -z "$head" ]]; then
491
- printf 'repo %s: head=detached state=detached\n' "$dir"
492
- elif in_base_heads "$head"; then
493
- printf 'repo %s: head=%s state=on-base\n' "$dir" "$head"
494
- else
495
- printf 'repo %s: head=%s state=on-branch\n' "$dir" "$head"
496
- fi
588
+ d="$ROOT/$dir"
589
+ h="$(git -C "$d" branch --show-current 2>/dev/null || true)"
590
+ st="$(classify "$d" "$h")"
591
+ sp="$(start_point "$d" "$h")"
592
+ base="$(repo_base "$d")"
593
+ if [[ "$st" == "on-base" ]]; then unp="$(unpublished "$d" "$h")"; else unp=n/a; fi
594
+ printf 'repo %s: head=%s state=%s base=%s start-point=%s unpublished=%s action=%s\n' \
595
+ "$dir" "${h:-none}" "$st" "${base:-none}" "$sp" "$unp" "$(decide "$st" "$policy" "$sp" "$unp")"
497
596
  own=""
498
- if [[ -f "$ROOT/$dir/.sassywares/config.yaml" ]]; then
499
- own="$(yflat_in "$ROOT/$dir/.sassywares/config.yaml" "commit${US}branch" 2>/dev/null || true)"
597
+ if [[ -f "$d/.sassywares/config.yaml" ]]; then
598
+ own="$(yflat_in "$d/.sassywares/config.yaml" "commit${US}branch" 2>/dev/null || true)"
500
599
  fi
501
600
  [[ -n "$own" && "$own" != "$policy" ]] && printf 'drift %s: commit.branch=%s\n' "$dir" "$own"
502
601
  done < <(nested_repos)
@@ -504,53 +603,38 @@ if (( BRANCH )); then
504
603
  fi
505
604
 
506
605
  head="${HEAD_ARG:-$(git -C "$ROOT" branch --show-current 2>/dev/null || true)}"
606
+ actual="$(git -C "$ROOT" branch --show-current 2>/dev/null || true)"
607
+ [[ -n "$HEAD_ARG" && "$HEAD_ARG" != "$actual" ]] && printf 'checked-out: %s\n' "${actual:-none}"
507
608
  printf 'head: %s\n' "${head:-none}"
508
609
 
509
- if [[ -z "$head" ]]; then
510
- state=detached
511
- elif in_base_heads "$head"; then
512
- state=on-base
513
- else
514
- state=on-branch
515
- fi
610
+ state="$(classify "$ROOT" "$head")"
516
611
  printf 'state: %s\n' "$state"
517
-
518
- case "$state:$policy" in
519
- on-branch:*) action=reuse ;;
520
- on-base:new) action=create ;;
521
- on-base:current) action=commit-here ;;
522
- detached:new) action=create ;;
523
- *) action=refuse ;;
524
- esac
525
- printf 'action: %s\n' "$action"
526
-
527
- # A new branch starts at the upstream tip, never at local HEAD: commits that
528
- # were stranded on the base earlier must not ride along silently.
529
- if [[ -n "$head" ]] && git -C "$ROOT" rev-parse --verify --quiet "refs/remotes/origin/$head" >/dev/null 2>&1; then
530
- printf 'start-point: origin/%s\n' "$head"
531
- printf 'unpublished: %s\n' "$(git -C "$ROOT" rev-list --count "origin/$head..$head" 2>/dev/null || echo 0)"
532
- else
533
- printf 'start-point: %s\n' "${head:-none}"
534
- printf 'unpublished: unknown\n'
535
- fi
536
-
537
- if [[ "$action" == "create" ]]; then
538
- # The head will be a brand-new name, so only the "*" rung can apply to it.
539
- pr_base="$(yflat "git${US}base_map${US}*" 2>/dev/null || true)"
540
- pr_src=config
612
+ start="$(start_point "$ROOT" "$head")"
613
+ unp=n/a
614
+ [[ "$state" == "on-base" ]] && unp="$(unpublished "$ROOT" "$head")"
615
+ printf 'action: %s\n' "$(decide "$state" "$policy" "$start" "$unp")"
616
+ printf 'start-point: %s\n' "$start"
617
+ printf 'unpublished: %s\n' "$unp"
618
+
619
+ # pr-base is provisional for a branch that does not exist yet: the name the
620
+ # skill picks can match a base_map family (release/*, hotfix/*) and retarget
621
+ # the PR. Pass --name once the name is known to get the final answer.
622
+ pr_src=config
623
+ if [[ -n "$NAME_ARG" ]]; then
624
+ pr_base="$(base_for_head "$NAME_ARG" 2>/dev/null || true)"
625
+ elif [[ "$state" == "on-base" || "$state" == "detached" ]]; then
626
+ pr_base="$(repo_base "$ROOT")"
541
627
  else
542
628
  pr_base="$(base_for_head "$head" 2>/dev/null || true)"
543
- pr_src=config
544
629
  fi
545
630
  if [[ -z "$pr_base" ]]; then
546
- # No config named a base. A probed value is not a decision: the skills must
547
- # confirm it with the user before it becomes a PR target.
548
631
  pr_base="$(detect_base "$ROOT" 2>/dev/null || true)"
549
632
  pr_src=probed
550
633
  fi
551
634
  [[ -n "$pr_base" ]] || pr_src=unknown
552
635
  printf 'pr-base: %s\n' "$pr_base"
553
636
  printf 'pr-base-source: %s\n' "$pr_src"
637
+ printf 'pr-base-final: %s\n' "$( [[ -n "$NAME_ARG" ]] && printf yes || printf no )"
554
638
  exit 0
555
639
  fi
556
640
 
@@ -28,7 +28,7 @@ disable-model-invocation: true
28
28
  4. **Commit or snapshot.** After the review gate — never before it — the docs and any remaining code become durable.
29
29
  - **`git.repo` is a remote.** Dispatch a `worker`: *load the `sassy-commit` skill and follow it with `branch:current`*, naming that the commit must include the plan, any new solutions docs, the concepts file, `AGENTS.md`, and any handbook just written — resolved from `artifacts.*`, never hardcoded. The branch already exists: `sassy-work` or `sassy-lfg` cut it before the first change, so the docs belong on it with the phase commits. It returns the hash and subject.
30
30
 
31
- **Ship never creates the branch.** If this run's head is a base branch with unpublished commits, that is a mis-run — `sassy-work` was supposed to cut before touching code. Stop and ask, naming the branch and the count; never relocate commits silently, never push to the base.
31
+ **Ship never creates the branch.** If this run's head is a base branch with unpublished commits, that is a mis-run — `sassy-work` was supposed to cut before touching code. Ask the commit worker to return `state` and `unpublished` from the `sassy-setup` skill's `scripts/detect.sh --branch`, and stop and ask if they show a base with a non-zero count. Never relocate commits silently, never push to the base.
32
32
  - **`git.repo: workspace`.** Commit code inside each affected repo on the run's shared branch — cut once at the start of the run, `branch:current` from then on — then run `.sassywares/workspace/snapshot`. The snapshot is the workspace's commit and it runs every time, right after approval. A missing or outdated `.sassywares/workspace/snapshot` is a blocker: report it and say to run `sassy-setup`.
33
33
  - **`git.repo: none`.** No commit exists here. Report that the work is not persisted and stop.
34
34
  5. **PR.** In a `remote` repo, dispatch a `worker`: *load the `sassy-commit-push-pr` skill and follow it with `branch:current` and `babysit:off`, forwarding `base:<branch>` when the caller supplied one* (step 6 owns the babysit). It returns the PR URL. In a `workspace`, open one PR per affected repo — the same branch name in each, `--base` from that repo's own `base_map` when it carries a `.sassywares/config.yaml`, else its `repos` entry — and return a table. A touched repo missing from `repos`, or a base that cannot be resolved, is a stop-and-ask, never a guess. `none` opens nothing.
@@ -49,7 +49,7 @@ disable-model-invocation: true
49
49
  | Conditional docs | Compound and handbook are skipped when they do not qualify. |
50
50
  | Docs in the commit | New docs and pointer lines ship in the same change. |
51
51
  | One branch | The branch is cut once, before the first change, by `sassy-work` or `sassy-lfg`. Ship and its docs commit land on it. |
52
- | Never create the branch | Ship never cuts a branch. Finding the run's head on a base branch with unpublished commits is a stop-and-ask. |
52
+ | Never create the branch | Ship never cuts a branch. A head on a base branch with unpublished commits is a stop-and-ask, decided from the commit worker's `state` and `unpublished`. |
53
53
  | Never default | Never push directly to the repo base branch. |
54
54
  | Snapshot after approval | In a workspace, the snapshot runs every time, immediately after the commit approval — never before it, never skipped. |
55
55
  | One PR per run | `pr.babysit` and `pr.merge` are single-PR. A fan-out gets one babysit run per PR and no automatic merge. |
@@ -46,8 +46,8 @@ Always gate before committing. One worker fixes findings, one re-check, then pre
46
46
 
47
47
  1. **Load the phase.** Read the plan and only the selected phase's section. If it is already `Done`, stop and report.
48
48
 
49
- If the phase is `In progress`, an earlier run was interrupted before both gates passed. Ask one question — resume at the gate, or re-run the cycle from scratch — and take the answer. Resuming skips straight to step 5 and does not rebuild the phase.
50
- 2. **Resolve the branch, before touching code.** Follow the `sassy-setup` skill's `references/branching.md` with this run's `branch:` token, if any. On `create`, cut the branch now — not at commit time — so no change ever exists on a base branch. On `reuse`, stay where you are, which is how a handed-off later phase or a resumed run lands on the branch the first phase cut. Carry the branch and its start point into every gate summary you present. Stop and ask when the reference says to.
49
+ If the phase is `In progress`, an earlier run was interrupted before both gates passed. Ask one question — resume at the gate, or re-run the cycle from scratch — and take the answer. Resuming skips the cycle and goes straight to step 5, but **still resolve the branch at step 2 first**: on `reuse` it is a no-op, and on `create` it is the difference between committing on the base and not.
50
+ 2. **Resolve the branch, before touching code.** Follow the `sassy-setup` skill's `references/branching.md` with this run's `branch:` token, if any. On `create`, cut the branch now — not at commit time — so no change ever exists on a base branch. On `reuse`, stay where you are, which is how a handed-off later phase or a resumed run lands on the branch the first phase cut. On `stop-and-ask`, present the choice and wait. Carry the branch and its start point into every gate summary you present.
51
51
  3. **Run the cycle.** Set the phase `In progress` in the Phase tracking table, then load the `sassy-cycle` skill and follow it, with:
52
52
  - work brief = the phase's goal, work items, and "Done when" criteria
53
53
  - intent reference = the plan path and phase
@@ -92,7 +92,7 @@ When the argument is not a plan path, it is one settled brief:
92
92
  | Rule | Requirement |
93
93
  |------|-------------|
94
94
  | Orchestrator only | Delegate all implementation and quality work to `sassy-cycle`. |
95
- | Branch before code | Resolve the branch per `references/branching.md` before the cycle runs, so no change ever lands on a base branch. |
95
+ | Branch before code | Resolve the branch per the `sassy-setup` skill's `references/branching.md` before the cycle runs, so no change ever lands on a base branch. |
96
96
  | One phase per run | Do exactly one phase. Never continue into the next phase in this session. |
97
97
  | Gate before commit and handoff | Do not commit, hand off, or close out until the user approves the code and the phase QA rows pass. |
98
98
  | Commit per phase | Commit the phase, its QA results, and the plan update via `sassy-commit` before handoff or close-out. |