@rckflr/llms-skills 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,75 @@ All notable changes to the [`@rckflr/llms-skills`](https://www.npmjs.com/package
4
4
  package. Format based on [Keep a Changelog](https://keepachangelog.com/); dates
5
5
  are the npm publish dates.
6
6
 
7
+ ## [Unreleased] — 0.4.0
8
+
9
+ ### Added
10
+ - **Signed knowledge freshness — the RAG-OKF v2 "vigencia" layer.** Two new
11
+ commands over a knowledge bundle, porting the proven
12
+ `ccdd/examples/okf-integration` sidecar and staying **wire-compatible**
13
+ with its Python tooling (same signed message
14
+ `vigencia:{concept}:{content_sha256}:{attested_at}:{valid_until}`, same
15
+ raw-hex ed25519 keys/signatures, same `freshness.yaml` /
16
+ `attestations.json` / `reviewers.json` files — attestations sign/verify
17
+ across both toolchains, covered by a verbatim fixture signed with the
18
+ Python reference):
19
+ - **`freshness <bundle> [--now ISO] [--json]`** — CI-friendly report of
20
+ three honestly-separated signals: content hashes say *not tampered*
21
+ (publish), age vs per-`type` TTLs says *recent* (a proxy — age ≠ truth),
22
+ and a signed human attestation says *still true*. Statuses: `fresh` /
23
+ `STALE` / `MISSING-TS` / `untracked` / `VIGENT` / `VOID-ATTEST` /
24
+ `EXPIRED-ATTEST` / `INVALID-ATTEST`. `on_stale: abort` turns stale
25
+ knowledge into a failing exit code.
26
+ - **`attest <bundle> --concept <rel.md> --by <reviewer> --until <ISO>
27
+ --key <hex-file>`** — a registered human signs "this content is still
28
+ true". The signature binds to the exact content sha (any edit voids it)
29
+ and expires at `--until` (re-affirmable without touching content); the
30
+ signing key must match the reviewer's registered pubkey. The machine
31
+ binds, verifies and expires; the human judges.
32
+ - Note: `content_sha256` is over **raw** file bytes (Python-reference
33
+ compatibility, no CRLF normalization) — pin your bundle's markdown
34
+ (`*.md text eol=lf` or `-text`) so git line-ending conversion cannot void
35
+ attestations.
36
+ - Hybrid embeddings (the other half of the v2 design) remain **not built**,
37
+ blocked upstream: `@rckflr/minimemory`'s OKF index is BM25-only by design,
38
+ there is no embedder in the stack, and float embeddings clash with
39
+ byte-exact content addressing.
40
+
41
+ ## [0.3.0] — 2026-07-10
42
+
43
+ ### Added
44
+ - **`--scope <name>` on `memory`** (Executable Skills v0.5 §2.5, resolves
45
+ core RFC v0.10 Open Question 6). Declares the project namespace for
46
+ multi-project origins: the manifest's `memory` block and every generated
47
+ `published` entry carry `scope`, and `publish` renders it as the **last**
48
+ key of both the skill lines and the `skills-memory` line
49
+ (`"scope":"kdd"`). Runtimes (mcpwasm ≥ 0.6.0) expose the tools as
50
+ `<scope>__<toolName>` and bind each scope's memory to its own snapshot.
51
+ Pattern `^[a-z][a-z0-9_-]*$`; invalid values fail fast. Re-running
52
+ `memory` with a different `--scope` (or none) refreshes the manifest
53
+ entries idempotently.
54
+ - `validate` understands scopes: accepts multiple `skills-memory` lines when
55
+ each carries a distinct `scope` (at most one unscoped), and reports invalid
56
+ `scope` values and duplicated scopes as errors.
57
+ - New test part: scoped fixture end-to-end (manifest wiring, key order,
58
+ validate, and **byte-identity with `scripts/generate.py`** on scoped
59
+ output — the mirror contract now covers scopes).
60
+
61
+ ### Changed
62
+ - `scripts/generate.py` mirrored: manifest entries and the `memory` block
63
+ accept `scope` and render it identically (byte-identity enforced by
64
+ `cli/test.mjs` Part 6). No `scope` anywhere ⇒ output identical to 0.2.1.
65
+
66
+ ## [0.2.1] — 2026-07-10
67
+
68
+ ### Fixed
69
+ - **Frontmatter values quoted with single quotes** (`type: 'Concept'`) are now
70
+ unquoted correctly: one *matching* pair of quotes (double or single) is
71
+ stripped. Found with a real OKF bundle (KDD) whose frontmatter uses single
72
+ quotes; before this fix the quotes leaked into concept `type`/`title`.
73
+ Mirrored in `scripts/generate.py` (`parse_yaml_frontmatter`) — byte-identity
74
+ between both generators re-verified.
75
+
7
76
  ## [0.2.0] — 2026-07-10
8
77
 
9
78
  ### Added
package/README.md CHANGED
@@ -67,6 +67,21 @@ What it generates: a **byte-deterministic** BM25 snapshot (canonicalized, so
67
67
  content-addressed). It also pins the snapshot as `-text` in `.gitattributes`
68
68
  so git's CRLF conversion can never break the published hash.
69
69
 
70
+ **Multi-project origins (scopes).** Publishing several projects on one origin
71
+ (e.g. a GitHub Pages root)? Add `--scope <name>` (pattern
72
+ `^[a-z][a-z0-9_-]*$`, Executable Skills v0.5 §2.5):
73
+
74
+ ```bash
75
+ npx @rckflr/llms-skills memory ./knowledge --scope kdd
76
+ ```
77
+
78
+ The manifest and the rendered lines carry `"scope":"kdd"`; runtimes
79
+ (mcpwasm ≥ 0.6.0) expose the tools as `kdd__search_knowledge` etc. and bind
80
+ each scope's memory to its own snapshot — no name collisions, one
81
+ `skills-memory` line per scope. Published bytes and hashes are untouched
82
+ (the rename is runtime-side), so `search_knowledge` stays the universal
83
+ template.
84
+
70
85
  Consumers need nothing new: `npx -y @rckflr/mcpwasm <origin>` (>= 0.4.0)
71
86
  verifies the snapshot and injects `host.memorySearch` on both runtimes.
72
87
  `memory <bundle> --check` is the CI guard. The BM25 engine
@@ -74,6 +89,35 @@ verifies the snapshot and injects `host.memorySearch` on both runtimes.
74
89
  default; if omitted, `memory` fails with a clear message and every other
75
90
  command works as before.
76
91
 
92
+ ## Knowledge freshness (`freshness` / `attest`)
93
+
94
+ Hashes prove *not tampered*; they say nothing about whether the content is
95
+ still **true**. The freshness layer separates three signals honestly:
96
+
97
+ ```bash
98
+ npx @rckflr/llms-skills freshness ./knowledge --now 2026-07-10 # CI report
99
+ npx @rckflr/llms-skills attest ./knowledge --concept policies/refunds.md \
100
+ --by human:mauricio --until 2027-07-10 --key mauricio.key # sign "still true"
101
+ ```
102
+
103
+ - **Age vs TTL** (`knowledge/freshness.yaml`): per-`type` TTL in days +
104
+ per-path overrides. A concept past its TTL reports `STALE`; `on_stale:
105
+ abort` makes the command exit 1 (CI gate). Age is a *proxy* — old-but-true
106
+ passes as stale, new-but-false passes as fresh. Which is why:
107
+ - **Signed attestations** (`knowledge/attestations.json`): a reviewer
108
+ registered in `knowledge/reviewers.json` (raw-hex ed25519 pubkey) signs
109
+ "this content is still true". The signature binds to the exact content
110
+ sha — **any edit voids it** — and expires at `valid_until`, re-affirmable
111
+ without touching the content. A valid attestation supersedes age
112
+ (`VIGENT`); tampering, expiry or an unregistered reviewer degrade it
113
+ loudly (`VOID-ATTEST` / `EXPIRED-ATTEST` / `INVALID-ATTEST`).
114
+
115
+ Wire-compatible with the original Python sidecar
116
+ (`ccdd/examples/okf-integration`): attestations sign/verify across both
117
+ toolchains. Note: the content sha is over raw bytes — pin your bundle's
118
+ markdown (`*.md text eol=lf`) so git CRLF conversion cannot void
119
+ attestations.
120
+
77
121
  ## Signing / attestation (L3)
78
122
 
79
123
  ```bash
@@ -11,12 +11,13 @@
11
11
  // Zero external dependencies.
12
12
 
13
13
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
14
- import { join, dirname, relative } from "node:path";
14
+ import { join, dirname, relative, isAbsolute } from "node:path";
15
15
  import {
16
16
  loadSkills, loadMemory, renderSkillsSection, renderSkillsMemoryLine, renderLlmsTxt,
17
17
  renderIndex, loadSigningKey, signSkills, publicKeyRawB64, generateKeyPair, validateLlmsTxt,
18
18
  } from "../lib/core.mjs";
19
19
  import { buildMemoryTargets } from "../lib/memory.mjs";
20
+ import { checkFreshness, attestVigencia } from "../lib/freshness.mjs";
20
21
 
21
22
  const args = process.argv.slice(2);
22
23
  const cmd = args[0];
@@ -29,10 +30,17 @@ const USAGE = `llms-skills — publisher CLI for the llms.txt Skills standard
29
30
 
30
31
  Usage:
31
32
  llms-skills init <name> [--tool] [--root <dir>] scaffold SKILL.md (+ tool.js) + manifest entry
32
- llms-skills memory <bundle-dir> [--check] [--root <dir>] [--license <spdx>]
33
+ llms-skills memory <bundle-dir> [--check] [--root <dir>] [--license <spdx>] [--scope <name>]
33
34
  OKF bundle -> BM25 snapshot + knowledge skills (RAG)
34
35
  llms-skills publish [--check] [--manifest <p>] [--root <dir>] generate llms.txt ## Skills + index.json
35
36
  llms-skills validate <src> [--strict] validate an llms.txt (path or URL)
37
+ llms-skills freshness <bundle-dir> [--now <ISO>] [--json] [--root <dir>]
38
+ check knowledge freshness: TTLs (freshness.yaml)
39
+ + signed human attestations (attestations.json)
40
+ llms-skills attest <bundle-dir> --concept <rel.md> --by <reviewer> --until <ISO>
41
+ --key <privkey-hex-file> [--on <ISO>] [--note <text>] [--root <dir>]
42
+ sign "this content is still true" (ed25519,
43
+ voided on edit, expires at --until)
36
44
  llms-skills keygen [--out <keyfile>] generate an ed25519 signing keypair
37
45
 
38
46
  The minimal case (L0) needs no signing key and no tool.js — just init + publish.
@@ -125,7 +133,7 @@ async function cmdMemory() {
125
133
  : { section_intro: "Remote Agent Skills published by this domain.", published: [] };
126
134
 
127
135
  const { targets, warnings, manifest: newManifest, snapshotSha, conceptCount } =
128
- await buildMemoryTargets({ root, bundleDir, manifest, license: flag("--license") });
136
+ await buildMemoryTargets({ root, bundleDir, manifest, license: flag("--license"), scope: flag("--scope") });
129
137
  for (const w of warnings) console.error(`[warn] ${w}`);
130
138
 
131
139
  const rel = (p) => (relative(root, p) || p).replaceAll("\\", "/");
@@ -237,6 +245,47 @@ function cmdKeygen() {
237
245
  }
238
246
 
239
247
  // ---- dispatch ----
248
+ // ---- freshness (RAG-OKF v2: signed knowledge freshness) ----
249
+ function resolveBundle(cmdName) {
250
+ const bundleArg = args[1];
251
+ if (!bundleArg || bundleArg.startsWith("--")) die(`${cmdName}: needs a bundle directory, e.g. \`llms-skills ${cmdName} ./knowledge\``);
252
+ const root = flag("--root") || process.cwd();
253
+ const bundleDir = isAbsolute(bundleArg) ? bundleArg : join(root, bundleArg);
254
+ if (!existsSync(bundleDir)) die(`${cmdName}: bundle directory not found: ${bundleDir}`);
255
+ return bundleDir;
256
+ }
257
+
258
+ function cmdFreshness() {
259
+ const bundleDir = resolveBundle("freshness");
260
+ const now = flag("--now") || new Date().toISOString().slice(0, 10);
261
+ const result = checkFreshness(bundleDir, now, (w) => console.error(`[warn] ${w}`));
262
+ if (has("--json")) {
263
+ console.log(JSON.stringify(result, null, 2));
264
+ } else {
265
+ console.log(`FRESHNESS @ ${result.now} (on_stale=${result.on_stale})`);
266
+ for (const r of result.concepts) {
267
+ const age = r.age_days === null || r.age_days === undefined ? "-" : `${r.age_days}d`;
268
+ const ttl = r.ttl_days === null || r.ttl_days === undefined ? "-" : `${r.ttl_days}d`;
269
+ const rem = r.remaining_days !== undefined && r.status === "fresh" ? ` (${r.remaining_days}d left)` : "";
270
+ const extra = r.detail ? ` [${r.by || "-"}] ${r.detail}` : "";
271
+ console.log(` [${String(r.status).padStart(14)}] ${r.concept.padEnd(32)} age=${age.padEnd(5)} ttl=${ttl}${rem}${extra}`);
272
+ }
273
+ console.log(` stale=${result.stale} missing_required_ts=${result.missing_required_ts}`);
274
+ }
275
+ if (result.fail) process.exit(1);
276
+ }
277
+
278
+ // ---- attest (sign one freshness attestation) ----
279
+ function cmdAttest() {
280
+ const bundleDir = resolveBundle("attest");
281
+ const concept = flag("--concept"); const by = flag("--by");
282
+ const until = flag("--until"); const keyPath = flag("--key");
283
+ if (!concept || !by || !until || !keyPath) die("attest: --concept, --by, --until and --key are required");
284
+ const on = flag("--on") || new Date().toISOString().slice(0, 10);
285
+ const entry = attestVigencia({ bundleDir, concept, by, on, until, keyPath, note: flag("--note") || "" });
286
+ console.log(`ATTESTED+SIGNED: ${concept} fresh per ${by} until ${until} (sha ${entry.content_sha256.slice(0, 8)}..., sig ${entry.signature.slice(0, 8)}...)`);
287
+ }
288
+
240
289
  (async () => {
241
290
  try {
242
291
  switch (cmd) {
@@ -244,6 +293,8 @@ function cmdKeygen() {
244
293
  case "memory": await cmdMemory(); break;
245
294
  case "publish": cmdPublish(); break;
246
295
  case "validate": await cmdValidate(); break;
296
+ case "freshness": cmdFreshness(); break;
297
+ case "attest": cmdAttest(); break;
247
298
  case "keygen": cmdKeygen(); break;
248
299
  case "-h": case "--help": case "help": case undefined: console.log(USAGE); break;
249
300
  default: die(`Unknown command: ${cmd}\n\n${USAGE}`);
package/lib/core.mjs CHANGED
@@ -46,6 +46,9 @@ export function parseFrontmatter(text) {
46
46
 
47
47
  const REQUIRED_FRONTMATTER = ["name", "description", "version", "license"];
48
48
 
49
+ // Executable Skills v0.5 SS2.5: namespace declarativo para origins multi-proyecto.
50
+ const SCOPE_RE = /^[a-z][a-z0-9_-]*$/;
51
+
49
52
  // ---- load skills from a manifest (mirrors generate.py load_skills) ----
50
53
  export function loadSkills(manifest, repoRoot) {
51
54
  const skills = [];
@@ -64,10 +67,14 @@ export function loadSkills(manifest, repoRoot) {
64
67
  toolUrl = entry.tool_url;
65
68
  toolSha256 = sha256NormalizedFile(toolPath);
66
69
  }
70
+ if (entry.scope !== undefined && !SCOPE_RE.test(String(entry.scope))) {
71
+ throw new Error(`${entry.path}: invalid 'scope' (pattern ^[a-z][a-z0-9_-]*$, Executable Skills v0.5 §2.5)`);
72
+ }
67
73
  skills.push({
68
74
  name: fm.name, description: fm.description, version: fm.version, license: fm.license,
69
75
  homepage: fm.homepage || null, url: entry.url, summary: entry.summary,
70
76
  sha256: sha256NormalizedFile(path), path, toolUrl, toolSha256,
77
+ scope: entry.scope !== undefined ? String(entry.scope) : null,
71
78
  });
72
79
  }
73
80
  return skills;
@@ -81,7 +88,14 @@ export function loadMemory(manifest, repoRoot) {
81
88
  }
82
89
  const snapshotPath = join(repoRoot, memory.snapshot_path);
83
90
  if (!existsSync(snapshotPath)) throw new Error(`snapshot not found: ${memory.snapshot_path}`);
84
- return { snapshot_url: memory.snapshot_url, format: memory.format, snapshot_sha256: sha256NormalizedFile(snapshotPath) };
91
+ if (memory.scope !== undefined && !SCOPE_RE.test(String(memory.scope))) {
92
+ throw new Error("manifest.memory: invalid 'scope' (pattern ^[a-z][a-z0-9_-]*$, Executable Skills v0.5 §2.5)");
93
+ }
94
+ return {
95
+ snapshot_url: memory.snapshot_url, format: memory.format,
96
+ snapshot_sha256: sha256NormalizedFile(snapshotPath),
97
+ scope: memory.scope !== undefined ? String(memory.scope) : null,
98
+ };
85
99
  }
86
100
 
87
101
  // ---- render: ## Skills section (compact JSON, exact key order of generate.py) ----
@@ -90,6 +104,7 @@ export function renderSkillsSection(manifest, skills) {
90
104
  for (const s of skills) {
91
105
  const meta = { version: s.version, license: s.license, sha256: s.sha256 };
92
106
  if (s.toolUrl && s.toolSha256) { meta.tool = s.toolUrl; meta.tool_sha256 = s.toolSha256; }
107
+ if (s.scope) meta.scope = s.scope;
93
108
  lines.push(`- [${s.name}](${s.url}): ${s.summary} <!-- skill: ${JSON.stringify(meta)} -->`);
94
109
  }
95
110
  return lines.join("\n") + "\n";
@@ -98,6 +113,7 @@ export function renderSkillsSection(manifest, skills) {
98
113
  export function renderSkillsMemoryLine(memory) {
99
114
  if (!memory) return "";
100
115
  const meta = { snapshot: memory.snapshot_url, snapshot_sha256: memory.snapshot_sha256, format: memory.format };
116
+ if (memory.scope) meta.scope = memory.scope;
101
117
  return `<!-- skills-memory: ${JSON.stringify(meta)} -->\n\n`;
102
118
  }
103
119
 
@@ -191,9 +207,20 @@ export function validateLlmsTxt(source, text) {
191
207
  const err = (message, line = "") => errors.push({ message, line });
192
208
  const warn = (message, line = "") => warnings.push({ message, line });
193
209
 
194
- // origin memory line (optional)
195
- const memMatch = text.split(/\r?\n/).map((l) => l.trim().match(MEMORY_CAPTURE_RE)).find(Boolean);
196
- if (memMatch) validateMemory(memMatch[1], source, err, warn);
210
+ // origin memory lines (optional; Executable Skills v0.5 SS2.5: one per
211
+ // scope, at most one without scope)
212
+ const memScopes = [];
213
+ for (const l of text.split(/\r?\n/)) {
214
+ const memMatch = l.trim().match(MEMORY_CAPTURE_RE);
215
+ if (!memMatch) continue;
216
+ const scopeKey = validateMemory(memMatch[1], source, err, warn);
217
+ if (scopeKey === undefined) continue; // JSON invalido, ya reportado
218
+ if (memScopes.includes(scopeKey)) {
219
+ err(scopeKey === ""
220
+ ? "skills-memory: more than one line without 'scope' (at most one allowed)"
221
+ : `skills-memory: duplicate line for scope '${scopeKey}'`);
222
+ } else memScopes.push(scopeKey);
223
+ }
197
224
 
198
225
  // find ## Skills section
199
226
  const lines = text.split(/\r?\n/);
@@ -238,6 +265,8 @@ export function validateLlmsTxt(source, text) {
238
265
  if (hasToolSha && !hasTool) err("'tool_sha256' declared without 'tool' (both required together)", raw.slice(0, 80));
239
266
  if (hasToolSha && !/^[a-fA-F0-9]{64}$/.test(String(metaParsed.tool_sha256)))
240
267
  err("Invalid tool_sha256 (must be 64 hex chars)", raw.slice(0, 80));
268
+ if ("scope" in metaParsed && !SCOPE_RE.test(String(metaParsed.scope)))
269
+ err("Invalid 'scope' (pattern ^[a-z][a-z0-9_-]*$, Executable Skills v0.5 §2.5)", raw.slice(0, 80));
241
270
  } catch (e) { err(`Invalid metadata JSON: ${e.message}`, raw.slice(0, 80)); }
242
271
  }
243
272
  const resolved = resolveSkillPath(url, source);
@@ -272,12 +301,15 @@ const MEMORY_CAPTURE_RE = /^<!--\s*skills-memory:\s*(.*?)\s*-->\s*$/;
272
301
  function validateMemory(memJson, source, err, warn) {
273
302
  let meta;
274
303
  try { meta = JSON.parse(memJson); }
275
- catch (e) { err(`skills-memory: invalid JSON: ${e.message}`); return; }
304
+ catch (e) { err(`skills-memory: invalid JSON: ${e.message}`); return undefined; }
305
+ const scopeKey = meta.scope === undefined ? "" : String(meta.scope);
306
+ if (meta.scope !== undefined && !SCOPE_RE.test(String(meta.scope)))
307
+ err(`skills-memory: invalid 'scope' (pattern ^[a-z][a-z0-9_-]*$, Executable Skills v0.5 §2.5)`);
276
308
  let ok = true;
277
309
  for (const key of ["snapshot", "snapshot_sha256", "format"]) {
278
310
  if (typeof meta[key] !== "string") { err(`skills-memory: missing or invalid '${key}' (must be string)`); ok = false; }
279
311
  }
280
- if (!ok) return;
312
+ if (!ok) return scopeKey;
281
313
  if (!/^[a-fA-F0-9]{64}$/.test(meta.snapshot_sha256)) err("skills-memory: invalid snapshot_sha256 (must be 64 hex chars)");
282
314
  if (meta.format !== "minimemory-okf-v1") warn(`skills-memory: format '${meta.format}' is not the only one recognized today (minimemory-okf-v1)`);
283
315
  const resolved = resolveSkillPath(meta.snapshot, source);
@@ -288,4 +320,5 @@ function validateMemory(memJson, source, err, warn) {
288
320
  if (actual !== meta.snapshot_sha256) err(`skills-memory: snapshot_sha256 mismatch: declared ${meta.snapshot_sha256}, actual ${actual}`);
289
321
  }
290
322
  }
323
+ return scopeKey;
291
324
  }
@@ -0,0 +1,271 @@
1
+ // freshness.mjs — signed knowledge freshness (RAG-OKF v2, "vigencia").
2
+ //
3
+ // Port of ccdd/examples/okf-integration (check_freshness.py + attest_vigencia.py),
4
+ // kept WIRE-COMPATIBLE with that Python tooling: same signed message
5
+ // `vigencia:{concept}:{content_sha256}:{attested_at}:{valid_until}`, same raw-hex
6
+ // ed25519 keys/signatures, same bundle-root files (freshness.yaml,
7
+ // attestations.json, reviewers.json). An attestation signed by either toolchain
8
+ // verifies in the other.
9
+ //
10
+ // Three distinct signals, honestly separated:
11
+ // - content hashes (publish) say "not tampered";
12
+ // - age vs TTL (freshness.yaml) says "recent" — a PROXY, age != truth;
13
+ // - a SIGNED human attestation says "still true", bound to the exact
14
+ // content sha (voided by any edit) and expiring at valid_until
15
+ // (re-affirmable without touching content). The machine binds, verifies
16
+ // and expires; the human judges.
17
+ //
18
+ // NOTE on hashing: content_sha256 here is over the RAW file bytes (no CRLF
19
+ // normalization) for compatibility with the Python reference. On Windows,
20
+ // git autocrlf can rewrite *.md on checkout and spuriously void attestations —
21
+ // pin your bundle's markdown as `*.md text eol=lf` (or -text) in .gitattributes.
22
+
23
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs";
24
+ import { join, resolve, sep } from "node:path";
25
+ import { createHash, createPublicKey, createPrivateKey, sign as cryptoSign, verify as cryptoVerify } from "node:crypto";
26
+ import { privateKeyFromSeed } from "./core.mjs";
27
+
28
+ const RESERVED = new Set(["index.md", "log.md"]);
29
+ const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
30
+
31
+ export function sha256RawFile(path) {
32
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
33
+ }
34
+
35
+ export function vigenciaMsg(concept, contentSha, on, until) {
36
+ return Buffer.from(`vigencia:${concept}:${contentSha}:${on}:${until}`, "utf8");
37
+ }
38
+
39
+ export function signVigenciaHex(privHex, msgBuf) {
40
+ const seed = Buffer.from(privHex.trim(), "hex");
41
+ if (seed.length !== 32) throw new Error("private key must be 32 raw ed25519 bytes in hex");
42
+ return cryptoSign(null, msgBuf, privateKeyFromSeed(seed)).toString("hex");
43
+ }
44
+
45
+ export function verifyVigenciaHex(pubHex, msgBuf, sigHex) {
46
+ try {
47
+ const raw = Buffer.from(String(pubHex), "hex");
48
+ if (raw.length !== 32) return false;
49
+ const pub = createPublicKey({ key: Buffer.concat([SPKI_ED25519_PREFIX, raw]), format: "der", type: "spki" });
50
+ return cryptoVerify(null, msgBuf, pub, Buffer.from(String(sigHex), "hex"));
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ // ---- minimal YAML subset parser for freshness.yaml --------------------------
57
+ // Supports exactly the reference file shape: top-level scalars, one-level
58
+ // nested maps (defaults/overrides), and inline lists ([A, B]). Comments and
59
+ // blank lines ignored. Anything deeper is out of scope (fail loud, not wrong).
60
+ export function parseFreshnessYaml(text) {
61
+ const root = {};
62
+ let current = null; // name of the open nested map
63
+ for (const rawLine of text.split(/\r?\n/)) {
64
+ const noComment = rawLine.replace(/(^|\s)#.*$/, "");
65
+ if (!noComment.trim()) continue;
66
+ const indented = /^\s/.test(noComment);
67
+ const m = noComment.trim().match(/^("?)([^":]+)\1\s*:\s*(.*)$/);
68
+ if (!m) throw new Error(`freshness.yaml: unsupported line: ${rawLine.trim()}`);
69
+ const key = m[2].trim();
70
+ let val = m[3].trim();
71
+ if (indented) {
72
+ if (!current) throw new Error(`freshness.yaml: indented key '${key}' outside a map`);
73
+ root[current][key] = coerce(val);
74
+ continue;
75
+ }
76
+ if (val === "") {
77
+ root[key] = {};
78
+ current = key;
79
+ continue;
80
+ }
81
+ current = null;
82
+ root[key] = coerce(val);
83
+ }
84
+ return root;
85
+
86
+ function coerce(v) {
87
+ if (/^\[.*\]$/.test(v)) {
88
+ const inner = v.slice(1, -1).trim();
89
+ return inner === "" ? [] : inner.split(",").map((s) => s.trim().replace(/^["']|["']$/g, ""));
90
+ }
91
+ v = v.replace(/^["']|["']$/g, "");
92
+ if (/^-?\d+$/.test(v)) return parseInt(v, 10);
93
+ return v;
94
+ }
95
+ }
96
+
97
+ // ---- frontmatter timestamp --------------------------------------------------
98
+ function parseDateISO(s) {
99
+ const m = String(s).trim().match(/^(\d{4})-(\d{2})-(\d{2})/);
100
+ if (!m) return null;
101
+ const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
102
+ return isNaN(d.getTime()) ? null : d;
103
+ }
104
+ const dayDiff = (a, b) => Math.floor((a.getTime() - b.getTime()) / 86400000);
105
+
106
+ function loadFrontmatter(text) {
107
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
108
+ if (!m) return null;
109
+ const fm = {};
110
+ for (const line of m[1].split(/\r?\n/)) {
111
+ const kv = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:\s*(.*)$/);
112
+ if (!kv) continue;
113
+ let v = kv[2].trim();
114
+ if (v.length >= 2 && ((v[0] === '"' && v.at(-1) === '"') || (v[0] === "'" && v.at(-1) === "'"))) v = v.slice(1, -1);
115
+ fm[kv[1]] = v;
116
+ }
117
+ return fm;
118
+ }
119
+
120
+ function* walkMd(dir, rel = "") {
121
+ for (const name of readdirSync(dir).sort()) {
122
+ const full = join(dir, name);
123
+ const r = rel ? rel + "/" + name : name;
124
+ if (statSync(full).isDirectory()) yield* walkMd(full, r);
125
+ else if (name.endsWith(".md") && !RESERVED.has(name)) yield [full, r];
126
+ }
127
+ }
128
+
129
+ function loadJsonIf(path, label, warn) {
130
+ if (!existsSync(path)) return null;
131
+ try {
132
+ return JSON.parse(readFileSync(path, "utf8"));
133
+ } catch (e) {
134
+ warn(`could not load ${label}: ${e.message}`);
135
+ return null;
136
+ }
137
+ }
138
+
139
+ // ---- check ------------------------------------------------------------------
140
+ // Returns { now, stale, missingRequiredTs, onStale, concepts: rows, fail }.
141
+ export function checkFreshness(bundleDir, nowISO, warn = () => {}) {
142
+ const polPath = join(bundleDir, "freshness.yaml");
143
+ if (!existsSync(polPath)) throw new Error(`freshness.yaml not found in ${bundleDir}`);
144
+ const pol = parseFreshnessYaml(readFileSync(polPath, "utf8"));
145
+ const now = parseDateISO(nowISO);
146
+ if (!now) throw new Error("--now must be ISO (YYYY-MM-DD)");
147
+
148
+ const defaults = pol.defaults || {};
149
+ const overrides = pol.overrides || {};
150
+ let onStale = pol.on_stale || "warn";
151
+ if (onStale !== "warn" && onStale !== "abort") {
152
+ warn(`unknown on_stale '${onStale}'; using 'warn'`);
153
+ onStale = "warn";
154
+ }
155
+ const requireTs = new Set(pol.require_timestamp_for_types || []);
156
+
157
+ const attData = loadJsonIf(join(bundleDir, "attestations.json"), "attestations.json", warn);
158
+ const attestations = {};
159
+ for (const a of (attData && Array.isArray(attData.attestations) ? attData.attestations : [])) {
160
+ if (a && typeof a === "object" && a.concept) attestations[a.concept] = a;
161
+ }
162
+ const reviewers = loadJsonIf(join(bundleDir, "reviewers.json"), "reviewers.json", warn) || {};
163
+
164
+ const rows = [];
165
+ let stale = 0, missing = 0;
166
+ for (const [full, rel] of walkMd(bundleDir)) {
167
+ const fm = loadFrontmatter(readFileSync(full, "utf8"));
168
+ if (fm === null) continue;
169
+ const ctype = fm.type;
170
+ const ttl = rel in overrides ? overrides[rel] : (ctype in defaults ? defaults[ctype] : null);
171
+
172
+ // Authoritative signal: a SIGNED human freshness attestation supersedes
173
+ // age — honored only if the ed25519 signature verifies against a reviewer
174
+ // registered in reviewers.json (the trust root).
175
+ const att = attestations[rel];
176
+ if (att !== undefined) {
177
+ const by = att.attested_by;
178
+ const pub = by ? reviewers[by] : undefined;
179
+ const signedSha = att.content_sha256 || "";
180
+ const msg = vigenciaMsg(rel, signedSha, att.attested_at || "", att.valid_until || "");
181
+ const sigOk = typeof pub === "string" && verifyVigenciaHex(pub, msg, att.signature || "");
182
+ if (!sigOk) {
183
+ stale++;
184
+ rows.push({ concept: rel, type: ctype, age_days: null, ttl_days: ttl, status: "INVALID-ATTEST", by, detail: "missing/invalid signature or unregistered reviewer" });
185
+ continue;
186
+ }
187
+ if (signedSha !== sha256RawFile(full)) {
188
+ stale++;
189
+ rows.push({ concept: rel, type: ctype, age_days: null, ttl_days: ttl, status: "VOID-ATTEST", by, detail: "content changed since attestation; re-attest" });
190
+ continue;
191
+ }
192
+ const until = att.valid_until ? parseDateISO(att.valid_until) : null;
193
+ if (!until || now.getTime() > until.getTime()) {
194
+ stale++;
195
+ rows.push({ concept: rel, type: ctype, age_days: null, ttl_days: ttl, status: "EXPIRED-ATTEST", by, detail: `attestation expired ${att.valid_until || "(no valid_until)"}; re-attest` });
196
+ continue;
197
+ }
198
+ rows.push({ concept: rel, type: ctype, age_days: null, ttl_days: ttl, status: "VIGENT", by, detail: `attested fresh until ${att.valid_until}` });
199
+ continue;
200
+ }
201
+
202
+ const ts = fm.timestamp !== undefined ? parseDateISO(fm.timestamp) : null;
203
+ if (!ts) {
204
+ const sev = requireTs.has(ctype) ? "MISSING-TS" : "no-ts";
205
+ if (sev === "MISSING-TS") missing++;
206
+ rows.push({ concept: rel, type: ctype, age_days: null, ttl_days: ttl, status: sev });
207
+ continue;
208
+ }
209
+ if (ttl === null || ttl === undefined) {
210
+ rows.push({ concept: rel, type: ctype, age_days: dayDiff(now, ts), ttl_days: null, status: "untracked" });
211
+ continue;
212
+ }
213
+ const age = dayDiff(now, ts);
214
+ const status = age > ttl ? "STALE" : "fresh";
215
+ if (status === "STALE") stale++;
216
+ rows.push({ concept: rel, type: ctype, age_days: age, ttl_days: ttl, status, remaining_days: ttl - age });
217
+ }
218
+
219
+ return {
220
+ now: nowISO, stale, missing_required_ts: missing, on_stale: onStale, concepts: rows,
221
+ fail: (stale + missing) > 0 && onStale === "abort",
222
+ };
223
+ }
224
+
225
+ // ---- attest -----------------------------------------------------------------
226
+ // Signs and upserts one freshness attestation into <bundle>/attestations.json.
227
+ export function attestVigencia({ bundleDir, concept, by, on, until, keyPath, note = "" }) {
228
+ const root = resolve(bundleDir);
229
+ const target = resolve(root, concept);
230
+ if (target !== root && !target.startsWith(root + sep)) {
231
+ throw new Error(`'${concept}' must live inside the bundle`);
232
+ }
233
+ if (!existsSync(target)) throw new Error(`no such concept: ${concept}`);
234
+ if (!parseDateISO(on) || !parseDateISO(until)) throw new Error("--on and --until must be ISO (YYYY-MM-DD)");
235
+
236
+ const regPath = join(root, "reviewers.json");
237
+ if (!existsSync(regPath)) {
238
+ throw new Error("no reviewers.json in the bundle. Register the reviewer's raw ed25519 pubkey (hex) there first.");
239
+ }
240
+ const registry = JSON.parse(readFileSync(regPath, "utf8"));
241
+ if (!(by in registry)) throw new Error(`'${by}' is not registered in reviewers.json`);
242
+ if (!existsSync(keyPath)) throw new Error(`no such private key file: ${keyPath}`);
243
+
244
+ const privHex = readFileSync(keyPath, "utf8").trim();
245
+ const contentSha = sha256RawFile(target);
246
+ const signature = signVigenciaHex(privHex, vigenciaMsg(concept, contentSha, on, until));
247
+
248
+ // Sanity: the key must actually belong to the registered reviewer.
249
+ if (!verifyVigenciaHex(registry[by], vigenciaMsg(concept, contentSha, on, until), signature)) {
250
+ throw new Error(`the private key does not match the registered pubkey for '${by}'`);
251
+ }
252
+
253
+ const storePath = join(root, "attestations.json");
254
+ let data = { vigencia_version: "0.2", attestations: [] };
255
+ if (existsSync(storePath)) {
256
+ data = JSON.parse(readFileSync(storePath, "utf8"));
257
+ if (!data || typeof data !== "object" || !Array.isArray(data.attestations)) {
258
+ throw new Error("attestations.json does not have the expected structure");
259
+ }
260
+ }
261
+ const entry = {
262
+ concept, content_sha256: contentSha, statement: "vigente",
263
+ attested_by: by, attested_at: on, valid_until: until, signature, note,
264
+ };
265
+ data.vigencia_version = "0.2";
266
+ data.attestations = data.attestations
267
+ .filter((a) => !(a && typeof a === "object" && a.concept === concept))
268
+ .concat([entry]);
269
+ writeFileSync(storePath, JSON.stringify(data, null, 2) + "\n", "utf8");
270
+ return entry;
271
+ }
package/lib/memory.mjs CHANGED
@@ -192,7 +192,11 @@ runtime executes it verbatim in a sandbox.
192
192
  // ---- orchestrator --------------------------------------------------------------
193
193
  // Builds everything under `root`. Returns { targets, warnings, snapshotSha, manifest }
194
194
  // where targets is [path, content] pairs (the caller writes or checks them).
195
- export async function buildMemoryTargets({ root, bundleDir, manifest, license }) {
195
+ export async function buildMemoryTargets({ root, bundleDir, manifest, license, scope }) {
196
+ // Executable Skills v0.5 SS2.5: namespace declarativo para origins multi-proyecto.
197
+ if (scope !== undefined && scope !== null && !/^[a-z][a-z0-9_-]*$/.test(String(scope))) {
198
+ throw new Error("memory: invalid --scope (pattern ^[a-z][a-z0-9_-]*$, Executable Skills v0.5 §2.5)");
199
+ }
196
200
  const bundleRel = relative(root, bundleDir).replaceAll("\\", "/");
197
201
  if (bundleRel.startsWith("..")) {
198
202
  throw new Error("memory: the bundle must live inside the publisher root (it is served as static content)");
@@ -236,17 +240,26 @@ export async function buildMemoryTargets({ root, bundleDir, manifest, license })
236
240
  snapshot_url: "/skills-index.snapshot",
237
241
  format: SNAPSHOT_FORMAT,
238
242
  };
243
+ if (scope) newManifest.memory.scope = String(scope);
244
+ else delete newManifest.memory.scope;
239
245
  if (!Array.isArray(newManifest.published)) newManifest.published = [];
240
246
  for (const [name, , summary] of SKILLS) {
241
247
  const path = `skills/${name}/SKILL.md`;
242
- if (!newManifest.published.some((e) => e && e.path === path)) {
243
- newManifest.published.push({
248
+ const existing = newManifest.published.find((e) => e && e.path === path);
249
+ if (!existing) {
250
+ const entry = {
244
251
  path,
245
252
  url: `/skills/${name}/SKILL.md`,
246
253
  summary,
247
254
  tool: `skills/${name}/tool.js`,
248
255
  tool_url: `/skills/${name}/tool.js`,
249
- });
256
+ };
257
+ if (scope) entry.scope = String(scope);
258
+ newManifest.published.push(entry);
259
+ } else if (scope) {
260
+ existing.scope = String(scope); // re-run con --scope: refresca el namespace
261
+ } else {
262
+ delete existing.scope; // re-run sin --scope: vuelve al default global
250
263
  }
251
264
  }
252
265
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rckflr/llms-skills",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "One-command publisher CLI for the llms.txt Skills standard: scaffold, hash, sign, and sync the ## Skills section + .well-known/agent-skills/index.json. Byte-identical to the reference Python generator; zero hard dependencies (BM25 knowledge snapshots via an optional wasm engine).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,6 +38,7 @@
38
38
  "CHANGELOG.md",
39
39
  "bin/llms-skills.mjs",
40
40
  "lib/core.mjs",
41
+ "lib/freshness.mjs",
41
42
  "lib/memory.mjs",
42
43
  "README.md"
43
44
  ],