@sassywares/skills 0.3.0 → 0.3.2

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
@@ -276,33 +276,26 @@ The suite ships as the npm package **`@sassywares/skills`**. Pin it, don't symli
276
276
  ### As a project dependency (recommended for teams)
277
277
 
278
278
  ```bash
279
- bun add -d @sassywares/skills
279
+ bun add --dev --exact @sassywares/skills@0.3.2
280
280
  ```
281
281
 
282
- Nothing scans `node_modules` on its own, so give your harness the directory it does read:
282
+ Nothing scans `node_modules` on its own, so link it where harnesses look — the package does that itself:
283
283
 
284
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
285
+ bunx sassy-skills sync
289
286
  ```
290
287
 
291
- `.agents/skills/` is read by Cursor and pi. Claude Code reads `.claude/skills/` only, so add one directory symlink:
288
+ That writes `.agents/skills/sassy-*` (read by pi and Cursor) and the `.claude/skills` link Claude Code needs. Add it as `"postinstall": "sassy-skills sync"` and teammates need nothing beyond `bun install`.
292
289
 
293
- ```bash
294
- mkdir -p .claude && ln -sfn ../.agents/skills .claude/skills
295
- ```
296
-
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.
290
+ Ignore both paths — they're derived from the lockfile, like any build output. `sassy-setup` does all of this for you, including the gitignore lines and the pinned version.
298
291
 
299
292
  ### As a pi package
300
293
 
301
294
  pi resolves packages directly, so no links are needed:
302
295
 
303
296
  ```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
297
+ pi install npm:@sassywares/skills@0.3.2 # user-level, all projects
298
+ pi install npm:@sassywares/skills@0.3.2 --local # project-level → commit .pi/settings.json
306
299
  ```
307
300
 
308
301
  ### While editing the suite
@@ -8,9 +8,10 @@
8
8
  // sassy-skills sync link skills into .agents/skills (and .claude/skills)
9
9
  // sassy-skills check exit 1 if the wiring is missing or stale
10
10
  // sassy-skills update bump to the latest published version, then sync
11
+ // sassy-skills outdated one-line nudge when a newer version is published
11
12
  //
12
- // Options: --root <dir> --quiet --help
13
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
13
+ // Options: --root <dir> --quiet --force --help
14
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
14
15
  import { tmpdir } from "node:os";
15
16
  import { execFileSync } from "node:child_process";
16
17
  import { dirname, join, resolve } from "node:path";
@@ -27,7 +28,17 @@ const args = process.argv.slice(2);
27
28
  const quiet = args.includes("--quiet");
28
29
  const rootFlag = args.indexOf("--root");
29
30
  const rootValueIndex = rootFlag >= 0 ? rootFlag + 1 : -1;
30
- const ROOT = rootFlag >= 0 ? resolve(args[rootFlag + 1] ?? ".") : process.cwd();
31
+ const rootArg = rootFlag >= 0 ? args[rootFlag + 1] : undefined;
32
+ if (rootFlag >= 0 && (!rootArg || rootArg.startsWith("-"))) {
33
+ console.error(" ✗ --root needs a directory");
34
+ process.exit(2);
35
+ }
36
+ const ROOT = rootArg ? resolve(rootArg) : process.cwd();
37
+ if (rootFlag >= 0) {
38
+ let ok = false;
39
+ try { ok = statSync(ROOT).isDirectory(); } catch { ok = false; }
40
+ if (!ok) { console.error(` ✗ --root ${ROOT} is not an existing directory`); process.exit(2); }
41
+ }
31
42
  if (args.includes("--help") || args.includes("-h")) {
32
43
  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
44
  process.exit(0);
@@ -50,13 +61,22 @@ function linkedSkills() {
50
61
  .sort();
51
62
  }
52
63
 
64
+ // Returns "linked", "kept", or "blocked". A blocked path is a real directory the
65
+ // user owns: never replaced, and always a failure, because the wiring is then
66
+ // incomplete and nothing the tool can do will fix it.
53
67
  function makeLink(target, linkPath) {
54
68
  try {
55
- if (lstatSync(linkPath).isSymbolicLink()) rmSync(linkPath);
56
- else { warn(`${linkPath} exists and is not a symlink — leaving it alone`); return false; }
69
+ const st = lstatSync(linkPath);
70
+ if (st.isSymbolicLink()) {
71
+ if (resolve(dirname(linkPath), readlinkSync(linkPath)) === target) return "kept";
72
+ rmSync(linkPath);
73
+ } else {
74
+ warn(`${linkPath} is a real directory, not a link — move it aside; nothing was replaced`);
75
+ return "blocked";
76
+ }
57
77
  } catch { /* does not exist */ }
58
78
  symlinkSync(target, linkPath, IS_WIN ? "junction" : "dir");
59
- return true;
79
+ return "linked";
60
80
  }
61
81
 
62
82
  function sync() {
@@ -64,12 +84,21 @@ function sync() {
64
84
  const agents = join(ROOT, ".agents", "skills");
65
85
  mkdirSync(agents, { recursive: true });
66
86
 
67
- let made = 0;
87
+ let linked = 0;
88
+ let blocked = 0;
68
89
  for (const name of skills) {
69
90
  const target = resolve(SKILLS_DIR, name);
70
- if (makeLink(target, join(agents, name))) made++;
91
+ const result = makeLink(target, join(agents, name));
92
+ if (result === "linked") linked++;
93
+ else if (result === "blocked") blocked++;
94
+ }
95
+ if (blocked) {
96
+ console.error(` ✗ ${blocked} of ${skills.length} skills could not be linked — see above`);
97
+ return 1;
71
98
  }
72
- ok(`${skills.length} skills linked into .agents/skills (${NAME}@${VERSION})`);
99
+ ok(linked
100
+ ? `${skills.length} skills linked into .agents/skills (${NAME}@${VERSION})`
101
+ : `${skills.length} skills already linked (${NAME}@${VERSION})`);
73
102
 
74
103
  // Claude Code reads .claude/skills only. A directory symlink is enough, and it
75
104
  // is the least invasive form when the directory does not already exist.
@@ -77,7 +106,13 @@ function sync() {
77
106
  try {
78
107
  const st = lstatSync(claude);
79
108
  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");
109
+ warn(".claude/skills is a real directory — Claude Code will not see the project skills until you link them");
110
+ return 1;
111
+ }
112
+ const points = resolve(dirname(claude), readlinkSync(claude));
113
+ if (points !== resolve(agents)) {
114
+ warn(`.claude/skills points at ${points}, not .agents/skills — relink it`);
115
+ return 1;
81
116
  }
82
117
  } catch {
83
118
  mkdirSync(dirname(claude), { recursive: true });
@@ -85,7 +120,7 @@ function sync() {
85
120
  ok("linked .claude/skills → .agents/skills");
86
121
  }
87
122
 
88
- if (made) log(`\nRestart or /reload your harness to pick up the new skills.`);
123
+ if (linked) log(`\nRestart or /reload your harness to pick up the new skills.`);
89
124
  return 0;
90
125
  }
91
126
 
@@ -107,7 +142,14 @@ function check() {
107
142
  problems++;
108
143
  }
109
144
  }
110
- if (!existsSync(join(ROOT, ".claude", "skills"))) warn(".claude/skills missing (Claude Code only)");
145
+ const claude = join(ROOT, ".claude", "skills");
146
+ try {
147
+ const st = lstatSync(claude);
148
+ if (!st.isSymbolicLink()) { warn(".claude/skills is a real directory, so Claude Code sees none of these"); problems++; }
149
+ else if (resolve(dirname(claude), readlinkSync(claude)) !== resolve(agents)) {
150
+ warn(".claude/skills points somewhere else"); problems++;
151
+ }
152
+ } catch { warn(".claude/skills missing (Claude Code only)"); }
111
153
  if (problems) { console.error(` ✗ ${problems} skill link(s) need attention — run: sassy-skills sync`); return 1; }
112
154
  ok(`${skills.length} skills correctly linked (${NAME}@${VERSION})`);
113
155
  return 0;
@@ -132,7 +174,22 @@ function update() {
132
174
  yarn: ["yarn", ["add", "--dev", "--exact", spec]],
133
175
  }[pm];
134
176
  log(`Updating ${NAME} with ${pm}…`);
135
- execFileSync(cmd[0], cmd[1], { cwd: ROOT, stdio: "inherit" });
177
+ try {
178
+ execFileSync(cmd[0], cmd[1], { cwd: ROOT, stdio: "inherit" });
179
+ } catch {
180
+ die(`${pm} could not update ${NAME} — nothing was relinked`);
181
+ }
182
+ // The running copy is the old version; its skills directory may be gone. Hand
183
+ // over to the freshly installed binary so the linker matches the new package.
184
+ const fresh = join(ROOT, "node_modules", ".bin", "sassy-skills");
185
+ if (existsSync(fresh)) {
186
+ try {
187
+ execFileSync(fresh, ["sync", "--root", ROOT], { stdio: "inherit" });
188
+ } catch {
189
+ die("updated, but the new binary could not relink — run: sassy-skills sync");
190
+ }
191
+ return 0;
192
+ }
136
193
  return sync();
137
194
  }
138
195
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sassywares/skills",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Lean, explicit-invocation Agent Skills for shipping fast: plan, work, review, validate, ship.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,5 +39,8 @@
39
39
  },
40
40
  "bin": {
41
41
  "sassy-skills": "./bin/sassy-skills.mjs"
42
+ },
43
+ "engines": {
44
+ "node": ">=18"
42
45
  }
43
46
  }
@@ -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,7 +25,7 @@ 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.
28
+ **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `node_modules/.bin/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
29
 
30
30
  ## Phase 1 — Diagnose inline, with `scout` as a helper
31
31
 
@@ -16,7 +16,7 @@ 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.
19
+ **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `node_modules/.bin/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
20
 
21
21
  ## Input
22
22
 
@@ -17,7 +17,7 @@ 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.
20
+ **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `node_modules/.bin/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
21
 
22
22
  ## Phase A — Discuss (brainstorm)
23
23
 
@@ -13,7 +13,7 @@ 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.
16
+ **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `node_modules/.bin/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
17
 
18
18
  **Orchestrator only.** Never edit code yourself. The `reviewer` finds; the `worker` fixes.
19
19
 
@@ -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.
@@ -37,7 +37,7 @@ disable-model-invocation: true
37
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
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
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.
40
+ - **Ignore the derived links — `repo` topology only.** Add `/.agents/skills/sassy-*` and `/.claude/skills` to `.gitignore` when missing, creating it only when there is none. In `workspace` topology never touch the project's root `.gitignore`; the snapshot's own ignore file covers that case.
41
41
  - **Write `.sassywares/README.md`** from `assets/sassywares-readme.md` when absent. Never overwrite it — it may carry project notes.
42
42
  - **Tell the user to `/reload`.** The links are invisible to the harness until it rescans.
43
43
 
@@ -57,7 +57,7 @@ disable-model-invocation: true
57
57
  | Never commit | Setup writes files and stops. Committing is `sassy-commit`. |
58
58
  | Skills are a dependency | Pin `@sassywares/skills`; never copy skill files into a project or maintain hand-made links. |
59
59
  | 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. |
60
+ | Write boundary | Only `.sassywares/`, the `package.json` scripts and devDependency, the lockfile, and — in `repo` topology — two `.gitignore` lines. Never skill content, and never a root `.gitignore` in a workspace. |
61
61
  | Detect, don't guess | An unknown base branch stays unknown and gets asked about. A probed value is reported as probed. |
62
62
  | Comments are part of the file | Every key keeps its description and allowed values. A stripped config is an incomplete config. |
63
63
  | Hooks are named, not written | Setup records hook skill names. Creating those skills is the project's work. |
@@ -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,7 +18,7 @@ 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.
21
+ **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `node_modules/.bin/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
22
 
23
23
  **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
24
 
@@ -31,7 +31,7 @@ disable-model-invocation: true
31
31
  - **`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
32
  - **`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
33
  - **`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.
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 `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
35
 
36
36
  **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
37
  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.
@@ -1,17 +1,20 @@
1
1
  ---
2
2
  name: sassy-skill
3
3
  description: >-
4
- Create, retune, or improve an Agent Skill for pi. Use when asked to create a
5
- skill, turn a workflow into a skill, fix a skill's description or triggering,
6
- restructure a bloated skill, or retune an existing one. Writes to the user or
7
- project skill directory and follows pi's Agent Skills rules.
4
+ Create, retune, or improve an Agent Skill. Asks where the skill should live —
5
+ inside this suite, in a project, or for one user — then writes it there with
6
+ the rules and release steps that location requires. Use when asked to create a
7
+ skill, turn a workflow into a skill, fix a description or triggering,
8
+ restructure a bloated skill, or retune an existing one.
8
9
  argument-hint: "[create|retune] [name, idea, or skill path]"
9
10
  disable-model-invocation: true
10
11
  ---
11
12
 
12
13
  # Sassy Skill
13
14
 
14
- **Outcome:** a well-formed Agent Skill directory installed at the user or project location, or an existing skill improved and re-verified. Pi's loader accepts it with no diagnostics.
15
+ **Outcome:** a well-formed Agent Skill at a location the user chose, or an existing skill improved and re-verified. The harness loads it with no diagnostics.
16
+
17
+ **Locations are not interchangeable** — they decide who gets the skill, how it ships, and what it may be called. Ask before writing.
15
18
 
16
19
  **Scope:** skills only — not extensions, packages, or prompt templates.
17
20
 
@@ -22,11 +25,26 @@ disable-model-invocation: true
22
25
  - **Description:** required, ≤1024 chars. Pi advertises name + description at startup; the body loads only when the skill is used. The description decides whether the skill ever fires — write **what it does and when to use it**.
23
26
  - **Frontmatter fields:** `name`, `description` (required); optional `license`, `compatibility`, `metadata`, `allowed-tools`, `disable-model-invocation`.
24
27
  - Use `disable-model-invocation: true` for skills that should run only via explicit `/skill:name`.
25
- - **Locations:** user `~/.agents/skills/<name>/`; project `<repo>/.agents/skills/<name>/` (discovered from cwd up to the repo root). Also the `skills` array in settings and pi packages.
26
28
  - **Reference other skills by name, never by absolute path.** The suite can live in `~/.agents/skills/`, a project `.agents/skills/`, or inside `node_modules/`, so "load the sassy-commit skill" is portable where a `~/…` path is not. Name a bundled file as "the sassy-setup skill's `references/config.md`".
27
29
  - Pi **validates at startup**; malformed or descriptionless skills warn. `/reload` applies changes.
28
30
  - Pi implements the **Agent Skills specification** (agentskills.io). For the full spec, pi's own `docs/skills.md` is authoritative — read it when the rules here are not enough.
29
31
 
32
+ ## Where the skill lives
33
+
34
+ Ask which one, unless the request already made it obvious. The answer changes the name, the sharing, and whether a release is needed.
35
+
36
+ | Location | Path | Who gets it | Extra work |
37
+ |---|---|---|---|
38
+ | **Internal** — part of this suite | `skills/<name>/` in the sassy-skills repo | Everyone, after a release | Name it `sassy-<name>`, bump `package.json`, run `scripts/sync.sh`, then tag to publish |
39
+ | **Project** — team workflow | `<repo>/.agents/skills/<name>/` | Everyone who clones that repo | Commit it; prefix the project (`tap2-…`) so it can't collide with a suite skill or a harness built-in |
40
+ | **User** — personal workflow | `~/.agents/skills/<name>/` | Only this person, every repo on this machine | None — but nothing gates it either; never put something a teammate needs here |
41
+
42
+ Recommend by audience: the team needs it → **project**; only the requester → **user**; it belongs to every project using this suite → **internal**.
43
+
44
+ Internal is the only one that implies a release. Never choose it silently: from the moment it lands, consumers see it only when they bump their pin. Say that out loud when you do.
45
+
46
+ Paths in the table are the defaults. Harnesses also read `skills` arrays in settings and installed packages, so a skill may live elsewhere — write down where it actually went.
47
+
30
48
  ## Shape rule — the lean rule
31
49
 
32
50
  - **Default to one `SKILL.md`.**
@@ -36,7 +54,7 @@ disable-model-invocation: true
36
54
 
37
55
  ## Workflow
38
56
 
39
- 1. **Intake.** What the skill does, when it should trigger, and whether it is **user-level** or **project-level**. For a retune, read the existing skill and name the specific failure (won't trigger, too long, wrong steps, stale).
57
+ 1. **Intake.** What the skill does, when it should trigger, and **where it lives** — internal, project, or user (see above). Ask; do not infer from the cwd. For a retune, read the existing skill and name the specific failure (won't trigger, too long, wrong steps, stale, wrong scope).
40
58
  2. **Choose the shape.** Single file, or `SKILL.md` + `references/` per the rule above.
41
59
  3. **Write the frontmatter.** A description that states both what and when.
42
60
  4. **Write the body.** Outcome, a hard-rules table, the workflow, and reference pointers. Plain English. Structured output where the skill returns a result. Match this setup's house style.
@@ -59,7 +77,7 @@ When asked to retune or improve a skill, change the **smallest** thing that fixe
59
77
  - **Won't trigger** → rewrite the description (what + when, concrete verbs).
60
78
  - **Too long / bloated** → extract conditional content to `references/`, or delete what never gets used.
61
79
  - **Wrong or stale steps** → correct the workflow against the current tree.
62
- - **Wrong scope** → move it between user and project locations.
80
+ - **Wrong scope** → move it between internal, project, and user. Moving into the suite is a release; moving out of it means the next release drops the skill for everyone.
63
81
 
64
82
  Re-verify after any retune.
65
83
 
@@ -13,7 +13,7 @@ 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.
16
+ **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `node_modules/.bin/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
17
 
18
18
  **Orchestrator only.** Never edit code yourself. The `validator` finds gaps; the `worker` closes them.
19
19
 
@@ -19,7 +19,7 @@ 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.
22
+ **Update nudge.** Once per run, if `node_modules/.bin/sassy-skills` exists, run `node_modules/.bin/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
23
 
24
24
  ## Input
25
25
 
@@ -1 +0,0 @@
1
- keep