@sassywares/skills 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -271,52 +271,40 @@ sassy-skills/
271
271
 
272
272
  ## Installation
273
273
 
274
- The suite ships as the npm package **`@sassywares/skills`**. Pin it, don't symlink it into a global directory — the version in your lockfile is the pin, and upgrading is one command.
275
-
276
- ### As a project dependency (recommended for teams)
274
+ The suite installs from this repository with the open `skills` CLI. That CLI vendors the skill files into your project and pins them, so a clone needs no network and every checkout runs the same skills.
277
275
 
278
276
  ```bash
279
- bun add -d @sassywares/skills
277
+ npx skills add sassywares/sassy-skills
280
278
  ```
281
279
 
282
- Nothing scans `node_modules` on its own, so give your harness the directory it does read:
280
+ That writes three things:
283
281
 
284
- ```bash
285
- mkdir -p .agents/skills
286
- for d in node_modules/@sassywares/skills/skills/*/; do
287
- ln -sfn "../../$d" ".agents/skills/$(basename "$d")"
288
- done
289
- ```
282
+ - `.agents/skills/sassy-*` — the skill files themselves, read by pi and Cursor
283
+ - `.claude/skills/sassy-*` — links for Claude Code, which reads only its own directory
284
+ - `skills-lock.json` — source and content hash per skill
290
285
 
291
- `.agents/skills/` is read by Cursor and pi. Claude Code reads `.claude/skills/` only, so add one directory symlink:
286
+ Commit the vendored skills and the lock; ignore `.claude/skills`, which is a link created at install time.
292
287
 
293
288
  ```bash
294
- mkdir -p .claude && ln -sfn ../.agents/skills .claude/skills
289
+ npx skills update -y # pull the latest from this repo
290
+ npx skills experimental_install # restore exactly what the lock pins
291
+ npx skills remove # take the suite out again
295
292
  ```
296
293
 
297
- Ignore both: they're derived from the lockfile, like any build output. A `postinstall` that runs the loop gives teammates zero setup beyond `bun install`; a preflight in the skills tells anyone who skipped it what to run.
294
+ `sassy-setup` does all of this for you, including the ignore line and the two package scripts.
298
295
 
299
- ### As a pi package
300
-
301
- pi resolves packages directly, so no links are needed:
296
+ ### While editing the suite
302
297
 
303
- ```bash
304
- pi install npm:@sassywares/skills@0.1.0 # user-level, all projects
305
- pi install npm:@sassywares/skills@0.1.0 --local # project-level → commit .pi/settings.json
306
- ```
298
+ `.pi/settings.json` in this repo declares `./skills`, so pi loads your working copy directly. Consumers see a change once it is pushed to `main` and they run `npx skills update` — no release step in between.
307
299
 
308
- ### While editing the suite
300
+ ### Also published as a package
309
301
 
310
- Point a consumer at your checkout instead of a published version:
302
+ `@sassywares/skills` is on npm for harnesses that install packages rather than skill directories:
311
303
 
312
304
  ```bash
313
- bun link @sassywares/skills
305
+ pi install npm:@sassywares/skills@0.4.0
314
306
  ```
315
307
 
316
- Inside this repo, `.pi/settings.json` already declares `./skills`, so pi loads the working copy directly.
317
-
318
- Get the skills however they arrive — package, link, or vendored copy — and invoke them with your harness's skill syntax.
319
-
320
308
  ## Repository maintenance
321
309
 
322
310
  `scripts/sync.sh` commits skill changes, rebases on `origin/main`, and pushes. Any agent that changes a skill must run it immediately, without asking for confirmation. It installs nothing anywhere — a change reaches consumers when a version is released and they bump their pin. `AGENTS.md` documents the workflow for agents working on this repo.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sassywares/skills",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Lean, explicit-invocation Agent Skills for shipping fast: plan, work, review, validate, ship.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,7 +24,6 @@
24
24
  ],
25
25
  "files": [
26
26
  "skills",
27
- "bin",
28
27
  "README.md",
29
28
  "CONCEPTS.md",
30
29
  "LICENSE"
@@ -37,7 +36,7 @@
37
36
  "./skills"
38
37
  ]
39
38
  },
40
- "bin": {
41
- "sassy-skills": "./bin/sassy-skills.mjs"
39
+ "engines": {
40
+ "node": ">=18"
42
41
  }
43
42
  }
@@ -50,9 +50,11 @@ Stop when any holds:
50
50
  - **Terminal** — merged or closed.
51
51
  - **Looks ready** — `MERGEABLE`, `CLEAN`, all checks terminal, no open review threads, no currency blocker, zero `needs-human` items. With `merge:on`, run the merge (and `delete-branch:on` branch deletion if set) before treating this as a stop; the merge turns it into the **Terminal** stop.
52
52
 
53
- **Merge hooks.** Immediately before merging, run `hooks.pr.before_merge` in declared order — one `worker` per hook, brief carrying the PR URL and head SHA. A missing skill or a `blocked` result means **do not merge**: report it and stop at Blocked. After a successful merge, run `hooks.pr.after_merge` the same way; these follow the merge, so they must leave the tree clean. In a workspace fan-out, everything is per PR.
53
+ **Merge hooks.** Immediately before merging, run `hooks.pr.before_merge` in declared order — one `worker` per hook, with the same brief every hook gets: plan path, changed files, artifact paths, base, branch, commit hash, and the PR URL plus head SHA. These run before the merge, so they must leave the tree clean. A missing skill or a `blocked` result means **do not merge**: report it and stop at Blocked. After a successful merge, run `hooks.pr.after_merge` the same way; these follow the merge, so they must leave the tree clean. In a workspace fan-out, everything is per PR.
54
54
 
55
- **Inert hooks.** With merging off, `before_merge` and `after_merge` cannot fire. Say so in one line — "not merging this run, merge hooks skipped" — rather than letting a declared hook vanish silently.
55
+ **Which merge hook fires.** `before_merge` needs a merge this run will perform. `after_merge` fires whenever this run observes the PR merged, including one that was already merged when the run started — so it must be idempotent.
56
+
57
+ **Inert hooks.** With merging off, `before_merge` never fires; say so. Say so in one line — "not merging this run, merge hooks skipped" — rather than letting a declared hook vanish silently.
56
58
  - **Blocked** — a `needs-human` item or a blocked-external dependency remains.
57
59
  - **Budget** — the requested duration or a 3-day backstop has elapsed.
58
60
 
@@ -30,7 +30,7 @@ Treat the reads as a snapshot; re-read branch and staged set right before commit
30
30
 
31
31
  **Config.** Resolve `.sassywares/config.yaml` (nearest, walking up) per the `sassy-setup` skill's `references/config.md`. Precedence: explicit token > session instruction > config > skill default. `commit.branch` supplies the default.
32
32
 
33
- **Branch option.** `branch:current|new [<name>]`. `current` commits on the checked-out branch. `new` cuts a branch from `origin/<base>` — `<base>` is `repos[<dir>]` in a workspace config, else `git.default_branch`, else the probed base — named `<name>` when given, else from the change. `sassy-commit-push-pr` forwards its own `branch` choice here.
33
+ **Branch option.** `branch:current|new [<name>]`. `current` commits on the checked-out branch. `new` cuts a branch from `origin/<base>` — `<base>` is `repos[<dir>]` in a workspace config, else `git.default_branch`, else the probed base **confirmed with the user** — named `<name>` when given, else from the change. `sassy-commit-push-pr` forwards its own `branch` choice here.
34
34
 
35
35
  **Not in a workspace root, not outside git.** With `git.repo: workspace` there is no local commit to make: refuse in one line and name the snapshot instead. With `git.repo: none`, refuse and say the project is deliberately outside git. Inside a nested repo, a normal commit is correct — the nearest work tree is the repo.
36
36
 
@@ -38,7 +38,7 @@ Treat the reads as a snapshot; re-read branch and staged set right before commit
38
38
 
39
39
  0. **Gather** the context above.
40
40
  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 origin <base>`, then `git checkout -b <name> origin/<base>`, then confirm `git merge-base HEAD origin/<base>` is `origin/<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.
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.
42
42
  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
43
  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
44
  5. **Message** — imperative subject naming the outcome, not the file list. Body only when the motivation is not obvious.
@@ -28,7 +28,8 @@ git diff HEAD
28
28
  git branch --show-current
29
29
  git log --oneline -10
30
30
  git rev-parse --abbrev-ref origin/HEAD
31
- gh repo view --json defaultBranchRef --jq .defaultBranchRef.namegh pr view --json url,body,baseRefName,headRefName
31
+ gh repo view --json defaultBranchRef --jq .defaultBranchRef.name
32
+ gh pr view --json url,body,baseRefName,headRefName
32
33
  gh pr list --head <branch> --state open --json url,number
33
34
  ```
34
35
 
@@ -36,7 +37,7 @@ Only an exit-0 empty list means "no open PR". A non-zero exit means **unknown**
36
37
 
37
38
  **Branch policy.** Default: if on the base branch or detached HEAD, create a feature branch named from the work and say so. `branch:current` or a plain request keeps the checked-out branch (that run pushes and skips the PR). Never ask whether to branch. Forward the resolved choice to `sassy-commit`: `branch:new <name>` when this run creates a branch, else `branch:current`.
38
39
 
39
- **Base branch.** `<base>` is `repos[<repo dir>]` from a workspace config, else `git.default_branch`, else the probed remote HEAD. An explicit `base:<branch>` token wins over all of them — use it when this PR targets something other than the day-to-day base, such as a release PR aimed at `main` while work lands on `staging`. **Never use `defaultBranchRef` as the PR base when the config names one.** Pass it explicitly: `gh pr create --base <base>`.
40
+ **Base branch.** `<base>` is `repos[<repo dir>]` from a workspace config, else `git.default_branch`, else the probed remote HEAD, **confirmed with the user before use**. An explicit `base:<branch>` token wins over all of them — use it when this PR targets something other than the day-to-day base, such as a release PR aimed at `main` while work lands on `staging`. **Never use `defaultBranchRef` as the PR base when the config names one.** Pass it explicitly: `gh pr create --base <base>`.
40
41
 
41
42
  **Refused topologies.** From a `workspace` root there is no single PR to open — refuse in one line and point at `sassy-ship`, which owns the per-repo fan-out. With `git.repo: none`, refuse and say why. Neither ever pushes directly to a base branch.
42
43
 
@@ -25,8 +25,6 @@ Trivial bugs whose cause is readable from the input may skip the scout, but stil
25
25
 
26
26
  **Search prior learnings first.** Resolve `.sassywares/config.yaml` (nearest, walking up) per the `sassy-setup` skill's `references/config.md`. For every existing path in `knowledge.read`, search the docs for the symptom's keywords, the module, and the failure type. A matching doc **seeds or confirms** a hypothesis — it never replaces the causal chain or the reproduction. Name the docs you consulted, and say plainly when none matched: no match is not evidence that nothing similar has happened.
27
27
 
28
- **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `sassy-skills outdated`. It is throttled to once a day per machine and silently does nothing offline. Pass any newer version on in one line; never block, retry, or fail on it.
29
-
30
28
  ## Phase 1 — Diagnose inline, with `scout` as a helper
31
29
 
32
30
  You own the diagnosis. The causal reasoning happens here, in the main context, because that is where the full picture lives and where the fix gate is presented.
@@ -16,8 +16,6 @@ disable-model-invocation: true
16
16
 
17
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.
18
18
 
19
- **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `sassy-skills outdated`. It is throttled to once a day per machine and silently does nothing offline. Pass any newer version on in one line; never block, retry, or fail on it.
20
-
21
19
  ## Input
22
20
 
23
21
  Detect the shape from the argument:
@@ -17,8 +17,6 @@ disable-model-invocation: true
17
17
 
18
18
  **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 land in `artifacts.plans`.
19
19
 
20
- **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `sassy-skills outdated`. It is throttled to once a day per machine and silently does nothing offline. Pass any newer version on in one line; never block, retry, or fail on it.
21
-
22
20
  ## Phase A — Discuss (brainstorm)
23
21
 
24
22
  Have one focused discussion to turn the idea into something plannable.
@@ -13,8 +13,6 @@ disable-model-invocation: true
13
13
 
14
14
  **Outcome:** the scope reviewed with concrete findings; the approved fixes applied; everything left uncommitted.
15
15
 
16
- **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `sassy-skills outdated`. It is throttled to once a day per machine and silently does nothing offline. Pass any newer version on in one line; never block, retry, or fail on it.
17
-
18
16
  **Orchestrator only.** Never edit code yourself. The `reviewer` finds; the `worker` fixes.
19
17
 
20
18
  ## Review dimensions
@@ -20,7 +20,7 @@ disable-model-invocation: true
20
20
  ## Steps
21
21
 
22
22
  1. **Resolve the start.** The argument's path, else the working directory. If a `.sassywares/config.yaml` is already found walking up, this is a **verify run**: run the checks in step 7 and report, without rewriting anything.
23
- 2. **Detect.** Run `scripts/detect.sh --root <path>`. It prints a proposed config: topology (`repo`, `workspace`, `none`), the remote name, a probed base branch, nested repos with their bases, and which artifact paths exist.
23
+ 2. **Detect.** Run `scripts/detect.sh --root <path>`. It prints a proposed config: topology (`repo`, `workspace`, `none`), the remote name, a probed base branch, and nested repos with their bases.
24
24
  3. **Ask only what detection cannot answer.** Never re-ask what the tree already said.
25
25
  - **`workspace`** — the snapshot mirror: create a **private** GitHub repo, or connect an existing one? Creating it needs explicit approval and a named slug first.
26
26
  - **`none`** — confirm the intent: this folder is deliberately outside git, so commit, PR, and babysit refuse here.
@@ -30,16 +30,14 @@ disable-model-invocation: true
30
30
  - **`pr.*`** — default `babysit: true`, `merge: false`, `delete_branch: false`. Say plainly that `merge: true` authorizes merging every babysat PR in this project without asking.
31
31
  - **Hooks** — offer, do not impose. Ask whether the project wants steps injected at `before_create`, `after_create`, `before_merge`, or `after_merge`, and write the skills they name. Leave them `[]` when they have nothing to add. **Never write a hook skill yourself** — hooks are project skills, and copying one of ours into that role is the fork this mechanism exists to prevent.
32
32
  4. **Write the config.** Build the file from the proposal plus the answers. It is written **fully commented**: every key keeps a description and its allowed values, exactly as the detector emits them. Never strip those comments to save lines — for a teammate opening the file, the comments are the documentation. If a config already exists, show what changes and ask before writing — never silently overwrite. Keep every key; an unset key falls back to its documented default, so a short file is legal when nothing deviates.
33
- 5. **Wire the skills.** The suite is the package `@sassywares/skills`, and its own CLI creates the links. Never copy skill files into a project.
33
+ 5. **Wire the skills.** The suite is installed from upstream with the open `skills` CLI, which vendors the skill files into the project and pins them. Never copy skill files in by hand, never add the suite as a dependency.
34
34
 
35
- - **It needs a `package.json`.** With none, say so and ask whether to add a minimal manifest or skip the wiring. Do not create one unasked, and do not invent a second distribution mechanism for non-JS projects.
36
- - **Detect the package manager** from the lockfile: `bun.lock`/`bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, `package-lock.json`. Ask when several disagree or none exists.
37
- - **Add the dependency pinned exactly** — `bun add --dev --exact @sassywares/skills@<version>`, `npm install --save-dev --save-exact …`, `pnpm add --save-dev --save-exact …`, `yarn add --dev --exact …`. Exact, because one pinned version for every teammate and every harness is the entire point.
38
- - **Scripts**, adding only what is missing: `"skills:sync": "sassy-skills sync"`, `"skills:update": "sassy-skills update"`, and `"postinstall": "sassy-skills sync"`. **Never overwrite an existing `postinstall`** — if one is there, leave it alone and tell the user to chain `&& sassy-skills sync`, so setup cannot erase someone's install steps.
39
- - **Run `sassy-skills sync`.** The package's CLI is now in `node_modules/.bin`; it links `.agents/skills/sassy-*` and creates `.claude/skills → ../.agents/skills`. The linking rules travel with the lockfile, so they upgrade with the suite instead of drifting in the repo.
40
- - **Ignore the derived links.** Add `/.agents/skills/sassy-*` and `/.claude/skills` to `.gitignore` when missing. Create `.gitignore` only when there is none.
35
+ - **Install it**: `npx -y skills add sassywares/sassy-skills -y`. That writes `.agents/skills/sassy-*` (real files), per-agent links such as `.claude/skills/sassy-*`, and `skills-lock.json`.
36
+ - **Scripts**, adding only what is missing: `"skills:sync": "npx -y skills experimental_install"` (restores exactly what the lock pins) and `"skills:update": "npx -y skills update -y"` (pulls the latest from upstream). **Never rewrite an existing `postinstall`** — leave it alone and tell the user to chain what they need.
37
+ - **Commit the vendored skills and the lock.** `.agents/skills/sassy-*` and `skills-lock.json` are the pin; a clone then needs no network.
38
+ - **Ignore `.claude/skills` only.** It is a link created at install time. Create `.gitignore` only when there is none — and in `workspace` topology never touch the project's root `.gitignore` at all.
41
39
  - **Write `.sassywares/README.md`** from `assets/sassywares-readme.md` when absent. Never overwrite it — it may carry project notes.
42
- - **Tell the user to `/reload`.** The links are invisible to the harness until it rescans.
40
+ - **Tell the user to `/reload`.** The skills are invisible to the harness until it rescans.
43
41
 
44
42
  6. **Install the snapshot, `workspace` only.** Read `references/workspace.md` and follow it.
45
43
 
@@ -55,9 +53,9 @@ disable-model-invocation: true
55
53
  | No silent overwrite | An existing config or an existing `.sassywares/workspace/snapshot` is shown and asked about, never replaced blind. |
56
54
  | Version is reported | Setup names the snapshot version it installed; `--check` flags an installed copy older than the skill's. |
57
55
  | Never commit | Setup writes files and stops. Committing is `sassy-commit`. |
58
- | Skills are a dependency | Pin `@sassywares/skills`; never copy skill files into a project or maintain hand-made links. |
56
+ | Skills come from upstream | Install with `npx skills add`; never hand-copy skill files or maintain the links yourself. |
59
57
  | Scripts are shared | Adding a missing script is fine; rewriting an existing `postinstall` or `skills:*` entry is not. |
60
- | Write boundary | Only `.sassywares/`, the `package.json` scripts and devDependency, the lockfile, and two `.gitignore` lines. Never skill content. |
58
+ | Write boundary | Only `.sassywares/`, the vendored `.agents/skills/sassy-*`, `skills-lock.json`, two `package.json` scripts, and one `.gitignore` line. Never a root `.gitignore` in a workspace. |
61
59
  | Detect, don't guess | An unknown base branch stays unknown and gets asked about. A probed value is reported as probed. |
62
60
  | Comments are part of the file | Every key keeps its description and allowed values. A stripped config is an incomplete config. |
63
61
  | Hooks are named, not written | Setup records hook skill names. Creating those skills is the project's work. |
@@ -4,14 +4,16 @@ Project config and tooling for the Sassy Skills suite. Written by `sassy-setup`.
4
4
 
5
5
  ## Skills
6
6
 
7
- Installed as the npm package `@sassywares/skills`, pinned exactly in `package.json`. The entries in `.agents/skills/` and the `.claude/skills` symlink are **derived** from that package — never edit them by hand and never commit them.
7
+ Installed from upstream with the open `skills` CLI, which vendors the skills into
8
+ `.agents/skills/` and pins them in `skills-lock.json`:
8
9
 
9
10
  ```bash
10
- bun run skills:sync # recreate the links after a fresh clone
11
- bun run skills:update # bump to the latest published version, then relink
11
+ npx skills add sassywares/sassy-skills # first install
12
+ bun run skills:sync # restore exactly what the lock pins
13
+ bun run skills:update # pull the latest from upstream
12
14
  ```
13
15
 
14
- Skills print a one-line nudge when a newer version is published — once a day, and silently nothing when offline. It is never a blocker.
16
+ The vendored skills and the lock are committed; `.claude/skills` is a link and is not.
15
17
 
16
18
  `.agents/skills/` is read by pi and Cursor; Claude Code reads `.claude/skills/` only, which is why that symlink exists. After any change, `/reload` your harness.
17
19
 
@@ -22,7 +24,7 @@ If a skill tells you another skill is missing, run `skills:sync` and restart. Su
22
24
  `config.yaml` holds the project's paths, git topology, and PR defaults. Every key is documented inline, including its allowed values, and the full schema is in the `sassy-setup` skill's `references/config.md`. Verify the file still matches the tree:
23
25
 
24
26
  ```bash
25
- node_modules/@sassywares/skills/skills/sassy-setup/scripts/detect.sh --root . --check
27
+ .agents/skills/sassy-setup/scripts/detect.sh --root . --check
26
28
  ```
27
29
 
28
30
  ## Hooks
@@ -168,9 +168,9 @@ first commit, then passed to every repo. Multi-repo runs pass
168
168
  Cut from the base, then verify:
169
169
 
170
170
  ```
171
- git fetch origin <base>
172
- git checkout -b <name> origin/<base>
173
- git merge-base HEAD origin/<base> # must be origin/<base>
171
+ git fetch <remote> <base>
172
+ git checkout -b <name> <remote>/<base>
173
+ git merge-base HEAD <remote>/<base> # must be <remote>/<base>
174
174
  ```
175
175
 
176
176
  An ancestry check that fails means the branch was cut from the wrong base —
@@ -25,6 +25,8 @@ done
25
25
 
26
26
  START="${ROOT_ARG:-$PWD}"
27
27
  [[ -d "$START" ]] || { echo "not a directory: $START" >&2; exit 2; }
28
+ # Absolute, or the walk-up loop below re-reads "." forever.
29
+ START="$(cd "$START" && pwd -P)"
28
30
 
29
31
  SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
30
32
  ASSET_SNAPSHOT="$SKILL_DIR/assets/snapshot"
@@ -38,8 +40,24 @@ while :; do
38
40
  probe="$(dirname "$probe")"
39
41
  done
40
42
 
43
+ # A --check run validates the config against the tree the CONFIG describes, which
44
+ # is not the git toplevel when you are standing inside one of its nested repos.
45
+ if [[ -n "$CONFIG" ]]; then
46
+ ROOT="${CONFIG%/.sassywares/config.yaml}"
47
+ if git -C "$ROOT" rev-parse --show-toplevel >/dev/null 2>&1; then
48
+ TOPOLOGY="repo"
49
+ else
50
+ TOPOLOGY="workspace"
51
+ shopt -s nullglob
52
+ NESTED_FOUND=0
53
+ for child in "$ROOT"/*/; do
54
+ [[ -d "$child/.git" ]] && { NESTED_FOUND=1; break; }
55
+ done
56
+ shopt -u nullglob
57
+ [[ "$NESTED_FOUND" -eq 1 ]] || TOPOLOGY="none"
58
+ fi
41
59
  # Topology root: the git toplevel when inside a work tree, else the start dir.
42
- if TOPLEVEL="$(git -C "$START" rev-parse --show-toplevel 2>/dev/null)"; then
60
+ elif TOPLEVEL="$(git -C "$START" rev-parse --show-toplevel 2>/dev/null)"; then
43
61
  ROOT="$TOPLEVEL"
44
62
  TOPOLOGY="repo"
45
63
  else
@@ -96,8 +114,10 @@ detect_base() {
96
114
  }
97
115
 
98
116
  remote_name() {
99
- local dir="$1"
100
- git -C "$dir" remote 2>/dev/null | head -n 1
117
+ local dir="$1" remotes
118
+ remotes="$(git -C "$dir" remote 2>/dev/null)"
119
+ if printf '%s\n' "$remotes" | grep -qx origin; then printf 'origin'; return 0; fi
120
+ printf '%s\n' "$remotes" | head -n 1
101
121
  }
102
122
 
103
123
  nested_repos() {
@@ -131,7 +151,8 @@ case "$TOPOLOGY" in
131
151
  repo)
132
152
  REPO_NAME="$(remote_name "$ROOT")"
133
153
  REPO_NAME="${REPO_NAME:-origin}"
134
- DEFAULT_BRANCH="$(detect_base "$ROOT")"
154
+ # A check run reads the base from the config; probing costs a network call.
155
+ (( CHECK )) || DEFAULT_BRANCH="$(detect_base "$ROOT")"
135
156
  ;;
136
157
  workspace)
137
158
  MIRROR_REMOTE="$(config_value remote 2>/dev/null || true)"
@@ -174,14 +195,13 @@ if (( CHECK )); then
174
195
 
175
196
  if [[ "$TOPOLOGY" == "repo" ]]; then
176
197
  configured_base="$(config_value default_branch 2>/dev/null || true)"
198
+ [[ -n "$configured_base" ]] || DEFAULT_BRANCH="$(detect_base "$ROOT")"
177
199
  if [[ -z "$configured_base" ]]; then
178
200
  bad "git.default_branch is unset — name the branch new work is cut from"
179
201
  elif [[ -n "$DEFAULT_BRANCH" && "$configured_base" != "$DEFAULT_BRANCH" ]]; then
180
202
  # A team base is a policy choice: staging, dev, or the release branch can
181
203
  # all differ from the remote default. Report it, do not call it drift.
182
204
  note " default_branch '$configured_base' overrides the remote default '$DEFAULT_BRANCH' — expected when PRs target a team base"
183
- elif [[ -z "$DEFAULT_BRANCH" ]]; then
184
- note " remote default undetectable — '$configured_base' taken as declared"
185
205
  fi
186
206
  configured_remote="$(config_value remote 2>/dev/null || true)"
187
207
  [[ -z "$configured_remote" ]] || bad "git.remote is set but git.repo is not workspace"
@@ -6,7 +6,7 @@ description: >-
6
6
  delegated to subagents. Default `mode:manual` presents a shipping summary and
7
7
  waits before anything is written, committed, pushed, or opened. `mode:auto`
8
8
  runs the close-out with no review.
9
- argument-hint: "<plan path or topic> [steps:compound,handbook,commit,pr,babysit] [merge:on|off] [delete-branch:on|off] [mode:manual|auto]"
9
+ argument-hint: "<plan path or topic> [steps:compound,handbook,commit,pr,babysit] [base:<branch>] [merge:on|off] [delete-branch:on|off] [mode:manual|auto]"
10
10
  disable-model-invocation: true
11
11
  ---
12
12
 
@@ -18,8 +18,6 @@ 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`. Precedence: explicit token > session instruction > config > skill default. `pr.babysit`, `pr.merge`, and `pr.delete_branch` seed the close-out; `git.repo` decides whether this run commits, snapshots, or refuses; `hooks.pr.*` adds project steps to the PR pipeline.
20
20
 
21
- **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `sassy-skills outdated`. It is throttled to once a day per machine and silently does nothing offline. Pass any newer version on in one line; never block, retry, or fail on it.
22
-
23
21
  **Subagent-maximal by design.** The orchestrator delegates every heavy step (docs, tree reads, git, gh) to subagents and only receives compact summaries. It never reads diffs, file contents, or the tree itself.
24
22
 
25
23
  ## Steps (in order)
@@ -31,7 +29,7 @@ disable-model-invocation: true
31
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.
32
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`.
33
31
  - **`git.repo: none`.** No commit exists here. Report that the work is not persisted and stop.
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`* (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 `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.
32
+ 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 `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.
35
33
 
36
34
  **The PR step owns the PR hooks.** `before_create` and `after_create` run inside `sassy-commit-push-pr`, once per PR; ship neither runs nor repeats them.
37
35
  6. **Babysit.** With `pr.babysit` (default `true`), dispatch a `worker`: *load the `sassy-babysit` skill and follow it for the PR just created, one checkpoint*, forwarding any `merge:on|off` and `delete-branch:on|off` the caller supplied, else `pr.merge` and `pr.delete_branch`. It reports looks-ready or blocked. For a continuous watch, tell the user to run `sassy-babysit` in-session.
@@ -13,8 +13,6 @@ disable-model-invocation: true
13
13
 
14
14
  **Outcome:** a grounded conformance judgment against an intent; the approved gaps closed; everything left uncommitted.
15
15
 
16
- **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `sassy-skills outdated`. It is throttled to once a day per machine and silently does nothing offline. Pass any newer version on in one line; never block, retry, or fail on it.
17
-
18
16
  **Orchestrator only.** Never edit code yourself. The `validator` finds gaps; the `worker` closes them.
19
17
 
20
18
  **This skill needs an intent.** Validation without something to validate against is meaningless. If the user gave no plan path or intent, ask for one before doing anything.
@@ -19,8 +19,6 @@ disable-model-invocation: true
19
19
 
20
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.
21
21
 
22
- **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `sassy-skills outdated`. It is throttled to once a day per machine and silently does nothing offline. Pass any newer version on in one line; never block, retry, or fail on it.
23
-
24
22
  ## Input
25
23
 
26
24
  Detect the shape from the argument:
@@ -1,193 +0,0 @@
1
- #!/usr/bin/env node
2
- // sassy-skills — wire this package's skills into a project.
3
- //
4
- // Nothing scans node_modules on its own, so the harnesses that read a project
5
- // skills directory need links. This tool creates them, checks them, and updates
6
- // the pinned version.
7
- //
8
- // sassy-skills sync link skills into .agents/skills (and .claude/skills)
9
- // sassy-skills check exit 1 if the wiring is missing or stale
10
- // sassy-skills update bump to the latest published version, then sync
11
- //
12
- // Options: --root <dir> --quiet --help
13
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
14
- import { tmpdir } from "node:os";
15
- import { execFileSync } from "node:child_process";
16
- import { dirname, join, resolve } from "node:path";
17
- import { fileURLToPath } from "node:url";
18
-
19
- const HERE = dirname(fileURLToPath(import.meta.url));
20
- const SKILLS_DIR = resolve(HERE, "..", "skills");
21
- const PKG = JSON.parse(readFileSync(resolve(HERE, "..", "package.json"), "utf8"));
22
- const NAME = PKG.name;
23
- const VERSION = PKG.version;
24
- const REGISTRY = process.env.SASSY_SKILLS_REGISTRY ?? "https://registry.npmjs.org";
25
-
26
- const args = process.argv.slice(2);
27
- const quiet = args.includes("--quiet");
28
- const rootFlag = args.indexOf("--root");
29
- const rootValueIndex = rootFlag >= 0 ? rootFlag + 1 : -1;
30
- const ROOT = rootFlag >= 0 ? resolve(args[rootFlag + 1] ?? ".") : process.cwd();
31
- if (args.includes("--help") || args.includes("-h")) {
32
- console.log(`sassy-skills@${VERSION}\n\n sync link skills into .agents/skills and .claude/skills\n check exit 1 when the wiring is missing or stale\n update bump ${NAME} to the latest version, then sync\n outdated print a one-line nudge when a newer version is published (throttled, offline-safe)\n\nOptions: --root <dir> --quiet --force`);
33
- process.exit(0);
34
- }
35
-
36
- const command = args.find((a, i) => !a.startsWith("-") && i !== rootValueIndex) ?? "sync";
37
-
38
- const log = (...a) => { if (!quiet) console.log(...a); };
39
- const ok = (m) => log(` ✓ ${m}`);
40
- const warn = (m) => console.error(` ! ${m}`);
41
- const die = (m) => { console.error(` ✗ ${m}`); process.exit(1); };
42
-
43
- const IS_WIN = process.platform === "win32";
44
-
45
- function linkedSkills() {
46
- if (!existsSync(SKILLS_DIR)) die(`package has no skills/ directory at ${SKILLS_DIR}`);
47
- return readdirSync(SKILLS_DIR, { withFileTypes: true })
48
- .filter((e) => e.isDirectory() && existsSync(join(SKILLS_DIR, e.name, "SKILL.md")))
49
- .map((e) => e.name)
50
- .sort();
51
- }
52
-
53
- function makeLink(target, linkPath) {
54
- try {
55
- if (lstatSync(linkPath).isSymbolicLink()) rmSync(linkPath);
56
- else { warn(`${linkPath} exists and is not a symlink — leaving it alone`); return false; }
57
- } catch { /* does not exist */ }
58
- symlinkSync(target, linkPath, IS_WIN ? "junction" : "dir");
59
- return true;
60
- }
61
-
62
- function sync() {
63
- const skills = linkedSkills();
64
- const agents = join(ROOT, ".agents", "skills");
65
- mkdirSync(agents, { recursive: true });
66
-
67
- let made = 0;
68
- for (const name of skills) {
69
- const target = resolve(SKILLS_DIR, name);
70
- if (makeLink(target, join(agents, name))) made++;
71
- }
72
- ok(`${skills.length} skills linked into .agents/skills (${NAME}@${VERSION})`);
73
-
74
- // Claude Code reads .claude/skills only. A directory symlink is enough, and it
75
- // is the least invasive form when the directory does not already exist.
76
- const claude = join(ROOT, ".claude", "skills");
77
- try {
78
- const st = lstatSync(claude);
79
- if (!st.isSymbolicLink()) {
80
- warn(".claude/skills exists and is not a symlink — Claude Code will not see the project skills unless you link them yourself");
81
- }
82
- } catch {
83
- mkdirSync(dirname(claude), { recursive: true });
84
- symlinkSync(IS_WIN ? join(ROOT, ".agents", "skills") : "../.agents/skills", claude, IS_WIN ? "junction" : "dir");
85
- ok("linked .claude/skills → .agents/skills");
86
- }
87
-
88
- if (made) log(`\nRestart or /reload your harness to pick up the new skills.`);
89
- return 0;
90
- }
91
-
92
- function check() {
93
- const skills = linkedSkills();
94
- const agents = join(ROOT, ".agents", "skills");
95
- let problems = 0;
96
-
97
- for (const name of skills) {
98
- const link = join(agents, name);
99
- const target = resolve(SKILLS_DIR, name);
100
- try {
101
- const st = lstatSync(link);
102
- if (!st.isSymbolicLink()) { warn(`${name}: real directory, not a link`); problems++; continue; }
103
- const points = resolve(agents, readlinkSync(link));
104
- if (points !== target) { warn(`${name}: points at ${points}, expected ${target}`); problems++; }
105
- } catch {
106
- warn(`${name}: not linked`);
107
- problems++;
108
- }
109
- }
110
- if (!existsSync(join(ROOT, ".claude", "skills"))) warn(".claude/skills missing (Claude Code only)");
111
- if (problems) { console.error(` ✗ ${problems} skill link(s) need attention — run: sassy-skills sync`); return 1; }
112
- ok(`${skills.length} skills correctly linked (${NAME}@${VERSION})`);
113
- return 0;
114
- }
115
-
116
- function detectPM() {
117
- if (existsSync(join(ROOT, "bun.lock")) || existsSync(join(ROOT, "bun.lockb"))) return "bun";
118
- if (existsSync(join(ROOT, "pnpm-lock.yaml"))) return "pnpm";
119
- if (existsSync(join(ROOT, "yarn.lock"))) return "yarn";
120
- if (existsSync(join(ROOT, "package-lock.json"))) return "npm";
121
- return null;
122
- }
123
-
124
- function update() {
125
- const pm = detectPM();
126
- if (!pm) die("no lockfile found — cannot tell which package manager to update with");
127
- const spec = `${NAME}@latest`;
128
- const cmd = {
129
- bun: ["bun", ["add", "--dev", "--exact", spec]],
130
- npm: ["npm", ["install", "--save-dev", "--save-exact", spec]],
131
- pnpm: ["pnpm", ["add", "--save-dev", "--save-exact", spec]],
132
- yarn: ["yarn", ["add", "--dev", "--exact", spec]],
133
- }[pm];
134
- log(`Updating ${NAME} with ${pm}…`);
135
- execFileSync(cmd[0], cmd[1], { cwd: ROOT, stdio: "inherit" });
136
- return sync();
137
- }
138
-
139
- function newerThan(a, b) {
140
- const pa = String(a).split(/[.+-]/).map((x) => parseInt(x, 10) || 0);
141
- const pb = String(b).split(/[.+-]/).map((x) => parseInt(x, 10) || 0);
142
- for (let i = 0; i < 3; i++) {
143
- if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
144
- }
145
- return false;
146
- }
147
-
148
- function updateHint() {
149
- const pm = detectPM();
150
- const script = pm === "bun" ? "bun run skills:update"
151
- : pm ? `${pm} run skills:update`
152
- : "sassy-skills update";
153
- return script;
154
- }
155
-
156
- // Non-blocking version nudge. Throttled to once a day per machine, silent when
157
- // offline, and it never fails a run — a stale pin is worth mentioning, not
158
- // worth blocking work over.
159
- async function outdated(force = false) {
160
- const cacheFile = join(tmpdir(), "sassy-skills-version-check.json");
161
- const TTL = 24 * 60 * 60 * 1000;
162
- let cached = null;
163
- try { cached = JSON.parse(readFileSync(cacheFile, "utf8")); } catch { /* first run */ }
164
-
165
- let latest = null;
166
- const fresh = cached && typeof cached.checkedAt === "number" && Date.now() - cached.checkedAt < TTL;
167
- if (fresh && !force) {
168
- latest = cached.latest ?? null;
169
- } else {
170
- try {
171
- const res = await fetch(`${REGISTRY}/${encodeURIComponent(NAME)}/latest`, {
172
- signal: AbortSignal.timeout(2000),
173
- headers: { accept: "application/json" },
174
- });
175
- const body = await res.json();
176
- latest = typeof body?.version === "string" ? body.version : null;
177
- } catch { latest = null; }
178
- try { writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), latest })); } catch { /* read-only tmp */ }
179
- }
180
-
181
- if (!latest || !newerThan(latest, VERSION)) return 0;
182
- console.log(` ↑ ${NAME} ${VERSION} → ${latest} available — ${updateHint()}`);
183
- return 0;
184
- }
185
-
186
- switch (command) {
187
- case "outdated": process.exit(await outdated(args.includes("--force")));
188
- case "sync": process.exit(sync());
189
- case "check": process.exit(check());
190
- case "update": process.exit(update());
191
- default:
192
- die(`unknown command: ${command} (try --help)`);
193
- }
@@ -1 +0,0 @@
1
- keep