@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 +35 -1
- package/README.md +29 -0
- package/bin/llms-skills.mjs +52 -1
- package/lib/freshness.mjs +271 -0
- package/package.json +2 -1
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.
|
|
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
|
package/bin/llms-skills.mjs
CHANGED
|
@@ -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
|
+
"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
|
],
|