@personaxis/core 0.11.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.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/dist/agent.d.ts +105 -0
  3. package/dist/agent.js +429 -0
  4. package/dist/appraisal.d.ts +196 -0
  5. package/dist/appraisal.js +159 -0
  6. package/dist/approval.d.ts +42 -0
  7. package/dist/approval.js +71 -0
  8. package/dist/blackboard.d.ts +86 -0
  9. package/dist/blackboard.js +139 -0
  10. package/dist/compile/assemble.d.ts +44 -0
  11. package/dist/compile/assemble.js +338 -0
  12. package/dist/compile/dist.d.ts +30 -0
  13. package/dist/compile/dist.js +66 -0
  14. package/dist/compile/faithfulness.d.ts +47 -0
  15. package/dist/compile/faithfulness.js +112 -0
  16. package/dist/compile/index.d.ts +10 -0
  17. package/dist/compile/index.js +10 -0
  18. package/dist/compile/targets.d.ts +62 -0
  19. package/dist/compile/targets.js +114 -0
  20. package/dist/config-layers.d.ts +42 -0
  21. package/dist/config-layers.js +59 -0
  22. package/dist/config-scan.d.ts +30 -0
  23. package/dist/config-scan.js +118 -0
  24. package/dist/context.d.ts +67 -0
  25. package/dist/context.js +178 -0
  26. package/dist/envelopes.d.ts +48 -0
  27. package/dist/envelopes.js +131 -0
  28. package/dist/events.d.ts +135 -0
  29. package/dist/events.js +19 -0
  30. package/dist/evolution-view.d.ts +43 -0
  31. package/dist/evolution-view.js +67 -0
  32. package/dist/generated/version.d.ts +1 -0
  33. package/dist/generated/version.js +2 -0
  34. package/dist/governance.d.ts +108 -0
  35. package/dist/governance.js +204 -0
  36. package/dist/heuristic-appraiser.d.ts +13 -0
  37. package/dist/heuristic-appraiser.js +48 -0
  38. package/dist/hooks.d.ts +52 -0
  39. package/dist/hooks.js +122 -0
  40. package/dist/index.d.ts +53 -0
  41. package/dist/index.js +53 -0
  42. package/dist/injection.d.ts +48 -0
  43. package/dist/injection.js +141 -0
  44. package/dist/live-sync.d.ts +60 -0
  45. package/dist/live-sync.js +85 -0
  46. package/dist/llm-appraiser.d.ts +31 -0
  47. package/dist/llm-appraiser.js +143 -0
  48. package/dist/lock.d.ts +26 -0
  49. package/dist/lock.js +99 -0
  50. package/dist/loop.d.ts +52 -0
  51. package/dist/loop.js +253 -0
  52. package/dist/memory-kinds.d.ts +67 -0
  53. package/dist/memory-kinds.js +106 -0
  54. package/dist/memory.d.ts +123 -0
  55. package/dist/memory.js +228 -0
  56. package/dist/model-config.d.ts +60 -0
  57. package/dist/model-config.js +102 -0
  58. package/dist/persona-theme.d.ts +57 -0
  59. package/dist/persona-theme.js +183 -0
  60. package/dist/persona.d.ts +88 -0
  61. package/dist/persona.js +93 -0
  62. package/dist/ports/index.d.ts +81 -0
  63. package/dist/ports/index.js +50 -0
  64. package/dist/provenance.d.ts +63 -0
  65. package/dist/provenance.js +132 -0
  66. package/dist/recompile-marker.d.ts +19 -0
  67. package/dist/recompile-marker.js +36 -0
  68. package/dist/registry.d.ts +99 -0
  69. package/dist/registry.js +168 -0
  70. package/dist/responder.d.ts +47 -0
  71. package/dist/responder.js +78 -0
  72. package/dist/sandbox.d.ts +120 -0
  73. package/dist/sandbox.js +265 -0
  74. package/dist/self-evolution.d.ts +148 -0
  75. package/dist/self-evolution.js +364 -0
  76. package/dist/session-writer.d.ts +52 -0
  77. package/dist/session-writer.js +104 -0
  78. package/dist/sessions.d.ts +96 -0
  79. package/dist/sessions.js +179 -0
  80. package/dist/sigil.d.ts +41 -0
  81. package/dist/sigil.js +92 -0
  82. package/dist/skill-lifecycle.d.ts +74 -0
  83. package/dist/skill-lifecycle.js +126 -0
  84. package/dist/skill-review.d.ts +25 -0
  85. package/dist/skill-review.js +67 -0
  86. package/dist/spec-edit.d.ts +30 -0
  87. package/dist/spec-edit.js +137 -0
  88. package/dist/state-engine.d.ts +40 -0
  89. package/dist/state-engine.js +50 -0
  90. package/dist/state-rebuild.d.ts +48 -0
  91. package/dist/state-rebuild.js +60 -0
  92. package/dist/sync.d.ts +32 -0
  93. package/dist/sync.js +95 -0
  94. package/dist/tool-calling.d.ts +59 -0
  95. package/dist/tool-calling.js +168 -0
  96. package/dist/tool-repair.d.ts +20 -0
  97. package/dist/tool-repair.js +119 -0
  98. package/dist/tools/exec.d.ts +46 -0
  99. package/dist/tools/exec.js +132 -0
  100. package/dist/tools/registry.d.ts +38 -0
  101. package/dist/tools/registry.js +162 -0
  102. package/dist/trace.d.ts +63 -0
  103. package/dist/trace.js +162 -0
  104. package/dist/verification.d.ts +65 -0
  105. package/dist/verification.js +190 -0
  106. package/package.json +38 -0
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Conversation sessions — persistent, per-persona chat history (F3).
3
+ *
4
+ * The REPL keeps the live conversation in memory and re-sends it each turn; that is
5
+ * correct for continuity but vanishes on exit. Sessions persist it so a user can leave
6
+ * and `/resume`, the way Claude Code does. Layout mirrors the rest of the persona's
7
+ * artifacts and recurses: the root's sessions live in `.personaxis/sessions/`, a sub's in
8
+ * `.personaxis/personas/<slug>/sessions/`. One append-only `<id>.jsonl` per conversation:
9
+ * a header line + one line per turn.
10
+ *
11
+ * Dependency-free (node:fs only), like memory.ts — sessions are a runtime artifact with
12
+ * no schema (same status as episodic.jsonl / self-edits.jsonl).
13
+ */
14
+ import type { ChatMessage } from "./tool-calling.js";
15
+ export type SessionKind = "root" | "sub" | "direct-sub" | "delegation";
16
+ export interface SessionHeader {
17
+ type: "header";
18
+ id: string;
19
+ kind: SessionKind;
20
+ participants: string[];
21
+ name: string;
22
+ created: string;
23
+ /** Hierarchical address of the owning persona ("" = root) — provenance. */
24
+ persona: string;
25
+ }
26
+ export interface SessionTurn {
27
+ type: "turn";
28
+ /** "note" marks a non-conversational provenance entry (e.g. a delegation record).
29
+ * "summary" is a persisted /compact checkpoint: on reload it REPLACES every turn before it
30
+ * (which stay in the file for audit) and the verbatim turns after it are kept. */
31
+ role: "user" | "assistant" | "note" | "summary";
32
+ content: string;
33
+ ts: string;
34
+ /** Who produced an assistant/note turn (address; "(root)" for the root). */
35
+ from?: string;
36
+ /** FR.6 threading (Claude Code's transcript shape): this turn's stable id. */
37
+ uuid?: string;
38
+ /** FR.6: the uuid this turn replies to — makes branches/regenerations explicit. */
39
+ parent_uuid?: string;
40
+ }
41
+ export interface SessionSummary {
42
+ id: string;
43
+ name: string;
44
+ kind: SessionKind;
45
+ created: string;
46
+ updated: string;
47
+ turns: number;
48
+ path: string;
49
+ }
50
+ export declare function sessionsDir(personaPath: string): string;
51
+ /** A filesystem-safe, sortable session id (ISO timestamp + short random suffix). */
52
+ export declare function newSessionId(now?: Date): string;
53
+ /** Create the session file with its header. No-op if it already exists. */
54
+ export declare function ensureSession(personaPath: string, header: Omit<SessionHeader, "type">): void;
55
+ /** Append one turn. Requires the session to already exist (call ensureSession first).
56
+ * Returns the turn's uuid (generated when not supplied) for parent_uuid threading. */
57
+ export declare function appendTurn(personaPath: string, id: string, turn: {
58
+ role: SessionTurn["role"];
59
+ content: string;
60
+ from?: string;
61
+ ts?: string;
62
+ uuid?: string;
63
+ parentUuid?: string;
64
+ }): string | undefined;
65
+ export declare function readSession(personaPath: string, id: string): {
66
+ header?: SessionHeader;
67
+ turns: SessionTurn[];
68
+ };
69
+ /**
70
+ * Persist a /compact checkpoint. Appends a `summary` turn; on reload it replaces every earlier
71
+ * turn (kept in the file for audit) so `/resume` returns the COMPACTED conversation, not the raw
72
+ * bloat. Verbatim turns appended AFTER this checkpoint are preserved until the next compaction.
73
+ * Best-effort: no-op if the session file doesn't exist yet.
74
+ */
75
+ export declare function recordCompaction(personaPath: string, id: string, summary: string): void;
76
+ /**
77
+ * Rehydrate a session into a ChatMessage[] for the agent. Notes are dropped. If a /compact
78
+ * checkpoint (summary turn) exists, the LAST one replaces everything before it: the rehydrated
79
+ * conversation is `[<summary as a user message>, ...verbatim turns appended after the checkpoint]`.
80
+ */
81
+ export declare function loadConversation(personaPath: string, id: string): ChatMessage[];
82
+ /** All sessions for a persona, newest-activity first. */
83
+ export declare function listSessions(personaPath: string): SessionSummary[];
84
+ /** Set a session's display name (rewrites the header line). */
85
+ export declare function renameSession(personaPath: string, id: string, name: string): void;
86
+ /** Resolve a session by exact id, else by case-insensitive name fragment (newest first). */
87
+ export declare function findSession(personaPath: string, query: string): SessionSummary | undefined;
88
+ /** Deterministic fallback title from the first user message. */
89
+ export declare function fallbackName(firstMessage: string): string;
90
+ /** Auto-title a session from its first user message via the LLM (best-effort; may throw). */
91
+ export declare function nameSession(llm: {
92
+ endpoint: string;
93
+ model: string;
94
+ apiKey?: string;
95
+ fetchImpl?: typeof fetch;
96
+ }, firstMessage: string): Promise<string>;
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Conversation sessions — persistent, per-persona chat history (F3).
3
+ *
4
+ * The REPL keeps the live conversation in memory and re-sends it each turn; that is
5
+ * correct for continuity but vanishes on exit. Sessions persist it so a user can leave
6
+ * and `/resume`, the way Claude Code does. Layout mirrors the rest of the persona's
7
+ * artifacts and recurses: the root's sessions live in `.personaxis/sessions/`, a sub's in
8
+ * `.personaxis/personas/<slug>/sessions/`. One append-only `<id>.jsonl` per conversation:
9
+ * a header line + one line per turn.
10
+ *
11
+ * Dependency-free (node:fs only), like memory.ts — sessions are a runtime artifact with
12
+ * no schema (same status as episodic.jsonl / self-edits.jsonl).
13
+ */
14
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
15
+ import { randomUUID } from "node:crypto";
16
+ import { dirname, join } from "node:path";
17
+ export function sessionsDir(personaPath) {
18
+ return join(dirname(personaPath), "sessions");
19
+ }
20
+ function sessionFile(personaPath, id) {
21
+ return join(sessionsDir(personaPath), `${id}.jsonl`);
22
+ }
23
+ /** A filesystem-safe, sortable session id (ISO timestamp + short random suffix). */
24
+ export function newSessionId(now = new Date()) {
25
+ const ts = now.toISOString().replace(/[:.]/g, "-");
26
+ const rand = Math.random().toString(36).slice(2, 7);
27
+ return `${ts}-${rand}`;
28
+ }
29
+ /** Create the session file with its header. No-op if it already exists. */
30
+ export function ensureSession(personaPath, header) {
31
+ const p = sessionFile(personaPath, header.id);
32
+ if (existsSync(p))
33
+ return;
34
+ mkdirSync(sessionsDir(personaPath), { recursive: true });
35
+ writeFileSync(p, JSON.stringify({ type: "header", ...header }) + "\n", "utf-8");
36
+ }
37
+ /** Append one turn. Requires the session to already exist (call ensureSession first).
38
+ * Returns the turn's uuid (generated when not supplied) for parent_uuid threading. */
39
+ export function appendTurn(personaPath, id, turn) {
40
+ const p = sessionFile(personaPath, id);
41
+ if (!existsSync(p))
42
+ return undefined;
43
+ const uuid = turn.uuid ?? randomUUID();
44
+ const entry = {
45
+ type: "turn",
46
+ ts: turn.ts ?? new Date().toISOString(),
47
+ role: turn.role,
48
+ content: turn.content,
49
+ uuid,
50
+ ...(turn.parentUuid ? { parent_uuid: turn.parentUuid } : {}),
51
+ ...(turn.from ? { from: turn.from } : {}),
52
+ };
53
+ appendFileSync(p, JSON.stringify(entry) + "\n", "utf-8");
54
+ return uuid;
55
+ }
56
+ function parseRows(p) {
57
+ const rows = [];
58
+ for (const line of readFileSync(p, "utf-8").split("\n")) {
59
+ if (!line.trim())
60
+ continue;
61
+ try {
62
+ rows.push(JSON.parse(line));
63
+ }
64
+ catch {
65
+ /* skip a corrupt line */
66
+ }
67
+ }
68
+ return rows;
69
+ }
70
+ export function readSession(personaPath, id) {
71
+ const p = sessionFile(personaPath, id);
72
+ if (!existsSync(p))
73
+ return { turns: [] };
74
+ const rows = parseRows(p);
75
+ return {
76
+ header: rows.find((r) => r.type === "header"),
77
+ turns: rows.filter((r) => r.type === "turn"),
78
+ };
79
+ }
80
+ /**
81
+ * Persist a /compact checkpoint. Appends a `summary` turn; on reload it replaces every earlier
82
+ * turn (kept in the file for audit) so `/resume` returns the COMPACTED conversation, not the raw
83
+ * bloat. Verbatim turns appended AFTER this checkpoint are preserved until the next compaction.
84
+ * Best-effort: no-op if the session file doesn't exist yet.
85
+ */
86
+ export function recordCompaction(personaPath, id, summary) {
87
+ appendTurn(personaPath, id, { role: "summary", content: summary });
88
+ }
89
+ /**
90
+ * Rehydrate a session into a ChatMessage[] for the agent. Notes are dropped. If a /compact
91
+ * checkpoint (summary turn) exists, the LAST one replaces everything before it: the rehydrated
92
+ * conversation is `[<summary as a user message>, ...verbatim turns appended after the checkpoint]`.
93
+ */
94
+ export function loadConversation(personaPath, id) {
95
+ const turns = readSession(personaPath, id).turns;
96
+ let lastSummary = -1;
97
+ for (let i = 0; i < turns.length; i++)
98
+ if (turns[i].role === "summary")
99
+ lastSummary = i;
100
+ const out = [];
101
+ if (lastSummary >= 0) {
102
+ out.push({ role: "user", content: `<summary of earlier conversation>\n${turns[lastSummary].content}\n</summary>` });
103
+ }
104
+ for (let i = lastSummary + 1; i < turns.length; i++) {
105
+ const t = turns[i];
106
+ if (t.role === "user" || t.role === "assistant")
107
+ out.push({ role: t.role, content: t.content });
108
+ }
109
+ return out;
110
+ }
111
+ /** All sessions for a persona, newest-activity first. */
112
+ export function listSessions(personaPath) {
113
+ const dir = sessionsDir(personaPath);
114
+ if (!existsSync(dir))
115
+ return [];
116
+ const out = [];
117
+ for (const f of readdirSync(dir)) {
118
+ if (!f.endsWith(".jsonl"))
119
+ continue;
120
+ const id = f.slice(0, -6);
121
+ const { header, turns } = readSession(personaPath, id);
122
+ out.push({
123
+ id,
124
+ name: header?.name ?? id,
125
+ kind: header?.kind ?? "root",
126
+ created: header?.created ?? "",
127
+ updated: turns.at(-1)?.ts ?? header?.created ?? "",
128
+ turns: turns.filter((t) => t.role === "user" || t.role === "assistant").length,
129
+ path: sessionFile(personaPath, id),
130
+ });
131
+ }
132
+ return out.sort((a, b) => b.updated.localeCompare(a.updated));
133
+ }
134
+ /** Set a session's display name (rewrites the header line). */
135
+ export function renameSession(personaPath, id, name) {
136
+ const p = sessionFile(personaPath, id);
137
+ if (!existsSync(p))
138
+ return;
139
+ const rows = parseRows(p);
140
+ const header = rows.find((r) => r.type === "header");
141
+ if (!header)
142
+ return;
143
+ header.name = name;
144
+ writeFileSync(p, rows.map((r) => JSON.stringify(r)).join("\n") + "\n", "utf-8");
145
+ }
146
+ /** Resolve a session by exact id, else by case-insensitive name fragment (newest first). */
147
+ export function findSession(personaPath, query) {
148
+ const all = listSessions(personaPath);
149
+ return all.find((s) => s.id === query) ?? all.find((s) => s.name.toLowerCase().includes(query.toLowerCase()));
150
+ }
151
+ /** Deterministic fallback title from the first user message. */
152
+ export function fallbackName(firstMessage) {
153
+ const words = firstMessage.trim().replace(/\s+/g, " ").split(" ").slice(0, 6).join(" ");
154
+ return (words || "session").slice(0, 48);
155
+ }
156
+ /** Auto-title a session from its first user message via the LLM (best-effort; may throw). */
157
+ export async function nameSession(llm, firstMessage) {
158
+ const fetchImpl = llm.fetchImpl ?? fetch;
159
+ const res = await fetchImpl(`${llm.endpoint.replace(/\/$/, "")}/chat/completions`, {
160
+ method: "POST",
161
+ headers: { "content-type": "application/json", ...(llm.apiKey ? { authorization: `Bearer ${llm.apiKey}` } : {}) },
162
+ body: JSON.stringify({
163
+ model: llm.model,
164
+ messages: [
165
+ { role: "system", content: "You title chat sessions. Reply with ONLY a 2-5 word title in Title Case. No quotes, no trailing punctuation." },
166
+ { role: "user", content: `First message:\n${firstMessage.slice(0, 400)}` },
167
+ ],
168
+ temperature: 0,
169
+ max_tokens: 16,
170
+ }),
171
+ });
172
+ if (!res.ok)
173
+ throw new Error(`namer HTTP ${res.status}`);
174
+ const json = (await res.json());
175
+ const out = (json.choices?.[0]?.message?.content ?? "").trim().replace(/^["']|["']$/g, "").replace(/[.]+$/, "");
176
+ if (!out)
177
+ throw new Error("empty title");
178
+ return out.slice(0, 60);
179
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Persona sigil — a deterministic, per-persona visual identity.
3
+ *
4
+ * Requirement (plan/09-ascii-ux): showing a persona must be *differentiated per
5
+ * persona*, never generic. We derive a stable seed from the persona's identity
6
+ * (canonical_id / display_name) and shape parameters from its quantitative
7
+ * layers (personality, affect, values), then render a symmetric ASCII glyph plus
8
+ * a color. The same persona always renders the same sigil; different specs render
9
+ * visibly different sigils.
10
+ *
11
+ * The sigil also "breathes": given the live state.json values (mood/affect) it
12
+ * shifts density and character set, so a *living* persona looks alive — and looks
13
+ * alive in a way that is its own, not a shared spinner.
14
+ *
15
+ * Pure + dependency-free: returns data; the CLI applies ANSI color.
16
+ */
17
+ import type { PersonaFrontmatter } from "./persona.js";
18
+ export interface SigilParams {
19
+ seed: number;
20
+ /** 0..255 ANSI-256 color index derived from the persona. */
21
+ color: number;
22
+ /** Glyph palette (light -> dense) chosen by the seed. */
23
+ charset: string[];
24
+ size: number;
25
+ }
26
+ export interface Sigil {
27
+ params: SigilParams;
28
+ /** Rows of the rendered glyph (already mirrored / symmetric). */
29
+ grid: string[];
30
+ }
31
+ export declare function sigilParams(frontmatter: PersonaFrontmatter, size?: number): SigilParams;
32
+ /**
33
+ * Render a symmetric glyph. `intensity` in [0,1] shifts how dense the glyph is
34
+ * (driven by live mood/affect for the "breathing" effect).
35
+ */
36
+ export declare function renderSigil(params: SigilParams, intensity?: number): Sigil;
37
+ /**
38
+ * Map live state values to a breathing intensity. Higher valence/tone/energy =>
39
+ * a denser, brighter glyph. Phase animates a subtle pulse over `frame`.
40
+ */
41
+ export declare function liveIntensity(values: Record<string, number>, frame?: number): number;
package/dist/sigil.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Persona sigil — a deterministic, per-persona visual identity.
3
+ *
4
+ * Requirement (plan/09-ascii-ux): showing a persona must be *differentiated per
5
+ * persona*, never generic. We derive a stable seed from the persona's identity
6
+ * (canonical_id / display_name) and shape parameters from its quantitative
7
+ * layers (personality, affect, values), then render a symmetric ASCII glyph plus
8
+ * a color. The same persona always renders the same sigil; different specs render
9
+ * visibly different sigils.
10
+ *
11
+ * The sigil also "breathes": given the live state.json values (mood/affect) it
12
+ * shifts density and character set, so a *living* persona looks alive — and looks
13
+ * alive in a way that is its own, not a shared spinner.
14
+ *
15
+ * Pure + dependency-free: returns data; the CLI applies ANSI color.
16
+ */
17
+ import { createHash } from "node:crypto";
18
+ const CHARSETS = [
19
+ [" ", "·", ":", "*", "✦", "█"],
20
+ [" ", ".", "+", "x", "#", "█"],
21
+ [" ", "˙", "∘", "○", "◍", "●"],
22
+ [" ", "·", "✶", "✷", "✸", "✹"],
23
+ [" ", "░", "▒", "▓", "█", "█"],
24
+ [" ", "⋄", "◇", "◈", "◆", "▰"],
25
+ ];
26
+ function seedFrom(frontmatter) {
27
+ const id = frontmatter.identity;
28
+ const meta = frontmatter.metadata;
29
+ const key = id?.canonical_id ?? id?.display_name ?? meta?.name ?? "persona";
30
+ const hex = createHash("sha256").update(String(key)).digest("hex").slice(0, 8);
31
+ return parseInt(hex, 16) >>> 0;
32
+ }
33
+ /** A tiny xorshift PRNG so the whole sigil is reproducible from one seed. */
34
+ function rng(seed) {
35
+ let s = seed || 0x9e3779b9;
36
+ return () => {
37
+ s ^= s << 13;
38
+ s ^= s >>> 17;
39
+ s ^= s << 5;
40
+ s >>>= 0;
41
+ return s / 0xffffffff;
42
+ };
43
+ }
44
+ export function sigilParams(frontmatter, size = 7) {
45
+ const seed = seedFrom(frontmatter);
46
+ return {
47
+ seed,
48
+ color: 17 + (seed % 214), // skip the dim 0-16 range
49
+ charset: CHARSETS[seed % CHARSETS.length],
50
+ size: size % 2 === 0 ? size + 1 : size,
51
+ };
52
+ }
53
+ /**
54
+ * Render a symmetric glyph. `intensity` in [0,1] shifts how dense the glyph is
55
+ * (driven by live mood/affect for the "breathing" effect).
56
+ */
57
+ export function renderSigil(params, intensity = 0.5) {
58
+ const { size, charset } = params;
59
+ const half = Math.ceil(size / 2);
60
+ const next = rng(params.seed);
61
+ const rows = [];
62
+ const density = 0.25 + intensity * 0.6;
63
+ for (let y = 0; y < size; y++) {
64
+ let left = "";
65
+ for (let x = 0; x < half; x++) {
66
+ const r = next();
67
+ // radial falloff so the glyph reads as a centered sigil
68
+ const cy = (y - (size - 1) / 2) / size;
69
+ const cx = (x - (size - 1) / 2) / size;
70
+ const radial = 1 - Math.min(1, Math.sqrt(cx * cx + cy * cy) * 1.4);
71
+ const v = r * (0.5 + radial) * density;
72
+ const idx = Math.max(0, Math.min(charset.length - 1, Math.floor(v * charset.length)));
73
+ left += charset[idx];
74
+ }
75
+ const mid = size % 2 === 1 ? left[left.length - 1] : "";
76
+ const right = left.slice(0, size - half).split("").reverse().join("");
77
+ rows.push((left + (mid ? "" : "") + right).slice(0, size));
78
+ }
79
+ return { params, grid: rows };
80
+ }
81
+ /**
82
+ * Map live state values to a breathing intensity. Higher valence/tone/energy =>
83
+ * a denser, brighter glyph. Phase animates a subtle pulse over `frame`.
84
+ */
85
+ export function liveIntensity(values, frame = 0) {
86
+ const tone = values["mood.tone"] ?? 0;
87
+ const valence = values["affect.valence"] ?? 0;
88
+ const arousal = values["affect.arousal"] ?? values["mood.energy"] ?? 0;
89
+ const base = 0.5 + (tone + valence) * 0.25 + arousal * 0.1;
90
+ const pulse = Math.sin(frame / 2) * 0.08 * (0.5 + arousal);
91
+ return Math.max(0.05, Math.min(1, base + pulse));
92
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Skill lifecycle governance (F6 — plan/06-skills; SkillsVote, Liu et al., 2026).
3
+ *
4
+ * We don't author skills; we use them from external sources. Beyond the pre-use
5
+ * security review (skill-review.ts), a skill must be *governed over its life*:
6
+ * - attribution: record each use + outcome (success/failure) -> success rate;
7
+ * - recommendation: rank candidates by security verdict, success rate, and
8
+ * capability match — never recommend an unreviewed/dangerous skill;
9
+ * - evolution: evidence-gated — a new version is promoted ONLY if it beats the
10
+ * incumbent on success rate over a minimum sample (revert-on-regression);
11
+ * persistently failing skills are deprecated.
12
+ *
13
+ * Append-only JSONL ledger (auditable, never rewritten), folded for current state.
14
+ */
15
+ export type SkillOp = "register" | "use" | "outcome" | "promote" | "deprecate";
16
+ export interface SkillEvent {
17
+ ts: string;
18
+ skill: string;
19
+ version: string;
20
+ op: SkillOp;
21
+ outcome?: "success" | "failure";
22
+ note?: string;
23
+ }
24
+ export interface SkillStats {
25
+ skill: string;
26
+ version: string;
27
+ uses: number;
28
+ successes: number;
29
+ failures: number;
30
+ successRate: number;
31
+ status: "active" | "deprecated";
32
+ }
33
+ export interface SkillCandidate {
34
+ skill: string;
35
+ version: string;
36
+ capabilities: string[];
37
+ /** Verdict from reviewSkill(): ok | review | danger. */
38
+ reviewVerdict: "ok" | "review" | "danger";
39
+ }
40
+ export interface Recommendation {
41
+ skill: string;
42
+ version: string;
43
+ score: number;
44
+ reasons: string[];
45
+ }
46
+ export declare class SkillLedger {
47
+ private file;
48
+ constructor(baseDir: string);
49
+ private append;
50
+ events(): SkillEvent[];
51
+ register(skill: string, version: string): void;
52
+ recordUse(skill: string, version: string): void;
53
+ recordOutcome(skill: string, version: string, success: boolean, note?: string): void;
54
+ deprecate(skill: string, version: string, note?: string): void;
55
+ promote(skill: string, version: string, note?: string): void;
56
+ stats(skill: string, version: string): SkillStats;
57
+ /**
58
+ * Evidence-gated evolution. Promote `to` over `from` only if `to` has at least
59
+ * `minSample` outcomes AND its success rate beats `from` by `margin`. Otherwise
60
+ * keep the incumbent (revert-on-regression). Returns the decision.
61
+ */
62
+ evolve(skill: string, from: string, to: string, opts?: {
63
+ minSample?: number;
64
+ margin?: number;
65
+ }): {
66
+ promoted: boolean;
67
+ reason: string;
68
+ };
69
+ /**
70
+ * Recommend skills for a task. Dangerous skills are excluded; the rest are ranked
71
+ * by capability match × trust(review) × (0.5 + successRate).
72
+ */
73
+ recommend(candidates: SkillCandidate[], taskTokens: string[]): Recommendation[];
74
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Skill lifecycle governance (F6 — plan/06-skills; SkillsVote, Liu et al., 2026).
3
+ *
4
+ * We don't author skills; we use them from external sources. Beyond the pre-use
5
+ * security review (skill-review.ts), a skill must be *governed over its life*:
6
+ * - attribution: record each use + outcome (success/failure) -> success rate;
7
+ * - recommendation: rank candidates by security verdict, success rate, and
8
+ * capability match — never recommend an unreviewed/dangerous skill;
9
+ * - evolution: evidence-gated — a new version is promoted ONLY if it beats the
10
+ * incumbent on success rate over a minimum sample (revert-on-regression);
11
+ * persistently failing skills are deprecated.
12
+ *
13
+ * Append-only JSONL ledger (auditable, never rewritten), folded for current state.
14
+ */
15
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+ export class SkillLedger {
18
+ file;
19
+ constructor(baseDir) {
20
+ this.file = join(baseDir, "skills-ledger.jsonl");
21
+ }
22
+ append(e) {
23
+ mkdirSync(dirname(this.file), { recursive: true });
24
+ appendFileSync(this.file, JSON.stringify(e) + "\n", "utf-8");
25
+ }
26
+ events() {
27
+ if (!existsSync(this.file))
28
+ return [];
29
+ return readFileSync(this.file, "utf-8")
30
+ .split("\n")
31
+ .filter((l) => l.trim())
32
+ .map((l) => JSON.parse(l));
33
+ }
34
+ register(skill, version) {
35
+ this.append({ ts: new Date().toISOString(), skill, version, op: "register" });
36
+ }
37
+ recordUse(skill, version) {
38
+ this.append({ ts: new Date().toISOString(), skill, version, op: "use" });
39
+ }
40
+ recordOutcome(skill, version, success, note) {
41
+ this.append({ ts: new Date().toISOString(), skill, version, op: "outcome", outcome: success ? "success" : "failure", note });
42
+ }
43
+ deprecate(skill, version, note) {
44
+ this.append({ ts: new Date().toISOString(), skill, version, op: "deprecate", note });
45
+ }
46
+ promote(skill, version, note) {
47
+ this.append({ ts: new Date().toISOString(), skill, version, op: "promote", note });
48
+ }
49
+ stats(skill, version) {
50
+ let uses = 0, successes = 0, failures = 0, deprecated = false;
51
+ for (const e of this.events()) {
52
+ if (e.skill !== skill || e.version !== version)
53
+ continue;
54
+ if (e.op === "use")
55
+ uses++;
56
+ else if (e.op === "outcome") {
57
+ if (e.outcome === "success")
58
+ successes++;
59
+ else
60
+ failures++;
61
+ }
62
+ else if (e.op === "deprecate")
63
+ deprecated = true;
64
+ else if (e.op === "promote")
65
+ deprecated = false;
66
+ }
67
+ const total = successes + failures;
68
+ return {
69
+ skill,
70
+ version,
71
+ uses,
72
+ successes,
73
+ failures,
74
+ successRate: total === 0 ? 0 : Number((successes / total).toFixed(3)),
75
+ status: deprecated ? "deprecated" : "active",
76
+ };
77
+ }
78
+ /**
79
+ * Evidence-gated evolution. Promote `to` over `from` only if `to` has at least
80
+ * `minSample` outcomes AND its success rate beats `from` by `margin`. Otherwise
81
+ * keep the incumbent (revert-on-regression). Returns the decision.
82
+ */
83
+ evolve(skill, from, to, opts = {}) {
84
+ const minSample = opts.minSample ?? 5;
85
+ const margin = opts.margin ?? 0.02;
86
+ const a = this.stats(skill, from);
87
+ const b = this.stats(skill, to);
88
+ const bTotal = b.successes + b.failures;
89
+ if (bTotal < minSample) {
90
+ return { promoted: false, reason: `candidate has ${bTotal} outcomes (< minSample ${minSample})` };
91
+ }
92
+ if (b.successRate >= a.successRate + margin) {
93
+ this.promote(skill, to, `beats ${from} (${b.successRate} >= ${a.successRate}+${margin})`);
94
+ this.deprecate(skill, from, `superseded by ${to}`);
95
+ return { promoted: true, reason: `${to} promoted over ${from}` };
96
+ }
97
+ return { promoted: false, reason: `${to} (${b.successRate}) does not beat ${from} (${a.successRate}) by ${margin}` };
98
+ }
99
+ /**
100
+ * Recommend skills for a task. Dangerous skills are excluded; the rest are ranked
101
+ * by capability match × trust(review) × (0.5 + successRate).
102
+ */
103
+ recommend(candidates, taskTokens) {
104
+ const recs = [];
105
+ for (const c of candidates) {
106
+ const reasons = [];
107
+ if (c.reviewVerdict === "danger") {
108
+ continue; // never recommend a dangerous skill
109
+ }
110
+ const st = this.stats(c.skill, c.version);
111
+ if (st.status === "deprecated")
112
+ continue;
113
+ const caps = new Set(c.capabilities);
114
+ const matched = taskTokens.filter((t) => caps.has(t));
115
+ const matchScore = taskTokens.length ? matched.length / taskTokens.length : 0;
116
+ const trust = c.reviewVerdict === "ok" ? 1 : 0.6;
117
+ const score = Number((matchScore * trust * (0.5 + st.successRate)).toFixed(3));
118
+ if (matched.length > 0)
119
+ reasons.push(`matched: ${matched.join(", ")}`);
120
+ reasons.push(`review: ${c.reviewVerdict}`, `successRate: ${st.successRate}`);
121
+ if (score > 0)
122
+ recs.push({ skill: c.skill, version: c.version, score, reasons });
123
+ }
124
+ return recs.sort((a, b) => b.score - a.score);
125
+ }
126
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Skill security review (F6 — plan/06-skills).
3
+ *
4
+ * We don't maintain skills; we USE them from external sources (Anthropic, GitHub,
5
+ * ClawHub). But ~26% of community skills carry vulnerabilities (Xu & Yan, 2026)
6
+ * and the ClawHavoc campaign shipped 1,200+ malicious skills. So every skill is
7
+ * scanned BEFORE use: risky shell/network/eval/secret patterns are flagged, a
8
+ * verdict is produced, and a content hash is returned for allowlisting. Never
9
+ * auto-update; pin by hash.
10
+ */
11
+ export type Severity = "info" | "warn" | "danger";
12
+ export interface SkillFinding {
13
+ severity: Severity;
14
+ rule: string;
15
+ file: string;
16
+ match: string;
17
+ }
18
+ export interface SkillReview {
19
+ skillPath: string;
20
+ files: string[];
21
+ hash: string;
22
+ findings: SkillFinding[];
23
+ verdict: "ok" | "review" | "danger";
24
+ }
25
+ export declare function reviewSkill(skillPath: string): SkillReview;