@sassywares/skills 0.5.5 → 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
 
@@ -259,6 +259,14 @@ The fix gate offers four options:
259
259
 
260
260
  The artifact decides QA: a plan document carries manual QA tables that gate phase completion, an in-context brief does not. Gates are deliberate stops; the only way past one is the user's approval. `mode:auto` is accepted only by `sassy-ship` and `sassy-compound`.
261
261
 
262
+ ## Branching
263
+
264
+ `sassy-work`, `sassy-lfg`, and `sassy-debug` decide the branch **before they touch code**, from `commit.branch` (default `new`) compared against `base_map`. On `new`, the run cuts a branch and every commit — phases, docs, ship — lands on it, so no change ever exists on a base branch. A run that is already on a branch that is not a base reuses it, which is how a handed-off phase, a resumed run, or `sassy-lfg` after `sassy-work` converges instead of cutting a second branch.
265
+
266
+ A base is **any `base_map` value**, not just the remote default: in a repo whose `"*"` targets `staging`, standing on `staging` counts as standing on a base. That distinction is the whole point of the key — see [`skills/sassy-setup/references/branching.md`](skills/sassy-setup/references/branching.md).
267
+
268
+ The three skills take `branch:infer|current|new` to override the config for one run: `infer` (default) means the config decides, `new` forces a branch, `current` commits where you stand. `sassy-ship` never creates a branch; finding the head on a base branch with unpublished commits is a stop-and-ask.
269
+
262
270
  ## Repository layout
263
271
 
264
272
  ```text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sassywares/skills",
3
- "version": "0.5.5",
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",
@@ -28,9 +28,11 @@ Treat the reads as a snapshot; re-read branch and staged set right before commit
28
28
 
29
29
  **Never bypass hooks.** No `--no-verify`, and never pass it through to a project script this run invokes. A repo's git hooks are the layer that cannot be skipped, so disabling them silently removes an enforcement the project relies on.
30
30
 
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.
31
+ **Config.** Resolve `.sassywares/config.yaml` (nearest, walking up) per the `sassy-setup` skill's `references/config.md`. `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 `base_map` from the repo's own config when it has one, else `repos[<dir>]` from the workspace config, 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
+ **Who may pass a token.** Precedence is explicit token > session instruction > config > skill default, and a token counts as explicit only when the **caller** supplied it — the user typed it, or an earlier gate of the same run authorized it. A skill must never pass its own default as a token: that promotes a default over the project's config and makes `commit.branch` unreachable. When the entry skill (`sassy-work`, `sassy-lfg`, `sassy-debug`) has already resolved the branch through `sassy-setup`'s `references/branching.md`, it passes `branch:current`, because the branch exists and the work belongs on it.
34
+
35
+ **Branch option.** `branch:current|new [<name>]`. `current` commits on the checked-out branch. `new` cuts a branch from `origin/<base>` — `<base>` is `base_map` from the repo's own config when it has one, else `repos[<dir>]` from the workspace config, else the probed base **confirmed with the user** — named `<name>` when given, else from the change. Use `new` only when no branch has been cut yet; the entry skills cut at the start of a run, so a `new` from them means the cut was skipped. `sassy-commit-push-pr` forwards its own `branch` choice here.
34
36
 
35
37
  **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
38
 
@@ -38,7 +40,12 @@ Treat the reads as a snapshot; re-read branch and staged set right before commit
38
40
 
39
41
  0. **Gather** the context above.
40
42
  1. **Nothing to commit** — if there are no staged, modified, or untracked files, say so and stop.
41
- 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 exists, pick a non-conflicting suffix. With `branch:current` (default), commit on the checked-out branch; if that is the base branch or a detached HEAD, say so in one line before proceeding.
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.
42
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:`.
43
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.
44
51
  5. **Message** — imperative subject naming the outcome, not the file list. Body only when the motivation is not obvious.
@@ -16,7 +16,9 @@ disable-model-invocation: true
16
16
 
17
17
  **Options.** `branch:new|current` selects the branch policy below. `babysit:on|off` (default `on`) controls the closing babysit. `checkpoint` bounds that babysit to a single tick. `merge:on|off` and `delete-branch:on|off` (both default `off`) are forwarded unchanged to `sassy-babysit`.
18
18
 
19
- **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` seeds the branch policy, `pr.*` seeds the babysit options, and `hooks.pr` supplies the `before_create` / `after_create` steps for this run's head.
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
+
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.
20
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
 
@@ -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.** 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`.
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
 
@@ -5,7 +5,7 @@ description: >-
5
5
  through sassy-cycle (fix, review, validate), and finally ship it
6
6
  through sassy-ship after code approval. Never auto-commits. Use when asked to
7
7
  debug or fix a failure.
8
- argument-hint: "[issue reference, error message, test path, or description]"
8
+ argument-hint: "[issue reference, error message, test path, or description] [branch:infer|current|new]"
9
9
  disable-model-invocation: true
10
10
  ---
11
11
 
@@ -15,6 +15,8 @@ Find the root cause of a failure, get approval for the fix, fix it through the s
15
15
 
16
16
  **Done when:** the full causal chain is stated with `file:line` evidence, the fix gate was answered with one of its four options, and either the fix has passed the quality cycle and shipped after code approval, or the run ended at the fix gate.
17
17
 
18
+ **Config.** Resolve `.sassywares/config.yaml` (nearest, walking up) per the `sassy-setup` skill's `references/config.md`. `commit.branch` decides whether the fix needs its own branch; `sassy-work` makes that decision when the fix starts, because the branch belongs to the work and not to the diagnosis.
19
+
18
20
  **Escalate rather than persist:** 2–3 hypotheses exhausted without confirmation, or 3 failed fix attempts, means diagnose *why* instead of trying again. One hypothesis, one change at a time — never shotgun-debug. Before escalating, re-search the prior learnings with the keywords of what you just ruled out: an exhausted hypothesis list is often a documented trap.
19
21
 
20
22
  ## Phase 0 — Triage
@@ -57,7 +59,11 @@ This gate is mandatory even when the original request said "fix the bug". Do not
57
59
 
58
60
  ### Option 1 — Fix it now
59
61
 
60
- Load the `sassy-work` skill with the fix brief as the work brief and the diagnosis from Phase 1 as the intent reference. Work runs one cycle, presents its own code approval gate, commits, and stops before ship. Continue to Phase 3.
62
+ Load the `sassy-work` skill with the fix brief as the work brief and the diagnosis from Phase 1 as the intent reference, forwarding this run's `branch:` token if it was given. Work runs one cycle, presents its own code approval gate, commits, and stops before ship.
63
+
64
+ The branch decision belongs to work, not to this phase: diagnosis writes nothing, so cutting a branch before the fix gate would leave an empty branch every time the gate ends in *Write a plan*, *Hand off*, or *Stop here*. Work resolves it per `sassy-setup`'s `references/branching.md` before it touches code, so the fix is on its own branch from the first edit and still never lands on a base branch.
65
+
66
+ Continue to Phase 3.
61
67
 
62
68
  ### Option 2 — Write a plan
63
69
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Execute a whole sassy plan phase by phase, or one settled in-context brief,
5
5
  then close out via sassy-ship after code approval. The orchestrator only
6
6
  coordinates. Best for small plans that one context can hold.
7
- argument-hint: "<plan path | brief>"
7
+ argument-hint: "<plan path | brief> [branch:infer|current|new]"
8
8
  disable-model-invocation: true
9
9
  ---
10
10
 
@@ -14,7 +14,7 @@ disable-model-invocation: true
14
14
 
15
15
  **The orchestrator does not do work.** Read the plan, dispatch `sassy-cycle` per phase, then `sassy-ship`. Never edit application code. Keep the main context small — all heavy work happens in subagents.
16
16
 
17
- **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. Plans live under `artifacts.plans`; `git.repo` decides how the close-out persists.
17
+ **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. Plans live under `artifacts.plans`; `git.repo` decides how the close-out persists; `commit.branch` decides whether the run needs its own branch.
18
18
 
19
19
  ## Input
20
20
 
@@ -38,18 +38,17 @@ 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. **Run every phase, in order.** For each phase not `Done`:
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
+ 3. **Run every phase, in order.** For each phase not `Done`:
42
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.
43
44
  - On `Status: done` and `Validator: Conforms`, leave the phase `In progress` — its QA rows are still owed, and only the gate closes them.
44
45
  - On `Status: blocked`, mark it `Blocked` and stop the run.
45
- 3. **The gate.** After the last phase, report: the plan path, each phase's status, what changed per phase, and the validator/reviewer summaries. Then give every phase's QA checklist verbatim — row ID, check, how to run it, what passing looks like — with unreported rows `Pending`. Ask for both decisions: code approval and the QA result per row. Wait. Do not commit.
46
- 4. **Resolve the gate.** Three outcomes:
46
+ 4. **The gate.** After the last phase, report: the plan path, each phase's status, what changed per phase, and the validator/reviewer summaries. Then give every phase's QA checklist verbatim — row ID, check, how to run it, what passing looks like — with unreported rows `Pending`. Ask for both decisions: code approval and the QA result per row. Wait. Do not commit.
47
+ 5. **Resolve the gate.** Three outcomes:
47
48
  - **Code approved and every QA row passes** → mark the rows `Done` with the user's notes, mark the phases `Done`, then run the close-out below.
48
49
  - **Code approved, QA still pending** → tick nothing, mark nothing `Done`, commit nothing, ship nothing. Report which rows are outstanding and how to resume (`/skill:sassy-lfg <plan>`), and stop.
49
50
  - **Any QA row fails** → run one more `sassy-cycle` on that phase with the failing rows as the intent reference, then present the gate again. A row that still fails is `Blocked` with the reason and stops the run.
50
- 5. **Close-out.** On both gates passing, load the `sassy-ship` skill and follow it. Do only the close-out steps the user approves. Babysit options (`merge:on|off`, `delete-branch:on|off`) are forwarded through `sassy-ship` to `sassy-babysit`.
51
-
52
- Work commits to the current branch. `sassy-ship` creates the shipping branch at close-out when the checked-out branch is the repo default.
51
+ 6. **Close-out.** On both gates passing, load the `sassy-ship` skill and follow it. Do only the close-out steps the user approves. Babysit options (`merge:on|off`, `delete-branch:on|off`) are forwarded through `sassy-ship` to `sassy-babysit`. The branch from step 2 already carries the work, so ship commits and opens the PR on it; ship never creates the branch.
53
52
 
54
53
  ## Brief constraints
55
54
 
@@ -63,15 +62,17 @@ Every worker brief this run dispatches must include:
63
62
 
64
63
  When the argument is not a plan path, the brief is the one phase:
65
64
 
66
- 1. 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
- 2. Present the code approval gate with no QA checklist, and wait.
68
- 3. On code approval, run `sassy-ship`. Do not run `sassy-plan` or write a plan file.
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
+ 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
+ 3. Present the code approval gate with no QA checklist, and wait.
68
+ 4. On code approval, run `sassy-ship`. Do not run `sassy-plan` or write a plan file.
69
69
 
70
70
  ## Hard rules
71
71
 
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 the `sassy-setup` skill's `references/branching.md` before any phase runs, so no change lands on a base branch. |
75
76
  | Phases in plan order | Phases run one after another, in plan order. |
76
77
  | Done needs both gates | Mark a phase `Done` only when the user approves the code and every QA row for it is `Done`. |
77
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. |
@@ -27,6 +27,7 @@ disable-model-invocation: true
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
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
+ - **`commit.branch`** — default `new`: the run cuts a branch before the first change and never commits on a base branch. Offer `current` for a repo that genuinely wants work committed where it stands, and say plainly that `current` lets a run commit onto its base branch.
30
31
  - **`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
32
  - **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
33
  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.
@@ -0,0 +1,132 @@
1
+ # Branching
2
+
3
+ One branch decision per run, made **before** the first change. `sassy-work`,
4
+ `sassy-lfg`, and `sassy-debug` all resolve it here, through one detector, so the
5
+ three cannot drift apart the way three hand-copied paragraphs would.
6
+
7
+ ## Resolve
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
+
13
+ ```bash
14
+ bash scripts/detect.sh --branch [--policy infer|current|new] [--name BRANCH]
15
+ ```
16
+
17
+ `--policy` is the `branch:` token: `infer` (the default), `current`, or `new`.
18
+ Omit it to let `commit.branch` decide; `branch:infer` means exactly that and is
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`.
24
+
25
+ ## Output — repo topology
26
+
27
+ | Key | Meaning |
28
+ |---|---|
29
+ | `note` | present only when the config is unusable, e.g. a bad `commit.branch`; it names the fallback taken |
30
+ | `topology` | `repo`, `workspace`, or `none` |
31
+ | `config` | the config file in force, or `none` |
32
+ | `configured` | `commit.branch` from the config (after validation), or `none` |
33
+ | `policy` | what actually applies: the token, else the config, else `new` |
34
+ | `checked-out` | printed only with `--head`, when you asked about a branch you are not on |
35
+ | `head` | the branch being decided for |
36
+ | `state` | `on-base`, `on-branch`, `detached`, or `no-git` |
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` |
40
+ | `pr-base` | the base a PR from this run targets |
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` |
43
+
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.
47
+
48
+ ## Token authorship
49
+
50
+ Pass `--policy` only when the **caller** asked for it — the user typed
51
+ `branch:new`, or the run's own gate summary already recorded the decision.
52
+
53
+ Never pass a token that is merely this skill's own default. Doing that launders
54
+ a default into "explicit token", which outranks the project's config and makes
55
+ `commit.branch` unreachable. Omitting `--policy` is how a skill defers to
56
+ config. This applies to every token these skills forward, not just `branch`.
57
+
58
+ ## Act on `action`
59
+
60
+ ### `create`
61
+
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,
69
+ so a retried run converges on the same name instead of forking `-2`.
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`;
75
+ stop and ask when it has diverged.
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`.
79
+
80
+ ### `reuse`
81
+
82
+ Report `head` and carry on. Do **not** re-cut because `commit.branch` is `new`:
83
+ a second phase, a resumed run, or a handed-off session is already standing on
84
+ the branch the first one cut. That is the whole reason this is inferred rather
85
+ than forced.
86
+
87
+ ### `commit-here`
88
+
89
+ Only reachable with `policy: current`, which is an explicit statement that
90
+ committing on the base branch is intended. Proceed, and say in the run's gate
91
+ summary that the commits are landing on `<head>` and that `pr-base` is
92
+ `<pr-base>`. Never infer this from a skill default.
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
+
100
+ ### `refuse`
101
+
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.
105
+
106
+ ## Workspace
107
+
108
+ Resolve once at the workspace root; the run uses **one branch name across every
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.
122
+
123
+ ## After the branch exists
124
+
125
+ Downstream steps forward `branch:current` to `sassy-commit`, because the decision
126
+ has already been made and the commits belong on that branch. `sassy-ship` never
127
+ creates the branch — by the time it runs, the branch carries the work. If it ever
128
+ finds the run's head on a base branch with unpublished commits, that is a
129
+ mis-run: stop and ask, never relocate commits silently.
130
+
131
+ Report the branch and its start point in every gate summary. A decision the user
132
+ cannot see is a decision they cannot correct.
@@ -25,6 +25,21 @@ 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.** 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.
42
+
28
43
  Read it once per run. An orchestrator resolves every config-backed value and
29
44
  passes the resolved values into subagent briefs; subagents never re-read the
30
45
  file. A skill invoked directly resolves it itself.
@@ -50,7 +65,7 @@ git:
50
65
  repos: # workspace only: sibling dir -> PR base
51
66
  fe: dev
52
67
  commit:
53
- branch: current # current | new
68
+ branch: new # current | new
54
69
  pr:
55
70
  babysit: true
56
71
  merge: false
@@ -76,7 +91,7 @@ hooks:
76
91
  | `git.remote` | mirror of `git.repo` | Workspace only: mirror remote (URL or `owner/name`) |
77
92
  | `git.mirror_branch` | `main` | Workspace only: the branch the mirror pushes to |
78
93
  | `repos` | `{}` | Workspace only: repo dir name → the base its PRs target |
79
- | `commit.branch` | `current` | `current` commits where you are; `new` cuts a branch |
94
+ | `commit.branch` | `new` | Resolved once per run, before the first change. `new` never commits on a base branch: `sassy-work`, `sassy-lfg`, and `sassy-debug` cut a branch first. `current` commits where you stand, base branch included |
80
95
  | `pr.babysit` | `true` | Whether close-out babysits the PR it opened |
81
96
  | `pr.merge` | `false` | `true` authorizes merging a single babysat PR |
82
97
  | `pr.delete_branch` | `false` | Delete the head branch once merged |
@@ -143,6 +158,35 @@ without asking, so the version line is how staleness surfaces.
143
158
  In `workspace` and `none`, changes still land on disk. Nothing is silently
144
159
  dropped; the refusal names why and what to run instead.
145
160
 
161
+ ## `commit.branch` — decide once, before code
162
+
163
+ Resolved once per run, by the entry skill (`sassy-work`, `sassy-lfg`,
164
+ `sassy-debug`), **before the first change**, through
165
+ `sassy-setup/references/branching.md`. Not at commit time: committing first and
166
+ re-branching later leaves the commits on a base branch, and cutting the new
167
+ branch from `origin/<base>` then leaves them behind entirely.
168
+
169
+ | Value | Meaning |
170
+ |-------|---------|
171
+ | `current` | Commit where you stand, even on a base branch. An explicit statement that this repo's work in progress is acceptable |
172
+ | `new` (default) | Never commit on a base branch. Cut one first, then stay on it for the rest of the run |
173
+
174
+ A branch that is already checked out and is **not** a base is reused, whatever
175
+ the config says. That is what makes a second phase, a resumed run, or a
176
+ handed-off session converge on the branch the first one cut instead of cutting
177
+ another.
178
+
179
+ A base is **any `base_map` value**, plus the remote default — not the remote
180
+ default alone. In the config above `staging` is a base because `"*"` targets it,
181
+ even though `origin/HEAD` is `main`. Comparing only against `origin/HEAD` is how
182
+ phase work once got committed straight onto `staging`.
183
+
184
+ The `branch:infer|current|new` token on those skills overrides this key for one
185
+ run; `infer` means this key decides. In a workspace the key is resolved from the
186
+ workspace config for the whole run, one branch name in every repo; a nested
187
+ repo whose own `commit.branch` disagrees is drift, because one run cannot follow
188
+ two policies.
189
+
146
190
  ## Resolution chains
147
191
 
148
192
  **Effective repo.** Inside a git work tree, the nearest toplevel is the repo —
@@ -186,9 +230,10 @@ config to. When a repo has both, the nested config wins and `--check` reports
186
230
  the disagreement rather than picking silently. A nested config needs no ladder
187
231
  of its own unless its PRs target more than one branch.
188
232
 
189
- **Branch name**, one per run. Derived once from the plan slug or topic before the
190
- first commit, then passed to every repo. Multi-repo runs pass
191
- `branch:new <name>`; a per-repo derived name would diverge.
233
+ **Branch name**, one per run. Derived once from the plan slug or topic when the
234
+ run starts, then used in every repo. The branch is cut at the start of the run,
235
+ not at commit time, so every repo sits on it before any change exists; each
236
+ repo cuts from its own upstream base and reuses the name in the others.
192
237
 
193
238
  Cut from the base, then verify:
194
239
 
@@ -7,12 +7,22 @@
7
7
  # Usage:
8
8
  # detect.sh [--root DIR] print a proposed config to stdout
9
9
  # detect.sh [--head BRANCH] resolve the base and hooks for one head
10
+ # detect.sh --branch [--policy P] resolve the branch decision for this run
10
11
  # detect.sh [--root DIR] --check compare the existing config to reality
12
+ #
13
+ # --policy takes infer|current|new: the skill-level token. infer (the default)
14
+ # defers to commit.branch in the config, and new is the fallback when the config
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.
11
18
  set -euo pipefail
12
19
 
13
20
  ROOT_ARG=""
14
21
  HEAD_ARG=""
22
+ POLICY_ARG=""
23
+ NAME_ARG=""
15
24
  CHECK=0
25
+ BRANCH=0
16
26
 
17
27
  while (( $# )); do
18
28
  case "$1" in
@@ -20,8 +30,13 @@ while (( $# )); do
20
30
  --root=*) ROOT_ARG="${1#*=}" ;;
21
31
  --head) shift; [[ $# -ge 1 ]] || { echo "missing BRANCH after --head" >&2; exit 2; }; HEAD_ARG="$1" ;;
22
32
  --head=*) HEAD_ARG="${1#*=}" ;;
33
+ --policy) shift; [[ $# -ge 1 ]] || { echo "missing POLICY after --policy" >&2; exit 2; }; POLICY_ARG="$1" ;;
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#*=}" ;;
37
+ --branch) BRANCH=1 ;;
23
38
  --check) CHECK=1 ;;
24
- -h|--help) sed -n '2,10p' "${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 ;;
25
40
  *) echo "unknown argument: $1" >&2; exit 2 ;;
26
41
  esac
27
42
  shift
@@ -29,6 +44,9 @@ done
29
44
 
30
45
  START="${ROOT_ARG:-$PWD}"
31
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; }
32
50
  # Absolute, or the walk-up loop below re-reads "." forever.
33
51
  START="$(cd "$START" && pwd -P)"
34
52
 
@@ -409,11 +427,217 @@ if (( CHECK )); then
409
427
  [[ -n "$val" ]] && note " artifacts.$key: $val"
410
428
  done
411
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
+
412
440
  if (( drift )); then exit 1; fi
413
441
  note " config matches the tree"
414
442
  exit 0
415
443
  fi
416
444
 
445
+ # --- Branch decision -------------------------------------------------------
446
+ # Read-only. Resolves commit.branch into the single decision the entry skills
447
+ # (sassy-work, sassy-lfg, sassy-debug) make before they start: cut a branch,
448
+ # reuse the one checked out, or commit where you stand.
449
+ #
450
+ # Every base_map VALUE counts as a base branch, not just the remote default.
451
+ # tap2's day-to-day base is `staging`, so comparing only against origin/HEAD
452
+ # declared staging "not a base" and let phase work commit onto it.
453
+ if (( BRANCH )); then
454
+ policy="${POLICY_ARG:-infer}"
455
+ case "$policy" in
456
+ infer|current|new) ;;
457
+ *) echo "unknown --policy: $policy (expected infer, current, or new)" >&2; exit 2 ;;
458
+ esac
459
+ configured="$(yflat "commit${US}branch" 2>/dev/null || 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)"
486
+ [[ -n "$v" ]] && out="$out"$'\n'"$v"
487
+ fi
488
+ out="$(printf '%s\n' "$out" | grep -v '^$' | sort -u || true)"
489
+ # Probing through gh costs a network call, so only when nothing else said.
490
+ [[ -n "$out" ]] || out="$(detect_base "$dir" 2>/dev/null || true)"
491
+ printf '%s\n' "$out" | grep -v '^$' || true
492
+ }
493
+
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.
497
+ in_base_heads() {
498
+ local dir="$1" want="$2" b
499
+ while IFS= read -r b; do
500
+ [[ "$b" == "$want" ]] && return 0
501
+ done < <(base_heads_in "$dir")
502
+ return 1
503
+ }
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"
569
+ printf 'topology: %s\n' "$TOPOLOGY"
570
+ printf 'config: %s\n' "${CONFIG:-none}"
571
+ printf 'configured: %s\n' "${configured:-none}"
572
+ printf 'policy: %s\n' "$policy"
573
+
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
580
+ exit 0
581
+ fi
582
+
583
+ if [[ "$TOPOLOGY" == "workspace" ]]; then
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.
586
+ while IFS= read -r dir; do
587
+ [[ -n "$dir" ]] || continue
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")"
596
+ own=""
597
+ if [[ -f "$d/.sassywares/config.yaml" ]]; then
598
+ own="$(yflat_in "$d/.sassywares/config.yaml" "commit${US}branch" 2>/dev/null || true)"
599
+ fi
600
+ [[ -n "$own" && "$own" != "$policy" ]] && printf 'drift %s: commit.branch=%s\n' "$dir" "$own"
601
+ done < <(nested_repos)
602
+ exit 0
603
+ fi
604
+
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}"
608
+ printf 'head: %s\n' "${head:-none}"
609
+
610
+ state="$(classify "$ROOT" "$head")"
611
+ printf 'state: %s\n' "$state"
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")"
627
+ else
628
+ pr_base="$(base_for_head "$head" 2>/dev/null || true)"
629
+ fi
630
+ if [[ -z "$pr_base" ]]; then
631
+ pr_base="$(detect_base "$ROOT" 2>/dev/null || true)"
632
+ pr_src=probed
633
+ fi
634
+ [[ -n "$pr_base" ]] || pr_src=unknown
635
+ printf 'pr-base: %s\n' "$pr_base"
636
+ printf 'pr-base-source: %s\n' "$pr_src"
637
+ printf 'pr-base-final: %s\n' "$( [[ -n "$NAME_ARG" ]] && printf yes || printf no )"
638
+ exit 0
639
+ fi
640
+
417
641
  # --- Emit a proposal -------------------------------------------------------
418
642
  # Every key carries its meaning and its allowed values. The file is the
419
643
  # documentation a teammate reads, so the comments are part of the output, not
@@ -532,12 +756,12 @@ esac
532
756
  cat <<'EOF'
533
757
 
534
758
  # --- Commit ---------------------------------------------------------------
535
- # current: commit on the checked-out branch.
536
- # new: cut a branch from the base map for the current head; name it in multi-repo runs so
537
- # every repo gets the same branch name.
759
+ # current: commit on the checked-out branch, even on a base branch.
760
+ # new: never commit on a base branch — sassy-work, sassy-lfg, and sassy-debug
761
+ # cut a branch at the start of the run and stay on it.
538
762
  commit:
539
763
  EOF
540
- kv branch current 34 '# current | new'
764
+ kv branch new 34 '# current | new'
541
765
  cat <<'EOF'
542
766
 
543
767
  # --- Pull requests --------------------------------------------------------
@@ -26,8 +26,10 @@ disable-model-invocation: true
26
26
  2. **Compound (conditional).** If the work produced a qualifying learning, dispatch a `worker` whose task is: *load the `sassy-compound` skill and follow it with `mode:auto`* — with a compact learnings brief (the solved problems, root causes, and solutions taken from the cycle results, not from re-reading the tree). It always gets `mode:auto` because ship's own gate already covered the approval. Skip cleanly when nothing qualifies, and skip entirely when `knowledge.write` is `false`.
27
27
  3. **Handbook (conditional).** If the feature is self-contained and independent enough that a teammate would benefit from a handover, dispatch a `worker` whose task is: *load the `sassy-handbook` skill and follow it* — with the plan path and topic. The worker reads the tree itself. Skip for small or tightly-coupled changes.
28
28
  4. **Commit or snapshot.** After the review gate — never before it — the docs and any remaining code become durable.
29
- - **`git.repo` is a remote.** Dispatch a `worker`: *load the `sassy-commit` skill and follow it*, 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. Use `branch:current` when already on a feature branch, so the docs land with the phase commits. When the checked-out branch is the base branch, use `branch:new <name>` so the branch is created from `origin/<base>` and carries every phase commit; a workspace run passes the run's shared branch name. Never push directly to the base branch. It returns the hash and subject.
30
- - **`git.repo: workspace`.** Commit code inside each affected repo on the run's shared branch (cut once on the run's first commit, `branch:current` after), 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`.
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
+
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
+ - **`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`.
31
33
  - **`git.repo: none`.** No commit exists here. Report that the work is not persisted and stop.
32
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.
33
35
 
@@ -46,7 +48,8 @@ disable-model-invocation: true
46
48
  | Delegate everything | The orchestrator runs no git, gh, or file work itself. |
47
49
  | Conditional docs | Compound and handbook are skipped when they do not qualify. |
48
50
  | Docs in the commit | New docs and pointer lines ship in the same change. |
49
- | One branch | Docs land on the current feature branch; from the base branch, ship creates a feature branch so every phase commit moves with it. |
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. A head on a base branch with unpublished commits is a stop-and-ask, decided from the commit worker's `state` and `unpublished`. |
50
53
  | Never default | Never push directly to the repo base branch. |
51
54
  | Snapshot after approval | In a workspace, the snapshot runs every time, immediately after the commit approval — never before it, never skipped. |
52
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. |
@@ -5,7 +5,7 @@ description: >-
5
5
  hand off to a fresh session for the next phase (or close out via sassy-ship if
6
6
  it was the last). Also runs one settled in-context brief and stops before ship.
7
7
  The orchestrator only coordinates.
8
- argument-hint: "<plan path | brief> [phase:<n>]"
8
+ argument-hint: "<plan path | brief> [phase:<n>] [branch:infer|current|new]"
9
9
  disable-model-invocation: true
10
10
  ---
11
11
 
@@ -17,7 +17,7 @@ disable-model-invocation: true
17
17
 
18
18
  **The orchestrator does not do work.** Read the plan, dispatch `sassy-cycle`, apply its result, update the plan's tracking table, dispatch `sassy-commit` or the snapshot, and report. Never edit application code.
19
19
 
20
- **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. `git.repo` decides what the phase commit does, `artifacts.plans` decides where the plan lives.
20
+ **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. `git.repo` decides what the phase commit does, `artifacts.plans` decides where the plan lives, and `commit.branch` decides whether this run needs its own branch.
21
21
 
22
22
  ## Input
23
23
 
@@ -46,26 +46,25 @@ 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 4 and does not rebuild the phase.
50
- 2. **Run the cycle.** Set the phase `In progress` in the Phase tracking table, then load the `sassy-cycle` skill and follow it, with:
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
+ 3. **Run the cycle.** Set the phase `In progress` in the Phase tracking table, then load the `sassy-cycle` skill and follow it, with:
51
52
  - work brief = the phase's goal, work items, and "Done when" criteria
52
53
  - intent reference = the plan path and phase
53
- 3. **Evaluate the phase.** On `Status: done` with `Validator: Conforms` and no critical review findings, note that the code is ready but leave the phase `In progress` — both gates must pass before it closes. On `Status: blocked`, set it `Blocked` in the Phase tracking table and stop; a blocked phase is a real outcome and needs no approval.
54
- 4. **Present the phase gate.** Report the phase, what changed, findings raised/resolved, the validator grade, and the reviewer summary. Then give the QA checklist verbatim: each row's ID, check, how to run it, and what passing looks like, with unreported rows `Pending`. Ask for both decisions — code approval and the QA result per row. Wait.
55
- 5. **Resolve the gate.** Three outcomes, nothing in between:
56
- - **Code approved and every QA row passes** → go to step 6.
54
+ 4. **Evaluate the phase.** On `Status: done` with `Validator: Conforms` and no critical review findings, note that the code is ready but leave the phase `In progress` — both gates must pass before it closes. On `Status: blocked`, set it `Blocked` in the Phase tracking table and stop; a blocked phase is a real outcome and needs no approval.
55
+ 5. **Present the phase gate.** Report the phase, what changed, findings raised/resolved, the validator grade, and the reviewer summary. Then give the QA checklist verbatim: each row's ID, check, how to run it, and what passing looks like, with unreported rows `Pending`. Ask for both decisions — code approval and the QA result per row. Wait.
56
+ 6. **Resolve the gate.** Three outcomes, nothing in between:
57
+ - **Code approved and every QA row passes** → go to step 7.
57
58
  - **Code approved, QA still pending** → record that code is approved but leave the phase `In progress`, tick nothing, commit nothing, hand off nothing, and report that the phase is waiting on manual QA plus the command that resumes it.
58
59
  - **Any QA row fails** → run `sassy-cycle` once more on the same phase, with the failing rows as the intent reference and a work brief that fixes exactly those failures. Then present the gate again. If a row still fails after that one pass, mark it `Blocked` with the reason and stop.
59
- 6. **Commit the phase.** On both gates passing, first mark the phase's QA rows `Done` with the user's notes and the phase `Done` in the Phase tracking table, then persist according to `git.repo`:
60
+ 7. **Commit the phase.** On both gates passing, first mark the phase's QA rows `Done` with the user's notes and the phase `Done` in the Phase tracking table, then persist according to `git.repo`:
60
61
 
61
- - **A remote** — dispatch a `worker` whose task is: *load the `sassy-commit` skill and follow it with `branch:current`*, naming that the commit must include the phase's code changes, the QA table results, and the now-updated Phase tracking table. It returns the hash and subject.
62
- - **`workspace`** — dispatch a `worker` per affected repo to commit that repo's code on the run's shared branch (cut once on the run's first commit with `branch:new <name>`, `branch:current` after), then run `.sassywares/workspace/snapshot` for the plan, QA results, and docs at the root. The gate's approval is the commit approval; the snapshot runs right after it and is never skipped. A missing or outdated `.sassywares/workspace/snapshot` is a blocker — say to run `sassy-setup`.
62
+ - **A remote** — dispatch a `worker` whose task is: *load the `sassy-commit` skill and follow it with `branch:current`*, naming that the commit must include the phase's code changes, the QA table results, and the now-updated Phase tracking table. It returns the hash and subject. The branch already exists: step 2 cut it, so committing on it is the point of `branch:current` here.
63
+ - **`workspace`** — dispatch a `worker` per affected repo to commit that repo's code on the run's shared branch, then run `.sassywares/workspace/snapshot` for the plan, QA results, and docs at the root. The gate's approval is the commit approval; the snapshot runs right after it and is never skipped. A missing or outdated `.sassywares/workspace/snapshot` is a blocker — say to run `sassy-setup`.
63
64
  - **`none`** — nothing is persisted. Report that the phase's work is uncommitted and stop; never hand off on top of it.
64
65
 
65
- Work commits on the current branch. `sassy-ship` creates the shipping branch at close-out when the checked-out branch is the repo base.
66
-
67
66
  Do not hand off or close out until the commit or snapshot succeeds.
68
- 7. **On commit success:**
67
+ 8. **On commit success:**
69
68
  - **A next phase exists** → hand off so it starts in a fresh session:
70
69
 
71
70
  ```text
@@ -82,16 +81,18 @@ Always gate before committing. One worker fixes findings, one re-check, then pre
82
81
 
83
82
  When the argument is not a plan path, it is one settled brief:
84
83
 
85
- 1. 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 cycles.
86
- 2. Present the code approval gate with no QA checklist, and wait.
87
- 3. On code approval, persist the brief's changes the same way the plan path does: a `sassy-commit` delegation with `branch:current` in a remote repo, or the workspace snapshot in a workspace.
88
- 4. Stop and report. Shipping is a separate step (`sassy-ship`), or `sassy-lfg` would have taken it all the way. Never create or update a plan file.
84
+ 1. Resolve the branch exactly as the plan path does — `references/branching.md`, this run's `branch:` token — before the cycle runs.
85
+ 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 cycles.
86
+ 3. Present the code approval gate with no QA checklist, and wait.
87
+ 4. On code approval, persist the brief's changes the same way the plan path does: a `sassy-commit` delegation with `branch:current` in a remote repo, or the workspace snapshot in a workspace.
88
+ 5. Stop and report. Shipping is a separate step (`sassy-ship`), or `sassy-lfg` would have taken it all the way. Never create or update a plan file.
89
89
 
90
90
  ## Hard rules
91
91
 
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 the `sassy-setup` skill's `references/branching.md` before the cycle runs, so no change ever lands on a base branch. |
95
96
  | One phase per run | Do exactly one phase. Never continue into the next phase in this session. |
96
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. |
97
98
  | Commit per phase | Commit the phase, its QA results, and the plan update via `sassy-commit` before handoff or close-out. |