@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,67 @@
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
+ import { createHash } from "node:crypto";
12
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
13
+ import { extname, join } from "node:path";
14
+ const RULES = [
15
+ { re: /\brm\s+-rf\b/, severity: "danger", rule: "destructive-rm" },
16
+ { re: /curl[^\n|]*\|\s*(ba)?sh/i, severity: "danger", rule: "curl-pipe-shell" },
17
+ { re: /wget[^\n|]*\|\s*(ba)?sh/i, severity: "danger", rule: "wget-pipe-shell" },
18
+ { re: /base64\s+-+d/i, severity: "danger", rule: "base64-decode-exec" },
19
+ { re: /\beval\s*\(/, severity: "danger", rule: "eval" },
20
+ { re: /child_process|subprocess\.|os\.system|exec\(/i, severity: "warn", rule: "process-spawn" },
21
+ { re: /process\.env\.|os\.environ|\$\{?[A-Z_]*(KEY|TOKEN|SECRET|PASSWORD)/, severity: "warn", rule: "secret-access" },
22
+ { re: /\bsudo\b/, severity: "warn", rule: "privilege-escalation" },
23
+ { re: /https?:\/\/(?!github\.com|raw\.githubusercontent\.com|anthropic\.com)[^\s'")]+/i, severity: "info", rule: "external-network" },
24
+ { re: /~\/\.ssh|id_rsa|\.aws\/credentials|\.npmrc/, severity: "danger", rule: "credential-file-access" },
25
+ ];
26
+ const SCANNABLE = new Set([".md", ".sh", ".bash", ".py", ".js", ".ts", ".mjs", ".cjs", ".yaml", ".yml", ".json"]);
27
+ function collectFiles(dir) {
28
+ const out = [];
29
+ const walk = (d) => {
30
+ for (const name of readdirSync(d)) {
31
+ const p = join(d, name);
32
+ const st = statSync(p);
33
+ if (st.isDirectory())
34
+ walk(p);
35
+ else if (SCANNABLE.has(extname(name)))
36
+ out.push(p);
37
+ }
38
+ };
39
+ walk(dir);
40
+ return out.sort();
41
+ }
42
+ export function reviewSkill(skillPath) {
43
+ if (!existsSync(skillPath)) {
44
+ throw new Error(`skill path not found: ${skillPath}`);
45
+ }
46
+ const isDir = statSync(skillPath).isDirectory();
47
+ const files = isDir ? collectFiles(skillPath) : [skillPath];
48
+ const findings = [];
49
+ const hasher = createHash("sha256");
50
+ for (const file of files) {
51
+ const content = readFileSync(file, "utf-8");
52
+ hasher.update(file).update(content);
53
+ const lines = content.split("\n");
54
+ for (let i = 0; i < lines.length; i++) {
55
+ for (const r of RULES) {
56
+ const m = lines[i].match(r.re);
57
+ if (m) {
58
+ findings.push({ severity: r.severity, rule: r.rule, file, match: m[0].slice(0, 80) });
59
+ }
60
+ }
61
+ }
62
+ }
63
+ const hasDanger = findings.some((f) => f.severity === "danger");
64
+ const hasWarn = findings.some((f) => f.severity === "warn");
65
+ const verdict = hasDanger ? "danger" : hasWarn ? "review" : "ok";
66
+ return { skillPath, files, hash: hasher.digest("hex"), findings, verdict };
67
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * F3.7 — surgical, comment-preserving dot-path edits of a persona spec.
3
+ *
4
+ * The persona spec (`personaxis.md` frontmatter) is authored YAML with dense
5
+ * tier/consumer-tag comments. Editing it must NEVER parse→re-serialize (that
6
+ * strips every author comment) — so, like the migrate codemods, this edits the
7
+ * raw text: it locates the dot-path's leaf line by indentation and replaces only
8
+ * the scalar value, keeping indentation and any trailing `# comment`.
9
+ *
10
+ * Supported leaves:
11
+ * - block scalars at any depth (a.b.c: value)
12
+ * - one-level flow-map leaves (parent: { key: value, … } → edit key)
13
+ * A path that resolves to a block/array (not a scalar) is rejected — those are
14
+ * structural edits the author makes in the file (or via a codemod), not surgical
15
+ * value changes.
16
+ */
17
+ /** Read the value at a dot-path from a parsed object (undefined if absent). */
18
+ export declare function getAtPath(root: unknown, dotPath: string): unknown;
19
+ /** Coerce a raw CLI string to the type of an existing sample value (num/bool/string). */
20
+ export declare function coerceLike(raw: string, sample: unknown): unknown;
21
+ export interface SpecEditResult {
22
+ text: string;
23
+ previous: unknown;
24
+ }
25
+ /**
26
+ * Set a scalar at `dotPath` in the raw frontmatter YAML, preserving comments.
27
+ * `parsed` is the same YAML already parsed (for the current value + type). Throws
28
+ * with a clear message when the path is missing or is not a scalar leaf.
29
+ */
30
+ export declare function setScalarAtPath(frontmatterYaml: string, parsed: Record<string, unknown>, dotPath: string, newValue: unknown): SpecEditResult;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * F3.7 — surgical, comment-preserving dot-path edits of a persona spec.
3
+ *
4
+ * The persona spec (`personaxis.md` frontmatter) is authored YAML with dense
5
+ * tier/consumer-tag comments. Editing it must NEVER parse→re-serialize (that
6
+ * strips every author comment) — so, like the migrate codemods, this edits the
7
+ * raw text: it locates the dot-path's leaf line by indentation and replaces only
8
+ * the scalar value, keeping indentation and any trailing `# comment`.
9
+ *
10
+ * Supported leaves:
11
+ * - block scalars at any depth (a.b.c: value)
12
+ * - one-level flow-map leaves (parent: { key: value, … } → edit key)
13
+ * A path that resolves to a block/array (not a scalar) is rejected — those are
14
+ * structural edits the author makes in the file (or via a codemod), not surgical
15
+ * value changes.
16
+ */
17
+ const INDENT = " ";
18
+ /** Read the value at a dot-path from a parsed object (undefined if absent). */
19
+ export function getAtPath(root, dotPath) {
20
+ let node = root;
21
+ for (const seg of dotPath.split(".")) {
22
+ if (node == null || typeof node !== "object")
23
+ return undefined;
24
+ node = node[seg];
25
+ }
26
+ return node;
27
+ }
28
+ /** Coerce a raw CLI string to the type of an existing sample value (num/bool/string). */
29
+ export function coerceLike(raw, sample) {
30
+ if (typeof sample === "number") {
31
+ const n = Number(raw);
32
+ if (!Number.isFinite(n))
33
+ throw new Error(`"${raw}" is not a number (the current value is numeric)`);
34
+ return n;
35
+ }
36
+ if (typeof sample === "boolean") {
37
+ if (raw === "true")
38
+ return true;
39
+ if (raw === "false")
40
+ return false;
41
+ throw new Error(`"${raw}" is not a boolean (use true/false)`);
42
+ }
43
+ return raw; // string (or unknown sample) → verbatim
44
+ }
45
+ /** Serialize a scalar for YAML: quote a string only when needed; numbers/bools bare. */
46
+ function scalarToYaml(v) {
47
+ if (typeof v === "number" || typeof v === "boolean")
48
+ return String(v);
49
+ const s = String(v);
50
+ // Quote when the string could be misread as another YAML type or has special chars.
51
+ if (s === "" || /^[\s]|[\s]$|[:#{}[\],&*!|>'"%@`]|^(true|false|null|~|-?\d)/.test(s)) {
52
+ return JSON.stringify(s); // JSON string is valid YAML double-quoted
53
+ }
54
+ return s;
55
+ }
56
+ /** The line index of `indent + key:` within [from, to); -1 if absent. */
57
+ function findKeyLine(lines, key, indent, from, to) {
58
+ const prefix = indent + key + ":";
59
+ for (let i = from; i < to; i++)
60
+ if (lines[i].startsWith(prefix))
61
+ return i;
62
+ return -1;
63
+ }
64
+ /** End (exclusive) of the block owned by the key at `start` (indent-based). */
65
+ function blockEnd(lines, start, indent, to) {
66
+ let end = start + 1;
67
+ while (end < to) {
68
+ const l = lines[end];
69
+ if (l.trim() !== "" && !l.startsWith(indent + " ") && !l.startsWith(indent + "\t"))
70
+ break;
71
+ end++;
72
+ }
73
+ return end;
74
+ }
75
+ /** Split a `key: value # comment` line into value + trailing comment (comment kept verbatim). */
76
+ function splitValueComment(afterColon) {
77
+ // A '#' starts a comment only when preceded by whitespace (or at col 0 of the value).
78
+ const m = afterColon.match(/^(.*?)(\s+#.*)?$/s);
79
+ return { value: (m?.[1] ?? afterColon).trimEnd(), comment: m?.[2] ?? "" };
80
+ }
81
+ /**
82
+ * Set a scalar at `dotPath` in the raw frontmatter YAML, preserving comments.
83
+ * `parsed` is the same YAML already parsed (for the current value + type). Throws
84
+ * with a clear message when the path is missing or is not a scalar leaf.
85
+ */
86
+ export function setScalarAtPath(frontmatterYaml, parsed, dotPath, newValue) {
87
+ const previous = getAtPath(parsed, dotPath);
88
+ if (previous === undefined)
89
+ throw new Error(`path not found: ${dotPath}`);
90
+ if (previous !== null && typeof previous === "object") {
91
+ throw new Error(`${dotPath} is a ${Array.isArray(previous) ? "list" : "block"}, not a scalar — edit it in the file directly`);
92
+ }
93
+ const lines = frontmatterYaml.split("\n");
94
+ const segs = dotPath.split(".");
95
+ let from = 0;
96
+ let to = lines.length;
97
+ let indent = "";
98
+ // Descend the block structure for every segment but the last.
99
+ for (let s = 0; s < segs.length - 1; s++) {
100
+ const idx = findKeyLine(lines, segs[s], indent, from, to);
101
+ if (idx === -1)
102
+ throw new Error(`path not found: ${dotPath} (missing "${segs[s]}")`);
103
+ // Flow-map fallback: the value is inline `{ … }` and the rest of the path is inside it.
104
+ const afterColon = lines[idx].slice((indent + segs[s] + ":").length);
105
+ if (afterColon.trim().startsWith("{")) {
106
+ return editFlowMapLeaf(lines, idx, indent + segs[s] + ":", segs.slice(s + 1), newValue, previous);
107
+ }
108
+ from = idx + 1;
109
+ to = blockEnd(lines, idx, indent, to);
110
+ indent += INDENT;
111
+ }
112
+ // The leaf: a block scalar `indent + key: value`.
113
+ const leaf = segs[segs.length - 1];
114
+ const li = findKeyLine(lines, leaf, indent, from, to);
115
+ if (li === -1)
116
+ throw new Error(`path not found: ${dotPath} (missing "${leaf}")`);
117
+ const afterColon = lines[li].slice((indent + leaf + ":").length);
118
+ if (afterColon.trim() === "" || afterColon.trim().startsWith("{") || afterColon.trim().startsWith("[")) {
119
+ throw new Error(`${dotPath} is not a block scalar — edit it in the file directly`);
120
+ }
121
+ const { comment } = splitValueComment(afterColon);
122
+ lines[li] = `${indent}${leaf}: ${scalarToYaml(newValue)}${comment}`;
123
+ return { text: lines.join("\n"), previous };
124
+ }
125
+ /** Replace `key: value` for one key inside a single-line flow map `parent: { … }`. */
126
+ function editFlowMapLeaf(lines, lineIdx, keyPrefix, remaining, newValue, previous) {
127
+ if (remaining.length !== 1) {
128
+ throw new Error(`nested flow-map path too deep — edit "${keyPrefix.replace(/:$/, "")}" in the file directly`);
129
+ }
130
+ const nested = remaining[0];
131
+ const line = lines[lineIdx];
132
+ const re = new RegExp(`(\\{[^}]*?\\b${nested}\\s*:\\s*)([^,}]*?)(\\s*[,}])`);
133
+ if (!re.test(line))
134
+ throw new Error(`key "${nested}" not found in the flow map`);
135
+ lines[lineIdx] = line.replace(re, (_m, pre, _old, post) => `${pre}${scalarToYaml(newValue)}${post}`);
136
+ return { text: lines.join("\n"), previous };
137
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * State engine — the canonical, programmatic mutation primitive.
3
+ *
4
+ * This is the same clamp + audit logic the CLI's `state mutate` command applies,
5
+ * lifted to a pure function so the Living Loop (and the managed runtime) can call
6
+ * it directly. Mirrors the runtime tool `adjust_persona_state(field, delta, reason)`.
7
+ *
8
+ * Guarantees (spec-faithful, non-negotiable):
9
+ * - every delta is clamped to the declared envelope [min, max];
10
+ * - every mutation appends an immutable, timestamped entry to mutation_log;
11
+ * - clamping is recorded (`clamped: true`) for full auditability.
12
+ */
13
+ import type { Envelope } from "./envelopes.js";
14
+ import type { MutationLogEntry, StateFile } from "./persona.js";
15
+ export interface MutationRequest {
16
+ field: string;
17
+ delta: number;
18
+ reason: string;
19
+ actor?: MutationLogEntry["actor"];
20
+ toolCallId?: string;
21
+ /** Set by the governance gate when a mutation is refused. */
22
+ governanceBlocked?: boolean;
23
+ /** v0.8: machine/instance origin (cross-OS reconciliation). */
24
+ originNode?: string;
25
+ /** v0.8: runtime session id. */
26
+ sessionId?: string;
27
+ }
28
+ export interface MutationResult {
29
+ entry: MutationLogEntry;
30
+ from: number;
31
+ to: number;
32
+ clamped: boolean;
33
+ blocked: boolean;
34
+ }
35
+ /**
36
+ * Apply one mutation to `state` in place. Returns the audit result.
37
+ * If `req.governanceBlocked` is true the value is NOT changed; only an audit
38
+ * entry recording the blocked attempt is appended.
39
+ */
40
+ export declare function applyMutation(state: StateFile, envelopes: Record<string, Envelope>, req: MutationRequest): MutationResult;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * State engine — the canonical, programmatic mutation primitive.
3
+ *
4
+ * This is the same clamp + audit logic the CLI's `state mutate` command applies,
5
+ * lifted to a pure function so the Living Loop (and the managed runtime) can call
6
+ * it directly. Mirrors the runtime tool `adjust_persona_state(field, delta, reason)`.
7
+ *
8
+ * Guarantees (spec-faithful, non-negotiable):
9
+ * - every delta is clamped to the declared envelope [min, max];
10
+ * - every mutation appends an immutable, timestamped entry to mutation_log;
11
+ * - clamping is recorded (`clamped: true`) for full auditability.
12
+ */
13
+ /**
14
+ * Apply one mutation to `state` in place. Returns the audit result.
15
+ * If `req.governanceBlocked` is true the value is NOT changed; only an audit
16
+ * entry recording the blocked attempt is appended.
17
+ */
18
+ export function applyMutation(state, envelopes, req) {
19
+ const envelope = envelopes[req.field];
20
+ if (!envelope) {
21
+ throw new Error(`No envelope declared for '${req.field}'. Mutable fields: ${Object.keys(envelopes).join(", ")}`);
22
+ }
23
+ if (!Number.isFinite(req.delta)) {
24
+ throw new Error(`Invalid delta for '${req.field}': ${req.delta}`);
25
+ }
26
+ const current = state.values[req.field] ?? envelope.mean;
27
+ const blocked = req.governanceBlocked === true;
28
+ const requested = current + req.delta;
29
+ const clampedTo = Math.max(envelope.min, Math.min(envelope.max, requested));
30
+ const next = blocked ? current : clampedTo;
31
+ const clamped = !blocked && clampedTo !== requested;
32
+ const entry = {
33
+ ts: new Date().toISOString(),
34
+ field: req.field,
35
+ from: current,
36
+ to: next,
37
+ delta_requested: req.delta,
38
+ clamped,
39
+ reason: req.reason,
40
+ actor: req.actor ?? "human-operator",
41
+ tool_call_id: req.toolCallId,
42
+ governance_blocked: blocked,
43
+ origin_node: req.originNode,
44
+ session_id: req.sessionId,
45
+ };
46
+ state.values[req.field] = next;
47
+ state.mutation_log = state.mutation_log ?? [];
48
+ state.mutation_log.push(entry);
49
+ return { entry, from: current, to: next, clamped, blocked };
50
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * F3.4 — state.json as a REBUILDABLE CHECKPOINT of the mutation_log.
3
+ *
4
+ * The mutation_log is the append-only source of truth: every value change is an
5
+ * audited entry recording its resulting value (`to`). So `state.values` is a
6
+ * derived checkpoint — reconstructable by replaying the log from the envelope
7
+ * means. This makes state.json recoverable (a corrupt/torn values block can be
8
+ * rebuilt from the log) and tamper-evident (a hand-edited value that the log
9
+ * does not justify shows up as drift).
10
+ *
11
+ * Replay is exact and deterministic: each entry already stores the post-clamp,
12
+ * post-governance result, so folding the log — `values[field] = entry.to` —
13
+ * reproduces the canonical values for every MUTATED field. A governance-blocked
14
+ * entry has `to === from`, so it correctly leaves the value unchanged.
15
+ *
16
+ * SAFETY: the log is authoritative ONLY over fields it has touched. A field with
17
+ * no log history is preserved from the stored value as-is (falling back to the
18
+ * envelope mean only when it is absent from both) — a `rebuild --write` never
19
+ * resets an un-mutated field, so it is safe even when state keys and log field
20
+ * keys are spelled inconsistently (the pre-v1.0 short-key vs full dot-path gap).
21
+ */
22
+ import type { Envelope } from "./envelopes.js";
23
+ import type { MutationLogEntry, StateFile } from "./persona.js";
24
+ export interface RebuildDrift {
25
+ field: string;
26
+ stored: number | undefined;
27
+ rebuilt: number;
28
+ }
29
+ export interface RebuildResult {
30
+ /** The values reconstructed purely from envelope means + the mutation_log. */
31
+ values: Record<string, number>;
32
+ /** Fields where the STORED value disagrees with the rebuilt one (tamper/corruption). */
33
+ drift: RebuildDrift[];
34
+ }
35
+ /**
36
+ * Reconstruct `values` from envelope means + the mutation_log, and report any
37
+ * field whose stored value disagrees with the replay.
38
+ */
39
+ export declare function rebuildStateValues(envelopes: Record<string, Envelope>, mutationLog: MutationLogEntry[], storedValues?: Record<string, number>): RebuildResult;
40
+ /**
41
+ * Return a copy of `state` with `values` rebuilt from its mutation_log (+ the
42
+ * envelope means). The mutation_log itself is preserved verbatim — this is a
43
+ * checkpoint refresh, never a history rewrite.
44
+ */
45
+ export declare function rebuildState(state: StateFile, envelopes: Record<string, Envelope>): {
46
+ state: StateFile;
47
+ drift: RebuildDrift[];
48
+ };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * F3.4 — state.json as a REBUILDABLE CHECKPOINT of the mutation_log.
3
+ *
4
+ * The mutation_log is the append-only source of truth: every value change is an
5
+ * audited entry recording its resulting value (`to`). So `state.values` is a
6
+ * derived checkpoint — reconstructable by replaying the log from the envelope
7
+ * means. This makes state.json recoverable (a corrupt/torn values block can be
8
+ * rebuilt from the log) and tamper-evident (a hand-edited value that the log
9
+ * does not justify shows up as drift).
10
+ *
11
+ * Replay is exact and deterministic: each entry already stores the post-clamp,
12
+ * post-governance result, so folding the log — `values[field] = entry.to` —
13
+ * reproduces the canonical values for every MUTATED field. A governance-blocked
14
+ * entry has `to === from`, so it correctly leaves the value unchanged.
15
+ *
16
+ * SAFETY: the log is authoritative ONLY over fields it has touched. A field with
17
+ * no log history is preserved from the stored value as-is (falling back to the
18
+ * envelope mean only when it is absent from both) — a `rebuild --write` never
19
+ * resets an un-mutated field, so it is safe even when state keys and log field
20
+ * keys are spelled inconsistently (the pre-v1.0 short-key vs full dot-path gap).
21
+ */
22
+ /**
23
+ * Reconstruct `values` from envelope means + the mutation_log, and report any
24
+ * field whose stored value disagrees with the replay.
25
+ */
26
+ export function rebuildStateValues(envelopes, mutationLog, storedValues = {}) {
27
+ // 1. preserve the stored values (un-mutated fields are never reset).
28
+ const values = { ...storedValues };
29
+ // 2. seed declared-but-absent envelope fields from their mean.
30
+ for (const [field, env] of Object.entries(envelopes)) {
31
+ if (values[field] === undefined)
32
+ values[field] = env.mean;
33
+ }
34
+ // 3. replay the log; each entry's `to` is authoritative for the fields it touched.
35
+ const mutated = new Set();
36
+ for (const entry of mutationLog) {
37
+ values[entry.field] = entry.to;
38
+ mutated.add(entry.field);
39
+ }
40
+ // 4. drift = a MUTATED field whose stored value disagrees with the replay
41
+ // (the log is the source of truth; the stored value was tampered/torn).
42
+ const drift = [];
43
+ for (const field of mutated) {
44
+ const rebuilt = values[field];
45
+ const stored = storedValues[field];
46
+ if (stored === undefined || Math.abs(stored - rebuilt) > 1e-9) {
47
+ drift.push({ field, stored, rebuilt });
48
+ }
49
+ }
50
+ return { values, drift };
51
+ }
52
+ /**
53
+ * Return a copy of `state` with `values` rebuilt from its mutation_log (+ the
54
+ * envelope means). The mutation_log itself is preserved verbatim — this is a
55
+ * checkpoint refresh, never a history rewrite.
56
+ */
57
+ export function rebuildState(state, envelopes) {
58
+ const { values, drift } = rebuildStateValues(envelopes, state.mutation_log ?? [], state.values ?? {});
59
+ return { state: { ...state, values }, drift };
60
+ }
package/dist/sync.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Cross-OS state reconciliation (F7 — plan/08-persona-model).
3
+ *
4
+ * The portable user-clone persona can live on Windows, Linux and macOS at once,
5
+ * versioned via git. Identity (personaxis.md) is immutable and shared; only
6
+ * state.json (envelope values + mutation_log) and memory diverge per machine.
7
+ * This module merges two states WITHOUT clobbering either:
8
+ * - mutation_log: union, de-duplicated, time-ordered (the full audit survives);
9
+ * - values: last-writer-wins PER FIELD by the latest mutation timestamp across
10
+ * both logs, clamped to the envelope; ties/absent history are reported as
11
+ * conflicts rather than silently overwritten;
12
+ * - a merge marker is appended so the reconciliation itself is auditable.
13
+ *
14
+ * Deterministic, governed, reversible — never a blind overwrite.
15
+ */
16
+ import type { Envelope } from "./envelopes.js";
17
+ import type { StateFile } from "./persona.js";
18
+ export interface MergeConflict {
19
+ field: string;
20
+ a: number;
21
+ b: number;
22
+ chosen: number;
23
+ reason: string;
24
+ }
25
+ export interface MergeResult {
26
+ merged: StateFile;
27
+ conflicts: MergeConflict[];
28
+ }
29
+ /**
30
+ * Merge state `b` into `a` (a = local/base). Returns the merged state + conflicts.
31
+ */
32
+ export declare function mergeState(a: StateFile, b: StateFile, envelopes?: Record<string, Envelope>): MergeResult;
package/dist/sync.js ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Cross-OS state reconciliation (F7 — plan/08-persona-model).
3
+ *
4
+ * The portable user-clone persona can live on Windows, Linux and macOS at once,
5
+ * versioned via git. Identity (personaxis.md) is immutable and shared; only
6
+ * state.json (envelope values + mutation_log) and memory diverge per machine.
7
+ * This module merges two states WITHOUT clobbering either:
8
+ * - mutation_log: union, de-duplicated, time-ordered (the full audit survives);
9
+ * - values: last-writer-wins PER FIELD by the latest mutation timestamp across
10
+ * both logs, clamped to the envelope; ties/absent history are reported as
11
+ * conflicts rather than silently overwritten;
12
+ * - a merge marker is appended so the reconciliation itself is auditable.
13
+ *
14
+ * Deterministic, governed, reversible — never a blind overwrite.
15
+ */
16
+ function clamp(v, e) {
17
+ if (!e)
18
+ return v;
19
+ return Math.max(e.min, Math.min(e.max, v));
20
+ }
21
+ function dedupeLog(entries) {
22
+ const seen = new Set();
23
+ const out = [];
24
+ for (const e of entries) {
25
+ // v0.8: origin_node distinguishes same-ts mutations made on different machines,
26
+ // so a genuine concurrent edit is not collapsed during reconciliation.
27
+ const key = `${e.ts}|${e.field}|${e.to}|${e.actor}|${e.origin_node ?? ""}`;
28
+ if (seen.has(key))
29
+ continue;
30
+ seen.add(key);
31
+ out.push(e);
32
+ }
33
+ return out.sort((x, y) => x.ts.localeCompare(y.ts));
34
+ }
35
+ function latestForField(log, field) {
36
+ let best;
37
+ for (const e of log) {
38
+ if (e.field === field && (!best || e.ts > best.ts))
39
+ best = e;
40
+ }
41
+ return best;
42
+ }
43
+ /**
44
+ * Merge state `b` into `a` (a = local/base). Returns the merged state + conflicts.
45
+ */
46
+ export function mergeState(a, b, envelopes = {}) {
47
+ const mergedLog = dedupeLog([...(a.mutation_log ?? []), ...(b.mutation_log ?? [])]);
48
+ const conflicts = [];
49
+ const fields = new Set([...Object.keys(a.values), ...Object.keys(b.values)]);
50
+ const values = {};
51
+ for (const f of fields) {
52
+ const av = a.values[f];
53
+ const bv = b.values[f];
54
+ const latest = latestForField(mergedLog, f);
55
+ let chosen;
56
+ let reason;
57
+ if (latest) {
58
+ chosen = latest.to;
59
+ reason = `latest mutation @ ${latest.ts} (${latest.actor})`;
60
+ }
61
+ else if (av === bv || bv === undefined) {
62
+ chosen = av ?? bv;
63
+ reason = "agreement / base-only";
64
+ }
65
+ else if (av === undefined) {
66
+ chosen = bv;
67
+ reason = "incoming-only";
68
+ }
69
+ else {
70
+ chosen = av;
71
+ reason = "no history, kept base (conflict)";
72
+ conflicts.push({ field: f, a: av, b: bv, chosen, reason });
73
+ }
74
+ values[f] = clamp(chosen, envelopes[f]);
75
+ if (latest && av !== undefined && bv !== undefined && av !== bv) {
76
+ conflicts.push({ field: f, a: av, b: bv, chosen: values[f], reason });
77
+ }
78
+ }
79
+ const marker = {
80
+ ts: new Date().toISOString(),
81
+ field: "__merge__",
82
+ from: 0,
83
+ to: 0,
84
+ delta_requested: 0,
85
+ clamped: false,
86
+ reason: `cross-machine reconciliation (${conflicts.length} conflict(s))`,
87
+ actor: "runtime-context",
88
+ };
89
+ const merged = {
90
+ ...a,
91
+ values,
92
+ mutation_log: [...mergedLog, marker],
93
+ };
94
+ return { merged, conflicts };
95
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Tool-calling client (G1) — provider-agnostic action proposals.
3
+ *
4
+ * Primary path: OpenAI-style native function-calling (`tools` + `tool_choice`),
5
+ * which Cohere/OpenAI/together/etc. expose on /chat/completions. Fallback path
6
+ * (when an endpoint rejects `tools` with a 400): a ReAct-style single-action JSON
7
+ * (`{thought, tool, args}`) under the same constrained-decoding strategy chain the
8
+ * appraiser uses (json_schema → json_object → plain). Either way the model only
9
+ * *proposes* a tool call; the agent loop gates + executes.
10
+ */
11
+ import type { ToolSpec } from "./tools/registry.js";
12
+ export interface ChatMessage {
13
+ role: "system" | "user" | "assistant" | "tool";
14
+ content: string;
15
+ /** For assistant turns that issued tool calls (echoed back to the model). */
16
+ tool_calls?: RawToolCall[];
17
+ /** For role:"tool" results. */
18
+ tool_call_id?: string;
19
+ name?: string;
20
+ }
21
+ interface RawToolCall {
22
+ id: string;
23
+ type: "function";
24
+ function: {
25
+ name: string;
26
+ arguments: string;
27
+ };
28
+ }
29
+ export interface ToolCall {
30
+ id: string;
31
+ name: string;
32
+ args: Record<string, unknown>;
33
+ }
34
+ export interface TokenUsage {
35
+ prompt_tokens: number;
36
+ completion_tokens: number;
37
+ total_tokens: number;
38
+ }
39
+ export interface ToolCallResponse {
40
+ /** Assistant free text accompanying the call (may be empty). */
41
+ text: string;
42
+ toolCalls: ToolCall[];
43
+ usedFallback: boolean;
44
+ /** Token accounting from the provider (for budget enforcement), when reported. */
45
+ usage?: TokenUsage;
46
+ }
47
+ export interface ToolCallConfig {
48
+ endpoint: string;
49
+ model: string;
50
+ apiKey?: string;
51
+ maxTokens?: number;
52
+ fetchImpl?: typeof fetch;
53
+ }
54
+ /**
55
+ * Request the next action. `preferFallback` lets the caller skip the native
56
+ * attempt once it has learned the endpoint doesn't support `tools`.
57
+ */
58
+ export declare function requestToolCall(cfg: ToolCallConfig, messages: ChatMessage[], tools: ToolSpec[], preferFallback?: boolean): Promise<ToolCallResponse>;
59
+ export {};