@sassywares/skills 0.5.8 → 0.5.10

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
@@ -115,7 +115,7 @@ Debugging:
115
115
  | Skill | What it does |
116
116
  |---|---|
117
117
  | **`sassy-skill`** | Creates, retunes, or restructures Agent Skills. |
118
- | **`sassy-setup`** | Detects the project topology and writes `.sassywares/config.yaml`; installs the snapshot tooling for a multi-repo workspace. |
118
+ | **`sassy-setup`** | Detects the project topology and writes `.sassywares/config.yaml`; installs the snapshot tooling for a multi-repo workspace; commits the files it wrote. `mode:check` verifies an existing setup, including the vendored pin. |
119
119
  | **`sassy-compare`** | Runs one prompt against two models in isolated worktrees, with optional per-side thinking levels, and compares output, code changes, timing, tokens, and errors. |
120
120
 
121
121
  ## Project config
@@ -305,7 +305,9 @@ npx skills experimental_install # restore exactly what the lock pins
305
305
  npx skills remove # take the suite out again
306
306
  ```
307
307
 
308
- `sassy-setup` does all of this for you, including the ignore line and the two package scripts.
308
+ `npx skills check` is an alias for `update`: it moves the pin, it does not validate it. To check a project's setup without touching the pin, run `sassy-setup` with `mode:check`.
309
+
310
+ `sassy-setup` does all of this for you — installing the suite, adding the ignore line, verifying the pin, and committing what it wrote.
309
311
 
310
312
  ### While editing the suite
311
313
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sassywares/skills",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "description": "Lean, explicit-invocation Agent Skills for shipping fast: plan, work, review, validate, ship.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,10 +42,11 @@ Treat the reads as a snapshot; re-read branch and staged set right before commit
42
42
  1. **Nothing to commit** — if there are no staged, modified, or untracked files, say so and stop.
43
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
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.
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`, passing `--policy current` so the detector knows the caller chose this branch. **Base means any `base_map` value, not just `origin/HEAD`.** Read `action` before committing:
46
+ - `commit-here` — the caller's `branch:current` (or a config that says `current`) chose this branch. Commit, and name the branch in the summary you return.
47
+ - `reuse` — a branch that is not a base. Commit.
48
+ - `create` — you were told `current` but nothing chose it and this is a base: 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.
49
+ - `stop-and-ask` or `refuse` — report the reason line and stop.
49
50
  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:`.
50
51
  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.
51
52
  5. **Message** — imperative subject naming the outcome, not the file list. Body only when the motivation is not obvious.
@@ -4,22 +4,34 @@ description: >-
4
4
  Detect a project's topology and wire it for the Sassy Skills suite: write
5
5
  .sassywares/config.yaml, vendor the skills from upstream with the open
6
6
  `skills` CLI, and install the snapshot tooling in a multi-repo workspace.
7
- Use when asked to set up, configure, or verify a project.
8
- argument-hint: "[path] [--check]"
7
+ Use when asked to set up or configure a project, or to check an existing
8
+ setup (`mode:check`).
9
+ argument-hint: "[path] [mode:init|check]"
9
10
  disable-model-invocation: true
10
11
  ---
11
12
 
12
13
  # Sassy Setup
13
14
 
14
- **Outcome:** `.sassywares/config.yaml` at the project root matching the tree it describes, the skills vendored and pinned in `skills-lock.json` and visible to the harness, and — in `workspace` mode — the snapshot tooling installed.
15
+ **Outcome:** `.sassywares/config.yaml` at the project root matching the tree it describes, the skills vendored and pinned in `skills-lock.json` and visible to the harness, the snapshot tooling installed in `workspace` mode, and every file setup wrote committed — so the project is wired and the user is good to go in one run.
15
16
 
16
17
  **Read `references/config.md` first.** It is the schema and the resolution rules; this skill only decides and writes.
17
18
 
18
19
  **Never a repo by force.** Setup records topology, it does not change it. No `git init`, ever.
19
20
 
21
+ ## Modes
22
+
23
+ | Mode | What it does |
24
+ |------|--------------|
25
+ | `init` (default) | Detect, ask, write, wire, verify, commit, report |
26
+ | `check` | Verify an existing setup against the tree and report |
27
+
28
+ The argument names the mode: `mode:init`, `mode:check` (or `--check`, the older spelling). A run with no mode that finds a `.sassywares/config.yaml` walking up is a **check** run — a folder that is already wired is verified, never rebuilt from detection.
29
+
30
+ A check run never edits the config and never rewrites the tree: it prints what `detect.sh --check` says, and its only possible write is step 8 committing setup-owned files that were already dirty. A fix is an `init` run, where each change is shown and approved first.
31
+
20
32
  ## Steps
21
33
 
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.
34
+ 1. **Resolve the start and the mode.** The path argument, else the working directory; the mode per above. On `check`, run steps 7 → 8 → 9 and stop.
23
35
  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
36
  3. **Ask only what detection cannot answer.** Never re-ask what the tree already said.
25
37
  - **`workspace`** — the snapshot mirror: create a **private** GitHub repo, or connect an existing one? Creating it needs explicit approval and a named slug first.
@@ -34,34 +46,47 @@ disable-model-invocation: true
34
46
  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.
35
47
 
36
48
  - **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`.
37
- - **Do not wire updates.** How and when the project refreshes the suite is the project's decision, not setup's: no `skills:*` scripts, no `postinstall` edit. Mention the commands (`npx skills update`, `npx skills experimental_install`) and stop there.
38
- - **Commit the vendored skills and the lock.** `.agents/skills/sassy-*` and `skills-lock.json` are the pin; a clone then needs no network.
49
+ - **Do not wire updates.** How and when the project refreshes the suite is the project's decision, not setup's: no `skills:*` scripts, no `postinstall` edit. Mention the commands (`npx skills update`, `npx skills experimental_install`) and stop there. Note for yourself that `npx skills check` is an alias of `update` — it is not a validator, and running it here would silently move the pin.
50
+ - **The pin is the commit.** `.agents/skills/sassy-*` and `skills-lock.json` are the pin, and step 8 commits them together so a clone needs no network.
39
51
  - **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.
40
52
  - **Write `.sassywares/README.md`** from `assets/sassywares-readme.md` when absent. Never overwrite it — it may carry project notes.
41
53
  - **Tell the user to `/reload`.** The skills are invisible to the harness until it rescans.
42
54
 
43
55
  6. **Install the snapshot, `workspace` only.** Read `references/workspace.md` and follow it.
56
+ 7. **Verify.** Run `scripts/detect.sh --root <path> --check`. It validates the config against the tree — topology and remotes, `base_map` shape and cycles, `repos` against each nested repo, hooks, artifacts paths, `knowledge`, `pr.*` booleans, `commit.branch`, any unknown key, and in `workspace` mode the installed snapshot's version — plus the vendored pin: every `.agents/skills/sassy-*` folder hashed against `skills-lock.json`, the lock's coverage both ways, and whether git carries the lock. Report every `drift:` line verbatim, or that the config matches the tree. Drift blocks step 8.
57
+ 8. **Commit what setup owns.** `repo` topology only, and only when step 7 is clean. The file list is exactly what setup wrote or found unwritten:
58
+
59
+ `.sassywares/config.yaml`, `.sassywares/README.md`, `.agents/skills/sassy-*`, `skills-lock.json`, and `.gitignore` when step 5 created it. Named files only — never `git add -A`, never `git add .`.
44
60
 
45
- 7. **Verify.** Run `scripts/detect.sh --root <path> --check`. Report every `drift:` line it prints — including an outdated `snapshot` version — or that the config matches the tree, plus that `skills-lock.json` is tracked and matches the vendored skills.
46
- 8. **Report.** The config path, the topology, the resolved artifact and knowledge paths, that the vendored skills and lock are committed and `/reload` is needed, and in `workspace` mode what was installed. **Commit nothing.**
61
+ Dispatch `sassy-commit` with that list and `branch:current`. The token is explicit, not this skill's default: wiring is a one-time bootstrap commit, and cutting a branch would leave the project configured but uncommitted until someone ships it. Name the branch it landed on, base branch included.
62
+
63
+ **Nothing else** unless the user asks: a plan, a doc, a source file, or anything already dirty in the tree stays uncommitted, because setup's commit is not a place to launder work in progress. A user who says "commit my plan too" widens the list with the file they named — never with a sweep for it. On a `check` run this fires only when something setup owns is dirty: an uncommitted pin, a config edited since the last commit. Nothing dirty, nothing to commit.
64
+
65
+ - **`workspace`** — nothing to commit: the root is not a repo, and `references/workspace.md` forbids running the snapshot here. Say the root config and the pin land with the project's next snapshot.
66
+ - **`none`** — nothing to commit, deliberately.
67
+
68
+ 9. **Report.** The config path, the topology, the resolved artifact and knowledge paths, the commit (hash and branch) or why there is none, and in `workspace` mode what was installed. Remind: `/reload` so the harness rescans. Close with what is left over — at most dirt setup deliberately did not commit — and nothing else: a clean setup leaves the user good to go.
47
69
 
48
70
  ## Hard rules
49
71
 
50
72
  | Rule | Requirement |
51
73
  |------|-------------|
52
- | No `git init` | Setup never turns a folder into a repo, and never touches an existing repo's git state. |
74
+ | No `git init` | Setup never turns a folder into a repo, and never does remote or ref surgery. The step 8 commit is its only git write. |
75
+ | Commit only what setup owns | The config, the setup README, the vendored `sassy-*` folders, `skills-lock.json`, and a `.gitignore` setup created. Never a file the user wrote or the repo already had, unless they name it. |
76
+ | Verify before commit | A config that fails `--check` is reported, never committed. |
53
77
  | Approval to create a remote | Creating the mirror repo needs an explicit yes and a named slug first. |
54
78
  | No silent overwrite | An existing config or an existing `.sassywares/workspace/snapshot` is shown and asked about, never replaced blind. |
55
79
  | Version is reported | Setup names the snapshot version it installed; `--check` flags an installed copy older than the skill's. |
56
- | Never commit | Setup writes files and stops. Committing is `sassy-commit`. |
57
80
  | Skills come from upstream | Install with `npx skills add`; never hand-copy skill files or maintain the links yourself. |
58
81
  | Never edit the manifest | No dependencies, no scripts, no `postinstall`. Setup installs the suite; the project decides how it updates. |
59
- | Write boundary | Only `.sassywares/`, the vendored `.agents/skills/sassy-*`, `skills-lock.json`, and one `.gitignore` line. Never `package.json`, never a root `.gitignore` in a workspace. |
82
+ | Write boundary | Only `.sassywares/`, the vendored `.agents/skills/sassy-*`, `skills-lock.json`, and one `.gitignore` line — and the same list is step 8's commit. Never `package.json`, never a root `.gitignore` in a workspace. |
60
83
  | Detect, don't guess | An unknown base branch stays unknown and gets asked about. A probed value is reported as probed. |
61
84
  | Comments are part of the file | Every key keeps its description and allowed values. A stripped config is an incomplete config. |
62
85
  | Hooks are named, not written | Setup records hook skill names. Creating those skills is the project's work. |
63
- | Repair is verify | Run against an existing config, setup reports drift — it does not rewrite the file without approval. |
86
+ | Repair is `check` | Run against an existing config, setup reports drift — it does not rewrite the file without approval. |
64
87
 
65
88
  ## What setup is not
66
89
 
67
90
  Not a migration tool. A project already using another convention keeps its files; setup records where they already are. There is no rename step, and no fallback to `docs/` behind a configured path.
91
+
92
+ Not an updater. `check` reports a pin that is stale or edited; it never moves it. Updating the suite is `npx skills update`, which is the project's decision and not setup's.
@@ -23,12 +23,14 @@ If a skill tells you another skill is missing, run `npx skills add sassywares/sa
23
23
 
24
24
  ## Config
25
25
 
26
- `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:
26
+ `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 setup still matches the tree — the config, the hooks it names, and the vendored pin:
27
27
 
28
28
  ```bash
29
29
  .agents/skills/sassy-setup/scripts/detect.sh --root . --check
30
30
  ```
31
31
 
32
+ Or ask for `mode:check` on the setup skill, which runs the same check and reports it. Neither moves the pin: `npx skills check` is an alias of `update`.
33
+
32
34
  ## Hooks
33
35
 
34
36
  `hooks.pr` names project skills that run inside the PR pipeline, keyed by the PR's **head** branch: `"*"` for every PR, an exact branch, or a family such as `release/*`, overriding per point (`before_create`, `after_create`, `before_merge`, `after_merge`). An entry is `<skill> <args…>`; the args mean whatever that skill says they mean — `mode:auto` pre-authorizes the step. Hooks are ordinary skills in `.agents/skills/`, which makes them **code**: review them like code, and never point a hook at a copy of a suite skill.
@@ -354,9 +354,21 @@ per-PR say-so, because one switch would merge N PRs nobody re-read.
354
354
  `concepts`), must resolve inside the project, and must not be the root itself.
355
355
  - `git.repo` is `workspace`, `none`, or one existing remote name.
356
356
  - `git.remote` is required when `git.repo: workspace`; forbidden otherwise.
357
- - `repos` is allowed only when `git.repo: workspace`.
357
+ `git.mirror_branch` and `repos` are workspace-only too.
358
358
  - `hooks` is allowed only with the four keys under `hooks.pr`, each a list of
359
359
  skill names that resolve to `.agents/skills/<name>/SKILL.md`.
360
360
  - `commit.branch` is `current` or `new`; `pr.*` are `true` or `false`. Write
361
361
  booleans as `true`/`false`, never `on`/`off` — bare `on`/`off` are YAML 1.1
362
362
  booleans and parse inconsistently.
363
+ - `knowledge.read` is an inline list (`read: [a, b]`). A block list is not read
364
+ by the config reader, so it would silently scout nothing.
365
+ - An unknown key is an error, not a fallback: a typo parses, is ignored, and the
366
+ run uses a default this project never chose.
367
+
368
+ `scripts/detect.sh --check` enforces all of the above and reports each breach as
369
+ a `drift:` line, exiting non-zero. It also verifies the vendored pin offline:
370
+ every `.agents/skills/sassy-*` folder is hashed with the `skills` CLI's own
371
+ definition and compared to `computedHash` in `skills-lock.json`, the lock is
372
+ checked against the installed set in both directions, and git-tracked state of
373
+ the lock is reported. A stale, hand-edited, or half-installed suite is drift
374
+ even when the config itself matches the tree.
@@ -8,7 +8,8 @@
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
10
  # detect.sh --branch [--policy P] resolve the branch decision for this run
11
- # detect.sh [--root DIR] --check compare the existing config to reality
11
+ # detect.sh [--root DIR] --check compare the existing config, and the
12
+ # vendored pin, to reality
12
13
  #
13
14
  # --policy takes infer|current|new: the skill-level token. infer (the default)
14
15
  # defers to commit.branch in the config, and new is the fallback when the config
@@ -242,6 +243,16 @@ nested_repos() {
242
243
 
243
244
  path_exists() { [[ -e "$ROOT/$1" ]]; }
244
245
 
246
+ # A configured path that leaves the project is a document written somewhere the
247
+ # project does not own, so it fails closed rather than being created.
248
+ escapes_root() {
249
+ local p="${1#./}"
250
+ case "$p" in
251
+ ..|../*|*/..|*/../*|*"/../"*) return 0 ;;
252
+ esac
253
+ return 1
254
+ }
255
+
245
256
  pick_artifact() {
246
257
  local candidate="$1" fallback="$2"
247
258
  if path_exists "$candidate"; then printf '%s' "$candidate"; else printf '%s' "$fallback"; fi
@@ -303,6 +314,27 @@ if (( CHECK )); then
303
314
  esac
304
315
  fi
305
316
 
317
+ # A typo'd key is the quietest failure in the file: it parses, it is ignored,
318
+ # and the run silently falls back to a default the project never chose. The
319
+ # whitelist is the schema in references/config.md; every key there has a
320
+ # validator of its own below, so anything landing here is unknown.
321
+ while IFS=$'\t' read -r kpath _; do
322
+ [[ -n "$kpath" ]] || continue
323
+ key="${kpath//$US/.}"
324
+ case "$key" in
325
+ artifacts.plans|artifacts.solutions|artifacts.handbooks|artifacts.concepts) ;;
326
+ knowledge.read|knowledge.write) ;;
327
+ git.repo|git.remote|git.mirror_branch) ;;
328
+ git.default_branch) ;; # has its own message below
329
+ git.base_map.*) ;;
330
+ repos.*) ;;
331
+ commit.branch) ;;
332
+ pr.babysit|pr.merge|pr.delete_branch) ;;
333
+ hooks.pr.*) ;; # shape checked with the hooks
334
+ *) bad "unknown key '$key' — it is ignored, so the run uses the default" ;;
335
+ esac
336
+ done < <(yaml_flat "$CONFIG")
337
+
306
338
  if [[ -n "$(yflat "git${US}default_branch")" ]]; then
307
339
  if [[ "$TOPOLOGY" == "workspace" ]]; then
308
340
  bad "git.default_branch is gone — the mirror's branch is git.mirror_branch"
@@ -315,6 +347,9 @@ if (( CHECK )); then
315
347
  if [[ -n "$(yflat "git${US}remote")" ]]; then
316
348
  bad "git.remote is workspace-only — a repo config names its remote in git.repo"
317
349
  fi
350
+ if [[ -n "$(yflat "git${US}mirror_branch")" ]]; then
351
+ bad "git.mirror_branch is workspace-only — a repo routes branches in git.base_map"
352
+ fi
318
353
  if [[ -z "$(yflat "git${US}base_map${US}*")" ]]; then
319
354
  bad "git.base_map has no \"*\" entry — it is the base for ordinary work"
320
355
  else
@@ -344,6 +379,17 @@ if (( CHECK )); then
344
379
  fi
345
380
  fi
346
381
 
382
+ # `repos` is the workspace inventory. In a repo config it is a dead map, and
383
+ # a reader would trust it over the base_map that actually applies.
384
+ if [[ "$configured_repo" != "workspace" ]]; then
385
+ while IFS= read -r rname; do
386
+ [[ -n "$rname" ]] || continue
387
+ bad "repos.$rname is workspace-only — git.repo is '${configured_repo:-unset}'"
388
+ done < <(yaml_flat "$CONFIG" \
389
+ | awk -F'\t' -v us="$US" '{n=split($1,a,us); if (n>=2 && a[1]=="repos") print a[2]}' \
390
+ | sort -u)
391
+ fi
392
+
347
393
  if [[ "$TOPOLOGY" == "workspace" ]]; then
348
394
  [[ -n "$MIRROR_REMOTE" ]] || bad "git.remote is required when git.repo is workspace"
349
395
  installed="$ROOT/.sassywares/workspace/snapshot"
@@ -411,6 +457,22 @@ if (( CHECK )); then
411
457
  done
412
458
  done < <(hook_patterns)
413
459
 
460
+ # `hooks.pr.<head>.<point>` is the only shape, and the four points are the
461
+ # only moments a skill asks for. A fifth point name is a step that never fires.
462
+ while IFS=$'\t' read -r depth hpath; do
463
+ [[ -n "$depth" ]] || continue
464
+ if [[ "$depth" != "4" ]]; then
465
+ bad "hooks entry '${hpath//$US/.}' is not head -> point -> list"
466
+ continue
467
+ fi
468
+ point="${hpath##*$US}"
469
+ case "$point" in
470
+ before_create|after_create|before_merge|after_merge) ;;
471
+ *) bad "hooks point '$point' is not one of before_create|after_create|before_merge|after_merge" ;;
472
+ esac
473
+ done < <(yaml_flat "$CONFIG" \
474
+ | awk -F'\t' -v us="$US" '{n=split($1,a,us); if (n>=2 && a[1]=="hooks") print n "\t" $1}')
475
+
414
476
  # Resolve for one head, so nobody has to merge maps in their head.
415
477
  if [[ -n "$HEAD_ARG" ]]; then
416
478
  if [[ "$TOPOLOGY" == "repo" ]]; then
@@ -422,9 +484,65 @@ if (( CHECK )); then
422
484
  done
423
485
  fi
424
486
 
487
+ # artifacts is the one block that fails closed everywhere it is read, so a
488
+ # bad value here is drift, not a note: a typo must not write docs outside the
489
+ # project. Existence is not drift — the directory is created on first write.
425
490
  for key in plans solutions handbooks concepts; do
426
491
  val="$(block_value artifacts "$key" || true)"
427
- [[ -n "$val" ]] && note " artifacts.$key: $val"
492
+ [[ -n "$val" ]] || continue
493
+ case "$val" in
494
+ /*|~*) bad "artifacts.$key '$val' is not relative to the config" ;;
495
+ .|./) bad "artifacts.$key is the project root itself" ;;
496
+ *)
497
+ if escapes_root "$val"; then
498
+ bad "artifacts.$key '$val' walks outside the project"
499
+ elif [[ "$key" == "concepts" && "$val" == */ ]]; then
500
+ bad "artifacts.concepts '$val' is a directory — concepts is one file"
501
+ elif path_exists "$val"; then
502
+ note " artifacts.$key: $val (exists)"
503
+ else
504
+ note " artifacts.$key: $val (created on first write)"
505
+ fi
506
+ ;;
507
+ esac
508
+ done
509
+
510
+ # knowledge.read fails soft at read time by design, which is exactly why a
511
+ # broken value has to be visible here: nothing else will ever complain.
512
+ while IFS= read -r kline; do
513
+ [[ -n "$kline" ]] || continue
514
+ bad "knowledge.read must be an inline list — write read: [docs/solutions, CONCEPTS.md]"
515
+ done < <(sed -n '/^knowledge:/,/^[a-z]/p' "$CONFIG" | grep -E '^[[:space:]]*-[[:space:]]' || true)
516
+ readv="$(yflat "knowledge${US}read" || true)"
517
+ while IFS= read -r kpath; do
518
+ [[ -n "$kpath" ]] || continue
519
+ case "$kpath" in
520
+ /*|~*) bad "knowledge.read '$kpath' is not a path relative to the config" ;;
521
+ *) if escapes_root "$kpath"; then
522
+ bad "knowledge.read '$kpath' walks outside the project"
523
+ elif ! path_exists "$kpath"; then
524
+ note " knowledge.read '$kpath' does not exist — skipped, not fatal"
525
+ fi ;;
526
+ esac
527
+ done < <(list_items "$readv")
528
+ val="$(yflat "knowledge${US}write" || true)"
529
+ if [[ -n "$val" ]]; then
530
+ case "$val" in
531
+ true|false) note " knowledge.write: $val" ;;
532
+ *) bad "knowledge.write '$val' must be true|false" ;;
533
+ esac
534
+ fi
535
+
536
+ # pr.* answers a policy question, so a value that is neither boolean is a
537
+ # policy nobody decided. Bare on/off are YAML 1.1 booleans and parse
538
+ # inconsistently across readers.
539
+ for key in babysit merge delete_branch; do
540
+ val="$(block_value pr "$key" || true)"
541
+ [[ -n "$val" ]] || continue
542
+ case "$val" in
543
+ true|false) note " pr.$key: $val" ;;
544
+ *) bad "pr.$key '$val' must be true|false, never on/off" ;;
545
+ esac
428
546
  done
429
547
 
430
548
  # commit.branch steers whether a run may touch a base branch, so an invalid
@@ -437,6 +555,33 @@ if (( CHECK )); then
437
555
  esac
438
556
  fi
439
557
 
558
+ # The pin. A config can match the tree perfectly while the vendored suite is
559
+ # stale, edited, or half-installed, and every skill downstream then runs
560
+ # something other than what the lock says. Offline, no network.
561
+ if command -v node >/dev/null 2>&1; then
562
+ while IFS=$'\t' read -r kind msg; do
563
+ case "$kind" in
564
+ drift) bad "$msg" ;;
565
+ note) note " $msg" ;;
566
+ esac
567
+ done < <(node "$SKILL_DIR/scripts/lockcheck.mjs" --root "$ROOT" 2>/dev/null || true)
568
+ else
569
+ note " node not found — the vendored pin is unverified (the skills CLI needs node)"
570
+ fi
571
+
572
+ if [[ "$TOPOLOGY" == "repo" ]]; then
573
+ # Tracked, not merely present: a clone has to get the pin without a network
574
+ # call, so an untracked lock is a setup that does not travel.
575
+ if [[ -n "$(git -C "$ROOT" ls-files -- skills-lock.json 2>/dev/null)" ]]; then
576
+ note " skills-lock.json is tracked"
577
+ else
578
+ note " skills-lock.json is not tracked yet — the setup commit carries it"
579
+ fi
580
+ if [[ -e "$ROOT/.claude/skills" ]] && ! git -C "$ROOT" check-ignore -q -- .claude/skills 2>/dev/null; then
581
+ note " .claude/skills is not gitignored — install-time links may get committed"
582
+ fi
583
+ fi
584
+
440
585
  if (( drift )); then exit 1; fi
441
586
  note " config matches the tree"
442
587
  exit 0
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ // Verify the vendored sassy-* folders against skills-lock.json.
3
+ //
4
+ // Read-only, offline. The folder hash is the `skills` CLI's own definition —
5
+ // sha256 over each file's relative path then its bytes, files sorted by relative
6
+ // path — so "identical to the pin" is a fact this script asserts, not a claim
7
+ // the agent has to make in prose.
8
+ //
9
+ // Usage: lockcheck.mjs [--root DIR]
10
+ // Prints "<drift|note>\t<message>" per finding and always exits 0: the caller
11
+ // owns the verdict and the formatting.
12
+
13
+ import { createHash } from "node:crypto";
14
+ import { readdir, readFile } from "node:fs/promises";
15
+ import { existsSync } from "node:fs";
16
+ import { join, relative } from "node:path";
17
+
18
+ const SUITE = "sassywares/sassy-skills";
19
+ const SASSY = /^sassy-/;
20
+
21
+ const argv = process.argv.slice(2);
22
+ let root = process.cwd();
23
+ for (let i = 0; i < argv.length; i += 1) {
24
+ if (argv[i] === "--root") {
25
+ root = argv[i + 1];
26
+ i += 1;
27
+ }
28
+ }
29
+
30
+ const lines = [];
31
+ const drift = (message) => lines.push(`drift\t${message}`);
32
+ const note = (message) => lines.push(`note\t${message}`);
33
+
34
+ async function collect(base, dir, files) {
35
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
36
+ const full = join(dir, entry.name);
37
+ if (entry.isDirectory()) {
38
+ if (entry.name === ".git" || entry.name === "node_modules") continue;
39
+ await collect(base, full, files);
40
+ } else if (entry.isFile()) {
41
+ files.push({
42
+ path: relative(base, full).split("\\").join("/"),
43
+ body: await readFile(full),
44
+ });
45
+ }
46
+ }
47
+ }
48
+
49
+ async function folderHash(dir) {
50
+ const files = [];
51
+ await collect(dir, dir, files);
52
+ files.sort((a, b) => a.path.localeCompare(b.path));
53
+ const hash = createHash("sha256");
54
+ for (const file of files) {
55
+ hash.update(file.path);
56
+ hash.update(file.body);
57
+ }
58
+ return hash.digest("hex");
59
+ }
60
+
61
+ async function main() {
62
+ const lockPath = join(root, "skills-lock.json");
63
+ if (!existsSync(lockPath)) {
64
+ drift(`no skills-lock.json — the suite is not pinned; install with npx skills add ${SUITE}`);
65
+ return;
66
+ }
67
+
68
+ let lock;
69
+ try {
70
+ lock = JSON.parse(await readFile(lockPath, "utf8"));
71
+ } catch {
72
+ drift(`skills-lock.json is not valid JSON — re-install with npx skills add ${SUITE}`);
73
+ return;
74
+ }
75
+
76
+ const pinned = Object.entries(lock.skills ?? {}).filter(
77
+ ([name, entry]) => SASSY.test(name) || String(entry.source ?? "").includes("sassy-skills"),
78
+ );
79
+ if (pinned.length === 0) {
80
+ drift(`skills-lock.json pins no sassy-* skill — install with npx skills add ${SUITE}`);
81
+ return;
82
+ }
83
+
84
+ const suite = join(root, ".agents", "skills");
85
+ if (!existsSync(suite)) {
86
+ drift(`no .agents/skills — the vendored copies are missing; install with npx skills add ${SUITE}`);
87
+ return;
88
+ }
89
+
90
+ const installed = (await readdir(suite, { withFileTypes: true }))
91
+ .filter((entry) => entry.isDirectory() && SASSY.test(entry.name))
92
+ .map((entry) => entry.name);
93
+ const installedNames = new Set(installed);
94
+ const pinnedNames = new Set(pinned.map(([name]) => name));
95
+ let matched = 0;
96
+
97
+ for (const [name, entry] of pinned) {
98
+ if (!installedNames.has(name)) {
99
+ drift(`vendored ${name} is missing from .agents/skills — re-install with npx skills add ${SUITE}`);
100
+ continue;
101
+ }
102
+ if ((await folderHash(join(suite, name))) !== entry.computedHash) {
103
+ drift(`${name} does not match the pin — edited locally or half-installed; re-install, or re-pin with npx skills add ${SUITE}`);
104
+ continue;
105
+ }
106
+ matched += 1;
107
+ }
108
+
109
+ for (const name of installed) {
110
+ if (!pinnedNames.has(name)) {
111
+ drift(`${name} is installed but not pinned in skills-lock.json — npx skills add ${SUITE} pins it`);
112
+ }
113
+ }
114
+
115
+ if (matched > 0) {
116
+ note(`${matched}/${pinned.length} sassy-* skill folders match skills-lock.json byte for byte`);
117
+ }
118
+
119
+ const sources = [...new Set(pinned.map(([, entry]) => String(entry.source ?? "unknown")))];
120
+ if (sources.length === 1 && sources[0] !== SUITE) {
121
+ note(`pinned from ${sources[0]}, not ${SUITE}`);
122
+ }
123
+ }
124
+
125
+ main()
126
+ .catch((error) => drift(`could not verify the pin: ${error.message}`))
127
+ .finally(() => {
128
+ if (lines.length > 0) process.stdout.write(`${lines.join("\n")}\n`);
129
+ });