fluffy-context 0.7.7 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +110 -553
- package/ROADMAP-1.0.md +70 -0
- package/dist/src/agent/api.d.ts +41 -0
- package/dist/src/agent/api.js +37 -0
- package/dist/src/agent/index.d.ts +9 -1
- package/dist/src/agent/index.js +3 -0
- package/dist/src/capture/context-filter.js +2 -0
- package/dist/src/cli/main.js +293 -15
- package/dist/src/cognition/admission.d.ts +8 -0
- package/dist/src/cognition/admission.js +24 -0
- package/dist/src/cognition/evidence-graph.d.ts +4 -0
- package/dist/src/cognition/evidence-graph.js +187 -0
- package/dist/src/cognition/evolution.d.ts +36 -0
- package/dist/src/cognition/evolution.js +221 -0
- package/dist/src/cognition/import-memory.d.ts +18 -0
- package/dist/src/cognition/import-memory.js +124 -0
- package/dist/src/cognition/journal-segments.d.ts +11 -0
- package/dist/src/cognition/journal-segments.js +129 -0
- package/dist/src/cognition/journal.d.ts +13 -0
- package/dist/src/cognition/journal.js +186 -20
- package/dist/src/cognition/migration-v1.js +39 -3
- package/dist/src/cognition/projections.js +124 -2
- package/dist/src/cognition/recipes.d.ts +25 -0
- package/dist/src/cognition/recipes.js +149 -0
- package/dist/src/cognition/runtime.js +4 -0
- package/dist/src/cognition/skills.d.ts +45 -0
- package/dist/src/cognition/skills.js +184 -0
- package/dist/src/cognition/types.d.ts +181 -1
- package/dist/src/cognition/usage-report.js +30 -15
- package/dist/src/compiler/compile.js +101 -31
- package/dist/src/compiler/graph.d.ts +7 -0
- package/dist/src/compiler/graph.js +117 -0
- package/dist/src/compiler/identity.d.ts +1 -1
- package/dist/src/compiler/identity.js +2 -0
- package/dist/src/compiler/inject.d.ts +23 -0
- package/dist/src/compiler/inject.js +27 -0
- package/dist/src/compiler/sources.d.ts +3 -1
- package/dist/src/compiler/sources.js +10 -1
- package/dist/src/compiler/types.d.ts +22 -1
- package/dist/src/hooks/claude-code.js +17 -56
- package/dist/src/integrations/claude-code.js +30 -8
- package/dist/src/mcp/server.js +228 -1
- package/dist/src/runtime/knowledge.js +57 -8
- package/dist/src/runtime/setup.d.ts +27 -0
- package/dist/src/runtime/setup.js +85 -0
- package/dist/src/storage/layout.d.ts +5 -0
- package/dist/src/storage/layout.js +15 -0
- package/dist/src/storage/lock.js +19 -2
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +4 -1
- package/skills/fluffy-context/SKILL.md +46 -8
|
@@ -2,9 +2,10 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import { readdir, rm } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { atomicWriteJson } from '../storage/atomic-write.js';
|
|
5
|
-
import { cognitionBranchesDirectory, cognitionContextsProjectionPath, cognitionDeadendsProjectionPath, cognitionKnowledgeProjectionPath, cognitionNotesProjectionPath, cognitionProjectionCursorPath, cognitionRetrievalProjectionPath, } from '../storage/layout.js';
|
|
5
|
+
import { cognitionBranchesDirectory, cognitionContextsProjectionPath, cognitionDeadendsProjectionPath, cognitionExperiencesProjectionPath, cognitionEvidenceGraphProjectionPath, cognitionKnowledgeProjectionPath, cognitionNotesProjectionPath, cognitionProjectionCursorPath, cognitionProposalsProjectionPath, cognitionRetrievalProjectionPath, cognitionSkillsProjectionPath, cognitionRecipesProjectionPath, } from '../storage/layout.js';
|
|
6
6
|
import { readJournal } from './journal.js';
|
|
7
7
|
import { buildRetrievalProjection } from './retrieval.js';
|
|
8
|
+
import { buildEvidenceGraph } from './evidence-graph.js';
|
|
8
9
|
export function workspaceKey(anchor) {
|
|
9
10
|
return crypto.createHash('sha256')
|
|
10
11
|
.update(`${anchor.worktreeId ?? 'default'}\0${anchor.ref ?? anchor.branch ?? 'detached'}`)
|
|
@@ -48,6 +49,55 @@ function pathFocusFrom(event) {
|
|
|
48
49
|
.sort()
|
|
49
50
|
.slice(-100);
|
|
50
51
|
}
|
|
52
|
+
function evolutionRecord(event) {
|
|
53
|
+
const payload = event.payload;
|
|
54
|
+
return isRecord(payload.record) ? payload.record : null;
|
|
55
|
+
}
|
|
56
|
+
function isRecord(value) {
|
|
57
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
58
|
+
}
|
|
59
|
+
function lifecycleEntry(event) {
|
|
60
|
+
const payload = event.payload;
|
|
61
|
+
const transition = payload.transition;
|
|
62
|
+
if (!isRecord(transition) || transition.entity !== 'proposal' || typeof transition.entityId !== 'string' || typeof transition.from !== 'string' || typeof transition.to !== 'string' || typeof transition.reason !== 'string')
|
|
63
|
+
return null;
|
|
64
|
+
return {
|
|
65
|
+
eventId: event.eventId,
|
|
66
|
+
occurredAt: event.occurredAt,
|
|
67
|
+
from: transition.from,
|
|
68
|
+
to: transition.to,
|
|
69
|
+
reason: transition.reason,
|
|
70
|
+
...(isRecord(transition.supersedes) ? { supersedes: transition.supersedes } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function skillLifecycleEntry(event) {
|
|
74
|
+
const payload = event.payload;
|
|
75
|
+
const transition = payload.transition;
|
|
76
|
+
if (!isRecord(transition) || transition.entity !== 'skill' || typeof transition.entityId !== 'string' || typeof transition.from !== 'string' || typeof transition.to !== 'string' || typeof transition.reason !== 'string')
|
|
77
|
+
return null;
|
|
78
|
+
return {
|
|
79
|
+
eventId: event.eventId,
|
|
80
|
+
occurredAt: event.occurredAt,
|
|
81
|
+
from: transition.from,
|
|
82
|
+
to: transition.to,
|
|
83
|
+
reason: transition.reason,
|
|
84
|
+
...(isRecord(transition.supersedes) ? { supersedes: transition.supersedes } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function recipeLifecycleEntry(event) {
|
|
88
|
+
const payload = event.payload;
|
|
89
|
+
const transition = payload.transition;
|
|
90
|
+
if (!isRecord(transition) || transition.entity !== 'compiler-recipe' || typeof transition.entityId !== 'string' || typeof transition.from !== 'string' || typeof transition.to !== 'string' || typeof transition.reason !== 'string')
|
|
91
|
+
return null;
|
|
92
|
+
return {
|
|
93
|
+
eventId: event.eventId,
|
|
94
|
+
occurredAt: event.occurredAt,
|
|
95
|
+
from: transition.from,
|
|
96
|
+
to: transition.to,
|
|
97
|
+
reason: transition.reason,
|
|
98
|
+
...(isRecord(transition.supersedes) ? { supersedes: transition.supersedes } : {}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
51
101
|
function latestOpenObservationIds(events, notes) {
|
|
52
102
|
const latest = new Map();
|
|
53
103
|
for (const event of events) {
|
|
@@ -66,7 +116,7 @@ function latestOpenObservationIds(events, notes) {
|
|
|
66
116
|
return new Set([...latest.values()].flatMap(({ eventId, record }) => record.status === 'open' && record.absorbedBySnapshotId === null ? [eventId] : []));
|
|
67
117
|
}
|
|
68
118
|
export function projectEvents(events) {
|
|
69
|
-
const projection = { schemaVersion: 2, contexts: {}, notes: {}, knowledge: {}, deadends: {}, workspaces: {} };
|
|
119
|
+
const projection = { schemaVersion: 2, contexts: {}, notes: {}, knowledge: {}, deadends: {}, experiences: {}, proposals: {}, skills: {}, recipes: {}, evidenceGraph: { schemaVersion: 1, derivationVersion: 'evidence-graph-v1', nodes: [], edges: [] }, workspaces: {} };
|
|
70
120
|
const openObservationIds = latestOpenObservationIds(events, projection.notes);
|
|
71
121
|
for (const event of events) {
|
|
72
122
|
const key = workspaceKey(event.git);
|
|
@@ -96,6 +146,72 @@ export function projectEvents(events) {
|
|
|
96
146
|
if (deadendId)
|
|
97
147
|
projection.deadends[deadendId] = payload;
|
|
98
148
|
}
|
|
149
|
+
if (event.type === 'experience.recorded') {
|
|
150
|
+
const experience = evolutionRecord(event);
|
|
151
|
+
if (experience && typeof experience.experienceId === 'string')
|
|
152
|
+
projection.experiences[experience.experienceId] = experience;
|
|
153
|
+
}
|
|
154
|
+
if (event.type === 'evolution.proposed') {
|
|
155
|
+
const proposal = evolutionRecord(event);
|
|
156
|
+
if (proposal && typeof proposal.proposalId === 'string') {
|
|
157
|
+
projection.proposals[proposal.proposalId] = { proposal, status: proposal.status, lifecycle: [], acceptedArtifact: null };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (event.type === 'evolution.candidate.accepted') {
|
|
161
|
+
const artifact = evolutionRecord(event);
|
|
162
|
+
if (artifact && projection.proposals[artifact.proposalId])
|
|
163
|
+
projection.proposals[artifact.proposalId].acceptedArtifact = artifact;
|
|
164
|
+
}
|
|
165
|
+
if (['evolution.verified', 'evolution.published', 'evolution.rejected', 'evolution.deprecated', 'evolution.superseded'].includes(event.type)) {
|
|
166
|
+
const entry = lifecycleEntry(event);
|
|
167
|
+
const state = entry ? projection.proposals[event.payload.transition.entityId] : null;
|
|
168
|
+
if (entry && state && state.status === entry.from) {
|
|
169
|
+
state.status = entry.to;
|
|
170
|
+
state.lifecycle.push(entry);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (event.type === 'skill.materialized') {
|
|
174
|
+
const skill = evolutionRecord(event);
|
|
175
|
+
if (skill && typeof skill.skillId === 'string' && !projection.skills[skill.skillId]) {
|
|
176
|
+
projection.skills[skill.skillId] = { skill, status: skill.status, lifecycle: [], invocations: [], outcomes: [] };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (['evolution.verified', 'evolution.published', 'evolution.rejected', 'evolution.deprecated', 'evolution.superseded'].includes(event.type)) {
|
|
180
|
+
const skillEntry = skillLifecycleEntry(event);
|
|
181
|
+
const skillState = skillEntry ? projection.skills[event.payload.transition.entityId] : null;
|
|
182
|
+
if (skillEntry && skillState && skillState.status === skillEntry.from) {
|
|
183
|
+
skillState.status = skillEntry.to;
|
|
184
|
+
skillState.lifecycle.push(skillEntry);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (event.type === 'recipe.materialized') {
|
|
188
|
+
const recipe = evolutionRecord(event);
|
|
189
|
+
if (recipe && typeof recipe.recipeId === 'string' && !projection.recipes[recipe.recipeId]) {
|
|
190
|
+
projection.recipes[recipe.recipeId] = { recipe, status: recipe.status, lifecycle: [] };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (['evolution.verified', 'evolution.published', 'evolution.rejected', 'evolution.deprecated', 'evolution.superseded'].includes(event.type)) {
|
|
194
|
+
const recipeEntry = recipeLifecycleEntry(event);
|
|
195
|
+
const recipeState = recipeEntry ? projection.recipes[event.payload.transition.entityId] : null;
|
|
196
|
+
if (recipeEntry && recipeState && recipeState.status === recipeEntry.from) {
|
|
197
|
+
recipeState.status = recipeEntry.to;
|
|
198
|
+
recipeState.lifecycle.push(recipeEntry);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (event.type === 'skill.invoked') {
|
|
202
|
+
const invocation = evolutionRecord(event);
|
|
203
|
+
const skillState = invocation ? projection.skills[invocation.skillId] : null;
|
|
204
|
+
if (invocation && skillState && skillState.status === 'published' && !skillState.invocations.some((item) => item.invocationId === invocation.invocationId))
|
|
205
|
+
skillState.invocations.push(invocation);
|
|
206
|
+
}
|
|
207
|
+
if (event.type === 'skill.outcome.recorded') {
|
|
208
|
+
const outcome = evolutionRecord(event);
|
|
209
|
+
if (outcome) {
|
|
210
|
+
const skillState = Object.values(projection.skills).find((state) => state.invocations.some((invocation) => invocation.invocationId === outcome.invocationId));
|
|
211
|
+
if (skillState && !skillState.outcomes.some((item) => item.invocationId === outcome.invocationId))
|
|
212
|
+
skillState.outcomes.push(outcome);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
99
215
|
if (event.type === 'note.recorded' && openObservationIds.has(event.eventId)) {
|
|
100
216
|
workspace.openObservationEventIds = [...workspace.openObservationEventIds, event.eventId].slice(-50);
|
|
101
217
|
}
|
|
@@ -104,6 +220,7 @@ export function projectEvents(events) {
|
|
|
104
220
|
}
|
|
105
221
|
projection.workspaces[key] = workspace;
|
|
106
222
|
}
|
|
223
|
+
projection.evidenceGraph = buildEvidenceGraph(events);
|
|
107
224
|
return projection;
|
|
108
225
|
}
|
|
109
226
|
async function removeObsoleteWorkspaceFiles(projectRoot, workspaceKeys) {
|
|
@@ -136,6 +253,11 @@ export async function rebuildProjections(projectRoot) {
|
|
|
136
253
|
atomicWriteJson(cognitionNotesProjectionPath(projectRoot), projection.notes),
|
|
137
254
|
atomicWriteJson(cognitionKnowledgeProjectionPath(projectRoot), projection.knowledge),
|
|
138
255
|
atomicWriteJson(cognitionDeadendsProjectionPath(projectRoot), projection.deadends),
|
|
256
|
+
atomicWriteJson(cognitionExperiencesProjectionPath(projectRoot), projection.experiences),
|
|
257
|
+
atomicWriteJson(cognitionProposalsProjectionPath(projectRoot), projection.proposals),
|
|
258
|
+
atomicWriteJson(cognitionSkillsProjectionPath(projectRoot), projection.skills),
|
|
259
|
+
atomicWriteJson(cognitionRecipesProjectionPath(projectRoot), projection.recipes),
|
|
260
|
+
atomicWriteJson(cognitionEvidenceGraphProjectionPath(projectRoot), projection.evidenceGraph),
|
|
139
261
|
atomicWriteJson(cognitionRetrievalProjectionPath(projectRoot), retrieval),
|
|
140
262
|
...Object.values(projection.workspaces).map((workspace) => atomicWriteJson(`${cognitionBranchesDirectory(projectRoot)}/${workspace.workspaceKey}.json`, workspace)),
|
|
141
263
|
]);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CompilerRecipeSection, CompilerRecipeSource, CompilerRecipeState, EvolutionArtifactStatus, EvolutionLineageReference } from './types.js';
|
|
2
|
+
export interface MaterializeRecipeInput {
|
|
3
|
+
proposalId: string;
|
|
4
|
+
title: string;
|
|
5
|
+
preferredSources?: CompilerRecipeSource[];
|
|
6
|
+
preferredSections?: CompilerRecipeSection[];
|
|
7
|
+
sectionBudgets?: Partial<Record<CompilerRecipeSection, number>>;
|
|
8
|
+
}
|
|
9
|
+
export interface TransitionRecipeInput {
|
|
10
|
+
recipeId: string;
|
|
11
|
+
to: Extract<EvolutionArtifactStatus, 'verified' | 'published' | 'deprecated' | 'superseded'>;
|
|
12
|
+
rationale: string;
|
|
13
|
+
supersedes?: EvolutionLineageReference;
|
|
14
|
+
}
|
|
15
|
+
export declare function listRecipes(startPath?: string): Promise<CompilerRecipeState[]>;
|
|
16
|
+
export declare function inspectRecipe(startPath: string | undefined, recipeId: string): Promise<CompilerRecipeState>;
|
|
17
|
+
export declare function materializeRecipe(startPath: string | undefined, input: MaterializeRecipeInput): Promise<{
|
|
18
|
+
materialized: boolean;
|
|
19
|
+
recipeId: string;
|
|
20
|
+
eventId: string;
|
|
21
|
+
}>;
|
|
22
|
+
export declare function transitionRecipe(startPath: string | undefined, input: TransitionRecipeInput): Promise<{
|
|
23
|
+
transitioned: boolean;
|
|
24
|
+
eventId: string;
|
|
25
|
+
}>;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { readGitWorktreeState } from '../git/git-adapter.js';
|
|
3
|
+
import { resolveProjectRoot } from '../project/project-resolver.js';
|
|
4
|
+
import { inspectEvolutionProposal } from './evolution.js';
|
|
5
|
+
import { appendEvent, readJournal } from './journal.js';
|
|
6
|
+
import { v2Enabled } from './orient.js';
|
|
7
|
+
import { projectEvents, rebuildProjections } from './projections.js';
|
|
8
|
+
const sources = new Set(['context', 'knowledge', 'deadend', 'note']);
|
|
9
|
+
const sections = new Set(['summary', 'pending', 'decision', 'risk', 'note', 'knowledge', 'deadend', 'activity']);
|
|
10
|
+
function canonical(value) {
|
|
11
|
+
if (value === null || typeof value !== 'object')
|
|
12
|
+
return JSON.stringify(value);
|
|
13
|
+
if (Array.isArray(value))
|
|
14
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
15
|
+
const record = value;
|
|
16
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(',')}}`;
|
|
17
|
+
}
|
|
18
|
+
function hash(value) {
|
|
19
|
+
return crypto.createHash('sha256').update(canonical(value)).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
function requireText(value, label) {
|
|
22
|
+
const text = value.trim();
|
|
23
|
+
if (!text)
|
|
24
|
+
throw new Error(`${label} must not be empty`);
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
27
|
+
function preferences(value, allowed, label) {
|
|
28
|
+
if (value === undefined)
|
|
29
|
+
return undefined;
|
|
30
|
+
if (!Array.isArray(value) || value.length > allowed.size || value.some((item) => !allowed.has(item)) || new Set(value).size !== value.length)
|
|
31
|
+
throw new Error(`invalid recipe ${label}`);
|
|
32
|
+
return [...value];
|
|
33
|
+
}
|
|
34
|
+
function budgets(value) {
|
|
35
|
+
if (value === undefined)
|
|
36
|
+
return undefined;
|
|
37
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value) || Object.entries(value).some(([section, budget]) => !sections.has(section) || !Number.isSafeInteger(budget) || budget < 0 || budget > 100_000))
|
|
38
|
+
throw new Error('invalid recipe section budgets');
|
|
39
|
+
return Object.fromEntries(Object.entries(value).sort());
|
|
40
|
+
}
|
|
41
|
+
function isRecord(value) {
|
|
42
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
function eventRecord(event) {
|
|
45
|
+
const payload = event.payload;
|
|
46
|
+
return isRecord(payload.record) ? payload.record : null;
|
|
47
|
+
}
|
|
48
|
+
function matchingRecord(record, expected, ignored = []) {
|
|
49
|
+
const actual = { ...record };
|
|
50
|
+
for (const field of ignored)
|
|
51
|
+
delete actual[field];
|
|
52
|
+
return canonical(actual) === canonical(expected);
|
|
53
|
+
}
|
|
54
|
+
function anchor(git) {
|
|
55
|
+
return { worktreeId: git.worktreeId, branch: git.branch, ref: git.ref, head: git.commit };
|
|
56
|
+
}
|
|
57
|
+
async function enabledProject(startPath) {
|
|
58
|
+
const projectRoot = await resolveProjectRoot(startPath);
|
|
59
|
+
if (!await v2Enabled(projectRoot))
|
|
60
|
+
throw new Error('recipes require an enabled v2 runtime');
|
|
61
|
+
return projectRoot;
|
|
62
|
+
}
|
|
63
|
+
export async function listRecipes(startPath) {
|
|
64
|
+
const projectRoot = await enabledProject(startPath);
|
|
65
|
+
return Object.values(projectEvents(await readJournal(projectRoot)).recipes).sort((left, right) => left.recipe.recipeId.localeCompare(right.recipe.recipeId));
|
|
66
|
+
}
|
|
67
|
+
export async function inspectRecipe(startPath, recipeId) {
|
|
68
|
+
const id = requireText(recipeId, 'recipe ID');
|
|
69
|
+
const recipe = (await listRecipes(startPath)).find((item) => item.recipe.recipeId === id);
|
|
70
|
+
if (!recipe)
|
|
71
|
+
throw new Error(`recipe not found: ${id}`);
|
|
72
|
+
return recipe;
|
|
73
|
+
}
|
|
74
|
+
export async function materializeRecipe(startPath, input) {
|
|
75
|
+
const projectRoot = await enabledProject(startPath);
|
|
76
|
+
const proposalId = requireText(input.proposalId, 'proposal ID');
|
|
77
|
+
const proposalState = await inspectEvolutionProposal(projectRoot, proposalId);
|
|
78
|
+
if (proposalState.proposal.targetKind !== 'compiler-recipe' || proposalState.status !== 'candidate')
|
|
79
|
+
throw new Error(`recipe materialization requires a candidate compiler-recipe proposal: ${proposalId}`);
|
|
80
|
+
const artifact = proposalState.acceptedArtifact;
|
|
81
|
+
if (!artifact || artifact.kind !== 'compiler-recipe' || artifact.proposalId !== proposalId)
|
|
82
|
+
throw new Error(`recipe materialization requires an accepted compiler-recipe candidate: ${proposalId}`);
|
|
83
|
+
const title = requireText(input.title, 'recipe title');
|
|
84
|
+
const preferredSources = preferences(input.preferredSources, sources, 'source preferences');
|
|
85
|
+
const preferredSections = preferences(input.preferredSections, sections, 'section preferences');
|
|
86
|
+
const sectionBudgets = budgets(input.sectionBudgets);
|
|
87
|
+
const basis = { proposalId, title, applicability: artifact.applicability, preferredSources, preferredSections, sectionBudgets, lineage: artifact.lineage, proposalContentHash: artifact.contentHash };
|
|
88
|
+
const expected = {
|
|
89
|
+
recipeId: artifact.artifactId,
|
|
90
|
+
schemaVersion: 1,
|
|
91
|
+
proposalId,
|
|
92
|
+
title,
|
|
93
|
+
applicability: artifact.applicability,
|
|
94
|
+
...(preferredSources ? { preferredSources } : {}),
|
|
95
|
+
...(preferredSections ? { preferredSections } : {}),
|
|
96
|
+
...(sectionBudgets ? { sectionBudgets } : {}),
|
|
97
|
+
status: 'candidate',
|
|
98
|
+
contentHash: hash(basis),
|
|
99
|
+
lineage: artifact.lineage,
|
|
100
|
+
};
|
|
101
|
+
const existing = (await readJournal(projectRoot)).find((event) => event.type === 'recipe.materialized' && eventRecord(event)?.recipeId === expected.recipeId);
|
|
102
|
+
if (existing) {
|
|
103
|
+
const record = eventRecord(existing);
|
|
104
|
+
if (!matchingRecord(record, expected, ['createdAt']))
|
|
105
|
+
throw new Error(`Recipe already materialized with conflicting content: ${expected.recipeId}`);
|
|
106
|
+
return { materialized: false, recipeId: expected.recipeId, eventId: existing.eventId };
|
|
107
|
+
}
|
|
108
|
+
const recipe = { ...expected, createdAt: new Date().toISOString() };
|
|
109
|
+
const git = await readGitWorktreeState(projectRoot);
|
|
110
|
+
const result = await appendEvent(projectRoot, {
|
|
111
|
+
type: 'recipe.materialized', source: { kind: 'cli' }, idempotencyKey: `recipe-materialized:${recipe.recipeId}:${recipe.contentHash}`,
|
|
112
|
+
git: anchor(git), payload: { record: recipe },
|
|
113
|
+
});
|
|
114
|
+
if (result.appended)
|
|
115
|
+
await rebuildProjections(projectRoot);
|
|
116
|
+
return { materialized: result.appended, recipeId: recipe.recipeId, eventId: result.event.eventId };
|
|
117
|
+
}
|
|
118
|
+
export async function transitionRecipe(startPath, input) {
|
|
119
|
+
const projectRoot = await enabledProject(startPath);
|
|
120
|
+
const recipe = await inspectRecipe(projectRoot, input.recipeId);
|
|
121
|
+
const rationale = requireText(input.rationale, 'recipe rationale');
|
|
122
|
+
if (input.to === 'superseded' && !input.supersedes)
|
|
123
|
+
throw new Error('superseding a Recipe requires lineage');
|
|
124
|
+
const request = { entity: 'compiler-recipe', entityId: recipe.recipe.recipeId, to: input.to, reason: rationale, ...(input.supersedes ? { supersedes: input.supersedes } : {}) };
|
|
125
|
+
const existing = (await readJournal(projectRoot)).find((event) => {
|
|
126
|
+
if (!event.type.startsWith('evolution.'))
|
|
127
|
+
return false;
|
|
128
|
+
const transition = event.payload.transition;
|
|
129
|
+
return isRecord(transition) && matchingRecord(transition, request, ['from']);
|
|
130
|
+
});
|
|
131
|
+
if (existing)
|
|
132
|
+
return { transitioned: false, eventId: existing.eventId };
|
|
133
|
+
if (!((recipe.status === 'candidate' && (input.to === 'verified' || input.to === 'deprecated'))
|
|
134
|
+
|| (recipe.status === 'verified' && (input.to === 'published' || input.to === 'deprecated' || input.to === 'superseded'))
|
|
135
|
+
|| (recipe.status === 'published' && (input.to === 'deprecated' || input.to === 'superseded')))) {
|
|
136
|
+
throw new Error(`invalid Recipe transition: ${recipe.status} to ${input.to}`);
|
|
137
|
+
}
|
|
138
|
+
const git = await readGitWorktreeState(projectRoot);
|
|
139
|
+
const result = await appendEvent(projectRoot, {
|
|
140
|
+
type: `evolution.${input.to}`,
|
|
141
|
+
source: { kind: 'cli' },
|
|
142
|
+
idempotencyKey: `recipe-transition:${recipe.recipe.recipeId}:${input.to}:${hash({ rationale, supersedes: input.supersedes }).slice(0, 16)}`,
|
|
143
|
+
git: anchor(git),
|
|
144
|
+
payload: { transition: { ...request, from: recipe.status } },
|
|
145
|
+
});
|
|
146
|
+
if (result.appended)
|
|
147
|
+
await rebuildProjections(projectRoot);
|
|
148
|
+
return { transitioned: result.appended, eventId: result.event.eventId };
|
|
149
|
+
}
|
|
@@ -249,6 +249,10 @@ export async function serveRuntime(startPath, options = {}) {
|
|
|
249
249
|
return closing;
|
|
250
250
|
};
|
|
251
251
|
const touch = () => {
|
|
252
|
+
// Draining observations during shutdown can still notify activity. Never
|
|
253
|
+
// recreate an inactivity timer after closeServer has cleared the timers.
|
|
254
|
+
if (closing)
|
|
255
|
+
return;
|
|
252
256
|
if (inactivity)
|
|
253
257
|
clearTimeout(inactivity);
|
|
254
258
|
inactivity = setTimeout(() => { void close('inactivity').catch(() => undefined); }, inactivityTimeoutMs);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { EvolutionArtifactStatus, EvolutionLineageReference, SkillOutcome, SkillState } from './types.js';
|
|
2
|
+
export interface MaterializeSkillInput {
|
|
3
|
+
proposalId: string;
|
|
4
|
+
title: string;
|
|
5
|
+
procedure: string[];
|
|
6
|
+
}
|
|
7
|
+
export interface TransitionSkillInput {
|
|
8
|
+
skillId: string;
|
|
9
|
+
to: Extract<EvolutionArtifactStatus, 'verified' | 'published' | 'deprecated' | 'superseded'>;
|
|
10
|
+
rationale: string;
|
|
11
|
+
supersedes?: EvolutionLineageReference;
|
|
12
|
+
}
|
|
13
|
+
export interface RecordSkillInvocationInput {
|
|
14
|
+
skillId: string;
|
|
15
|
+
invocationId: string;
|
|
16
|
+
callerEventId: string;
|
|
17
|
+
inputHash: string;
|
|
18
|
+
}
|
|
19
|
+
export interface RecordSkillOutcomeInput {
|
|
20
|
+
skillId: string;
|
|
21
|
+
invocationId: string;
|
|
22
|
+
outcomeId: string;
|
|
23
|
+
callerEventId: string;
|
|
24
|
+
outcome: SkillOutcome['outcome'];
|
|
25
|
+
summary: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function listSkills(startPath?: string): Promise<SkillState[]>;
|
|
28
|
+
export declare function inspectSkill(startPath: string | undefined, skillId: string): Promise<SkillState>;
|
|
29
|
+
export declare function materializeSkill(startPath: string | undefined, input: MaterializeSkillInput): Promise<{
|
|
30
|
+
materialized: boolean;
|
|
31
|
+
skillId: string;
|
|
32
|
+
eventId: string;
|
|
33
|
+
}>;
|
|
34
|
+
export declare function transitionSkill(startPath: string | undefined, input: TransitionSkillInput): Promise<{
|
|
35
|
+
transitioned: boolean;
|
|
36
|
+
eventId: string;
|
|
37
|
+
}>;
|
|
38
|
+
export declare function recordSkillInvocation(startPath: string | undefined, input: RecordSkillInvocationInput): Promise<{
|
|
39
|
+
recorded: boolean;
|
|
40
|
+
eventId: string;
|
|
41
|
+
}>;
|
|
42
|
+
export declare function recordSkillOutcome(startPath: string | undefined, input: RecordSkillOutcomeInput): Promise<{
|
|
43
|
+
recorded: boolean;
|
|
44
|
+
eventId: string;
|
|
45
|
+
}>;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { readGitWorktreeState } from '../git/git-adapter.js';
|
|
3
|
+
import { resolveProjectRoot } from '../project/project-resolver.js';
|
|
4
|
+
import { appendEvent, readJournal } from './journal.js';
|
|
5
|
+
import { inspectEvolutionProposal } from './evolution.js';
|
|
6
|
+
import { v2Enabled } from './orient.js';
|
|
7
|
+
import { projectEvents, rebuildProjections } from './projections.js';
|
|
8
|
+
function canonical(value) {
|
|
9
|
+
if (value === null || typeof value !== 'object')
|
|
10
|
+
return JSON.stringify(value);
|
|
11
|
+
if (Array.isArray(value))
|
|
12
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
13
|
+
const record = value;
|
|
14
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(',')}}`;
|
|
15
|
+
}
|
|
16
|
+
function hash(value) {
|
|
17
|
+
return crypto.createHash('sha256').update(canonical(value)).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
function requireText(value, label) {
|
|
20
|
+
const text = value.trim();
|
|
21
|
+
if (!text)
|
|
22
|
+
throw new Error(`${label} must not be empty`);
|
|
23
|
+
return text;
|
|
24
|
+
}
|
|
25
|
+
function requireProcedure(procedure) {
|
|
26
|
+
if (!Array.isArray(procedure) || procedure.length === 0 || procedure.length > 32)
|
|
27
|
+
throw new Error('skill procedure must contain 1 to 32 steps');
|
|
28
|
+
return procedure.map((step) => requireText(step, 'skill procedure step'));
|
|
29
|
+
}
|
|
30
|
+
function isRecord(value) {
|
|
31
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
function eventRecord(event) {
|
|
34
|
+
const payload = event.payload;
|
|
35
|
+
return isRecord(payload.record) ? payload.record : null;
|
|
36
|
+
}
|
|
37
|
+
function matchingRecord(record, expected, ignored = []) {
|
|
38
|
+
const actual = { ...record };
|
|
39
|
+
for (const field of ignored)
|
|
40
|
+
delete actual[field];
|
|
41
|
+
return canonical(actual) === canonical(expected);
|
|
42
|
+
}
|
|
43
|
+
function anchor(git) {
|
|
44
|
+
return { worktreeId: git.worktreeId, branch: git.branch, ref: git.ref, head: git.commit };
|
|
45
|
+
}
|
|
46
|
+
async function enabledProject(startPath) {
|
|
47
|
+
const projectRoot = await resolveProjectRoot(startPath);
|
|
48
|
+
if (!await v2Enabled(projectRoot))
|
|
49
|
+
throw new Error('skills require an enabled v2 runtime');
|
|
50
|
+
return projectRoot;
|
|
51
|
+
}
|
|
52
|
+
export async function listSkills(startPath) {
|
|
53
|
+
const projectRoot = await enabledProject(startPath);
|
|
54
|
+
return Object.values(projectEvents(await readJournal(projectRoot)).skills).sort((left, right) => left.skill.skillId.localeCompare(right.skill.skillId));
|
|
55
|
+
}
|
|
56
|
+
export async function inspectSkill(startPath, skillId) {
|
|
57
|
+
const id = requireText(skillId, 'skill ID');
|
|
58
|
+
const skill = (await listSkills(startPath)).find((item) => item.skill.skillId === id);
|
|
59
|
+
if (!skill)
|
|
60
|
+
throw new Error(`skill not found: ${id}`);
|
|
61
|
+
return skill;
|
|
62
|
+
}
|
|
63
|
+
export async function materializeSkill(startPath, input) {
|
|
64
|
+
const projectRoot = await enabledProject(startPath);
|
|
65
|
+
const proposalId = requireText(input.proposalId, 'proposal ID');
|
|
66
|
+
const proposalState = await inspectEvolutionProposal(projectRoot, proposalId);
|
|
67
|
+
if (proposalState.proposal.targetKind !== 'skill' || proposalState.status !== 'candidate')
|
|
68
|
+
throw new Error(`skill materialization requires a candidate Skill proposal: ${proposalId}`);
|
|
69
|
+
const artifact = proposalState.acceptedArtifact;
|
|
70
|
+
if (!artifact || artifact.kind !== 'skill' || artifact.proposalId !== proposalId)
|
|
71
|
+
throw new Error(`skill materialization requires an accepted Skill candidate: ${proposalId}`);
|
|
72
|
+
const title = requireText(input.title, 'skill title');
|
|
73
|
+
const procedure = requireProcedure(input.procedure);
|
|
74
|
+
const basis = { proposalId, title, procedure, applicability: artifact.applicability, lineage: artifact.lineage, proposalContentHash: artifact.contentHash };
|
|
75
|
+
const expected = {
|
|
76
|
+
skillId: artifact.artifactId,
|
|
77
|
+
schemaVersion: 1,
|
|
78
|
+
proposalId,
|
|
79
|
+
title,
|
|
80
|
+
procedure,
|
|
81
|
+
applicability: artifact.applicability,
|
|
82
|
+
status: 'candidate',
|
|
83
|
+
contentHash: hash(basis),
|
|
84
|
+
lineage: artifact.lineage,
|
|
85
|
+
};
|
|
86
|
+
const existing = (await readJournal(projectRoot)).find((event) => event.type === 'skill.materialized' && eventRecord(event)?.skillId === expected.skillId);
|
|
87
|
+
if (existing) {
|
|
88
|
+
const record = eventRecord(existing);
|
|
89
|
+
if (!matchingRecord(record, expected, ['createdAt']))
|
|
90
|
+
throw new Error(`Skill already materialized with conflicting content: ${expected.skillId}`);
|
|
91
|
+
return { materialized: false, skillId: expected.skillId, eventId: existing.eventId };
|
|
92
|
+
}
|
|
93
|
+
const skill = { ...expected, createdAt: new Date().toISOString() };
|
|
94
|
+
const git = await readGitWorktreeState(projectRoot);
|
|
95
|
+
const result = await appendEvent(projectRoot, {
|
|
96
|
+
type: 'skill.materialized', source: { kind: 'cli' }, idempotencyKey: `skill-materialized:${skill.skillId}:${skill.contentHash}`,
|
|
97
|
+
git: anchor(git), payload: { record: skill },
|
|
98
|
+
});
|
|
99
|
+
if (result.appended)
|
|
100
|
+
await rebuildProjections(projectRoot);
|
|
101
|
+
return { materialized: result.appended, skillId: skill.skillId, eventId: result.event.eventId };
|
|
102
|
+
}
|
|
103
|
+
export async function transitionSkill(startPath, input) {
|
|
104
|
+
const projectRoot = await enabledProject(startPath);
|
|
105
|
+
const skill = await inspectSkill(projectRoot, input.skillId);
|
|
106
|
+
const rationale = requireText(input.rationale, 'skill rationale');
|
|
107
|
+
if (input.to === 'superseded' && !input.supersedes)
|
|
108
|
+
throw new Error('superseding a Skill requires lineage');
|
|
109
|
+
const request = { entity: 'skill', entityId: skill.skill.skillId, to: input.to, reason: rationale, ...(input.supersedes ? { supersedes: input.supersedes } : {}) };
|
|
110
|
+
const existing = (await readJournal(projectRoot)).find((event) => {
|
|
111
|
+
if (!event.type.startsWith('evolution.'))
|
|
112
|
+
return false;
|
|
113
|
+
const transition = event.payload.transition;
|
|
114
|
+
return isRecord(transition) && matchingRecord(transition, request, ['from']);
|
|
115
|
+
});
|
|
116
|
+
if (existing)
|
|
117
|
+
return { transitioned: false, eventId: existing.eventId };
|
|
118
|
+
if (!((skill.status === 'candidate' && (input.to === 'verified' || input.to === 'deprecated'))
|
|
119
|
+
|| (skill.status === 'verified' && (input.to === 'published' || input.to === 'deprecated' || input.to === 'superseded'))
|
|
120
|
+
|| (skill.status === 'published' && (input.to === 'deprecated' || input.to === 'superseded')))) {
|
|
121
|
+
throw new Error(`invalid Skill transition: ${skill.status} to ${input.to}`);
|
|
122
|
+
}
|
|
123
|
+
const git = await readGitWorktreeState(projectRoot);
|
|
124
|
+
const result = await appendEvent(projectRoot, {
|
|
125
|
+
type: `evolution.${input.to}`,
|
|
126
|
+
source: { kind: 'cli' },
|
|
127
|
+
idempotencyKey: `skill-transition:${skill.skill.skillId}:${input.to}:${hash({ rationale, supersedes: input.supersedes }).slice(0, 16)}`,
|
|
128
|
+
git: anchor(git),
|
|
129
|
+
payload: { transition: { ...request, from: skill.status } },
|
|
130
|
+
});
|
|
131
|
+
if (result.appended)
|
|
132
|
+
await rebuildProjections(projectRoot);
|
|
133
|
+
return { transitioned: result.appended, eventId: result.event.eventId };
|
|
134
|
+
}
|
|
135
|
+
export async function recordSkillInvocation(startPath, input) {
|
|
136
|
+
const projectRoot = await enabledProject(startPath);
|
|
137
|
+
const skill = await inspectSkill(projectRoot, input.skillId);
|
|
138
|
+
const invocation = {
|
|
139
|
+
invocationId: requireText(input.invocationId, 'invocation ID'),
|
|
140
|
+
skillId: skill.skill.skillId,
|
|
141
|
+
callerEventId: requireText(input.callerEventId, 'caller event ID'),
|
|
142
|
+
inputHash: requireText(input.inputHash, 'skill input hash'),
|
|
143
|
+
};
|
|
144
|
+
const existing = (await readJournal(projectRoot)).find((event) => event.type === 'skill.invoked' && eventRecord(event)?.invocationId === invocation.invocationId);
|
|
145
|
+
if (existing) {
|
|
146
|
+
if (!matchingRecord(eventRecord(existing), invocation, ['occurredAt']))
|
|
147
|
+
throw new Error(`Skill invocation ID already exists: ${invocation.invocationId}`);
|
|
148
|
+
return { recorded: false, eventId: existing.eventId };
|
|
149
|
+
}
|
|
150
|
+
if (skill.status !== 'published')
|
|
151
|
+
throw new Error(`Skill invocation requires a published Skill: ${skill.skill.skillId}`);
|
|
152
|
+
const git = await readGitWorktreeState(projectRoot);
|
|
153
|
+
const result = await appendEvent(projectRoot, { type: 'skill.invoked', source: { kind: 'cli' }, idempotencyKey: `skill-invocation:${invocation.invocationId}`, git: anchor(git), payload: { record: { ...invocation, occurredAt: new Date().toISOString() } } });
|
|
154
|
+
if (result.appended)
|
|
155
|
+
await rebuildProjections(projectRoot);
|
|
156
|
+
return { recorded: result.appended, eventId: result.event.eventId };
|
|
157
|
+
}
|
|
158
|
+
export async function recordSkillOutcome(startPath, input) {
|
|
159
|
+
const projectRoot = await enabledProject(startPath);
|
|
160
|
+
const skill = await inspectSkill(projectRoot, input.skillId);
|
|
161
|
+
const invocationId = requireText(input.invocationId, 'invocation ID');
|
|
162
|
+
const outcome = {
|
|
163
|
+
outcomeId: requireText(input.outcomeId, 'outcome ID'),
|
|
164
|
+
invocationId,
|
|
165
|
+
callerEventId: requireText(input.callerEventId, 'caller event ID'),
|
|
166
|
+
outcome: input.outcome,
|
|
167
|
+
summary: requireText(input.summary, 'skill outcome summary'),
|
|
168
|
+
};
|
|
169
|
+
const existing = (await readJournal(projectRoot)).find((event) => event.type === 'skill.outcome.recorded' && eventRecord(event)?.outcomeId === outcome.outcomeId);
|
|
170
|
+
if (existing) {
|
|
171
|
+
if (!matchingRecord(eventRecord(existing), outcome, ['occurredAt']))
|
|
172
|
+
throw new Error(`Skill outcome ID already exists: ${outcome.outcomeId}`);
|
|
173
|
+
return { recorded: false, eventId: existing.eventId };
|
|
174
|
+
}
|
|
175
|
+
if (!skill.invocations.some((item) => item.invocationId === invocationId))
|
|
176
|
+
throw new Error(`Skill invocation not found: ${invocationId}`);
|
|
177
|
+
if (skill.outcomes.some((item) => item.invocationId === invocationId))
|
|
178
|
+
throw new Error(`Skill outcome already recorded: ${invocationId}`);
|
|
179
|
+
const git = await readGitWorktreeState(projectRoot);
|
|
180
|
+
const result = await appendEvent(projectRoot, { type: 'skill.outcome.recorded', source: { kind: 'cli' }, idempotencyKey: `skill-outcome:${outcome.outcomeId}`, git: anchor(git), payload: { record: { ...outcome, occurredAt: new Date().toISOString() } } });
|
|
181
|
+
if (result.appended)
|
|
182
|
+
await rebuildProjections(projectRoot);
|
|
183
|
+
return { recorded: result.appended, eventId: result.event.eventId };
|
|
184
|
+
}
|