@sassywares/skills 0.5.9 → 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 +4 -2
- package/package.json +1 -1
- package/skills/sassy-setup/SKILL.md +37 -12
- package/skills/sassy-setup/assets/sassywares-readme.md +3 -1
- package/skills/sassy-setup/references/config.md +13 -1
- package/skills/sassy-setup/scripts/detect.sh +147 -2
- package/skills/sassy-setup/scripts/lockcheck.mjs +129 -0
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
|
-
`
|
|
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
|
@@ -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
|
|
8
|
-
|
|
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,
|
|
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
|
|
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
|
-
- **
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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" ]]
|
|
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
|
+
});
|