@rckflr/llms-skills 0.3.0 → 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,7 +4,41 @@ 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.3.0
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
8
42
 
9
43
  ### Added
10
44
  - **`--scope <name>` on `memory`** (Executable Skills v0.5 §2.5, resolves
package/README.md CHANGED
@@ -89,6 +89,35 @@ verifies the snapshot and injects `host.memorySearch` on both runtimes.
89
89
  default; if omitted, `memory` fails with a clear message and every other
90
90
  command works as before.
91
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
+
92
121
  ## Signing / attestation (L3)
93
122
 
94
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];
@@ -33,6 +34,13 @@ Usage:
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.
@@ -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}`);
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rckflr/llms-skills",
3
- "version": "0.3.0",
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
  ],