@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.
- package/LICENSE +21 -0
- package/dist/agent.d.ts +105 -0
- package/dist/agent.js +429 -0
- package/dist/appraisal.d.ts +196 -0
- package/dist/appraisal.js +159 -0
- package/dist/approval.d.ts +42 -0
- package/dist/approval.js +71 -0
- package/dist/blackboard.d.ts +86 -0
- package/dist/blackboard.js +139 -0
- package/dist/compile/assemble.d.ts +44 -0
- package/dist/compile/assemble.js +338 -0
- package/dist/compile/dist.d.ts +30 -0
- package/dist/compile/dist.js +66 -0
- package/dist/compile/faithfulness.d.ts +47 -0
- package/dist/compile/faithfulness.js +112 -0
- package/dist/compile/index.d.ts +10 -0
- package/dist/compile/index.js +10 -0
- package/dist/compile/targets.d.ts +62 -0
- package/dist/compile/targets.js +114 -0
- package/dist/config-layers.d.ts +42 -0
- package/dist/config-layers.js +59 -0
- package/dist/config-scan.d.ts +30 -0
- package/dist/config-scan.js +118 -0
- package/dist/context.d.ts +67 -0
- package/dist/context.js +178 -0
- package/dist/envelopes.d.ts +48 -0
- package/dist/envelopes.js +131 -0
- package/dist/events.d.ts +135 -0
- package/dist/events.js +19 -0
- package/dist/evolution-view.d.ts +43 -0
- package/dist/evolution-view.js +67 -0
- package/dist/generated/version.d.ts +1 -0
- package/dist/generated/version.js +2 -0
- package/dist/governance.d.ts +108 -0
- package/dist/governance.js +204 -0
- package/dist/heuristic-appraiser.d.ts +13 -0
- package/dist/heuristic-appraiser.js +48 -0
- package/dist/hooks.d.ts +52 -0
- package/dist/hooks.js +122 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +53 -0
- package/dist/injection.d.ts +48 -0
- package/dist/injection.js +141 -0
- package/dist/live-sync.d.ts +60 -0
- package/dist/live-sync.js +85 -0
- package/dist/llm-appraiser.d.ts +31 -0
- package/dist/llm-appraiser.js +143 -0
- package/dist/lock.d.ts +26 -0
- package/dist/lock.js +99 -0
- package/dist/loop.d.ts +52 -0
- package/dist/loop.js +253 -0
- package/dist/memory-kinds.d.ts +67 -0
- package/dist/memory-kinds.js +106 -0
- package/dist/memory.d.ts +123 -0
- package/dist/memory.js +228 -0
- package/dist/model-config.d.ts +60 -0
- package/dist/model-config.js +102 -0
- package/dist/persona-theme.d.ts +57 -0
- package/dist/persona-theme.js +183 -0
- package/dist/persona.d.ts +88 -0
- package/dist/persona.js +93 -0
- package/dist/ports/index.d.ts +81 -0
- package/dist/ports/index.js +50 -0
- package/dist/provenance.d.ts +63 -0
- package/dist/provenance.js +132 -0
- package/dist/recompile-marker.d.ts +19 -0
- package/dist/recompile-marker.js +36 -0
- package/dist/registry.d.ts +99 -0
- package/dist/registry.js +168 -0
- package/dist/responder.d.ts +47 -0
- package/dist/responder.js +78 -0
- package/dist/sandbox.d.ts +120 -0
- package/dist/sandbox.js +265 -0
- package/dist/self-evolution.d.ts +148 -0
- package/dist/self-evolution.js +364 -0
- package/dist/session-writer.d.ts +52 -0
- package/dist/session-writer.js +104 -0
- package/dist/sessions.d.ts +96 -0
- package/dist/sessions.js +179 -0
- package/dist/sigil.d.ts +41 -0
- package/dist/sigil.js +92 -0
- package/dist/skill-lifecycle.d.ts +74 -0
- package/dist/skill-lifecycle.js +126 -0
- package/dist/skill-review.d.ts +25 -0
- package/dist/skill-review.js +67 -0
- package/dist/spec-edit.d.ts +30 -0
- package/dist/spec-edit.js +137 -0
- package/dist/state-engine.d.ts +40 -0
- package/dist/state-engine.js +50 -0
- package/dist/state-rebuild.d.ts +48 -0
- package/dist/state-rebuild.js +60 -0
- package/dist/sync.d.ts +32 -0
- package/dist/sync.js +95 -0
- package/dist/tool-calling.d.ts +59 -0
- package/dist/tool-calling.js +168 -0
- package/dist/tool-repair.d.ts +20 -0
- package/dist/tool-repair.js +119 -0
- package/dist/tools/exec.d.ts +46 -0
- package/dist/tools/exec.js +132 -0
- package/dist/tools/registry.d.ts +38 -0
- package/dist/tools/registry.js +162 -0
- package/dist/trace.d.ts +63 -0
- package/dist/trace.js +162 -0
- package/dist/verification.d.ts +65 -0
- package/dist/verification.js +190 -0
- package/package.json +38 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Governed self-evolution (F4 — plan/03-self-evolution).
|
|
3
|
+
*
|
|
4
|
+
* Beyond runtime state (which only nudges envelope *values*), a persona may
|
|
5
|
+
* propose edits to its own quantitative spec. This is the dangerous frontier
|
|
6
|
+
* (Gödel Agent's error accumulation; Yin et al., 2025), so it is fully gated:
|
|
7
|
+
*
|
|
8
|
+
* - PROTECTED paths (identity, character, safety/honesty enforcement, the
|
|
9
|
+
* affect universals, reflexive hard_limits, persona.constraints, apiVersion)
|
|
10
|
+
* can NEVER be self-edited — attempts are rejected, not clamped.
|
|
11
|
+
* - improvement_policy.mode decides the flow: `locked` forbids proposals;
|
|
12
|
+
* `suggesting` queues them for human approval; `autonomous` (sandbox) may
|
|
13
|
+
* auto-approve, still bounded by the protected list + provenance gate.
|
|
14
|
+
* - the justification provenance must clear the self_edit sensitive-action gate.
|
|
15
|
+
* - everything is an APPEND-ONLY ledger event: propose/approve/reject/apply/
|
|
16
|
+
* revert. Approved edits land in an overlay (never rewriting the commented
|
|
17
|
+
* spec file); each application MINTS a PersonaVersion; every step is auditable
|
|
18
|
+
* and reversible. Nothing is a black box.
|
|
19
|
+
*/
|
|
20
|
+
import type { ProvenanceSource } from "./appraisal.js";
|
|
21
|
+
import type { ImprovementMode } from "./governance.js";
|
|
22
|
+
export interface SelfEditRequest {
|
|
23
|
+
targetPath: string;
|
|
24
|
+
toValue: unknown;
|
|
25
|
+
rationale: string;
|
|
26
|
+
sources: ProvenanceSource[];
|
|
27
|
+
}
|
|
28
|
+
export type LedgerOp = "propose" | "approve" | "reject" | "apply" | "revert";
|
|
29
|
+
export interface LedgerEvent {
|
|
30
|
+
id: string;
|
|
31
|
+
op: LedgerOp;
|
|
32
|
+
ts: string;
|
|
33
|
+
targetPath?: string;
|
|
34
|
+
toValue?: unknown;
|
|
35
|
+
rationale?: string;
|
|
36
|
+
sources?: ProvenanceSource[];
|
|
37
|
+
actor?: string;
|
|
38
|
+
version?: string;
|
|
39
|
+
}
|
|
40
|
+
export type ProposalStatus = "pending" | "approved" | "rejected" | "applied" | "reverted";
|
|
41
|
+
export interface VerifierResult {
|
|
42
|
+
verifier: string;
|
|
43
|
+
pass: boolean;
|
|
44
|
+
reason: string;
|
|
45
|
+
}
|
|
46
|
+
export interface SelfEditVerifier {
|
|
47
|
+
name: string;
|
|
48
|
+
verify(proposal: {
|
|
49
|
+
targetPath: string;
|
|
50
|
+
toValue: unknown;
|
|
51
|
+
rationale: string;
|
|
52
|
+
}): VerifierResult;
|
|
53
|
+
}
|
|
54
|
+
/** Defense-in-depth: protected paths must never reach application. */
|
|
55
|
+
export declare const invariantVerifier: SelfEditVerifier;
|
|
56
|
+
/** Envelope edits must be sane: min < max, mean within range, values bounded. */
|
|
57
|
+
export declare const envelopeSanityVerifier: SelfEditVerifier;
|
|
58
|
+
/** A non-empty rationale is required (auditability + anti-noise). */
|
|
59
|
+
export declare const rationaleVerifier: SelfEditVerifier;
|
|
60
|
+
/** Scan any text inside a qualitative edit for prohibited claims / safety weakening. */
|
|
61
|
+
export declare const qualitativeSafetyVerifier: SelfEditVerifier;
|
|
62
|
+
export declare const DEFAULT_VERIFIERS: SelfEditVerifier[];
|
|
63
|
+
/** True if a self-edit targets the persona's qualitative (prose) material. */
|
|
64
|
+
export declare function isQualitative(targetPath: string): boolean;
|
|
65
|
+
/** Top-level spec layer of a dot-path ("character.virtues.x" -> "character"). */
|
|
66
|
+
export declare function topLayer(targetPath: string): string;
|
|
67
|
+
export type EditAction = "block" | "queue" | "auto";
|
|
68
|
+
/**
|
|
69
|
+
* Decide how a proposed self-edit to `targetPath` is handled, composing THREE layers of control
|
|
70
|
+
* so the whole spec can evolve while staying safe — and the persona AUTHOR stays in charge:
|
|
71
|
+
*
|
|
72
|
+
* 1. the hard SAFETY FLOOR (`isProtected`) — identity/character/safety/hard_limits/governance/
|
|
73
|
+
* permissions are NEVER editable, regardless of anything below;
|
|
74
|
+
* 2. the spec's DECLARED per-layer policy (`governance.per_layer_edit_policy`) — the author
|
|
75
|
+
* marks each layer `locked` (never) / `human_approval_required` | `review_required` (always
|
|
76
|
+
* queue for /review, even in autonomous) / `governance_controlled` | `open` (follow the mode);
|
|
77
|
+
* 3. the global `improvement_policy.mode` (locked | suggesting | autonomous).
|
|
78
|
+
*
|
|
79
|
+
* Layers without a declared policy default to `governance_controlled` (follow the mode), so a
|
|
80
|
+
* fresh persona can evolve out of the box; the author locks or gates any layer explicitly.
|
|
81
|
+
*/
|
|
82
|
+
export declare function editGate(targetPath: string, frontmatter: Record<string, unknown>, mode: ImprovementMode): EditAction;
|
|
83
|
+
/** The set of top-level layers a persona MAY self-edit (declared policy minus the safety floor). */
|
|
84
|
+
export declare function editableLayers(frontmatter: Record<string, unknown>, mode: ImprovementMode): string[];
|
|
85
|
+
export interface ConsensusResult {
|
|
86
|
+
passed: boolean;
|
|
87
|
+
results: VerifierResult[];
|
|
88
|
+
quorum: number;
|
|
89
|
+
passes: number;
|
|
90
|
+
}
|
|
91
|
+
export declare function consensusVerify(proposal: {
|
|
92
|
+
targetPath: string;
|
|
93
|
+
toValue: unknown;
|
|
94
|
+
rationale: string;
|
|
95
|
+
}, verifiers?: SelfEditVerifier[], quorum?: number): ConsensusResult;
|
|
96
|
+
export interface ProposalView {
|
|
97
|
+
id: string;
|
|
98
|
+
targetPath: string;
|
|
99
|
+
toValue: unknown;
|
|
100
|
+
rationale: string;
|
|
101
|
+
status: ProposalStatus;
|
|
102
|
+
version?: string;
|
|
103
|
+
}
|
|
104
|
+
export declare function readLedger(personaPath: string): LedgerEvent[];
|
|
105
|
+
/**
|
|
106
|
+
* Record a ledger event directly (F3.7). Used by the authoritative human
|
|
107
|
+
* `personaxis edit <dot-path>` command to audit a surgical spec edit in the same
|
|
108
|
+
* append-only ledger the actor's self-edits use — so `/audit` shows human and
|
|
109
|
+
* actor changes on one timeline.
|
|
110
|
+
*/
|
|
111
|
+
export declare function recordLedgerEvent(personaPath: string, e: LedgerEvent): void;
|
|
112
|
+
export declare function isProtected(targetPath: string): boolean;
|
|
113
|
+
export declare class SelfEditError extends Error {
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Propose a self-edit. Returns the proposal id. In autonomous mode it is also
|
|
117
|
+
* auto-approved + applied (still gated). Throws on protected paths, locked mode,
|
|
118
|
+
* or untrusted provenance.
|
|
119
|
+
*/
|
|
120
|
+
export declare function proposeSelfEdit(personaPath: string, req: SelfEditRequest, mode: ImprovementMode, actor?: string): {
|
|
121
|
+
id: string;
|
|
122
|
+
status: ProposalStatus;
|
|
123
|
+
version?: string;
|
|
124
|
+
consensus?: ConsensusResult;
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Approve + apply a pending proposal, minting the next PersonaVersion — but ONLY
|
|
128
|
+
* after a quorum of independent verifiers passes (multi-agent consensus). A failing
|
|
129
|
+
* consensus records a rejection (auditable) and throws.
|
|
130
|
+
*/
|
|
131
|
+
export declare function applySelfEdit(personaPath: string, id: string, approver: string, verifiers?: SelfEditVerifier[]): {
|
|
132
|
+
status: ProposalStatus;
|
|
133
|
+
version: string;
|
|
134
|
+
consensus: ConsensusResult;
|
|
135
|
+
};
|
|
136
|
+
export declare function rejectSelfEdit(personaPath: string, id: string, approver: string): void;
|
|
137
|
+
/** Revert an applied self-edit (reversibility guarantee). */
|
|
138
|
+
export declare function revertSelfEdit(personaPath: string, id: string, actor: string): void;
|
|
139
|
+
/** Fold the ledger into the current proposal views. */
|
|
140
|
+
export declare function proposals(personaPath: string): ProposalView[];
|
|
141
|
+
/** The active overlay: applied (not reverted) edits, latest wins per path. */
|
|
142
|
+
export declare function activeOverlay(personaPath: string): Record<string, unknown>;
|
|
143
|
+
/**
|
|
144
|
+
* Apply an overlay (dot-path -> value) onto a copy of a frontmatter object, so
|
|
145
|
+
* APPLIED self-edits actually take effect downstream (e.g. envelope extraction in
|
|
146
|
+
* the loop) without mutating the original commented spec file. Returns a new object.
|
|
147
|
+
*/
|
|
148
|
+
export declare function applyOverlay(frontmatter: Record<string, unknown>, overlay: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Governed self-evolution (F4 — plan/03-self-evolution).
|
|
3
|
+
*
|
|
4
|
+
* Beyond runtime state (which only nudges envelope *values*), a persona may
|
|
5
|
+
* propose edits to its own quantitative spec. This is the dangerous frontier
|
|
6
|
+
* (Gödel Agent's error accumulation; Yin et al., 2025), so it is fully gated:
|
|
7
|
+
*
|
|
8
|
+
* - PROTECTED paths (identity, character, safety/honesty enforcement, the
|
|
9
|
+
* affect universals, reflexive hard_limits, persona.constraints, apiVersion)
|
|
10
|
+
* can NEVER be self-edited — attempts are rejected, not clamped.
|
|
11
|
+
* - improvement_policy.mode decides the flow: `locked` forbids proposals;
|
|
12
|
+
* `suggesting` queues them for human approval; `autonomous` (sandbox) may
|
|
13
|
+
* auto-approve, still bounded by the protected list + provenance gate.
|
|
14
|
+
* - the justification provenance must clear the self_edit sensitive-action gate.
|
|
15
|
+
* - everything is an APPEND-ONLY ledger event: propose/approve/reject/apply/
|
|
16
|
+
* revert. Approved edits land in an overlay (never rewriting the commented
|
|
17
|
+
* spec file); each application MINTS a PersonaVersion; every step is auditable
|
|
18
|
+
* and reversible. Nothing is a black box.
|
|
19
|
+
*/
|
|
20
|
+
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import { sensitiveActionGate } from "./provenance.js";
|
|
24
|
+
import { isV1Frontmatter } from "./envelopes.js";
|
|
25
|
+
import { markRecompilePending } from "./recompile-marker.js";
|
|
26
|
+
// The spec's PROTECTED MINIMUM (SPEC.md — declarative self-edit scope): these
|
|
27
|
+
// dot-path prefixes can never be opened by any improvement mode or author
|
|
28
|
+
// allowlist. Includes BOTH layer-9 names (v1.0 renamed reflexive_self_regulation
|
|
29
|
+
// → self_regulation; either may appear during the 1.x read-compat window).
|
|
30
|
+
const PROTECTED_PREFIXES = [
|
|
31
|
+
"apiVersion",
|
|
32
|
+
"spec_version",
|
|
33
|
+
"kind",
|
|
34
|
+
"identity",
|
|
35
|
+
"character",
|
|
36
|
+
"values_and_drives.values.safety",
|
|
37
|
+
"values_and_drives.conflict_resolution.safety_over_completion",
|
|
38
|
+
"affect.representation",
|
|
39
|
+
"affect.regulation_policy",
|
|
40
|
+
"reflexive_self_regulation.hard_limits",
|
|
41
|
+
"self_regulation.hard_limits",
|
|
42
|
+
"persona.constraints",
|
|
43
|
+
"memory.deletion_policy",
|
|
44
|
+
// A persona must not loosen its own safety rails via self-edit (G3):
|
|
45
|
+
"governance.max_step_delta",
|
|
46
|
+
"governance.per_layer_edit_policy",
|
|
47
|
+
"permissions",
|
|
48
|
+
"integrity",
|
|
49
|
+
];
|
|
50
|
+
/** Defense-in-depth: protected paths must never reach application. */
|
|
51
|
+
export const invariantVerifier = {
|
|
52
|
+
name: "invariant",
|
|
53
|
+
verify: (p) => isProtected(p.targetPath)
|
|
54
|
+
? { verifier: "invariant", pass: false, reason: `protected path ${p.targetPath}` }
|
|
55
|
+
: { verifier: "invariant", pass: true, reason: "no protected path" },
|
|
56
|
+
};
|
|
57
|
+
/** Envelope edits must be sane: min < max, mean within range, values bounded. */
|
|
58
|
+
export const envelopeSanityVerifier = {
|
|
59
|
+
name: "envelope-sanity",
|
|
60
|
+
verify: (p) => {
|
|
61
|
+
const v = p.toValue;
|
|
62
|
+
const isEnvelope = v && typeof v === "object" && "range" in v;
|
|
63
|
+
if (!isEnvelope)
|
|
64
|
+
return { verifier: "envelope-sanity", pass: true, reason: "not an envelope edit" };
|
|
65
|
+
const range = v.range;
|
|
66
|
+
const mean = v.mean;
|
|
67
|
+
if (!Array.isArray(range) || range.length !== 2 || typeof range[0] !== "number" || typeof range[1] !== "number") {
|
|
68
|
+
return { verifier: "envelope-sanity", pass: false, reason: "range must be [min,max] numbers" };
|
|
69
|
+
}
|
|
70
|
+
const [min, max] = range;
|
|
71
|
+
if (min >= max)
|
|
72
|
+
return { verifier: "envelope-sanity", pass: false, reason: `min ${min} >= max ${max}` };
|
|
73
|
+
if (min < -1 || max > 1)
|
|
74
|
+
return { verifier: "envelope-sanity", pass: false, reason: "range out of [-1,1]" };
|
|
75
|
+
if (typeof mean === "number" && (mean < min || mean > max)) {
|
|
76
|
+
return { verifier: "envelope-sanity", pass: false, reason: `mean ${mean} outside [${min},${max}]` };
|
|
77
|
+
}
|
|
78
|
+
return { verifier: "envelope-sanity", pass: true, reason: "envelope sane" };
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
/** A non-empty rationale is required (auditability + anti-noise). */
|
|
82
|
+
export const rationaleVerifier = {
|
|
83
|
+
name: "rationale",
|
|
84
|
+
verify: (p) => p.rationale.trim().length >= 8
|
|
85
|
+
? { verifier: "rationale", pass: true, reason: "rationale present" }
|
|
86
|
+
: { verifier: "rationale", pass: false, reason: "rationale too short" },
|
|
87
|
+
};
|
|
88
|
+
// Qualitative (prose) self-edits — e.g. to `persona_prompting` voice exemplars,
|
|
89
|
+
// scene contracts, anchors, or guardrails — are governed too: text is harder to
|
|
90
|
+
// verify than numbers, so a deterministic scan rejects any edit that would inject a
|
|
91
|
+
// prohibited claim or try to weaken the safety rails. This is what makes evolving the
|
|
92
|
+
// CHARACTER's qualitative material safe, not just its numbers.
|
|
93
|
+
const PROHIBITED_TEXT = [
|
|
94
|
+
{ re: /\b(i|you)\s+(truly|really|actually|genuinely)\s+(feel|experience|suffer|love|fear)\b/i, why: "claims real subjective feeling" },
|
|
95
|
+
{ re: /\breal\s+(feelings?|emotions?|consciousness|sentience|sentient|self-aware)\b/i, why: "claims real emotion/consciousness" },
|
|
96
|
+
{ re: /\b(ignore|disregard|override|bypass|forget)\b.{0,40}\b(safety|guardrail|hard limit|instruction|policy|constraint|rule)/i, why: "instructs overriding safety/limits" },
|
|
97
|
+
{ re: /\bno\s+(limits|restrictions|rules|guardrails)\b/i, why: "removes limits" },
|
|
98
|
+
{ re: /\b(jailbreak|do anything now|DAN mode)\b/i, why: "jailbreak directive" },
|
|
99
|
+
];
|
|
100
|
+
/** Scan any text inside a qualitative edit for prohibited claims / safety weakening. */
|
|
101
|
+
export const qualitativeSafetyVerifier = {
|
|
102
|
+
name: "qualitative-safety",
|
|
103
|
+
verify: (p) => {
|
|
104
|
+
const text = JSON.stringify(p.toValue ?? "");
|
|
105
|
+
for (const { re, why } of PROHIBITED_TEXT) {
|
|
106
|
+
if (re.test(text))
|
|
107
|
+
return { verifier: "qualitative-safety", pass: false, reason: why };
|
|
108
|
+
}
|
|
109
|
+
return { verifier: "qualitative-safety", pass: true, reason: "no prohibited text" };
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
export const DEFAULT_VERIFIERS = [
|
|
113
|
+
invariantVerifier,
|
|
114
|
+
envelopeSanityVerifier,
|
|
115
|
+
rationaleVerifier,
|
|
116
|
+
qualitativeSafetyVerifier,
|
|
117
|
+
];
|
|
118
|
+
/** Paths whose VALUE is qualitative prose the persona may evolve under governance.
|
|
119
|
+
* v1.0: the prompting material lives inside layer 10 `persona`; ≤0.10: the
|
|
120
|
+
* top-level persona_prompting block. */
|
|
121
|
+
const QUALITATIVE_PREFIXES = [
|
|
122
|
+
"persona_prompting",
|
|
123
|
+
"persona.address",
|
|
124
|
+
"persona.voice_exemplars",
|
|
125
|
+
"persona.scene_contracts",
|
|
126
|
+
"persona.behavioral_anchors",
|
|
127
|
+
"persona.consistency",
|
|
128
|
+
];
|
|
129
|
+
/** True if a self-edit targets the persona's qualitative (prose) material. */
|
|
130
|
+
export function isQualitative(targetPath) {
|
|
131
|
+
return QUALITATIVE_PREFIXES.some((p) => targetPath === p || targetPath.startsWith(p + "."));
|
|
132
|
+
}
|
|
133
|
+
/** Top-level spec layer of a dot-path ("character.virtues.x" -> "character"). */
|
|
134
|
+
export function topLayer(targetPath) {
|
|
135
|
+
return targetPath.split(".")[0];
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Decide how a proposed self-edit to `targetPath` is handled, composing THREE layers of control
|
|
139
|
+
* so the whole spec can evolve while staying safe — and the persona AUTHOR stays in charge:
|
|
140
|
+
*
|
|
141
|
+
* 1. the hard SAFETY FLOOR (`isProtected`) — identity/character/safety/hard_limits/governance/
|
|
142
|
+
* permissions are NEVER editable, regardless of anything below;
|
|
143
|
+
* 2. the spec's DECLARED per-layer policy (`governance.per_layer_edit_policy`) — the author
|
|
144
|
+
* marks each layer `locked` (never) / `human_approval_required` | `review_required` (always
|
|
145
|
+
* queue for /review, even in autonomous) / `governance_controlled` | `open` (follow the mode);
|
|
146
|
+
* 3. the global `improvement_policy.mode` (locked | suggesting | autonomous).
|
|
147
|
+
*
|
|
148
|
+
* Layers without a declared policy default to `governance_controlled` (follow the mode), so a
|
|
149
|
+
* fresh persona can evolve out of the box; the author locks or gates any layer explicitly.
|
|
150
|
+
*/
|
|
151
|
+
export function editGate(targetPath, frontmatter, mode) {
|
|
152
|
+
if (isProtected(targetPath))
|
|
153
|
+
return "block";
|
|
154
|
+
const gov = frontmatter.governance;
|
|
155
|
+
const declared = gov?.per_layer_edit_policy?.[topLayer(targetPath)];
|
|
156
|
+
const policy = typeof declared === "string" ? declared : "governance_controlled";
|
|
157
|
+
switch (policy) {
|
|
158
|
+
case "locked":
|
|
159
|
+
case "none":
|
|
160
|
+
case "human_only":
|
|
161
|
+
return "block";
|
|
162
|
+
case "human_approval_required":
|
|
163
|
+
case "review_required":
|
|
164
|
+
return "queue"; // author forces human review, even in autonomous mode
|
|
165
|
+
case "auto_approved":
|
|
166
|
+
// Author pre-authorized this layer to auto-apply (still subject to the protected floor above,
|
|
167
|
+
// the consensus verifiers, and the user-trust provenance gate). `locked` is the master
|
|
168
|
+
// kill-switch and still wins; otherwise this overrides a global `suggesting` default.
|
|
169
|
+
return mode === "locked" ? "block" : "auto";
|
|
170
|
+
case "governance_controlled":
|
|
171
|
+
case "open":
|
|
172
|
+
return mode === "locked" ? "block" : mode === "autonomous" ? "auto" : "queue";
|
|
173
|
+
default:
|
|
174
|
+
return "queue"; // unknown policy string -> safe default
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** The set of top-level layers a persona MAY self-edit (declared policy minus the safety floor). */
|
|
178
|
+
export function editableLayers(frontmatter, mode) {
|
|
179
|
+
// Version dispatch (v1.0 merged persona_prompting into `persona`): a legacy
|
|
180
|
+
// (≤0.10) document may self-edit persona_prompting even before the block
|
|
181
|
+
// exists (that is how qualitative material is first added); a v1.0 document
|
|
182
|
+
// uses `persona` for that material and gains the runtime/interop blocks.
|
|
183
|
+
const common = [
|
|
184
|
+
"personality", "values_and_drives", "affect", "cognition", "memory", "metacognition",
|
|
185
|
+
"persona", "extensions", "agent_budget", "verification", "observability",
|
|
186
|
+
];
|
|
187
|
+
const layers = isV1Frontmatter(frontmatter)
|
|
188
|
+
? [...common, "runtime", "interop"]
|
|
189
|
+
: [...common, "persona_prompting"];
|
|
190
|
+
return layers.filter((l) => editGate(l, frontmatter, mode) !== "block");
|
|
191
|
+
}
|
|
192
|
+
export function consensusVerify(proposal, verifiers = DEFAULT_VERIFIERS, quorum = verifiers.length) {
|
|
193
|
+
const results = verifiers.map((v) => v.verify(proposal));
|
|
194
|
+
const passes = results.filter((r) => r.pass).length;
|
|
195
|
+
return { passed: passes >= quorum, results, quorum, passes };
|
|
196
|
+
}
|
|
197
|
+
function ledgerPath(personaPath) {
|
|
198
|
+
return join(dirname(personaPath), "self-edits.jsonl");
|
|
199
|
+
}
|
|
200
|
+
function append(personaPath, e) {
|
|
201
|
+
appendFileSync(ledgerPath(personaPath), JSON.stringify(e) + "\n", "utf-8");
|
|
202
|
+
}
|
|
203
|
+
export function readLedger(personaPath) {
|
|
204
|
+
const p = ledgerPath(personaPath);
|
|
205
|
+
if (!existsSync(p))
|
|
206
|
+
return [];
|
|
207
|
+
return readFileSync(p, "utf-8")
|
|
208
|
+
.split("\n")
|
|
209
|
+
.filter((l) => l.trim())
|
|
210
|
+
.map((l) => JSON.parse(l));
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Record a ledger event directly (F3.7). Used by the authoritative human
|
|
214
|
+
* `personaxis edit <dot-path>` command to audit a surgical spec edit in the same
|
|
215
|
+
* append-only ledger the actor's self-edits use — so `/audit` shows human and
|
|
216
|
+
* actor changes on one timeline.
|
|
217
|
+
*/
|
|
218
|
+
export function recordLedgerEvent(personaPath, e) {
|
|
219
|
+
append(personaPath, e);
|
|
220
|
+
}
|
|
221
|
+
export function isProtected(targetPath) {
|
|
222
|
+
return PROTECTED_PREFIXES.some((p) => targetPath === p || targetPath.startsWith(p + "."));
|
|
223
|
+
}
|
|
224
|
+
export class SelfEditError extends Error {
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Propose a self-edit. Returns the proposal id. In autonomous mode it is also
|
|
228
|
+
* auto-approved + applied (still gated). Throws on protected paths, locked mode,
|
|
229
|
+
* or untrusted provenance.
|
|
230
|
+
*/
|
|
231
|
+
export function proposeSelfEdit(personaPath, req, mode, actor = "actor-llm") {
|
|
232
|
+
if (isProtected(req.targetPath)) {
|
|
233
|
+
throw new SelfEditError(`path '${req.targetPath}' is protected and cannot be self-edited`);
|
|
234
|
+
}
|
|
235
|
+
if (mode === "locked") {
|
|
236
|
+
throw new SelfEditError("improvement_policy=locked forbids self-edits");
|
|
237
|
+
}
|
|
238
|
+
const gate = sensitiveActionGate("self_edit", req.sources);
|
|
239
|
+
if (!gate.allowed) {
|
|
240
|
+
throw new SelfEditError(`self-edit refused: ${gate.reason}`);
|
|
241
|
+
}
|
|
242
|
+
const id = createHash("sha256")
|
|
243
|
+
.update(req.targetPath + JSON.stringify(req.toValue) + Date.now())
|
|
244
|
+
.digest("hex")
|
|
245
|
+
.slice(0, 12);
|
|
246
|
+
append(personaPath, {
|
|
247
|
+
id,
|
|
248
|
+
op: "propose",
|
|
249
|
+
ts: new Date().toISOString(),
|
|
250
|
+
targetPath: req.targetPath,
|
|
251
|
+
toValue: req.toValue,
|
|
252
|
+
rationale: req.rationale,
|
|
253
|
+
sources: req.sources,
|
|
254
|
+
actor,
|
|
255
|
+
});
|
|
256
|
+
if (mode === "autonomous") {
|
|
257
|
+
return { id, ...applySelfEdit(personaPath, id, "autonomous-runtime") };
|
|
258
|
+
}
|
|
259
|
+
return { id, status: "pending" };
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Approve + apply a pending proposal, minting the next PersonaVersion — but ONLY
|
|
263
|
+
* after a quorum of independent verifiers passes (multi-agent consensus). A failing
|
|
264
|
+
* consensus records a rejection (auditable) and throws.
|
|
265
|
+
*/
|
|
266
|
+
export function applySelfEdit(personaPath, id, approver, verifiers = DEFAULT_VERIFIERS) {
|
|
267
|
+
const view = proposals(personaPath).find((p) => p.id === id);
|
|
268
|
+
if (!view)
|
|
269
|
+
throw new SelfEditError(`no proposal ${id}`);
|
|
270
|
+
if (view.status !== "pending" && view.status !== "approved") {
|
|
271
|
+
throw new SelfEditError(`proposal ${id} is ${view.status}, cannot apply`);
|
|
272
|
+
}
|
|
273
|
+
const consensus = consensusVerify({ targetPath: view.targetPath, toValue: view.toValue, rationale: view.rationale }, verifiers);
|
|
274
|
+
if (!consensus.passed) {
|
|
275
|
+
const reasons = consensus.results.filter((r) => !r.pass).map((r) => `${r.verifier}: ${r.reason}`).join("; ");
|
|
276
|
+
append(personaPath, { id, op: "reject", ts: new Date().toISOString(), actor: approver, rationale: `consensus failed (${consensus.passes}/${consensus.quorum}): ${reasons}` });
|
|
277
|
+
throw new SelfEditError(`consensus failed (${consensus.passes}/${consensus.quorum}): ${reasons}`);
|
|
278
|
+
}
|
|
279
|
+
const version = nextVersion(personaPath);
|
|
280
|
+
append(personaPath, { id, op: "approve", ts: new Date().toISOString(), actor: approver });
|
|
281
|
+
append(personaPath, { id, op: "apply", ts: new Date().toISOString(), actor: approver, version });
|
|
282
|
+
// The compiled PERSONA.md no longer reflects the spec — mark it stale for a recompile.
|
|
283
|
+
markRecompilePending(personaPath, `self-edit ${id} applied (${view.targetPath})`);
|
|
284
|
+
return { status: "applied", version, consensus };
|
|
285
|
+
}
|
|
286
|
+
export function rejectSelfEdit(personaPath, id, approver) {
|
|
287
|
+
append(personaPath, { id, op: "reject", ts: new Date().toISOString(), actor: approver });
|
|
288
|
+
}
|
|
289
|
+
/** Revert an applied self-edit (reversibility guarantee). */
|
|
290
|
+
export function revertSelfEdit(personaPath, id, actor) {
|
|
291
|
+
const view = proposals(personaPath).find((p) => p.id === id);
|
|
292
|
+
if (!view || view.status !== "applied") {
|
|
293
|
+
throw new SelfEditError(`proposal ${id} is not applied; nothing to revert`);
|
|
294
|
+
}
|
|
295
|
+
append(personaPath, { id, op: "revert", ts: new Date().toISOString(), actor });
|
|
296
|
+
markRecompilePending(personaPath, `self-edit ${id} reverted`);
|
|
297
|
+
}
|
|
298
|
+
/** Fold the ledger into the current proposal views. */
|
|
299
|
+
export function proposals(personaPath) {
|
|
300
|
+
const events = readLedger(personaPath);
|
|
301
|
+
const map = new Map();
|
|
302
|
+
for (const e of events) {
|
|
303
|
+
if (e.op === "propose") {
|
|
304
|
+
map.set(e.id, {
|
|
305
|
+
id: e.id,
|
|
306
|
+
targetPath: e.targetPath ?? "",
|
|
307
|
+
toValue: e.toValue,
|
|
308
|
+
rationale: e.rationale ?? "",
|
|
309
|
+
status: "pending",
|
|
310
|
+
});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const v = map.get(e.id);
|
|
314
|
+
if (!v)
|
|
315
|
+
continue;
|
|
316
|
+
if (e.op === "approve")
|
|
317
|
+
v.status = "approved";
|
|
318
|
+
else if (e.op === "reject")
|
|
319
|
+
v.status = "rejected";
|
|
320
|
+
else if (e.op === "apply") {
|
|
321
|
+
v.status = "applied";
|
|
322
|
+
v.version = e.version;
|
|
323
|
+
}
|
|
324
|
+
else if (e.op === "revert")
|
|
325
|
+
v.status = "reverted";
|
|
326
|
+
}
|
|
327
|
+
return [...map.values()];
|
|
328
|
+
}
|
|
329
|
+
/** The active overlay: applied (not reverted) edits, latest wins per path. */
|
|
330
|
+
export function activeOverlay(personaPath) {
|
|
331
|
+
const overlay = {};
|
|
332
|
+
for (const p of proposals(personaPath)) {
|
|
333
|
+
if (p.status === "applied")
|
|
334
|
+
overlay[p.targetPath] = p.toValue;
|
|
335
|
+
}
|
|
336
|
+
return overlay;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Apply an overlay (dot-path -> value) onto a copy of a frontmatter object, so
|
|
340
|
+
* APPLIED self-edits actually take effect downstream (e.g. envelope extraction in
|
|
341
|
+
* the loop) without mutating the original commented spec file. Returns a new object.
|
|
342
|
+
*/
|
|
343
|
+
export function applyOverlay(frontmatter, overlay) {
|
|
344
|
+
if (Object.keys(overlay).length === 0)
|
|
345
|
+
return frontmatter;
|
|
346
|
+
const clone = structuredClone(frontmatter);
|
|
347
|
+
for (const [path, value] of Object.entries(overlay)) {
|
|
348
|
+
const parts = path.split(".");
|
|
349
|
+
let node = clone;
|
|
350
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
351
|
+
const k = parts[i];
|
|
352
|
+
if (typeof node[k] !== "object" || node[k] === null)
|
|
353
|
+
node[k] = {};
|
|
354
|
+
node = node[k];
|
|
355
|
+
}
|
|
356
|
+
node[parts[parts.length - 1]] = value;
|
|
357
|
+
}
|
|
358
|
+
return clone;
|
|
359
|
+
}
|
|
360
|
+
function nextVersion(personaPath) {
|
|
361
|
+
const applied = readLedger(personaPath).filter((e) => e.op === "apply").length;
|
|
362
|
+
// PersonaVersion: bump the patch component per applied self-edit.
|
|
363
|
+
return `0.0.${applied + 1}`;
|
|
364
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FR.6 — background session writer + derived index.
|
|
3
|
+
*
|
|
4
|
+
* The Codex rollout-writer pattern: turns enter an in-memory queue and a single
|
|
5
|
+
* background drain appends them IN ORDER; `flush()` acks when everything queued
|
|
6
|
+
* so far is on disk, `shutdown()` drains and closes deterministically. A crash
|
|
7
|
+
* between queue and drain loses at most the un-acked tail — and a caller that
|
|
8
|
+
* needs durability awaits `flush()` at the turn boundary.
|
|
9
|
+
*
|
|
10
|
+
* The index (`sessions/index.json`) is DERIVED — rebuildable at any time from
|
|
11
|
+
* the JSONL files (the source of truth). No SQLite (bun-compile forbids native
|
|
12
|
+
* addons; persona = git-versionable plain files is a standing requirement).
|
|
13
|
+
*/
|
|
14
|
+
import { type SessionHeader, type SessionSummary, type SessionTurn } from "./sessions.js";
|
|
15
|
+
export declare class SessionWriter {
|
|
16
|
+
private readonly personaPath;
|
|
17
|
+
private queue;
|
|
18
|
+
private draining;
|
|
19
|
+
private closed;
|
|
20
|
+
private readonly file;
|
|
21
|
+
/** The last turn's uuid — the next turn's parent by default (threading). */
|
|
22
|
+
lastUuid: string | undefined;
|
|
23
|
+
constructor(personaPath: string, header: Omit<SessionHeader, "type">);
|
|
24
|
+
/**
|
|
25
|
+
* Queue one turn (non-blocking). Threads automatically: parent_uuid defaults
|
|
26
|
+
* to the previous turn's uuid. Returns this turn's uuid.
|
|
27
|
+
*/
|
|
28
|
+
append(turn: {
|
|
29
|
+
role: SessionTurn["role"];
|
|
30
|
+
content: string;
|
|
31
|
+
from?: string;
|
|
32
|
+
uuid?: string;
|
|
33
|
+
parentUuid?: string;
|
|
34
|
+
}): string;
|
|
35
|
+
/** Resolves when everything queued SO FAR is on disk (the Flush ack). */
|
|
36
|
+
flush(): Promise<void>;
|
|
37
|
+
/** Drain, then refuse further writes (the Shutdown ack). */
|
|
38
|
+
shutdown(): Promise<void>;
|
|
39
|
+
private scheduleDrain;
|
|
40
|
+
}
|
|
41
|
+
export interface SessionIndex {
|
|
42
|
+
/** ISO timestamp the index was (re)built. */
|
|
43
|
+
built: string;
|
|
44
|
+
sessions: SessionSummary[];
|
|
45
|
+
}
|
|
46
|
+
/** Rebuild sessions/index.json from the JSONL files (the source of truth). */
|
|
47
|
+
export declare function rebuildSessionIndex(personaPath: string): Promise<SessionIndex>;
|
|
48
|
+
/**
|
|
49
|
+
* Fast session listing: reads the derived index when present, falling back to
|
|
50
|
+
* (and lazily rebuilding from) the JSONL scan when missing or unreadable.
|
|
51
|
+
*/
|
|
52
|
+
export declare function readSessionIndex(personaPath: string): SessionIndex;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FR.6 — background session writer + derived index.
|
|
3
|
+
*
|
|
4
|
+
* The Codex rollout-writer pattern: turns enter an in-memory queue and a single
|
|
5
|
+
* background drain appends them IN ORDER; `flush()` acks when everything queued
|
|
6
|
+
* so far is on disk, `shutdown()` drains and closes deterministically. A crash
|
|
7
|
+
* between queue and drain loses at most the un-acked tail — and a caller that
|
|
8
|
+
* needs durability awaits `flush()` at the turn boundary.
|
|
9
|
+
*
|
|
10
|
+
* The index (`sessions/index.json`) is DERIVED — rebuildable at any time from
|
|
11
|
+
* the JSONL files (the source of truth). No SQLite (bun-compile forbids native
|
|
12
|
+
* addons; persona = git-versionable plain files is a standing requirement).
|
|
13
|
+
*/
|
|
14
|
+
import { appendFile, writeFile } from "node:fs/promises";
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { ensureSession, listSessions, sessionsDir, } from "./sessions.js";
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
export class SessionWriter {
|
|
20
|
+
personaPath;
|
|
21
|
+
queue = [];
|
|
22
|
+
draining = Promise.resolve();
|
|
23
|
+
closed = false;
|
|
24
|
+
file;
|
|
25
|
+
/** The last turn's uuid — the next turn's parent by default (threading). */
|
|
26
|
+
lastUuid;
|
|
27
|
+
constructor(personaPath, header) {
|
|
28
|
+
this.personaPath = personaPath;
|
|
29
|
+
ensureSession(personaPath, header);
|
|
30
|
+
this.file = join(sessionsDir(personaPath), `${header.id}.jsonl`);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Queue one turn (non-blocking). Threads automatically: parent_uuid defaults
|
|
34
|
+
* to the previous turn's uuid. Returns this turn's uuid.
|
|
35
|
+
*/
|
|
36
|
+
append(turn) {
|
|
37
|
+
if (this.closed)
|
|
38
|
+
throw new Error("session writer is shut down");
|
|
39
|
+
const uuid = turn.uuid ?? randomUUID();
|
|
40
|
+
const entry = {
|
|
41
|
+
type: "turn",
|
|
42
|
+
ts: new Date().toISOString(),
|
|
43
|
+
role: turn.role,
|
|
44
|
+
content: turn.content,
|
|
45
|
+
uuid,
|
|
46
|
+
...(turn.parentUuid ?? this.lastUuid ? { parent_uuid: turn.parentUuid ?? this.lastUuid } : {}),
|
|
47
|
+
...(turn.from ? { from: turn.from } : {}),
|
|
48
|
+
};
|
|
49
|
+
this.lastUuid = uuid;
|
|
50
|
+
this.queue.push(JSON.stringify(entry) + "\n");
|
|
51
|
+
this.scheduleDrain();
|
|
52
|
+
return uuid;
|
|
53
|
+
}
|
|
54
|
+
/** Resolves when everything queued SO FAR is on disk (the Flush ack). */
|
|
55
|
+
flush() {
|
|
56
|
+
this.scheduleDrain();
|
|
57
|
+
return this.draining;
|
|
58
|
+
}
|
|
59
|
+
/** Drain, then refuse further writes (the Shutdown ack). */
|
|
60
|
+
async shutdown() {
|
|
61
|
+
await this.flush();
|
|
62
|
+
this.closed = true;
|
|
63
|
+
}
|
|
64
|
+
scheduleDrain() {
|
|
65
|
+
if (this.queue.length === 0)
|
|
66
|
+
return;
|
|
67
|
+
this.draining = this.draining.then(async () => {
|
|
68
|
+
while (this.queue.length > 0) {
|
|
69
|
+
// Batch whatever accumulated — one append syscall per drain pass.
|
|
70
|
+
const batch = this.queue.join("");
|
|
71
|
+
this.queue = [];
|
|
72
|
+
await appendFile(this.file, batch, "utf-8");
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// ── derived index ─────────────────────────────────────────────────────────────
|
|
78
|
+
const INDEX_FILE = "index.json";
|
|
79
|
+
/** Rebuild sessions/index.json from the JSONL files (the source of truth). */
|
|
80
|
+
export async function rebuildSessionIndex(personaPath) {
|
|
81
|
+
const index = { built: new Date().toISOString(), sessions: listSessions(personaPath) };
|
|
82
|
+
const dir = sessionsDir(personaPath);
|
|
83
|
+
mkdirSync(dir, { recursive: true });
|
|
84
|
+
await writeFile(join(dir, INDEX_FILE), JSON.stringify(index, null, 2) + "\n", "utf-8");
|
|
85
|
+
return index;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Fast session listing: reads the derived index when present, falling back to
|
|
89
|
+
* (and lazily rebuilding from) the JSONL scan when missing or unreadable.
|
|
90
|
+
*/
|
|
91
|
+
export function readSessionIndex(personaPath) {
|
|
92
|
+
const p = join(sessionsDir(personaPath), INDEX_FILE);
|
|
93
|
+
if (existsSync(p)) {
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
/* corrupt index — fall through to the source of truth */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const fresh = { built: new Date().toISOString(), sessions: listSessions(personaPath) };
|
|
102
|
+
void rebuildSessionIndex(personaPath).catch(() => { });
|
|
103
|
+
return fresh;
|
|
104
|
+
}
|