@csark0812/skeleton 1.5.2 → 1.5.3

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
@@ -2,17 +2,25 @@
2
2
 
3
3
  **Source of truth for** Package overview.
4
4
 
5
- <!-- doc-meta: owner=eng | last-reviewed=2026-07-13 -->
5
+ <!-- doc-meta: owner=eng | last-reviewed=2026-07-15 -->
6
6
 
7
- Single source of truth (SSOT) linter for agent-enabled repos.
7
+ Agent repos get messy fast. Skills get copied around, docs disagree, links go stale, and nobody remembers which file is actually canonical.
8
8
 
9
- Agent repos accumulate skills, rules, registries, and cross-linked docs faster than anyone can keep them straight by hand. Code repos solved this decades ago with ESLint — deterministic checks, CI gates, fix what you can before merge. Skeleton does the same job for documentation architecture: what's canonical, what links where, what must not exist, and whether your skill overrides are wired correctly.
9
+ Skeleton is an SSOT linter for that layer. You define the contract once; Skeleton checks it locally and in CI.
10
10
 
11
- Skeleton is **not** a runtime agent harness. It does not execute tools, enforce permissions, or manage agent memory. It is a CLI that audits docs, skills, and registries, and fails CI when invariants break.
11
+ If a canonical doc disappears, a registry drifts, a skill index stops matching disk, or a generated reference gets edited by hand, the audit fails before merge.
12
12
 
13
- ## Why
13
+ Think ESLint, but for the docs and skills your agents rely on.
14
14
 
15
- Code has linters. Agent repos need the same thing for docs and SSOT.
15
+ Skeleton is **not** a runtime agent harness. It doesn't execute tools, enforce permissions, or manage memory. It checks whether the repo around those systems still holds together.
16
+
17
+ ## Why this matters
18
+
19
+ Agents can read the repo. They can't reliably infer which of three conflicting docs wins, whether a synced skill should be edited here, or which validation command actually proves a change.
20
+
21
+ That stuff needs to be explicit. More importantly, it needs to stay true after the next 50 PRs.
22
+
23
+ Skeleton turns those repo conventions into checks:
16
24
 
17
25
  | Code repos | Agent repos |
18
26
  | ------------------------------------------------------- | -------------------------------------------------------------------------------------- |
@@ -20,7 +28,20 @@ Code has linters. Agent repos need the same thing for docs and SSOT.
20
28
  | `eslint --fix` on changed files | `skeleton validate changed` on changed docs and skills |
21
29
  | Pre-commit + CI gate | `--staged` pre-commit + `--base` CI gate |
22
30
 
23
- Skill linters (skillmark, agentlint, skillscheck) answer: _"Is this SKILL.md well-formed?"_ Skeleton answers: _"Does this repo's documentation system hold together?"_
31
+ Skill linters answer: _"Is this SKILL.md well-formed?"_
32
+
33
+ Skeleton answers the repo-level question: _"Does this whole thing still agree with itself?"_
34
+
35
+ ## What the research says
36
+
37
+ Research here is still early, but the direction is useful: more context isn't automatically better.
38
+
39
+ - A [2026 study of repo-level context files](https://doi.org/10.48550/arxiv.2602.11988) tested 438 coding tasks. Human-written files improved resolution by 4% on average; generated files reduced it by 3%. Both increased inference cost by more than 20%. The recommendation was pretty direct: keep instructions minimal and include what the agent can't infer.
40
+ - A [2025 METR randomized trial](https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/) found experienced open-source developers took 19% longer with early-2025 AI tools while believing they were faster.
41
+
42
+ Neither study proves Skeleton improves task success. That needs a Skeleton-specific benchmark.
43
+
44
+ What they do support is the constraint Skeleton is built around: keep the useful context small, make ownership clear, and verify the rest instead of trusting that it stayed correct.
24
45
 
25
46
  ## Quick start
26
47
 
@@ -29,7 +50,7 @@ npm install -D @csark0812/skeleton
29
50
  npx skeleton init --skills
30
51
  ```
31
52
 
32
- Init writes `.skeleton/`, merges validate scripts into `package.json`, and wires customize hooks for Cursor, Claude Code, and Codex.
53
+ That writes `.skeleton/`, adds the validation scripts, and wires customize hooks for Cursor, Claude Code, and Codex.
33
54
 
34
55
  Edit `.skeleton/config.yaml` for your repo layout, then verify:
35
56
 
@@ -37,7 +58,7 @@ Edit `.skeleton/config.yaml` for your repo layout, then verify:
37
58
  npx skeleton audit self
38
59
  ```
39
60
 
40
- See [install](docs/developer/install.md) for flags and options.
61
+ Flag details: [install](docs/developer/install.md).
41
62
 
42
63
  ## What it checks
43
64
 
@@ -50,7 +71,7 @@ See [install](docs/developer/install.md) for flags and options.
50
71
  - **Prose policy** (optional plugins) — YAML pattern rules; idle with no plugins
51
72
  - **Shell / JSON syntax** — lightweight checks on changed `.sh` and `.json` files
52
73
 
53
- Code validation (TypeScript, Python, Nx, pytest) stays in your repo. Skeleton handles SSOT-adjacent paths only.
74
+ Skeleton doesn't replace your code gates. Keep TypeScript, Python, Nx, pytest, etc. in the repo that owns them.
54
75
 
55
76
  ## The `.skeleton/` contract
56
77
 
@@ -75,7 +96,7 @@ Register it:
75
96
  skeleton register docs/developer/api.md
76
97
  ```
77
98
 
78
- Synced toolbox skills stay pristine. Project overrides live in `.skeleton/customize/<slug>.md` and inject via IDE hooks on skill read no editing synced `SKILL.md` files.
99
+ Synced skills stay pristine. Project overrides live in `.skeleton/customize/<slug>.md` and inject when the skill is read. No editing copied `SKILL.md` files.
79
100
 
80
101
  ## Commands
81
102
 
@@ -91,41 +112,33 @@ skeleton customize resolve <slug>
91
112
 
92
113
  **Validate changed** routes git diffs to the right audit:
93
114
 
94
- | Path | Action |
95
- | ----------------------------------------------------------------------------------- | --------------------------- |
96
- | Docs in scan perimeter | path-scoped audit |
97
- | Skill bodies (`SKILL.md` trees) | exit 1 → run `audit skills` |
98
- | `.sh`, `.bash`, `.zsh` | shellcheck or `bash -n` |
99
- | Other `.json` | JSONC-tolerant syntax check |
100
- | `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`, `.py`, `package.json`, `project.json` | skip (exits 1 if all skip) |
115
+ | Path | Action |
116
+ | ----------------------------------------------------------------------------------- | ------------------------------------------------------------- |
117
+ | Docs in scan perimeter | path-scoped audit |
118
+ | Owned skill bodies (`SKILL.md` trees) | exit 1 → run `audit skills` |
119
+ | Foreign / lockfile-synced skill bodies | skip lint in the owning skills/toolbox repo |
120
+ | `.sh`, `.bash`, `.zsh` | shellcheck or `bash -n` |
121
+ | Other `.json` | JSONC-tolerant syntax check |
122
+ | `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`, `.py`, `package.json`, `project.json` | skip (exits 1 if all skip) |
101
123
 
102
124
  Pre-commit: `skeleton validate changed --staged` (path-scoped, fast).
125
+
103
126
  CI: `skeleton validate changed --base origin/main` (global rules first, then changed files).
104
127
 
105
128
  ## Ecosystem
106
129
 
107
- Skeleton is the shared validation layer in a three-tier setup:
130
+ The model is simple:
108
131
 
109
- | Repo | Role |
110
- | -------------------- | -------------------------------------------------- |
111
- | **skeleton** | SSOT audit CLI (this repo) |
112
- | **toolbox** | Team skills + public agent preferences |
113
- | **personal-toolbox** | Private skills + personal preferences |
114
- | **Consumer apps** | Call skeleton for SSOT; keep code validation local |
132
+ | Layer | Role |
133
+ | ----------------- | ---------------------------------------------------------------------------------------------- |
134
+ | **Skeleton** | Defines and checks the SSOT contract |
135
+ | **Shared skills** | Reusable team or public skills. [toolbox](https://github.com/csark0812/toolbox) is one example |
136
+ | **Consumer apps** | Pull in the skills, run Skeleton on SSOT paths, and keep their own code gates |
115
137
 
116
- Skeleton never calls Nx or other task runners consumers call skeleton for doc and skill paths, then handle code paths themselves.
138
+ Skeleton never calls Nx or another app task runner. Consumer repos keep ownership of test, typecheck, and build.
117
139
 
118
140
  See [tiers](docs/tiers.md).
119
141
 
120
- ## Distribution
121
-
122
- | Channel | Installs |
123
- | ---------------------------------------------------- | ------------------------------------------------------- |
124
- | `npm install -D @csark0812/skeleton` | CLI, schemas, audit engine, hook script |
125
- | `npx skills add csark0812/skeleton --skill skeleton` | `/skeleton` agent skill (ops manual, not the installer) |
126
-
127
- One command for humans: `npx skeleton init --skills`.
128
-
129
142
  ## Docs
130
143
 
131
144
  - [Install](docs/developer/install.md)
@@ -145,6 +158,8 @@ bun install
145
158
  bun run check
146
159
  ```
147
160
 
148
- `bun run check` runs lint, test, typecheck, build, and `audit:self`. `validate:changed` is docs/config only for path-scoped work — it skips code/config extensions (see table) and skill-body edits need `audit skills`. All-skip / skill-only / missing paths exit non-zero. Use `bun test`, `bun run typecheck`, and `bun run build` for code.
161
+ `bun run check` = lint + test + typecheck + build + `audit:self`.
162
+
163
+ `validate:changed` is docs/config only for path-scoped work — it skips code/config extensions (see table above). Owned skill-body edits need `audit skills`. All-skip, owned skill paths (alone or mixed with docs), and missing paths exit non-zero. For code: `bun test`, `bun run typecheck`, `bun run build`.
149
164
 
150
165
  Optional: `brew install pre-commit` (or `pipx install pre-commit`), then `pre-commit install` to wire `.pre-commit-config.yaml`.
package/dist/cli.js CHANGED
@@ -15651,6 +15651,9 @@ var require_extend = __commonJS((exports, module) => {
15651
15651
  };
15652
15652
  });
15653
15653
 
15654
+ // src/cli.ts
15655
+ import { readFileSync as readFileSync22 } from "node:fs";
15656
+
15654
15657
  // src/audit/config/load.ts
15655
15658
  var import_ajv = __toESM(require_ajv(), 1);
15656
15659
  import { existsSync, readFileSync } from "node:fs";
@@ -16984,12 +16987,99 @@ async function loadPlugins(root, config) {
16984
16987
  }
16985
16988
 
16986
16989
  // src/audit/core/collect.ts
16987
- import { existsSync as existsSync6, readFileSync as readFileSync4, realpathSync as realpathSync4, statSync as statSync2 } from "node:fs";
16988
- import { join as join5, relative as relative4 } from "node:path";
16990
+ import { existsSync as existsSync7, readFileSync as readFileSync5, realpathSync as realpathSync4, statSync as statSync2 } from "node:fs";
16991
+ import { join as join6, relative as relative4 } from "node:path";
16992
+
16993
+ // src/audit/core/skill-roots.ts
16994
+ import { existsSync as existsSync6, readdirSync as readdirSync2, readlinkSync, realpathSync as realpathSync3 } from "node:fs";
16995
+ import { join as join5, relative as relative3 } from "node:path";
16996
+
16997
+ // src/audit/core/skill-provenance.ts
16998
+ import { existsSync as existsSync5, readFileSync as readFileSync4 } from "node:fs";
16999
+ import { join as join4 } from "node:path";
17000
+ var DEFAULT_SKILLS_LOCKFILE = "skills-lock.json";
17001
+ function isForeignLockSourceType(sourceType) {
17002
+ return sourceType !== "local";
17003
+ }
17004
+ function loadSkillsLock(root, lockfileRel = DEFAULT_SKILLS_LOCKFILE) {
17005
+ const warnings = [];
17006
+ const abs = join4(root, lockfileRel);
17007
+ if (!existsSync5(abs)) {
17008
+ return { lockfile: null, entries: {}, warnings };
17009
+ }
17010
+ let raw;
17011
+ try {
17012
+ raw = JSON.parse(readFileSync4(abs, "utf8"));
17013
+ } catch (error) {
17014
+ warnings.push(`malformed ${lockfileRel}: ${error instanceof Error ? error.message : String(error)}`);
17015
+ return { lockfile: lockfileRel, entries: {}, warnings };
17016
+ }
17017
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
17018
+ warnings.push(`${lockfileRel}: expected a JSON object`);
17019
+ return { lockfile: lockfileRel, entries: {}, warnings };
17020
+ }
17021
+ const obj = raw;
17022
+ if (obj.version !== 1) {
17023
+ warnings.push(`${lockfileRel}: unsupported version ${String(obj.version)} (expected 1); ignoring entries`);
17024
+ return { lockfile: lockfileRel, entries: {}, warnings };
17025
+ }
17026
+ const skillsRaw = obj.skills;
17027
+ if (!skillsRaw || typeof skillsRaw !== "object" || Array.isArray(skillsRaw)) {
17028
+ warnings.push(`${lockfileRel}: missing or invalid "skills" object`);
17029
+ return { lockfile: lockfileRel, entries: {}, warnings };
17030
+ }
17031
+ const entries = {};
17032
+ for (const [slug, value] of Object.entries(skillsRaw)) {
17033
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
17034
+ warnings.push(`${lockfileRel}: skill "${slug}" has invalid entry`);
17035
+ continue;
17036
+ }
17037
+ const entry = value;
17038
+ const source = entry.source;
17039
+ const sourceType = entry.sourceType;
17040
+ if (typeof source !== "string" || source.length === 0) {
17041
+ warnings.push(`${lockfileRel}: skill "${slug}" missing source`);
17042
+ continue;
17043
+ }
17044
+ if (typeof sourceType !== "string" || sourceType.length === 0) {
17045
+ warnings.push(`${lockfileRel}: skill "${slug}" missing sourceType`);
17046
+ continue;
17047
+ }
17048
+ const parsed = { source, sourceType };
17049
+ if (typeof entry.skillPath === "string")
17050
+ parsed.skillPath = entry.skillPath;
17051
+ if (typeof entry.computedHash === "string")
17052
+ parsed.computedHash = entry.computedHash;
17053
+ entries[slug] = parsed;
17054
+ }
17055
+ return { lockfile: lockfileRel, entries, warnings };
17056
+ }
17057
+ function classifySkillOwnership(slug, provenance, ownership) {
17058
+ const ownedOverrides = new Set(ownership?.ownedSlugs ?? []);
17059
+ const foreignOverrides = new Set(ownership?.foreignSlugs ?? []);
17060
+ if (ownedOverrides.has(slug))
17061
+ return "owned";
17062
+ if (foreignOverrides.has(slug))
17063
+ return "foreign";
17064
+ const entry = provenance.entries[slug];
17065
+ if (entry && isForeignLockSourceType(entry.sourceType))
17066
+ return "foreign";
17067
+ return "owned";
17068
+ }
17069
+ function resolveOwnershipForSlugs(slugs, provenance, ownership) {
17070
+ const ownedSlugs = [];
17071
+ const foreignSlugs = [];
17072
+ for (const slug of slugs) {
17073
+ if (classifySkillOwnership(slug, provenance, ownership) === "foreign") {
17074
+ foreignSlugs.push(slug);
17075
+ } else {
17076
+ ownedSlugs.push(slug);
17077
+ }
17078
+ }
17079
+ return { ownedSlugs, foreignSlugs };
17080
+ }
16989
17081
 
16990
17082
  // src/audit/core/skill-roots.ts
16991
- import { existsSync as existsSync5, readdirSync as readdirSync2, readlinkSync, realpathSync as realpathSync3 } from "node:fs";
16992
- import { join as join4, relative as relative3 } from "node:path";
16993
17083
  var NESTED_SKILL_ROOTS = [".claude/skills", ".agents/skills"];
16994
17084
  var FLAT_SKILL_DENYLIST = new Set([
16995
17085
  ".git",
@@ -17018,10 +17108,10 @@ function safeRealpath(path) {
17018
17108
  }
17019
17109
  }
17020
17110
  function listNestedSlugs(root, relRoot) {
17021
- const absRoot = join4(root, relRoot);
17022
- if (!existsSync5(absRoot))
17111
+ const absRoot = join5(root, relRoot);
17112
+ if (!existsSync6(absRoot))
17023
17113
  return [];
17024
- return readdirSync2(absRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && !NESTED_EXCLUDED_DIRS.has(entry.name)).filter((entry) => existsSync5(join4(absRoot, entry.name, "SKILL.md"))).map((entry) => entry.name).sort();
17114
+ return readdirSync2(absRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && !NESTED_EXCLUDED_DIRS.has(entry.name)).filter((entry) => existsSync6(join5(absRoot, entry.name, "SKILL.md"))).map((entry) => entry.name).sort();
17025
17115
  }
17026
17116
  function listFlatSlugs(root) {
17027
17117
  const slugs = [];
@@ -17030,7 +17120,7 @@ function listFlatSlugs(root) {
17030
17120
  continue;
17031
17121
  if (FLAT_SKILL_DENYLIST.has(entry.name))
17032
17122
  continue;
17033
- if (existsSync5(join4(root, entry.name, "SKILL.md"))) {
17123
+ if (existsSync6(join5(root, entry.name, "SKILL.md"))) {
17034
17124
  slugs.push(entry.name);
17035
17125
  }
17036
17126
  }
@@ -17040,8 +17130,8 @@ function detectSkillRoots(root) {
17040
17130
  const roots = [];
17041
17131
  let claudeReal = null;
17042
17132
  for (const relRoot of NESTED_SKILL_ROOTS) {
17043
- const abs = join4(root, relRoot);
17044
- if (!existsSync5(abs))
17133
+ const abs = join5(root, relRoot);
17134
+ if (!existsSync6(abs))
17045
17135
  continue;
17046
17136
  if (relRoot === ".claude/skills") {
17047
17137
  claudeReal = safeRealpath(abs);
@@ -17055,11 +17145,11 @@ function detectSkillRoots(root) {
17055
17145
  continue;
17056
17146
  try {
17057
17147
  const link = readlinkSync(abs);
17058
- if (link && claudeReal && safeRealpath(join4(root, link)) === claudeReal)
17148
+ if (link && claudeReal && safeRealpath(join5(root, link)) === claudeReal)
17059
17149
  continue;
17060
17150
  } catch {}
17061
17151
  }
17062
- if (listNestedSlugs(root, relRoot).length > 0 || existsSync5(abs)) {
17152
+ if (listNestedSlugs(root, relRoot).length > 0 || existsSync6(abs)) {
17063
17153
  roots.push({ kind: "nested", relPath: relRoot });
17064
17154
  }
17065
17155
  }
@@ -17069,12 +17159,13 @@ function detectSkillRoots(root) {
17069
17159
  }
17070
17160
  return roots;
17071
17161
  }
17072
- function buildSkillIndex(root) {
17162
+ function buildSkillIndex(root, ownership) {
17073
17163
  const roots = detectSkillRoots(root);
17074
17164
  const slugSet = new Set;
17075
17165
  const slugs = [];
17166
+ const flatSlugs = listFlatSlugs(root);
17076
17167
  for (const skillRoot of roots) {
17077
- const rootSlugs = skillRoot.kind === "nested" ? listNestedSlugs(root, skillRoot.relPath) : listFlatSlugs(root);
17168
+ const rootSlugs = skillRoot.kind === "nested" ? listNestedSlugs(root, skillRoot.relPath) : flatSlugs;
17078
17169
  for (const slug of rootSlugs) {
17079
17170
  if (!slugSet.has(slug)) {
17080
17171
  slugSet.add(slug);
@@ -17082,32 +17173,50 @@ function buildSkillIndex(root) {
17082
17173
  }
17083
17174
  }
17084
17175
  }
17085
- return { roots, slugs };
17176
+ const lockfileRel = ownership?.lockfile ?? DEFAULT_SKILLS_LOCKFILE;
17177
+ const provenance = loadSkillsLock(root, lockfileRel);
17178
+ const { ownedSlugs, foreignSlugs } = resolveOwnershipForSlugs(slugs, provenance, ownership);
17179
+ return { roots, slugs, flatSlugs, ownedSlugs, foreignSlugs, provenance };
17180
+ }
17181
+ function isForeignSkillSlug(index, slug) {
17182
+ return index.foreignSlugs.includes(slug);
17086
17183
  }
17087
17184
  function resolveSkillPath(index, root, slug) {
17088
17185
  for (const skillRoot of index.roots) {
17089
- const candidate = skillRoot.kind === "nested" ? join4(root, skillRoot.relPath, slug, "SKILL.md") : join4(root, slug, "SKILL.md");
17090
- if (existsSync5(candidate)) {
17186
+ const candidate = skillRoot.kind === "nested" ? join5(root, skillRoot.relPath, slug, "SKILL.md") : join5(root, slug, "SKILL.md");
17187
+ if (existsSync6(candidate)) {
17091
17188
  return normalizeRelPath(relative3(root, candidate));
17092
17189
  }
17093
17190
  }
17094
17191
  return null;
17095
17192
  }
17096
17193
  function isSkillPath(relPath, index) {
17194
+ return skillSlugForPath(relPath, index) !== null;
17195
+ }
17196
+ function skillSlugForPath(relPath, index) {
17097
17197
  const normalized = normalizeRelPath(relPath);
17098
- if (normalized.endsWith("/SKILL.md"))
17099
- return true;
17198
+ const flat = new Set(index.flatSlugs);
17100
17199
  for (const skillRoot of index.roots) {
17101
- const prefix = skillRoot.kind === "nested" ? `${skillRoot.relPath}/` : "";
17102
- if (prefix && normalized.startsWith(prefix))
17103
- return true;
17104
- if (skillRoot.kind === "flat") {
17105
- const first = normalized.split("/")[0];
17106
- if (first && index.slugs.includes(first))
17107
- return true;
17200
+ if (skillRoot.kind === "nested") {
17201
+ const prefix = `${skillRoot.relPath}/`;
17202
+ if (!normalized.startsWith(prefix))
17203
+ continue;
17204
+ const slug = normalized.slice(prefix.length).split("/")[0];
17205
+ if (slug && index.slugs.includes(slug))
17206
+ return slug;
17207
+ continue;
17108
17208
  }
17209
+ const first = normalized.split("/")[0];
17210
+ if (first && flat.has(first))
17211
+ return first;
17109
17212
  }
17110
- return false;
17213
+ return null;
17214
+ }
17215
+ function isForeignSkillPath(relPath, index) {
17216
+ const slug = skillSlugForPath(relPath, index);
17217
+ if (!slug)
17218
+ return false;
17219
+ return isForeignSkillSlug(index, slug);
17111
17220
  }
17112
17221
  var NESTED_SKILL_SLUG_RE = /(?:^|\/)\.(?:claude|agents)\/skills\/([a-z0-9-]+)\//;
17113
17222
  var FLAT_SKILL_REFERENCES_RE = /(?:^|\/)([a-z0-9-]+)\/references(?:\/|$)/;
@@ -17135,18 +17244,23 @@ function slugFromPath(filePath, workspaceRoot) {
17135
17244
  if (!flatSlug || FLAT_SKILL_DENYLIST.has(flatSlug))
17136
17245
  return null;
17137
17246
  if (workspaceRoot) {
17138
- if (!existsSync5(join4(workspaceRoot, flatSlug, "SKILL.md")))
17247
+ if (!existsSync6(join5(workspaceRoot, flatSlug, "SKILL.md")))
17139
17248
  return null;
17140
17249
  }
17141
17250
  return flatSlug;
17142
17251
  }
17143
17252
  function skillCollectAugments(index) {
17253
+ const owned = new Set(index.ownedSlugs);
17144
17254
  const patterns = [];
17145
17255
  for (const skillRoot of index.roots) {
17146
17256
  if (skillRoot.kind === "nested") {
17147
- patterns.push(`${skillRoot.relPath}/**`);
17257
+ for (const slug of index.ownedSlugs) {
17258
+ patterns.push(`${skillRoot.relPath}/${slug}/**`);
17259
+ }
17148
17260
  } else {
17149
- for (const slug of index.slugs) {
17261
+ for (const slug of index.flatSlugs) {
17262
+ if (!owned.has(slug))
17263
+ continue;
17150
17264
  patterns.push(`${slug}/**`);
17151
17265
  }
17152
17266
  }
@@ -17154,12 +17268,15 @@ function skillCollectAugments(index) {
17154
17268
  return patterns;
17155
17269
  }
17156
17270
  function listSkillMarkdownPaths(root, index) {
17271
+ const owned = new Set(index.ownedSlugs);
17157
17272
  const paths = new Set;
17158
17273
  for (const skillRoot of index.roots) {
17159
17274
  const slugs = skillRoot.kind === "nested" ? listNestedSlugs(root, skillRoot.relPath) : listFlatSlugs(root);
17160
17275
  for (const slug of slugs) {
17161
- const absDir = skillRoot.kind === "nested" ? join4(root, skillRoot.relPath, slug) : join4(root, slug);
17162
- if (!existsSync5(absDir))
17276
+ if (!owned.has(slug))
17277
+ continue;
17278
+ const absDir = skillRoot.kind === "nested" ? join5(root, skillRoot.relPath, slug) : join5(root, slug);
17279
+ if (!existsSync6(absDir))
17163
17280
  continue;
17164
17281
  for (const abs of globSync("**/*.{md,mdc}", {
17165
17282
  cwd: absDir,
@@ -17176,6 +17293,9 @@ function listSkillMarkdownPaths(root, index) {
17176
17293
  function listSkillSlugs(index) {
17177
17294
  return index.slugs;
17178
17295
  }
17296
+ function listOwnedSkillSlugs(index) {
17297
+ return index.ownedSlugs;
17298
+ }
17179
17299
 
17180
17300
  // src/audit/core/collect.ts
17181
17301
  var MARKDOWN_GLOBS = ["**/*.md", "**/*.mdc"];
@@ -17220,7 +17340,13 @@ function collectScanFiles(config, root, skillIndex) {
17220
17340
  if (skillIndex) {
17221
17341
  includePatterns.push(...skillCollectAugments(skillIndex));
17222
17342
  }
17223
- return expandPatterns(root, includePatterns, exclude);
17343
+ const files = expandPatterns(root, includePatterns, exclude);
17344
+ if (!skillIndex)
17345
+ return files;
17346
+ return files.filter((abs) => {
17347
+ const rel = normalizeRelPath(relative4(root, abs));
17348
+ return !isForeignSkillPath(rel, skillIndex);
17349
+ });
17224
17350
  }
17225
17351
  function collectBannedFiles(config, root) {
17226
17352
  if (config.scan.banned.length === 0)
@@ -17266,19 +17392,19 @@ function collectDocMetaPaths(config, root, registryPaths, skillIndex) {
17266
17392
  }
17267
17393
  const extras = ["docs/README.md", ".skeleton/registry.md"];
17268
17394
  for (const file of extras) {
17269
- const abs = join5(root, file);
17270
- if (existsSync6(abs))
17395
+ const abs = join6(root, file);
17396
+ if (existsSync7(abs))
17271
17397
  paths.push(normalizeRelPath(file));
17272
17398
  }
17273
17399
  for (const rel of registryPaths) {
17274
17400
  if (!rel.endsWith(".md") && !rel.endsWith(".mdc"))
17275
17401
  continue;
17276
- const abs = join5(root, rel);
17277
- if (existsSync6(abs))
17402
+ const abs = join6(root, rel);
17403
+ if (existsSync7(abs))
17278
17404
  paths.push(normalizeRelPath(rel));
17279
17405
  }
17280
17406
  for (const abs of collectScanFiles(config, root, skillIndex)) {
17281
- const content = readFileSync4(abs, "utf8");
17407
+ const content = readFileSync5(abs, "utf8");
17282
17408
  if (/<!--\s*doc-meta:/.test(content)) {
17283
17409
  paths.push(normalizeRelPath(relative4(root, abs)));
17284
17410
  }
@@ -17288,7 +17414,7 @@ function collectDocMetaPaths(config, root, registryPaths, skillIndex) {
17288
17414
  function validateScanRoots(config, root) {
17289
17415
  const missing = [];
17290
17416
  for (const tree of extractScanRootsFromInclude(config.scan.include)) {
17291
- if (!existsSync6(join5(root, tree)))
17417
+ if (!existsSync7(join6(root, tree)))
17292
17418
  missing.push(tree);
17293
17419
  }
17294
17420
  return missing;
@@ -17310,8 +17436,8 @@ function includeExplicitMarkdownPaths(files, paths, root) {
17310
17436
  const out = new Set(files);
17311
17437
  for (const raw of paths) {
17312
17438
  const rel = normalizeRelPath(raw);
17313
- const abs = join5(root, rel);
17314
- if (!existsSync6(abs))
17439
+ const abs = join6(root, rel);
17440
+ if (!existsSync7(abs))
17315
17441
  continue;
17316
17442
  if (isMarkdownFile(rel)) {
17317
17443
  out.add(abs);
@@ -17335,23 +17461,23 @@ function includeExplicitMarkdownPaths(files, paths, root) {
17335
17461
  return [...out];
17336
17462
  }
17337
17463
  function readFileContent(absPath) {
17338
- return readFileSync4(absPath, "utf8");
17464
+ return readFileSync5(absPath, "utf8");
17339
17465
  }
17340
17466
  function relPath(absPath, root) {
17341
17467
  return normalizeRelPath(relative4(root, absPath));
17342
17468
  }
17343
17469
 
17344
17470
  // src/audit/core/registry.ts
17345
- import { existsSync as existsSync7, readFileSync as readFileSync5 } from "node:fs";
17346
- import { join as join6, relative as relative5, resolve as resolve4 } from "node:path";
17471
+ import { existsSync as existsSync8, readFileSync as readFileSync6 } from "node:fs";
17472
+ import { join as join7, relative as relative5, resolve as resolve4 } from "node:path";
17347
17473
  var REGISTRY_TABLE_ROW_RE = /^\|\s*[^|]+\|\s*\[[^\]]*\]\(([^)]+)\)\s*\|/;
17348
17474
  var REGISTRY_TABLE_HEADER_RE = /\|\s*Topic\s*\|\s*Canonical file\s*\|/i;
17349
17475
  function parseRegistry(root) {
17350
- const abs = join6(root, REGISTRY_REL_PATH);
17351
- if (!existsSync7(abs)) {
17476
+ const abs = join7(root, REGISTRY_REL_PATH);
17477
+ if (!existsSync8(abs)) {
17352
17478
  return { paths: [], hasTableHeader: false };
17353
17479
  }
17354
- const content = readFileSync5(abs, "utf8");
17480
+ const content = readFileSync6(abs, "utf8");
17355
17481
  const hasTableHeader = REGISTRY_TABLE_HEADER_RE.test(content);
17356
17482
  const paths = [];
17357
17483
  for (const line of content.split(`
@@ -17362,7 +17488,7 @@ function parseRegistry(root) {
17362
17488
  const linkTarget = match[1].trim();
17363
17489
  if (linkTarget.startsWith("#"))
17364
17490
  continue;
17365
- const resolved = resolve4(join6(root, REGISTRY_DIR_REL), linkTarget);
17491
+ const resolved = resolve4(join7(root, REGISTRY_DIR_REL), linkTarget);
17366
17492
  paths.push(normalizeRelPath(relative5(root, resolved)));
17367
17493
  }
17368
17494
  return { paths: [...new Set(paths)], hasTableHeader };
@@ -17371,27 +17497,11 @@ function parseRegistryPaths(root) {
17371
17497
  return parseRegistry(root).paths;
17372
17498
  }
17373
17499
 
17374
- // src/audit/core/skills-lock.ts
17375
- import { existsSync as existsSync8, readFileSync as readFileSync6 } from "node:fs";
17376
- import { join as join7 } from "node:path";
17377
- var SKILLS_LOCK_REL = "skills-lock.json";
17378
- function lockedSkillSlugs(root) {
17379
- const abs = join7(root, SKILLS_LOCK_REL);
17380
- if (!existsSync8(abs))
17381
- return new Set;
17382
- try {
17383
- const parsed = JSON.parse(readFileSync6(abs, "utf8"));
17384
- return new Set(Object.keys(parsed.skills ?? {}));
17385
- } catch {
17386
- return new Set;
17387
- }
17388
- }
17389
-
17390
17500
  // src/audit/core/context.ts
17391
17501
  function createContext(options = {}) {
17392
17502
  const root = options.root ?? findRepoRoot();
17393
17503
  const config = loadConfig(root);
17394
- const skillIndex = buildSkillIndex(root);
17504
+ const skillIndex = buildSkillIndex(root, config.skillOwnership);
17395
17505
  let files = collectScanFiles(config, root, skillIndex);
17396
17506
  if (options.includeExcludedSkillTrees) {
17397
17507
  files = includeExplicitMarkdownPaths(files, listSkillMarkdownPaths(root, skillIndex), root);
@@ -17411,7 +17521,7 @@ function createContext(options = {}) {
17411
17521
  registryHasTableHeader: registry.hasTableHeader,
17412
17522
  retiredSkills: new Set(retiredSkills(config)),
17413
17523
  skillIndex,
17414
- lockedSkillSlugs: lockedSkillSlugs(root),
17524
+ lockedSkillSlugs: new Set(skillIndex.foreignSlugs),
17415
17525
  policies: options.policies ?? []
17416
17526
  };
17417
17527
  }
@@ -29325,10 +29435,10 @@ function findLocalCanonicalLinks(root2, content3, sourceFile) {
29325
29435
  }
29326
29436
  return links;
29327
29437
  }
29328
- function discoverSkillReferencePlans(root2) {
29329
- const index2 = buildSkillIndex(root2);
29438
+ function discoverSkillReferencePlans(root2, ownership) {
29439
+ const index2 = buildSkillIndex(root2, ownership);
29330
29440
  const plans = [];
29331
- for (const slug2 of index2.slugs) {
29441
+ for (const slug2 of index2.ownedSlugs) {
29332
29442
  const skillDir = join10(root2, slug2);
29333
29443
  if (!existsSync12(join10(skillDir, "SKILL.md")))
29334
29444
  continue;
@@ -29419,12 +29529,13 @@ function listAllGeneratedFiles(root2) {
29419
29529
  walk(root2);
29420
29530
  return files;
29421
29531
  }
29422
- function runGeneratedReferencesCheck(root2) {
29532
+ function runGeneratedReferencesCheck(root2, ownership) {
29423
29533
  const issues = [];
29424
29534
  const canonicalDir = join11(root2, CANONICAL_REFS_DIR);
29425
29535
  if (!existsSync13(canonicalDir))
29426
29536
  return issues;
29427
- const plans = discoverSkillReferencePlans(root2);
29537
+ const skillIndex = buildSkillIndex(root2, ownership);
29538
+ const plans = discoverSkillReferencePlans(root2, ownership);
29428
29539
  const needed = new Set;
29429
29540
  for (const plan of plans) {
29430
29541
  for (const refPath of plan.refPaths) {
@@ -29455,6 +29566,8 @@ function runGeneratedReferencesCheck(root2) {
29455
29566
  }
29456
29567
  }
29457
29568
  for (const generatedRel of listAllGeneratedFiles(root2)) {
29569
+ if (isForeignSkillPath(generatedRel, skillIndex))
29570
+ continue;
29458
29571
  if (!needed.has(generatedRel)) {
29459
29572
  issues.push(issue("generated-references", generatedRel, "orphaned generated copy — run skeleton references sync"));
29460
29573
  }
@@ -29486,7 +29599,7 @@ function runGeneratedReferencesCheck(root2) {
29486
29599
  return issues;
29487
29600
  }
29488
29601
  function runGeneratedReferencesRule(ctx) {
29489
- return runGeneratedReferencesCheck(ctx.root);
29602
+ return runGeneratedReferencesCheck(ctx.root, ctx.config.skillOwnership);
29490
29603
  }
29491
29604
  var generatedReferencesRule = {
29492
29605
  id: "generated-references",
@@ -29801,7 +29914,8 @@ function validateReadmeTaxonomy(ctx, index2, diskSlugs) {
29801
29914
  continue;
29802
29915
  const taxonomySlugs = parseReadmeTaxonomySlugs(readme);
29803
29916
  const nestedSlugs = diskSlugs.filter((slug2) => existsSync17(join14(ctx.root, skillRoot.relPath, slug2, "SKILL.md")));
29804
- const publicSlugs = nestedSlugs.filter((slug2) => !nonPublic.has(slug2));
29917
+ const foreign = new Set(index2.foreignSlugs);
29918
+ const publicSlugs = nestedSlugs.filter((slug2) => !nonPublic.has(slug2) && !foreign.has(slug2));
29805
29919
  const relReadme = `${skillRoot.relPath}/README.md`;
29806
29920
  for (const slug2 of publicSlugs) {
29807
29921
  if (!taxonomySlugs.includes(slug2)) {
@@ -29820,30 +29934,45 @@ function runSkillIndexRule(ctx) {
29820
29934
  const issues = [];
29821
29935
  const index2 = ctx.skillIndex;
29822
29936
  const diskSlugs = listSkillSlugs(index2);
29937
+ for (const warning of index2.provenance.warnings) {
29938
+ issues.push(issue("skill-index", index2.provenance.lockfile ?? "skills-lock.json", `skill provenance: ${warning}`, {
29939
+ severity: "warning"
29940
+ }));
29941
+ }
29823
29942
  issues.push(...validateReadmeTaxonomy(ctx, index2, diskSlugs));
29943
+ const owned = new Set(index2.ownedSlugs);
29824
29944
  for (const skillRoot of index2.roots) {
29825
- const scanRoot = skillRoot.kind === "nested" ? join14(ctx.root, skillRoot.relPath) : join14(ctx.root);
29826
- if (skillRoot.kind === "nested" && existsSync17(scanRoot)) {
29827
- for (const skillMd of walkSkillMarkdown(scanRoot)) {
29828
- issues.push(...scanFileForSkillLinks(ctx, skillMd, index2));
29945
+ if (skillRoot.kind === "nested") {
29946
+ for (const slug2 of index2.ownedSlugs) {
29947
+ const skillDir = join14(ctx.root, skillRoot.relPath, slug2);
29948
+ if (!existsSync17(skillDir))
29949
+ continue;
29950
+ for (const skillMd of walkSkillMarkdown(skillDir)) {
29951
+ issues.push(...scanFileForSkillLinks(ctx, skillMd, index2));
29952
+ }
29829
29953
  }
29954
+ continue;
29830
29955
  }
29831
- if (skillRoot.kind === "flat") {
29832
- for (const slug2 of index2.slugs) {
29833
- const skillDir = join14(ctx.root, slug2);
29834
- if (existsSync17(skillDir)) {
29835
- for (const skillMd of walkSkillMarkdown(skillDir)) {
29836
- issues.push(...scanFileForSkillLinks(ctx, skillMd, index2));
29837
- }
29838
- }
29956
+ for (const slug2 of index2.flatSlugs) {
29957
+ if (!owned.has(slug2))
29958
+ continue;
29959
+ const skillDir = join14(ctx.root, slug2);
29960
+ if (!existsSync17(skillDir))
29961
+ continue;
29962
+ for (const skillMd of walkSkillMarkdown(skillDir)) {
29963
+ issues.push(...scanFileForSkillLinks(ctx, skillMd, index2));
29839
29964
  }
29840
29965
  }
29841
29966
  }
29842
29967
  return issues;
29843
29968
  }
29844
29969
  var skillIndexRule = { id: "skill-index", run: runSkillIndexRule };
29845
- function skillCountOnDisk(ctx) {
29846
- return listSkillSlugs(ctx.skillIndex).length;
29970
+ function skillAuditSuffix(ctx) {
29971
+ const owned = listOwnedSkillSlugs(ctx.skillIndex).length;
29972
+ const foreign = ctx.skillIndex.foreignSlugs.length;
29973
+ if (foreign === 0)
29974
+ return ` (${owned} skills on disk)`;
29975
+ return ` (${owned} owned skills audited, ${foreign} foreign ignored)`;
29847
29976
  }
29848
29977
 
29849
29978
  // src/audit/rules/index.ts
@@ -30012,7 +30141,7 @@ async function runAudit(options) {
30012
30141
  json: options.json,
30013
30142
  label,
30014
30143
  fileCount: options.suite === "docs" || options.suite === "self" ? ctx.files.length : undefined,
30015
- successSuffix: options.suite === "skills" ? ` (${skillCountOnDisk(ctx)} skills on disk)` : undefined
30144
+ successSuffix: options.suite === "skills" ? skillAuditSuffix(ctx) : undefined
30016
30145
  });
30017
30146
  }
30018
30147
 
@@ -30111,6 +30240,88 @@ function resolveCustomizeFromRoot(slug2, startDir) {
30111
30240
  return resolveCustomize(root2, slug2);
30112
30241
  }
30113
30242
 
30243
+ // src/hooks/run.ts
30244
+ function parsePayload(raw) {
30245
+ if (!raw.trim())
30246
+ return {};
30247
+ return JSON.parse(raw);
30248
+ }
30249
+ function extractPath(payload) {
30250
+ const input = payload.tool_input ?? payload.toolInput ?? {};
30251
+ const candidates = [
30252
+ input.path,
30253
+ input.file_path,
30254
+ input.filePath,
30255
+ input.target_file,
30256
+ input.targetFile
30257
+ ];
30258
+ for (const value of candidates) {
30259
+ if (typeof value === "string" && value.trim())
30260
+ return normalizeRelPath(value.trim());
30261
+ }
30262
+ return null;
30263
+ }
30264
+ function extractSkillSlug(payload) {
30265
+ const tool = payload.tool_name ?? payload.toolName ?? "";
30266
+ if (tool === "Skill" || tool === "skill") {
30267
+ const input = payload.tool_input ?? payload.toolInput ?? {};
30268
+ const slug2 = input.skill ?? input.slug ?? input.name;
30269
+ if (typeof slug2 === "string" && slug2.trim())
30270
+ return slug2.trim();
30271
+ }
30272
+ const path2 = extractPath(payload);
30273
+ if (path2)
30274
+ return slugFromPath(path2, process.cwd());
30275
+ return null;
30276
+ }
30277
+ function cursorResponse(content3) {
30278
+ return JSON.stringify({ additional_context: content3 });
30279
+ }
30280
+ function claudeResponse(content3) {
30281
+ return JSON.stringify({
30282
+ hookSpecificOutput: {
30283
+ additionalContext: content3
30284
+ }
30285
+ });
30286
+ }
30287
+ function codexResponse(content3) {
30288
+ return JSON.stringify({ additionalContext: content3 });
30289
+ }
30290
+ function formatResponse(payload, content3) {
30291
+ const tool = payload.tool_name ?? payload.toolName ?? "";
30292
+ if (tool === "read_file")
30293
+ return codexResponse(content3);
30294
+ if (payload.hook_event_name?.toLowerCase().includes("claude"))
30295
+ return claudeResponse(content3);
30296
+ if (tool === "Read" && payload.hook_event_name === "postToolUse")
30297
+ return cursorResponse(content3);
30298
+ if (tool === "Read" || tool === "Skill")
30299
+ return claudeResponse(content3);
30300
+ return cursorResponse(content3);
30301
+ }
30302
+ function runCustomizeHook(raw) {
30303
+ try {
30304
+ const payload = parsePayload(raw);
30305
+ const slug2 = extractSkillSlug(payload);
30306
+ if (!slug2)
30307
+ return "{}";
30308
+ const resolved = resolveCustomizeFromRoot(slug2);
30309
+ if (!resolved.content)
30310
+ return "{}";
30311
+ const from = resolved.included.length > 0 ? resolved.included.join(", ") : resolved.path ?? ".skeleton/customize";
30312
+ const prefix = `
30313
+
30314
+ ---
30315
+ Customize override for /${slug2} (from ${from}):
30316
+
30317
+ `;
30318
+ return formatResponse(payload, prefix + resolved.content);
30319
+ } catch (error) {
30320
+ console.error(`customize hook error: ${error}`);
30321
+ return "{}";
30322
+ }
30323
+ }
30324
+
30114
30325
  // src/init/init.ts
30115
30326
  import { spawnSync as spawnSync2 } from "node:child_process";
30116
30327
  import { copyFileSync, existsSync as existsSync22, mkdirSync as mkdirSync2, readFileSync as readFileSync17 } from "node:fs";
@@ -30142,29 +30353,37 @@ function resolveTemplatesDir() {
30142
30353
  }
30143
30354
 
30144
30355
  // src/init/resolve-hook-command.ts
30145
- import { existsSync as existsSync20 } from "node:fs";
30356
+ import { existsSync as existsSync20, realpathSync as realpathSync6 } from "node:fs";
30146
30357
  import { createRequire as createRequire3 } from "node:module";
30147
30358
  import { dirname as dirname9, join as join17, relative as relative10, resolve as resolve8 } from "node:path";
30148
30359
  var PACKAGE_NAME = "@csark0812/skeleton";
30149
- var HOOK_DIST = "dist/hooks/customize-on-skill-read.js";
30150
- var HOOK_SRC = "src/hooks/customize-on-skill-read.ts";
30360
+ var CLI_DIST = "dist/cli.js";
30151
30361
  var PACKAGE_ROOT = resolvePackageRoot();
30362
+ var DEV_HOOK_COMMAND = "bun src/cli.ts hook customize";
30363
+ var FALLBACK_HOOK_COMMAND = `node node_modules/${PACKAGE_NAME}/${CLI_DIST} hook customize`;
30364
+ function safeRealpath2(path2) {
30365
+ try {
30366
+ return realpathSync6(path2);
30367
+ } catch {
30368
+ return path2;
30369
+ }
30370
+ }
30152
30371
  function toRepoRelative(cwd, absPath) {
30153
- const rel = relative10(cwd, absPath).replace(/\\/g, "/");
30372
+ const rel = relative10(safeRealpath2(cwd), safeRealpath2(absPath)).replace(/\\/g, "/");
30154
30373
  return rel.startsWith("..") ? absPath.replace(/\\/g, "/") : rel;
30155
30374
  }
30156
- function tryResolvePublished(cwd) {
30375
+ function tryResolvePublishedCli(cwd) {
30157
30376
  try {
30158
30377
  const req = createRequire3(join17(cwd, "package.json"));
30159
- return req.resolve(`${PACKAGE_NAME}/${HOOK_DIST}`);
30378
+ return req.resolve(`${PACKAGE_NAME}/${CLI_DIST}`);
30160
30379
  } catch {
30161
30380
  return null;
30162
30381
  }
30163
30382
  }
30164
- function walkNodeModules(cwd) {
30383
+ function walkNodeModulesCli(cwd) {
30165
30384
  let dir = cwd;
30166
30385
  while (true) {
30167
- const candidate = join17(dir, "node_modules", PACKAGE_NAME, HOOK_DIST);
30386
+ const candidate = join17(dir, "node_modules", PACKAGE_NAME, CLI_DIST);
30168
30387
  if (existsSync20(candidate))
30169
30388
  return candidate;
30170
30389
  const parent = dirname9(dir);
@@ -30178,28 +30397,25 @@ function isInsidePackageRoot(cwd) {
30178
30397
  const rel = relative10(PACKAGE_ROOT, resolve8(cwd)).replace(/\\/g, "/");
30179
30398
  return rel === "" || !rel.startsWith("..") && !rel.startsWith("/");
30180
30399
  }
30400
+ function nodeCliHookCommand(cliPath) {
30401
+ return `node ${cliPath} hook customize`;
30402
+ }
30181
30403
  function resolveHookCommand(cwd) {
30182
- const published = tryResolvePublished(cwd);
30404
+ if (isInsidePackageRoot(cwd))
30405
+ return DEV_HOOK_COMMAND;
30406
+ const published = tryResolvePublishedCli(cwd);
30183
30407
  if (published)
30184
- return toRepoRelative(cwd, published);
30185
- const hoisted = walkNodeModules(cwd);
30408
+ return nodeCliHookCommand(toRepoRelative(cwd, published));
30409
+ const hoisted = walkNodeModulesCli(cwd);
30186
30410
  if (hoisted)
30187
- return toRepoRelative(cwd, hoisted);
30188
- if (isInsidePackageRoot(cwd)) {
30189
- const distHook = join17(PACKAGE_ROOT, HOOK_DIST);
30190
- if (existsSync20(distHook))
30191
- return toRepoRelative(cwd, distHook);
30192
- const srcHook = join17(PACKAGE_ROOT, HOOK_SRC);
30193
- if (existsSync20(srcHook)) {
30194
- const rel = toRepoRelative(cwd, srcHook);
30195
- return rel.includes("/") ? `bun ${rel}` : `bun ./${rel}`;
30196
- }
30197
- }
30198
- return `node node_modules/${PACKAGE_NAME}/${HOOK_DIST}`;
30411
+ return nodeCliHookCommand(toRepoRelative(cwd, hoisted));
30412
+ return FALLBACK_HOOK_COMMAND;
30199
30413
  }
30200
30414
  function isSkeletonHookCommand(command) {
30201
30415
  if (!command)
30202
30416
  return false;
30417
+ if (/\bhook\s+customize\b/.test(command))
30418
+ return true;
30203
30419
  return /customize-on-skill-read\.(js|ts)\b/.test(command);
30204
30420
  }
30205
30421
 
@@ -30646,6 +30862,15 @@ import {
30646
30862
  writeFileSync as writeFileSync4
30647
30863
  } from "node:fs";
30648
30864
  import { dirname as dirname12, join as join20, relative as relative11 } from "node:path";
30865
+ function resolveOwnership(root2, override) {
30866
+ if (override !== undefined)
30867
+ return override;
30868
+ try {
30869
+ return loadConfig(root2).skillOwnership;
30870
+ } catch {
30871
+ return;
30872
+ }
30873
+ }
30649
30874
  function walkMarkdownFiles2(dir, root2) {
30650
30875
  const files = [];
30651
30876
  if (!existsSync24(dir))
@@ -30700,7 +30925,7 @@ function syncReferences(options = {}) {
30700
30925
  removed: [],
30701
30926
  skipped: []
30702
30927
  };
30703
- const plans = discoverSkillReferencePlans(root2);
30928
+ const plans = discoverSkillReferencePlans(root2, resolveOwnership(root2, options.ownership));
30704
30929
  for (const plan of plans) {
30705
30930
  const skillDir = join20(root2, plan.skill);
30706
30931
  for (const refPath of plan.refPaths) {
@@ -30756,7 +30981,13 @@ function runReferencesSync(options = {}) {
30756
30981
  }
30757
30982
  function runReferencesCheck(options = {}) {
30758
30983
  const root2 = options.root ?? process.cwd();
30759
- const issues = runGeneratedReferencesCheck(root2);
30984
+ let ownership;
30985
+ try {
30986
+ ownership = loadConfig(root2).skillOwnership;
30987
+ } catch {
30988
+ ownership = undefined;
30989
+ }
30990
+ const issues = runGeneratedReferencesCheck(root2, ownership);
30760
30991
  return printReport(issues, {
30761
30992
  strict: options.strict,
30762
30993
  json: options.json,
@@ -30823,7 +31054,7 @@ function pathFromRegistryLink(root2, link2) {
30823
31054
  }
30824
31055
  function isOutsideScan(root2, relPath2) {
30825
31056
  const config = loadConfig(root2);
30826
- const skillIndex = buildSkillIndex(root2);
31057
+ const skillIndex = buildSkillIndex(root2, config.skillOwnership);
30827
31058
  const scanned = collectScanFiles(config, root2, skillIndex).map((abs) => normalizeRelPath(relative12(root2, abs)));
30828
31059
  if (scanned.includes(relPath2))
30829
31060
  return false;
@@ -30976,7 +31207,7 @@ function isSkeletonYamlCandidate(normalized, ext) {
30976
31207
  return false;
30977
31208
  return true;
30978
31209
  }
30979
- function bucketFor(relPath2, root2, wiredPolicies) {
31210
+ function bucketFor(relPath2, root2, wiredPolicies, skillIndex) {
30980
31211
  const normalized = normalizeRelPath(relPath2);
30981
31212
  const ext = extname2(normalized).toLowerCase();
30982
31213
  const name = basename5(normalized);
@@ -30989,9 +31220,11 @@ function bucketFor(relPath2, root2, wiredPolicies) {
30989
31220
  return "policy";
30990
31221
  return "skip";
30991
31222
  }
30992
- const skillIndex = buildSkillIndex(root2);
30993
- if (isSkillPath(normalized, skillIndex))
31223
+ if (isSkillPath(normalized, skillIndex)) {
31224
+ if (isForeignSkillPath(normalized, skillIndex))
31225
+ return "foreign-skill";
30994
31226
  return "skills";
31227
+ }
30995
31228
  if (DOC_EXTENSIONS.has(ext)) {
30996
31229
  const config = loadConfig(root2);
30997
31230
  if (isInScanPerimeter(normalized, config, root2, skillIndex))
@@ -31102,6 +31335,7 @@ async function runValidateChanged(options = {}) {
31102
31335
  return 0;
31103
31336
  }
31104
31337
  const config = loadConfig(root2);
31338
+ const skillIndex = buildSkillIndex(root2, config.skillOwnership);
31105
31339
  let wiredPolicies;
31106
31340
  try {
31107
31341
  wiredPolicies = await collectWiredPolicyRelPaths(root2, config);
@@ -31118,6 +31352,7 @@ async function runValidateChanged(options = {}) {
31118
31352
  };
31119
31353
  let missing = 0;
31120
31354
  let skipped = 0;
31355
+ let foreignSkipped = 0;
31121
31356
  const orphans = [];
31122
31357
  for (const relPath2 of relPaths) {
31123
31358
  const normalized = normalizeRelPath(relPath2);
@@ -31132,11 +31367,16 @@ async function runValidateChanged(options = {}) {
31132
31367
  orphans.push(normalized);
31133
31368
  continue;
31134
31369
  }
31135
- const bucket = bucketFor(normalized, root2, wiredPolicies);
31370
+ const bucket = bucketFor(normalized, root2, wiredPolicies, skillIndex);
31136
31371
  if (bucket === "skip") {
31137
31372
  skipped++;
31138
31373
  continue;
31139
31374
  }
31375
+ if (bucket === "foreign-skill") {
31376
+ foreignSkipped++;
31377
+ console.log(`validate changed: skipping foreign skill ${normalized} (owned upstream; see skills-lock.json / skillOwnership)`);
31378
+ continue;
31379
+ }
31140
31380
  buckets[bucket].push(normalized);
31141
31381
  }
31142
31382
  if (orphans.length > 0) {
@@ -31184,24 +31424,24 @@ async function runValidateChanged(options = {}) {
31184
31424
  exitCode = 1;
31185
31425
  }
31186
31426
  if (buckets.skills.length > 0) {
31187
- const skillsOnly = buckets.docs.length === 0 && buckets.shell.length === 0 && buckets.json.length === 0 && buckets.policy.length === 0;
31188
- if (skillsOnly && !options.base) {
31427
+ if (!options.base) {
31189
31428
  console.error(`validate changed: skill paths need the full skills suite (path-scoped skill rules are empty).
31190
31429
  ` + ` Run: skeleton audit skills
31191
31430
  ` + " (audit self covers docs + .skeleton; excluded skill trees still need audit skills)");
31192
- return 1;
31193
- }
31194
- const skillExit = await runAudit({
31195
- suite: "skills",
31196
- strict: false,
31197
- json: false,
31198
- paths: buckets.skills,
31199
- only: null,
31200
- root: root2,
31201
- pathScopedOnly: true
31202
- });
31203
- if (skillExit !== 0)
31204
31431
  exitCode = 1;
31432
+ } else {
31433
+ const skillExit = await runAudit({
31434
+ suite: "skills",
31435
+ strict: false,
31436
+ json: false,
31437
+ paths: buckets.skills,
31438
+ only: null,
31439
+ root: root2,
31440
+ pathScopedOnly: true
31441
+ });
31442
+ if (skillExit !== 0)
31443
+ exitCode = 1;
31444
+ }
31205
31445
  }
31206
31446
  for (const relPath2 of buckets.shell) {
31207
31447
  if (validateShell(relPath2, root2) !== 0)
@@ -31227,7 +31467,7 @@ async function runValidateChanged(options = {}) {
31227
31467
  });
31228
31468
  if (proseExit !== 0)
31229
31469
  exitCode = 1;
31230
- const skillPaths = listSkillMarkdownPaths(root2, buildSkillIndex(root2));
31470
+ const skillPaths = listSkillMarkdownPaths(root2, skillIndex);
31231
31471
  if (skillPaths.length > 0) {
31232
31472
  const skillProseExit = await runAudit({
31233
31473
  suite: "skills",
@@ -31242,17 +31482,20 @@ async function runValidateChanged(options = {}) {
31242
31482
  exitCode = 1;
31243
31483
  }
31244
31484
  } else {
31245
- if (exitCode === 0) {
31246
- console.error(`validate changed: policy YAML changes need a full prose-policy pass (path-scoped docs are not enough).
31485
+ console.error(`validate changed: policy YAML changes need a full prose-policy pass (path-scoped docs are not enough).
31247
31486
  ` + ` Run: skeleton audit docs
31248
31487
  ` + ` And: skeleton audit skills
31249
31488
  ` + " (audit self covers docs + .skeleton; excluded skill trees still need audit skills)");
31250
- }
31251
31489
  return 1;
31252
31490
  }
31253
31491
  }
31254
31492
  if (exitCode === 0) {
31255
- const note = skipped > 0 ? ` (${skipped} path(s) skipped)` : "";
31493
+ const parts = [];
31494
+ if (skipped > 0)
31495
+ parts.push(`${skipped} path(s) skipped`);
31496
+ if (foreignSkipped > 0)
31497
+ parts.push(`${foreignSkipped} foreign skill(s) ignored`);
31498
+ const note = parts.length > 0 ? ` (${parts.join(", ")})` : "";
31256
31499
  console.log(`validate changed passed${note}.`);
31257
31500
  }
31258
31501
  return exitCode;
@@ -31270,6 +31513,7 @@ Commands:
31270
31513
  validate changed [paths…] [--staged] [--base <ref>]
31271
31514
  register <path> [--topic=…] [--dry-run] [--json]
31272
31515
  customize resolve <slug> [--json]
31516
+ hook customize (reads a host hook payload on stdin)
31273
31517
  references sync [--dry-run] [--no-rewrite-links]
31274
31518
  references check [--json] [--strict]`);
31275
31519
  }
@@ -31366,6 +31610,14 @@ async function main() {
31366
31610
  }
31367
31611
  process.exit(0);
31368
31612
  }
31613
+ if (command === "hook") {
31614
+ if (argv[1] !== "customize") {
31615
+ usage();
31616
+ process.exit(1);
31617
+ }
31618
+ process.stdout.write(runCustomizeHook(readFileSync22(0, "utf8")));
31619
+ process.exit(0);
31620
+ }
31369
31621
  if (command === "init") {
31370
31622
  const parsed = parseInitArgs(argv.slice(1));
31371
31623
  runInit(parsed);
@@ -13552,7 +13552,7 @@ function resolveCustomizeFromRoot(slug, startDir) {
13552
13552
  return resolveCustomize(root, slug);
13553
13553
  }
13554
13554
 
13555
- // src/hooks/customize-on-skill-read.ts
13555
+ // src/hooks/run.ts
13556
13556
  function parsePayload(raw) {
13557
13557
  if (!raw.trim())
13558
13558
  return {};
@@ -13611,20 +13611,15 @@ function formatResponse(payload, content) {
13611
13611
  return claudeResponse(content);
13612
13612
  return cursorResponse(content);
13613
13613
  }
13614
- function main() {
13614
+ function runCustomizeHook(raw) {
13615
13615
  try {
13616
- const raw = readFileSync4(0, "utf8");
13617
13616
  const payload = parsePayload(raw);
13618
13617
  const slug = extractSkillSlug(payload);
13619
- if (!slug) {
13620
- process.stdout.write("{}");
13621
- return;
13622
- }
13618
+ if (!slug)
13619
+ return "{}";
13623
13620
  const resolved = resolveCustomizeFromRoot(slug);
13624
- if (!resolved.content) {
13625
- process.stdout.write("{}");
13626
- return;
13627
- }
13621
+ if (!resolved.content)
13622
+ return "{}";
13628
13623
  const from = resolved.included.length > 0 ? resolved.included.join(", ") : resolved.path ?? ".skeleton/customize";
13629
13624
  const prefix = `
13630
13625
 
@@ -13632,10 +13627,12 @@ function main() {
13632
13627
  Customize override for /${slug} (from ${from}):
13633
13628
 
13634
13629
  `;
13635
- process.stdout.write(formatResponse(payload, prefix + resolved.content));
13630
+ return formatResponse(payload, prefix + resolved.content);
13636
13631
  } catch (error) {
13637
13632
  console.error(`customize hook error: ${error}`);
13638
- process.stdout.write("{}");
13633
+ return "{}";
13639
13634
  }
13640
13635
  }
13641
- main();
13636
+
13637
+ // src/hooks/customize-on-skill-read.ts
13638
+ process.stdout.write(runCustomizeHook(readFileSync4(0, "utf8")));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@csark0812/skeleton",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
4
4
  "description": "SSOT audit CLI for agent harness repos",
5
5
  "type": "module",
6
6
  "bin": {
@@ -55,6 +55,28 @@
55
55
  }
56
56
  }
57
57
  },
58
+ "skillOwnership": {
59
+ "type": "object",
60
+ "additionalProperties": false,
61
+ "description": "Provenance-aware skill body linting: foreign (synced) skills are skipped; owned local skills and .skeleton/customize stay audited",
62
+ "properties": {
63
+ "lockfile": {
64
+ "type": "string",
65
+ "minLength": 1,
66
+ "description": "Repo-relative skills lock path (default: skills-lock.json)"
67
+ },
68
+ "ownedSlugs": {
69
+ "type": "array",
70
+ "items": { "type": "string", "pattern": "^[a-z0-9-]+$" },
71
+ "description": "Force these skill slugs to be treated as owned (lint here) even when the lockfile marks them foreign"
72
+ },
73
+ "foreignSlugs": {
74
+ "type": "array",
75
+ "items": { "type": "string", "pattern": "^[a-z0-9-]+$" },
76
+ "description": "Force these skill slugs to be treated as foreign (skip body lint) even when absent from the lockfile"
77
+ }
78
+ }
79
+ },
58
80
  "plugins": {
59
81
  "type": "array",
60
82
  "items": { "type": "string", "minLength": 1 },
@@ -18,6 +18,12 @@ daysUntilStale: 180
18
18
  # alwaysInclude:
19
19
  # - shared-agent-references.md
20
20
 
21
+ # Optional: skill body ownership (skills-lock.json github entries are foreign by default)
22
+ # skillOwnership:
23
+ # lockfile: skills-lock.json
24
+ # ownedSlugs: []
25
+ # foreignSlugs: []
26
+
21
27
  # Optional: plugin `.ts` entries relative to .skeleton/ (built sibling `.mjs` required)
22
28
  # plugins: []
23
29
  # Optional: draft-marker allow-list prefixes (plus `_draft-*.md` filenames)