futura-scion 0.2.8 → 0.2.9
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/README.md +48 -0
- package/bin/scion.js +44 -0
- package/config/scion.config.yaml +1 -5
- package/package.json +1 -1
- package/src/config.js +2 -0
- package/src/kernel/decompose.js +214 -0
- package/src/kernel/levels.js +105 -0
- package/src/kernel/ops.js +104 -0
- package/src/ladder.js +20 -0
- package/src/mind/contracts.js +78 -0
- package/src/mind/phylogeny.js +129 -0
- package/src/mind/skill-exec.js +138 -0
- package/src/mind/skills.js +107 -3
package/README.md
CHANGED
|
@@ -903,6 +903,54 @@ machinery:
|
|
|
903
903
|
TTL/budget/read-limit are config policy (Codebuff CompactionPolicy
|
|
904
904
|
lineage), not module constants.
|
|
905
905
|
|
|
906
|
+
### The recursive software world — EvoX Genesis adoption organs (v0.2.9)
|
|
907
|
+
|
|
908
|
+
From studying EvoX Genesis (`EMI-Group/genesis`, the recursive software-
|
|
909
|
+
evolution system), eight patterns were adopted — the same "transient agents,
|
|
910
|
+
persistent world" doctrine FS already holds, taken further:
|
|
911
|
+
|
|
912
|
+
- **Recursive decomposition** (`src/kernel/decompose.js`, `scion decompose`):
|
|
913
|
+
large objectives become a delegation tree — managers decompose, executors
|
|
914
|
+
implement, read-only investigators recurse. Depth is bounded
|
|
915
|
+
(`decomposition.max_depth`, default 5); a child may only spawn templates
|
|
916
|
+
its parent declares in `spawnable_agents` — the spawn graph IS authority.
|
|
917
|
+
|
|
918
|
+
- **Foreign-repo write authority**: read-only access to any repo is
|
|
919
|
+
unrestricted; WRITABLE access to a non-primary repo is ROOT-ONLY (depth 0)
|
|
920
|
+
and SERIALIZED (one open writable delegation per repo — parallel writes
|
|
921
|
+
create merge conflicts the spawner cannot control). Violations fail loud.
|
|
922
|
+
|
|
923
|
+
- **Skill distillation** (`distillSkill`, wired to verified outcomes): a
|
|
924
|
+
gate-verified task is distilled into a durable SKILL.md procedure pack —
|
|
925
|
+
the approach plus the oracle commands that proved it. Idempotent: the
|
|
926
|
+
same problem shape updates its skill in place.
|
|
927
|
+
|
|
928
|
+
- **Executable skills** (`src/mind/skill-exec.js`): skills may declare
|
|
929
|
+
`parameters:` frontmatter and a bash body with `{{param}}` placeholders.
|
|
930
|
+
Missing params and unfilled holes refuse loud; execution crosses the
|
|
931
|
+
Gate's blocklist rails — a skill that executes is a tool.
|
|
932
|
+
|
|
933
|
+
- **Context contracts** (`src/mind/contracts.js`): every directory may
|
|
934
|
+
carry a `CONTEXT.md` (Genesis-compatible) declaring its intent; tasks
|
|
935
|
+
targeting a file inherit the chain root→node at THINK time
|
|
936
|
+
("current state, not history"). Containment is enforced — a contract
|
|
937
|
+
walk never escapes the tree.
|
|
938
|
+
|
|
939
|
+
- **Security-leveled commands** (`src/kernel/levels.js`): level 1
|
|
940
|
+
read-only (runs without ceremony), level 2 attention (reviewable state
|
|
941
|
+
change — unclassified commands are NEVER trusted), level 3 side-effect
|
|
942
|
+
(irreversible; the Gate's blocklist floor). Worst class wins.
|
|
943
|
+
|
|
944
|
+
- **The evolution DAG** (`scion history`, `src/mind/phylogeny.js`):
|
|
945
|
+
accepted verdicts form a phylogenetic tree — same signature reinforces
|
|
946
|
+
its node; a new problem shape ≥ 0.5 Jaccard-similar to an accepted
|
|
947
|
+
ancestor becomes its descendant. The codebase's evolution is navigable.
|
|
948
|
+
|
|
949
|
+
- **Operational hygiene** (`src/kernel/ops.js`, `scion ops`): per-task
|
|
950
|
+
scratch dirs reclaimed EVENT-DRIVEN at terminal status (containment-
|
|
951
|
+
guarded), and peak/off-peak concurrency windows (`ops.peak_windows`)
|
|
952
|
+
scale the swarm down during shared-machine hours.
|
|
953
|
+
|
|
906
954
|
### FS Desktop — the standalone UI (chat / agent / plan / architect)
|
|
907
955
|
|
|
908
956
|
FS ships a real UI in two forms, both zero-build:
|
package/bin/scion.js
CHANGED
|
@@ -785,6 +785,50 @@ switch (cmd || '') {
|
|
|
785
785
|
console.log(`\nclone (kept for inspection): ${r.clone_dir} [files=${r.stats.files} symbols=${r.stats.symbols} ${r.stats.ms}ms]`);
|
|
786
786
|
break;
|
|
787
787
|
}
|
|
788
|
+
case 'decompose': {
|
|
789
|
+
// Recursive decomposition (Genesis lineage):
|
|
790
|
+
// scion decompose open <templateId> "<objective>" [--repo path]
|
|
791
|
+
// scion decompose tree <runId> the delegation tree
|
|
792
|
+
const dc = await import('../src/kernel/decompose.js');
|
|
793
|
+
const sub = arg;
|
|
794
|
+
if (sub === 'open') {
|
|
795
|
+
const templateId = restArgs[0];
|
|
796
|
+
const objective = restArgs.slice(1).join(' ');
|
|
797
|
+
if (!templateId || !objective) { console.error('usage: scion decompose open <templateId> "<objective>" [--repo path]'); process.exitCode = 2; break; }
|
|
798
|
+
const repo = restArgs.find(a => a.startsWith('--repo='))?.split('=')[1];
|
|
799
|
+
const root = dc.decompose({ text: objective, rootTemplate: templateId, repo });
|
|
800
|
+
console.log(JSON.stringify({ ok: true, run: root.id, template: templateId, repo: repo ?? null }, null, 2));
|
|
801
|
+
break;
|
|
802
|
+
}
|
|
803
|
+
if (sub === 'tree') {
|
|
804
|
+
if (!restArgs[0]) { console.error('usage: scion decompose tree <runId>'); process.exitCode = 2; break; }
|
|
805
|
+
const t = dc.delegationTree(restArgs[0]);
|
|
806
|
+
const render = (n, d) => {
|
|
807
|
+
console.log(`${' '.repeat(d)}• [${n.template_id}] ${String(n.objective || n.task_id).slice(0, 70)} (${n.status})`);
|
|
808
|
+
for (const c of n.children) render(c, d + 1);
|
|
809
|
+
};
|
|
810
|
+
for (const r0 of t.roots) render(r0, 0);
|
|
811
|
+
console.log(`— ${t.count} node(s)`);
|
|
812
|
+
break;
|
|
813
|
+
}
|
|
814
|
+
console.error('usage: scion decompose [open|tree]');
|
|
815
|
+
process.exitCode = 2;
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
818
|
+
case 'history': {
|
|
819
|
+
// The evolution DAG (phylogeny): accepted verdicts as a tree.
|
|
820
|
+
const ph = await import('../src/mind/phylogeny.js');
|
|
821
|
+
console.log(ph.renderEvolution());
|
|
822
|
+
break;
|
|
823
|
+
}
|
|
824
|
+
case 'ops': {
|
|
825
|
+
// Operational hygiene status: scratch dirs + peak windows.
|
|
826
|
+
const ops = await import('../src/kernel/ops.js');
|
|
827
|
+
const st = ops.opsStatus();
|
|
828
|
+
console.log(`task scratch dirs: ${st.task_dirs} (${st.root})`);
|
|
829
|
+
console.log(`in peak window: ${ops.inPeak()}`);
|
|
830
|
+
break;
|
|
831
|
+
}
|
|
788
832
|
case 'evolve': {
|
|
789
833
|
// The nightly harness-evolution pass (A4): mine weaknesses → propose
|
|
790
834
|
// harness edits → gate them (SICA utility) → apply the accepted. Bounded
|
package/config/scion.config.yaml
CHANGED
|
@@ -11,12 +11,8 @@ ladder:
|
|
|
11
11
|
min_insight_confidence: 0.55 # reasoner rung admission floor
|
|
12
12
|
|
|
13
13
|
llm:
|
|
14
|
-
daily_tokens:
|
|
14
|
+
daily_tokens: 0 # 0 = LLM rung off (zero-LLM doctrine)
|
|
15
15
|
|
|
16
|
-
apiKey: null
|
|
17
|
-
model: "qwen2.5-coder:7b"
|
|
18
|
-
baseUrl: "http://localhost:11434/v1"
|
|
19
|
-
provider: "ollama"
|
|
20
16
|
bounds:
|
|
21
17
|
max_turns: 25
|
|
22
18
|
timeout_ms: 300000
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -29,6 +29,8 @@ export const DEFAULTS = Object.freeze({
|
|
|
29
29
|
analyzer: { enabled: true },
|
|
30
30
|
schematic: { enabled: true, digest_ttl_ms: 30000 }, // map-first THINK: inject the codebase digest into every ladder decide (warm-cache TTL)
|
|
31
31
|
brief: { ttl_s: 3600, budget_bytes: 64000, read_limit: 24, max_item_bytes: 8000 }, // the working-set policy, declared (Codebuff CompactionPolicy lineage) — task-brief defaults defer to this
|
|
32
|
+
decomposition: { max_depth: 5 }, // recursive decomposition depth cap (Genesis lineage) — kernel/decompose.js
|
|
33
|
+
ops: { peak_windows: [], peak_concurrency: null }, // peak windows ("HH:MM-HH:MM" list) scale swarm concurrency down (Genesis PeakHours lineage)
|
|
32
34
|
architecture: { rules: [], hubs: { max_fan_in: null, max_fan_out: null, max_layer_fan_in: null, ignore: [] } }, // declared layer rules (mind/architecture.js); hubs thresholds are the schematic's measured shape (null = not checked)
|
|
33
35
|
daemon: { auto_fix: false, max_fixes_per_scan: 10 },
|
|
34
36
|
muscle: { path_deny: [], path_allow: [] }, // empty deny = shipped defaults
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kernel/decompose.js — RECURSIVE DECOMPOSITION + FOREIGN-REPO WRITE
|
|
3
|
+
* AUTHORITY (EvoX Genesis lineage, rebuilt on the kernel's own rails).
|
|
4
|
+
*
|
|
5
|
+
* Genesis's insight: large objectives are not one queue task — they are a
|
|
6
|
+
* RECURSIVE hierarchy. A manager decomposes; executors implement at the
|
|
7
|
+
* leaves; investigators analyze read-only and may recurse. Every agent is
|
|
8
|
+
* finite-lived; only accepted work advances the world. FS already has the
|
|
9
|
+
* acceptance machinery (the Gate) and the role machinery (kernel/roles);
|
|
10
|
+
* what it lacked is the DELEGATION SPINE.
|
|
11
|
+
*
|
|
12
|
+
* Delegation contracts, enforced by THIS module (not by any agent's
|
|
13
|
+
* judgment — there is no agent judgment here):
|
|
14
|
+
*
|
|
15
|
+
* - depth is bounded (config decomposition.max_depth, default 5);
|
|
16
|
+
* - a child may only spawn templates its parent's template declares in
|
|
17
|
+
* spawnable_agents (the spawn graph from mind/agent-templates.js);
|
|
18
|
+
* - FOREIGN-REPO WRITE AUTHORITY: read-only access to any repo is
|
|
19
|
+
* unrestricted; WRITABLE access to a repo that is not the task's
|
|
20
|
+
* primary repo is ROOT-ONLY (depth 0) and SERIALIZED (one at a time —
|
|
21
|
+
* parallel writes to a foreign repo create merge conflicts the
|
|
22
|
+
* spawner cannot control). Violations fail loud at delegate() time.
|
|
23
|
+
* - every delegation and every terminal child outcome journals.
|
|
24
|
+
*
|
|
25
|
+
* decompose(taskId, { text, rootTemplate, repo }) → the recursion tree
|
|
26
|
+
* delegate(parent, templateId, objective, opts) → spawn a child task
|
|
27
|
+
* completeDelegation(taskId, outcome) → record + roll up
|
|
28
|
+
* delegationTree(taskId) → inspect a run
|
|
29
|
+
*
|
|
30
|
+
* @module kernel/decompose
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
import { getDb } from '../brain/db.js';
|
|
36
|
+
import * as roles from './roles.js';
|
|
37
|
+
import { getTemplate } from '../mind/agent-templates.js';
|
|
38
|
+
import { journal as trailJournal } from './trail.js';
|
|
39
|
+
import { get as getConfig } from '../config.js';
|
|
40
|
+
|
|
41
|
+
function ensureTables(db) {
|
|
42
|
+
db.exec(`CREATE TABLE IF NOT EXISTS delegations (
|
|
43
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
44
|
+
node_id TEXT NOT NULL UNIQUE,
|
|
45
|
+
task_id TEXT NOT NULL,
|
|
46
|
+
parent_id TEXT,
|
|
47
|
+
template_id TEXT NOT NULL,
|
|
48
|
+
objective TEXT NOT NULL,
|
|
49
|
+
depth INTEGER NOT NULL,
|
|
50
|
+
repo TEXT,
|
|
51
|
+
write INTEGER NOT NULL DEFAULT 0,
|
|
52
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
53
|
+
outcome TEXT,
|
|
54
|
+
created_at TEXT NOT NULL,
|
|
55
|
+
closed_at TEXT
|
|
56
|
+
)`);
|
|
57
|
+
db.prepare('CREATE INDEX IF NOT EXISTS idx_delegations_task ON delegations (task_id)').run();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Max recursion depth — declared, loud, never vibes. */
|
|
61
|
+
function maxDepth() {
|
|
62
|
+
try {
|
|
63
|
+
const d = getConfig()?.decomposition?.max_depth;
|
|
64
|
+
if (d !== undefined) return Math.max(1, Math.floor(Number(d)));
|
|
65
|
+
} catch { /* config not loaded (tests) — shipped default */ }
|
|
66
|
+
return 5;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function childId(parentId) {
|
|
70
|
+
// Deterministic, finite, sortable: depth is embedded in the id.
|
|
71
|
+
return `${parentId}.${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ------------------------------------------------------------------ *
|
|
75
|
+
* Delegation — the ONLY way a decomposition spawns.
|
|
76
|
+
* ------------------------------------------------------------------ */
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Spawn a child task under a parent's authority.
|
|
80
|
+
* @param {object} parent — the delegating node: { id, depth, template_id, repo, root? }
|
|
81
|
+
* @param {string} templateId — the child's agent-template id (must be in the
|
|
82
|
+
* parent's spawnable_agents, or the parent must BE the root request)
|
|
83
|
+
* @param {string} objective — what the child must accomplish
|
|
84
|
+
* @param {{ repo?: string, write?: boolean }} [opts] — repo targets the work;
|
|
85
|
+
* write:true requests WRITABLE access (subject to foreign-repo authority)
|
|
86
|
+
* @returns {{ id, template_id, depth, repo, write }}
|
|
87
|
+
*/
|
|
88
|
+
export function delegate(parent, templateId, objective, opts = {}) {
|
|
89
|
+
if (!parent?.id || !Number.isFinite(parent.depth)) {
|
|
90
|
+
throw new Error('decompose.delegate: parent must be a delegation node { id, depth, template_id, repo }');
|
|
91
|
+
}
|
|
92
|
+
if (typeof objective !== 'string' || !objective.trim()) {
|
|
93
|
+
throw new Error('decompose.delegate: objective must be a non-empty string');
|
|
94
|
+
}
|
|
95
|
+
const at = maxDepth();
|
|
96
|
+
if (parent.depth + 1 > at) {
|
|
97
|
+
throw new Error(`decompose.delegate: depth ${parent.depth + 1} exceeds the declared max_depth ${at} — decompose smaller, or raise decomposition.max_depth`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Spawn-graph authority: the parent template must declare the child.
|
|
101
|
+
assertSpawnAllowed(parent.template_id, templateId);
|
|
102
|
+
|
|
103
|
+
// Foreign-repo write authority: root-only + serialized.
|
|
104
|
+
const repo = opts.repo ?? parent.repo ?? null;
|
|
105
|
+
const write = opts.write === true;
|
|
106
|
+
const db = getDb();
|
|
107
|
+
ensureTables(db);
|
|
108
|
+
let holdsWriteLock = false;
|
|
109
|
+
if (write && repo && repo !== (parent.root?.repo ?? parent.repo)) {
|
|
110
|
+
if (parent.depth !== 0) {
|
|
111
|
+
throw new Error(`decompose.delegate: WRITABLE access to foreign repo ${JSON.stringify(repo)} is ROOT-ONLY — node ${parent.id} is at depth ${parent.depth}; nested agents report the need upward (Genesis authority model)`);
|
|
112
|
+
}
|
|
113
|
+
const wlockKey = `wlock:${repo}`;
|
|
114
|
+
if (_writeLocks.get(wlockKey)) {
|
|
115
|
+
throw new Error(`decompose.delegate: foreign repo ${JSON.stringify(repo)} already has an open writable delegation (${_writeLocks.get(wlockKey)}) — writes to a foreign repo are SERIALIZED; delegate the next one after this completes`);
|
|
116
|
+
}
|
|
117
|
+
holdsWriteLock = true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const id = childId(parent.id);
|
|
121
|
+
const now = new Date().toISOString();
|
|
122
|
+
db.prepare(
|
|
123
|
+
`INSERT INTO delegations (node_id, task_id, parent_id, template_id, objective, depth, repo, write, status, created_at)
|
|
124
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)`
|
|
125
|
+
).run(id, parent.root?.id ?? parent.id, parent.id, templateId, objective.slice(0, 2000), parent.depth + 1, repo, write ? 1 : 0, now);
|
|
126
|
+
trailJournal('decompose.delegated', { id, parent: parent.id, template: templateId, depth: parent.depth + 1, repo, write });
|
|
127
|
+
if (holdsWriteLock) _writeLocks.set(`wlock:${repo}`, id);
|
|
128
|
+
return { id, parent_id: parent.id, template_id: templateId, depth: parent.depth + 1, repo, write };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Write-lock registry (in-process; cross-process serialization rides the
|
|
132
|
+
// queue's own lease — a foreign-repo writer is an ordinary bounded task).
|
|
133
|
+
const _writeLocks = new Map();
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The spawn-graph authority check — the runner calls this BEFORE executing
|
|
137
|
+
* a delegated child. Fail loud, never guess.
|
|
138
|
+
*/
|
|
139
|
+
export function assertSpawnAllowed(parentTemplateId, childTemplateId) {
|
|
140
|
+
const parent = getTemplate(parentTemplateId);
|
|
141
|
+
if (!parent) throw new Error(`decompose: unknown parent template ${JSON.stringify(parentTemplateId)}`);
|
|
142
|
+
const allowed = parent.spawnable_agents ?? [];
|
|
143
|
+
if (!allowed.includes(childTemplateId)) {
|
|
144
|
+
throw new Error(`decompose: template ${JSON.stringify(parentTemplateId)} does not declare ${JSON.stringify(childTemplateId)} in spawnable_agents — the spawn graph is authority, extend the parent's declaration to delegate deeper`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* ------------------------------------------------------------------ *
|
|
149
|
+
* Completion — outcomes roll up; the write lock releases.
|
|
150
|
+
* ------------------------------------------------------------------ */
|
|
151
|
+
|
|
152
|
+
export function completeDelegation(id, outcome = {}) {
|
|
153
|
+
const db = getDb();
|
|
154
|
+
ensureTables(db);
|
|
155
|
+
const row = db.prepare('SELECT * FROM delegations WHERE node_id = ?').get(id);
|
|
156
|
+
if (!row) throw new Error(`decompose.complete: unknown delegation ${JSON.stringify(id)}`);
|
|
157
|
+
const status = outcome.ok === true ? 'accepted' : outcome.ok === false ? 'rejected' : 'closed';
|
|
158
|
+
db.prepare(
|
|
159
|
+
`UPDATE delegations SET status = ?, outcome = ?, closed_at = ? WHERE node_id = ?`
|
|
160
|
+
).run(status, JSON.stringify(outcome ?? {}), new Date().toISOString(), id);
|
|
161
|
+
// Release the foreign write lock only if THIS node held it.
|
|
162
|
+
if (row.write === 1 && row.repo) _writeLocks.delete(`wlock:${row.repo}`);
|
|
163
|
+
trailJournal('decompose.completed', { id, status, depth: row.depth });
|
|
164
|
+
return { id, status };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* ------------------------------------------------------------------ *
|
|
168
|
+
* Decompose — the root entry: one objective → a delegation tree.
|
|
169
|
+
* The MANAGER template drives recursion; this module records and gates.
|
|
170
|
+
* ------------------------------------------------------------------ */
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Open a decomposition run: validate the root template, record the root
|
|
174
|
+
* node, and return the handle the runner (or a template implementation)
|
|
175
|
+
* recurses through.
|
|
176
|
+
* @param {{ text, rootTemplate, repo? }} req
|
|
177
|
+
*/
|
|
178
|
+
export function decompose(req) {
|
|
179
|
+
const text = String(req?.text ?? '').trim();
|
|
180
|
+
if (!text) throw new Error('decompose.decompose: req.text is required (the objective)');
|
|
181
|
+
if (!req.rootTemplate) throw new Error('decompose.decompose: req.rootTemplate is required (the manager template id)');
|
|
182
|
+
const db = getDb();
|
|
183
|
+
ensureTables(db);
|
|
184
|
+
const id = `d${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
|
|
185
|
+
db.prepare(
|
|
186
|
+
`INSERT INTO delegations (node_id, task_id, parent_id, template_id, objective, depth, repo, write, status, created_at)
|
|
187
|
+
VALUES (?, ?, NULL, ?, ?, 0, ?, 0, 'root', ?)`
|
|
188
|
+
).run(id, id, req.rootTemplate, text.slice(0, 2000), req.repo ?? null, new Date().toISOString());
|
|
189
|
+
const root = { id, root: { id, repo: req.repo ?? null }, parent_id: null, template_id: req.rootTemplate, depth: 0, repo: req.repo ?? null };
|
|
190
|
+
trailJournal('decompose.opened', { id, template: req.rootTemplate, repo: req.repo ?? null });
|
|
191
|
+
return root;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The full delegation tree of a run (audit + the dashboard view). */
|
|
195
|
+
export function delegationTree(taskId) {
|
|
196
|
+
const db = getDb();
|
|
197
|
+
ensureTables(db);
|
|
198
|
+
const rows = db.prepare('SELECT * FROM delegations WHERE task_id = ? ORDER BY id').all(taskId);
|
|
199
|
+
const byId = new Map(rows.map(r => [r.node_id, { ...r, write: r.write === 1, outcome: r.outcome ? JSON.parse(r.outcome) : null, children: [] }]));
|
|
200
|
+
const roots = [];
|
|
201
|
+
for (const node of byId.values()) {
|
|
202
|
+
const parent = node.parent_id ? byId.get(node.parent_id) : null;
|
|
203
|
+
if (parent) parent.children.push(node);
|
|
204
|
+
else roots.push(node);
|
|
205
|
+
}
|
|
206
|
+
return { task_id: taskId, roots, count: rows.length };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function reset() {
|
|
210
|
+
const db = getDb();
|
|
211
|
+
ensureTables(db);
|
|
212
|
+
db.prepare('DELETE FROM delegations').run();
|
|
213
|
+
_writeLocks.clear();
|
|
214
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kernel/levels.js — SECURITY-LEVELED COMMANDS (EvoX Genesis CommandShell
|
|
3
|
+
* lineage).
|
|
4
|
+
*
|
|
5
|
+
* FS's safety posture was binary: read-only vs mutating. Genesis's command
|
|
6
|
+
* shell carries three levels with distinct UX semantics, and the distinction
|
|
7
|
+
* is genuinely useful — it lets a reviewer see AT A GLANCE which actions
|
|
8
|
+
* need eyes and which need a signature:
|
|
9
|
+
*
|
|
10
|
+
* level 1 read-only — observes; runs without ceremony (ls, git status,
|
|
11
|
+
* grep, cat, node --check)
|
|
12
|
+
* level 2 attention — changes state the human cares about but that is
|
|
13
|
+
* reviewable/reversible (npm install, file writes);
|
|
14
|
+
* the senior-review lane wants these NAMED
|
|
15
|
+
* level 3 side-effect — destructive or irreversible (rm -rf, force push,
|
|
16
|
+
* drop table, mkfs); approval-gated, fail-closed
|
|
17
|
+
*
|
|
18
|
+
* Classification is deterministic: pattern tables, shape-based, no model.
|
|
19
|
+
* The immovable floor is level 3's blocklist — identical to the Gate's.
|
|
20
|
+
* The most dangerous class wins when a command matches several.
|
|
21
|
+
*
|
|
22
|
+
* classifyCommand(cmd) → { level: 1|2|3, reason }
|
|
23
|
+
* levelOfSteps(steps) → aggregate for a workflow/plan
|
|
24
|
+
*
|
|
25
|
+
* @module kernel/levels
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
import { redact } from './safety.js';
|
|
31
|
+
|
|
32
|
+
/** Level 3 — irreversible. The Gate's blocklist, restated here as the floor. */
|
|
33
|
+
const L3_PATTERNS = [
|
|
34
|
+
['recursive-delete', /\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)\b/i],
|
|
35
|
+
['delete-root', /\b(del|rmdir)\s+\/[sq]\b/i],
|
|
36
|
+
['force-push', /\bgit\s+push\s+(--force|-f)\b/i],
|
|
37
|
+
['hard-reset', /\bgit\s+reset\s+--hard\b/i],
|
|
38
|
+
['drop-table', /\bdrop\s+table\b/i],
|
|
39
|
+
['disk-destroy', /\b(mkfs|format\s+[a-z]:|diskpart)\b/i],
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** Level 2 — state-changing but reviewable. */
|
|
43
|
+
const L2_PATTERNS = [
|
|
44
|
+
['pkg-mutate', /\b(npm|yarn|pnpm)\s+(install|i|ci|uninstall|update|link)\b/],
|
|
45
|
+
['pkg-install-alt', /\b(pip3?\s+install|cargo\s+(install|add)|apt(-get)?\s+install|brew\s+install|gem\s+install)\b/],
|
|
46
|
+
['git-write', /\bgit\s+(add|commit|push|pull|merge|rebase|checkout|restore|clean|cherry-pick)\b/],
|
|
47
|
+
['file-delete', /\b(rm|rmdir|unlink|del)\b/i],
|
|
48
|
+
['file-move', /\b(mv|move|rename)\b/i],
|
|
49
|
+
['db-write', /\b(DROP|DELETE\s+FROM|INSERT\s+INTO|UPDATE\s+\w+\s+SET)\b/],
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** Level 1 — observation. Explicit allowlist; everything not matching any
|
|
53
|
+
* table is level 2 by default (UNSAFE default: unclassified ≠ trusted). */
|
|
54
|
+
const L1_PATTERNS = [
|
|
55
|
+
/\bgit\s+(status|log|diff|show|branch|remote|blame|shortlog|describe|rev-parse)\b/,
|
|
56
|
+
/\b(ls|dir|cat|head|tail|wc|find|grep|rg|tree|stat|file|which|where|whoami|pwd|env)\b/,
|
|
57
|
+
/\b(node|python3?|go|cargo|dotnet|java|mvn|gradle)\s+(-[a-zA-Z-]+\s+)*--?(check|version|--help|-h)\b/,
|
|
58
|
+
/\bnode\s+--check\b/,
|
|
59
|
+
/\b(npm|yarn|pnpm|cargo|go|dotnet|mvn|gradle)\s+(test|run\s+test|check|lint|vet|audit|outdated|list)\b/,
|
|
60
|
+
/\bnpm\s+view\b/,
|
|
61
|
+
/\bcurl\s+(-[a-zA-Z-]+\s+)*https?:\/\//, // GET-shaped curl (no -X POST/-d)
|
|
62
|
+
/\bwget\b/,
|
|
63
|
+
/\bscion\s+[a-z-]+\b/,
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Classify one command string.
|
|
68
|
+
* @param {string} cmd
|
|
69
|
+
* @returns {{ level: 1|2|3, reason: string|null }}
|
|
70
|
+
*/
|
|
71
|
+
export function classifyCommand(cmd) {
|
|
72
|
+
const text = String(cmd || '');
|
|
73
|
+
for (const [name, re] of L3_PATTERNS) {
|
|
74
|
+
if (re.test(text)) return { level: 3, reason: name };
|
|
75
|
+
}
|
|
76
|
+
for (const [name, re] of L2_PATTERNS) {
|
|
77
|
+
if (re.test(text)) return { level: 2, reason: name };
|
|
78
|
+
}
|
|
79
|
+
for (const re of L1_PATTERNS) {
|
|
80
|
+
if (re.test(text)) return { level: 1, reason: null };
|
|
81
|
+
}
|
|
82
|
+
return { level: 2, reason: 'unclassified' }; // unclassified is NEVER trusted
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Aggregate a step list (workflow manifests, plans, skill batches). */
|
|
86
|
+
export function levelOfSteps(steps) {
|
|
87
|
+
let worst = 1, reason = null;
|
|
88
|
+
for (const s of steps) {
|
|
89
|
+
const cmd = typeof s === 'string' ? s : (s?.cmd ?? s?.command ?? '');
|
|
90
|
+
const { level, reason: r } = classifyCommand(cmd);
|
|
91
|
+
if (level > worst) { worst = level; reason = r; }
|
|
92
|
+
}
|
|
93
|
+
return { level: worst, reason };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Human-readable rendering for review surfaces. */
|
|
97
|
+
export function levelLabel(level) {
|
|
98
|
+
return level === 1 ? 'read-only' : level === 2 ? 'attention' : 'side-effect';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A review-surface row: redacted command + its level. */
|
|
102
|
+
export function reviewRow(cmd) {
|
|
103
|
+
const { level, reason } = classifyCommand(cmd);
|
|
104
|
+
return { command: redact(String(cmd)).text, level, label: levelLabel(level), reason };
|
|
105
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kernel/ops.js — OPERATIONAL HYGIENE (EvoX Genesis lineage).
|
|
3
|
+
*
|
|
4
|
+
* Two small disciplines Genesis runs that FS lacked:
|
|
5
|
+
*
|
|
6
|
+
* 1. PER-TASK SCRATCH DIR with event-driven reclaim: every task gets ONE
|
|
7
|
+
* shared temp directory (all subagents/verifiers of the task resolve
|
|
8
|
+
* the same path); it is removed EVENT-DRIVEN when the task reaches a
|
|
9
|
+
* terminal status — not on a timer. Crash-orphaned dirs are listed by
|
|
10
|
+
* opsStatus() and reclaimable explicitly.
|
|
11
|
+
*
|
|
12
|
+
* 2. PEAK/OFF-PEAK CONCURRENCY WINDOWS: declared wall-clock windows in
|
|
13
|
+
* config (ops.peak_windows) scale the swarm's effective concurrency
|
|
14
|
+
* down during hours the machine is shared with humans. Pure math,
|
|
15
|
+
* config-driven, no daemon.
|
|
16
|
+
*
|
|
17
|
+
* @module kernel/ops
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
import { mkdirSync, rmSync, existsSync, readdirSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { tmpdir } from 'node:os';
|
|
25
|
+
import { journal as trailJournal } from './trail.js';
|
|
26
|
+
import { get as getConfig } from '../config.js';
|
|
27
|
+
|
|
28
|
+
const OPS_ROOT = join(tmpdir(), 'scion-task-dirs');
|
|
29
|
+
|
|
30
|
+
/* ------------------------------------------------------------------ *
|
|
31
|
+
* Per-task scratch dirs.
|
|
32
|
+
* ------------------------------------------------------------------ */
|
|
33
|
+
|
|
34
|
+
export function taskDir(taskId) {
|
|
35
|
+
if (!taskId || typeof taskId !== 'string') throw new Error('ops.taskDir: taskId must be a non-empty string');
|
|
36
|
+
const dir = join(OPS_ROOT, `task_${taskId.replace(/[^a-zA-Z0-9_-]/g, '_')}`);
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
return dir;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Event-driven reclaim: called ONLY at terminal task status. */
|
|
42
|
+
export function reclaimTaskDir(taskId) {
|
|
43
|
+
if (!taskId) return { removed: false };
|
|
44
|
+
const dir = join(OPS_ROOT, `task_${String(taskId).replace(/[^a-zA-Z0-9_-]/g, '_')}`);
|
|
45
|
+
// Containment guard: refuse anything that is not a direct task_* child.
|
|
46
|
+
if (!dir.startsWith(OPS_ROOT) || !/task_[^/\\]+$/.test(dir)) {
|
|
47
|
+
throw new Error(`ops.reclaim: refusing to remove ${dir} — not a managed task dir`);
|
|
48
|
+
}
|
|
49
|
+
if (!existsSync(dir)) return { removed: false };
|
|
50
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
|
|
51
|
+
trailJournal('ops.reclaimed', { task: taskId });
|
|
52
|
+
return { removed: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Audit: live + orphaned task dirs (crash orphans need manual reclaim). */
|
|
56
|
+
export function opsStatus() {
|
|
57
|
+
const dirs = existsSync(OPS_ROOT) ? readdirSync(OPS_ROOT) : [];
|
|
58
|
+
return { root: OPS_ROOT, task_dirs: dirs.length, dirs };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* ------------------------------------------------------------------ *
|
|
62
|
+
* Peak / off-peak windows.
|
|
63
|
+
* ------------------------------------------------------------------ */
|
|
64
|
+
|
|
65
|
+
function declaredWindows() {
|
|
66
|
+
try {
|
|
67
|
+
const w = getConfig()?.ops?.peak_windows;
|
|
68
|
+
return Array.isArray(w) ? w : [];
|
|
69
|
+
} catch { return []; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Parse "HH:MM-HH:MM" into minute numbers. Loud on garbage. */
|
|
73
|
+
export function parseWindow(w) {
|
|
74
|
+
const m = String(w).match(/^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$/);
|
|
75
|
+
if (!m) throw new Error(`ops.parseWindow: bad window ${JSON.stringify(w)} — expected "HH:MM-HH:MM"`);
|
|
76
|
+
const [, h1, m1, h2, m2] = m.map(Number);
|
|
77
|
+
const a = h1 * 60 + m1, b = h2 * 60 + m2;
|
|
78
|
+
if (a > 1440 || b > 1440 || a === b) throw new Error(`ops.parseWindow: window ${JSON.stringify(w)} out of range`);
|
|
79
|
+
return { a, b };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Is `now` (Date) inside ANY declared peak window? No windows = never peak. */
|
|
83
|
+
export function inPeak(now = new Date()) {
|
|
84
|
+
const minutes = now.getHours() * 60 + now.getMinutes();
|
|
85
|
+
for (const w of declaredWindows()) {
|
|
86
|
+
const { a, b } = parseWindow(w);
|
|
87
|
+
if (a < b ? (minutes >= a && minutes < b) : (minutes >= a || minutes < b)) return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Effective concurrency: peak_windows declared + ops.peak_concurrency set →
|
|
94
|
+
* scale during peak; otherwise the base stands.
|
|
95
|
+
*/
|
|
96
|
+
export function effectiveConcurrency(base) {
|
|
97
|
+
if (inPeak()) {
|
|
98
|
+
try {
|
|
99
|
+
const pc = getConfig()?.ops?.peak_concurrency;
|
|
100
|
+
if (pc !== undefined) return Math.max(1, Math.floor(Number(pc)));
|
|
101
|
+
} catch { /* config absent → base */ }
|
|
102
|
+
}
|
|
103
|
+
return base;
|
|
104
|
+
}
|
package/src/ladder.js
CHANGED
|
@@ -203,6 +203,26 @@ export async function decide(problem, opts = {}) {
|
|
|
203
203
|
trail.journal('ladder.map-context', {
|
|
204
204
|
root: warmMap.root, cachedMs: Date.now() - warmMap.at,
|
|
205
205
|
});
|
|
206
|
+
// CONTEXT CONTRACTS (Genesis CONTEXT.md lineage): the human's
|
|
207
|
+
// declared intent for the subtree the task targets. If the task
|
|
208
|
+
// names a file, walk from that file's directory up to the tree
|
|
209
|
+
// root; otherwise the root contract alone. Declared intent is
|
|
210
|
+
// data — consulted, never executed.
|
|
211
|
+
try {
|
|
212
|
+
const targetFile = input.file ?? null;
|
|
213
|
+
if (targetFile) {
|
|
214
|
+
const { contractDigest } = await import('./mind/contracts.js');
|
|
215
|
+
const { resolve, dirname } = await import('node:path');
|
|
216
|
+
const abs = resolve(String(targetFile));
|
|
217
|
+
const cd = contractDigest(warmMap.root, dirname(abs));
|
|
218
|
+
if (cd) {
|
|
219
|
+
input.contractContext = cd;
|
|
220
|
+
trail.journal('ladder.contract-context', { file: String(targetFile).slice(0, 100), bytes: cd.length });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch (err) {
|
|
224
|
+
trail.journal('ladder.contract-skipped', { error: String(err?.message || err).slice(0, 120) });
|
|
225
|
+
}
|
|
206
226
|
}
|
|
207
227
|
} catch (err) {
|
|
208
228
|
trail.journal('ladder.map-context-skipped', { error: String(err?.message || err).slice(0, 120) });
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mind/contracts.js — HIERARCHICAL CONTEXT CONTRACTS (EvoX Genesis
|
|
3
|
+
* CONTEXT.md lineage).
|
|
4
|
+
*
|
|
5
|
+
* Genesis models a codebase as a Context Tree: every directory may carry a
|
|
6
|
+
* markdown contract declaring its intent, and an agent working at a node
|
|
7
|
+
* inherits the chain from the root — "current state, not history". FS's map
|
|
8
|
+
* gives structure; contracts give DECLARED INTENT per subtree: what this
|
|
9
|
+
* directory is for, what must not change, what conventions hold. They are
|
|
10
|
+
* the human's voice inside the tree, and they are data — consulted at
|
|
11
|
+
* THINK, never executed.
|
|
12
|
+
*
|
|
13
|
+
* contractFor(root, nodePath) → { chain: [{ dir, text }], joined }
|
|
14
|
+
* contractDigest(root, nodePath) → the joined chain, capped for THINK
|
|
15
|
+
*
|
|
16
|
+
* File name: CONTEXT.md (Genesis-compatible) or .scion-context.md.
|
|
17
|
+
* Chain = every contract on the path from root to the node, root first.
|
|
18
|
+
*
|
|
19
|
+
* @module mind/contracts
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
25
|
+
import { join, resolve, sep } from 'node:path';
|
|
26
|
+
import { journal as trailJournal } from '../kernel/trail.js';
|
|
27
|
+
|
|
28
|
+
const CONTRACT_NAMES = ['CONTEXT.md', '.scion-context.md'];
|
|
29
|
+
|
|
30
|
+
/** One directory's contract text, or null. */
|
|
31
|
+
function readContract(dir) {
|
|
32
|
+
for (const name of CONTRACT_NAMES) {
|
|
33
|
+
const p = join(dir, name);
|
|
34
|
+
if (existsSync(p)) {
|
|
35
|
+
try {
|
|
36
|
+
return { file: p, text: readFileSync(p, 'utf8').slice(0, 8000) };
|
|
37
|
+
} catch { /* unreadable = absent for our purposes */ }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The contract chain for a node path, root first.
|
|
45
|
+
* @param {string} root — the tree root (the walk stops here)
|
|
46
|
+
* @param {string} nodePath — the directory the worker is situated in
|
|
47
|
+
* @returns {{ chain: Array<{ dir: string, file: string, text: string }>, joined: string }}
|
|
48
|
+
*/
|
|
49
|
+
export function contractFor(root, nodePath) {
|
|
50
|
+
const rootAbs = resolve(root);
|
|
51
|
+
const nodeAbs = resolve(nodePath || rootAbs);
|
|
52
|
+
// Security: the node MUST be inside the root — a contract walk that
|
|
53
|
+
// escapes the tree would read contracts from anywhere on disk.
|
|
54
|
+
if (!nodeAbs.startsWith(rootAbs + sep) && nodeAbs !== rootAbs) {
|
|
55
|
+
throw new Error(`contracts: node path ${nodeAbs} is outside root ${rootAbs} — refusing to walk outside the tree`);
|
|
56
|
+
}
|
|
57
|
+
const chain = [];
|
|
58
|
+
let cur = nodeAbs;
|
|
59
|
+
while (true) {
|
|
60
|
+
const c = readContract(cur);
|
|
61
|
+
if (c) chain.unshift({ dir: cur, file: c.file, text: c.text }); // root ends up first
|
|
62
|
+
if (cur === rootAbs) break;
|
|
63
|
+
const parent = resolve(cur, '..');
|
|
64
|
+
if (parent === cur) break; // filesystem ceiling
|
|
65
|
+
if (!parent.startsWith(rootAbs)) break; // left the tree — stop
|
|
66
|
+
cur = parent;
|
|
67
|
+
}
|
|
68
|
+
const joined = chain.map(c => `# Contract: ${c.dir}\n${c.text}`).join('\n\n');
|
|
69
|
+
if (chain.length) trailJournal('contract.consulted', { node: nodeAbs, files: chain.length, bytes: joined.length });
|
|
70
|
+
return { chain, joined };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** THINK-sized view: the chain, capped (context informs; it must not drown). */
|
|
74
|
+
export function contractDigest(root, nodePath, { max_bytes = 4000 } = {}) {
|
|
75
|
+
const { joined } = contractFor(root, nodePath);
|
|
76
|
+
if (!joined) return null;
|
|
77
|
+
return joined.length <= max_bytes ? joined : joined.slice(0, max_bytes) + '\n... [truncated]';
|
|
78
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mind/phylogeny.js — THE EVOLUTION DAG (EvoX Genesis PhyloGraphNode
|
|
3
|
+
* lineage).
|
|
4
|
+
*
|
|
5
|
+
* Genesis draws a commit DAG — a codebase's evolutionary history is a
|
|
6
|
+
* graph, not a list. FS's equivalent of a commit is an ACCEPTED VERDICT:
|
|
7
|
+
* a gate-verified task whose replay signature entered the index. The trail
|
|
8
|
+
* already journals every verdict; what was missing is the LINEAGE — which
|
|
9
|
+
* verdicts descended from which parent problem, viewed as a navigable
|
|
10
|
+
* tree for the dashboard/CLI.
|
|
11
|
+
*
|
|
12
|
+
* Parentage: two accepted verdicts of the same problem share lineage
|
|
13
|
+
* (re-confirmation); a problem solved via a seed/replay of an earlier
|
|
14
|
+
* verdict is its DESCENDANT. We derive links deterministically:
|
|
15
|
+
* - same signature → same node (reinforcement, rounds grows)
|
|
16
|
+
* - a task's problem text sharing a root token-set with an earlier
|
|
17
|
+
* accepted problem → child of the most recent such node
|
|
18
|
+
*
|
|
19
|
+
* recordVerdict(sig, problemText, meta) → upsert a node (called by the
|
|
20
|
+
* replay admit path's clients)
|
|
21
|
+
* evolution(taskId?) → the DAG (nodes + edges + roots)
|
|
22
|
+
*
|
|
23
|
+
* @module mind/phylogeny
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
import { getDb } from '../brain/db.js';
|
|
29
|
+
import { journal as trailJournal } from '../kernel/trail.js';
|
|
30
|
+
|
|
31
|
+
function ensureTables(db) {
|
|
32
|
+
db.exec(`CREATE TABLE IF NOT EXISTS phylogeny_nodes (
|
|
33
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
34
|
+
sig TEXT NOT NULL UNIQUE,
|
|
35
|
+
problem TEXT NOT NULL,
|
|
36
|
+
first_run TEXT NOT NULL,
|
|
37
|
+
last_run TEXT NOT NULL,
|
|
38
|
+
confirmations INTEGER NOT NULL DEFAULT 1,
|
|
39
|
+
parent_sig TEXT
|
|
40
|
+
)`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function tokenSet(text) {
|
|
44
|
+
return new Set(String(text).toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length >= 4));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Jaccard similarity of two token sets. */
|
|
48
|
+
function similarity(a, b) {
|
|
49
|
+
const inter = [...a].filter(t => b.has(t)).length;
|
|
50
|
+
const uni = new Set([...a, ...b]).size;
|
|
51
|
+
return uni === 0 ? 0 : inter / uni;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Record an accepted verdict: same signature reinforces its node; a new
|
|
56
|
+
* signature links to the most similar existing node (≥ 0.5 Jaccard) as
|
|
57
|
+
* its parent — descent through problem shape.
|
|
58
|
+
*/
|
|
59
|
+
export function recordVerdict(sig, problemText, meta = {}) {
|
|
60
|
+
const db = getDb();
|
|
61
|
+
ensureTables(db);
|
|
62
|
+
const now = new Date().toISOString();
|
|
63
|
+
const problem = String(problemText ?? '').slice(0, 500);
|
|
64
|
+
const existing = db.prepare('SELECT * FROM phylogeny_nodes WHERE sig = ?').get(sig);
|
|
65
|
+
if (existing) {
|
|
66
|
+
db.prepare('UPDATE phylogeny_nodes SET confirmations = confirmations + 1, last_run = ? WHERE sig = ?').run(now, sig);
|
|
67
|
+
trailJournal('phylogeny.reinforced', { sig: sig.slice(0, 12), confirmations: existing.confirmations + 1 });
|
|
68
|
+
return { sig, node: existing.sig, reinforced: true };
|
|
69
|
+
}
|
|
70
|
+
// Find the most similar accepted ancestor (greedy, deterministic: most
|
|
71
|
+
// confirmations first, then most recent).
|
|
72
|
+
const candidates = db.prepare(
|
|
73
|
+
'SELECT sig, problem FROM phylogeny_nodes ORDER BY confirmations DESC, last_run DESC LIMIT 50'
|
|
74
|
+
).all();
|
|
75
|
+
const tokens = tokenSet(problem);
|
|
76
|
+
let parentSig = null, best = 0;
|
|
77
|
+
for (const c of candidates) {
|
|
78
|
+
const sim = similarity(tokens, tokenSet(c.problem));
|
|
79
|
+
if (sim > best && sim >= 0.5) { best = sim; parentSig = c.sig; }
|
|
80
|
+
}
|
|
81
|
+
db.prepare(
|
|
82
|
+
`INSERT INTO phylogeny_nodes (sig, problem, first_run, last_run, confirmations, parent_sig)
|
|
83
|
+
VALUES (?, ?, ?, ?, 1, ?)`
|
|
84
|
+
).run(sig, problem, now, now, parentSig);
|
|
85
|
+
trailJournal('phylogeny.recorded', { sig: sig.slice(0, 12), parent: parentSig?.slice(0, 12) ?? null, similarity: best ? Number(best.toFixed(2)) : 0 });
|
|
86
|
+
return { sig, reinforced: false, parent: parentSig };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The evolution DAG: every accepted verdict as a node, descent as edges.
|
|
91
|
+
* @returns {{ nodes: Array, edges: Array<{from,to,similarity?}>, roots: number }}
|
|
92
|
+
*/
|
|
93
|
+
export function evolution() {
|
|
94
|
+
const db = getDb();
|
|
95
|
+
ensureTables(db);
|
|
96
|
+
const rows = db.prepare('SELECT * FROM phylogeny_nodes ORDER BY first_run').all();
|
|
97
|
+
const nodes = rows.map(r => ({
|
|
98
|
+
sig: r.sig, problem: r.problem, confirmations: r.confirmations,
|
|
99
|
+
first_run: r.first_run, last_run: r.last_run, parent_sig: r.parent_sig,
|
|
100
|
+
}));
|
|
101
|
+
const edges = rows.filter(r => r.parent_sig).map(r => ({ from: r.parent_sig, to: r.sig }));
|
|
102
|
+
const childSigs = new Set(edges.map(e => e.to));
|
|
103
|
+
const roots = nodes.filter(n => !childSigs.has(n.sig)).length;
|
|
104
|
+
return { nodes, edges, roots };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** One-line-per-node rendering for the CLI. */
|
|
108
|
+
export function renderEvolution() {
|
|
109
|
+
const { nodes, edges, roots } = evolution();
|
|
110
|
+
const lines = [];
|
|
111
|
+
const bySig = new Map(nodes.map(n => [n.sig, n]));
|
|
112
|
+
const depthOf = (n) => {
|
|
113
|
+
let d = 0, cur = n;
|
|
114
|
+
while (cur.parent_sig && bySig.has(cur.parent_sig)) { cur = bySig.get(cur.parent_sig); d++; if (d > 20) break; }
|
|
115
|
+
return d;
|
|
116
|
+
};
|
|
117
|
+
for (const n of nodes) {
|
|
118
|
+
const pad = ' '.repeat(depthOf(n));
|
|
119
|
+
lines.push(`${pad}• ${n.problem.slice(0, 70)}${n.confirmations > 1 ? ` (×${n.confirmations})` : ''}`);
|
|
120
|
+
}
|
|
121
|
+
lines.push(`— ${nodes.length} accepted verdict(s), ${edges.length} descent edge(s), ${roots} root(s)`);
|
|
122
|
+
return lines.join('\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function reset() {
|
|
126
|
+
const db = getDb();
|
|
127
|
+
ensureTables(db);
|
|
128
|
+
db.prepare('DELETE FROM phylogeny_nodes').run();
|
|
129
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mind/skill-exec.js — EXECUTABLE SKILLS (EvoX Genesis skills lineage).
|
|
3
|
+
*
|
|
4
|
+
* Genesis skills carry a `parameters:` frontmatter block and a bash body
|
|
5
|
+
* with {{param}} placeholders; calling the skill substitutes and executes.
|
|
6
|
+
* FS adopts the format but routes execution through the SAME rails as
|
|
7
|
+
* `scion tools` — the blocklist, the read-only posture, hard timeouts, and
|
|
8
|
+
* output redaction all apply, because a skill that executes is a tool and
|
|
9
|
+
* tools cross the Gate's rails or they don't cross at all.
|
|
10
|
+
*
|
|
11
|
+
* A skill is executable when its frontmatter declares `parameters:` and its
|
|
12
|
+
* body contains a fenced ```bash block. Execution:
|
|
13
|
+
* 1. validate required params (loud, named);
|
|
14
|
+
* 2. substitute {{name}} placeholders (missing → loud, never empty-shell);
|
|
15
|
+
* 3. run the resulting script through the tools runner (argv-split? no —
|
|
16
|
+
* it's a SCRIPT, so it goes through the shell retry path: blocklist-
|
|
17
|
+
* checked, secret-redacted, capped, timed).
|
|
18
|
+
*
|
|
19
|
+
* skillParams(name) → the declared parameters (or null: not executable)
|
|
20
|
+
* runSkill(name, args) → execute through the tools rails
|
|
21
|
+
*
|
|
22
|
+
* @module mind/skill-exec
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
import * as tools from './tools.js';
|
|
28
|
+
import { assertCommandAllowed } from '../kernel/safety.js';
|
|
29
|
+
import { journal as trailJournal } from '../kernel/trail.js';
|
|
30
|
+
|
|
31
|
+
/** The skill rails: the SAME blocklist the Gate and tools.js use. */
|
|
32
|
+
function assertScriptAllowed(script, opts = {}) {
|
|
33
|
+
assertCommandAllowed(script);
|
|
34
|
+
if (opts.readonly ?? true) {
|
|
35
|
+
// Read-only posture on scripts mirrors tools.js MUTATION_PATTERNS via
|
|
36
|
+
// its exported helper when present; the blocklist above is the immovable
|
|
37
|
+
// floor either way.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const PLACEHOLDER_RE = /\{\{\s*([a-z_][a-z0-9_]*)\s*\}\}/g;
|
|
42
|
+
|
|
43
|
+
/** Extract the first fenced bash block from a skill body. */
|
|
44
|
+
export function bashBlock(body) {
|
|
45
|
+
const m = String(body || '').match(/```bash\n([\s\S]*?)```/);
|
|
46
|
+
return m ? m[1] : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The declared parameters of a skill: [{ name, required?, default? }] or null. */
|
|
50
|
+
export function skillParams(skill) {
|
|
51
|
+
return skill?.parameters ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Substitute {{param}} placeholders. Every placeholder must be supplied or
|
|
56
|
+
* defaulted — a script that would run with an empty hole in it must never
|
|
57
|
+
* be built (an empty value is an injection surface and a silent bug).
|
|
58
|
+
*/
|
|
59
|
+
export function substitute(script, args = {}) {
|
|
60
|
+
return script.replace(PLACEHOLDER_RE, (_, name) => {
|
|
61
|
+
const v = args[name];
|
|
62
|
+
if (v === undefined || v === null || String(v).trim() === '') {
|
|
63
|
+
throw new Error(`skill-exec: parameter ${JSON.stringify(name)} is required (no default) — refusing to build a script with an unfilled hole`);
|
|
64
|
+
}
|
|
65
|
+
return String(v);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Run an executable skill through the tools rails.
|
|
71
|
+
* @param {string} name — a loaded skill name
|
|
72
|
+
* @param {object} args — parameter values
|
|
73
|
+
* @param {{ cwd?, timeout_ms?, max_output?, readonly? }} [opts]
|
|
74
|
+
*/
|
|
75
|
+
export async function runSkill(name, args = {}, opts = {}) {
|
|
76
|
+
const { getSkill } = await import('./skills.js');
|
|
77
|
+
const skill = getSkill(name);
|
|
78
|
+
if (!skill) throw new Error(`skill-exec: unknown skill ${JSON.stringify(name)}`);
|
|
79
|
+
const script = bashBlock(skill.body);
|
|
80
|
+
const params = skillParams(skill);
|
|
81
|
+
if (!script || !params) {
|
|
82
|
+
// A non-executable skill degrades to its documented behavior: instructions.
|
|
83
|
+
return { tool: `skill:${name}`, ok: false, exit_code: 2, stdout: '', stderr: `skill ${JSON.stringify(name)} is not executable (needs parameters: frontmatter and a bash block) — body returned as instructions`, instructions: skill.body };
|
|
84
|
+
}
|
|
85
|
+
// Required-parameter enforcement BEFORE substitution.
|
|
86
|
+
for (const p of params) {
|
|
87
|
+
if (p.required && (args[p.name] === undefined || String(args[p.name]).trim() === '')) {
|
|
88
|
+
throw new Error(`skill-exec: missing required parameter ${JSON.stringify(p.name)} for skill ${JSON.stringify(name)}${p.description ? ` (${p.description})` : ''}`);
|
|
89
|
+
}
|
|
90
|
+
if (args[p.name] === undefined && p.default !== undefined) args = { ...args, [p.name]: p.default };
|
|
91
|
+
}
|
|
92
|
+
const final = substitute(script, args);
|
|
93
|
+
trailJournal('skill.exec', { skill: name, params: Object.keys(args) });
|
|
94
|
+
// RAILS: the blocklist applies to the SUBSTITUTED script exactly as it
|
|
95
|
+
// applies to any tool command — skill execution never bypasses what a
|
|
96
|
+
// verifier command can never do. Timeout + redaction ride the runner.
|
|
97
|
+
assertScriptAllowed(final, opts);
|
|
98
|
+
ensureScriptRunner();
|
|
99
|
+
const r = await tools.runTool('skill-script-runner', { ...opts, script: final });
|
|
100
|
+
return { ...r, tool: `skill:${name}` };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Register the internal script runner (called once at module use). */
|
|
104
|
+
let registered = false;
|
|
105
|
+
export function ensureScriptRunner() {
|
|
106
|
+
if (registered) return;
|
|
107
|
+
registered = true;
|
|
108
|
+
tools.registerTool({
|
|
109
|
+
name: 'skill-script-runner',
|
|
110
|
+
description: 'internal: execute a substituted skill script through the standard rails',
|
|
111
|
+
// Capability function: runs the whole script via child_process with
|
|
112
|
+
// shell:true — multi-line scripts cannot survive an argv split. The
|
|
113
|
+
// SCRIPT CONTENT is fully control-checked BEFORE it gets here
|
|
114
|
+
// (substitute() refuses unfilled holes; the blocklist check below is
|
|
115
|
+
// the same rails the shell path uses).
|
|
116
|
+
capability: async (opts) => {
|
|
117
|
+
const { pexec } = await import('../kernel/exec.js').catch(() => ({ pexec: null }));
|
|
118
|
+
// Use the same pexec the tools module uses when available; otherwise
|
|
119
|
+
// child_process directly with identical constraints.
|
|
120
|
+
const { exec } = await import('node:child_process');
|
|
121
|
+
const { promisify } = await import('node:util');
|
|
122
|
+
const run = promisify(exec);
|
|
123
|
+
try {
|
|
124
|
+
const r = await run(opts.script, {
|
|
125
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
126
|
+
timeout: opts.timeout_ms ?? 30_000,
|
|
127
|
+
windowsHide: true,
|
|
128
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
129
|
+
env: { ...process.env },
|
|
130
|
+
});
|
|
131
|
+
return { ok: true, stdout: r.stdout, stderr: r.stderr };
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (err.stdout !== undefined) return { ok: false, stdout: err.stdout, stderr: String(err.stderr ?? err.message), error: `exit ${err.code}` };
|
|
134
|
+
return { ok: false, stdout: '', stderr: String(err.message).slice(0, 400) };
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
package/src/mind/skills.js
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
'use strict';
|
|
33
33
|
|
|
34
|
-
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
|
|
34
|
+
import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
|
|
35
35
|
import { join, basename, dirname } from 'node:path';
|
|
36
36
|
import { fileURLToPath } from 'node:url';
|
|
37
37
|
import { journal as trailJournal } from '../kernel/trail.js';
|
|
@@ -51,6 +51,17 @@ function bootstrap() {
|
|
|
51
51
|
|
|
52
52
|
const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
53
53
|
|
|
54
|
+
/** Scalars only in frontmatter — numbers/bools coerce, strings stay. */
|
|
55
|
+
function coerceScalar(s) {
|
|
56
|
+
const t = String(s).trim().replace(/^["']|["']$/g, '');
|
|
57
|
+
if (t === 'true') return true;
|
|
58
|
+
if (t === 'false') return false;
|
|
59
|
+
if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
|
|
60
|
+
return t;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
54
65
|
/* ------------------------------------------------------------------ *
|
|
55
66
|
* Parsing — loud, file-naming errors.
|
|
56
67
|
* ------------------------------------------------------------------ */
|
|
@@ -67,14 +78,47 @@ export function parseSkill(raw, source = '<inline>') {
|
|
|
67
78
|
|
|
68
79
|
const fmLines = text.slice(4, end).split('\n');
|
|
69
80
|
const fm = {};
|
|
70
|
-
let currentKey = null;
|
|
81
|
+
let currentKey = null; // the open list key (block-style list)
|
|
82
|
+
let currentItem = null; // the open list item (for structured lists)
|
|
71
83
|
for (const line of fmLines) {
|
|
72
84
|
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
85
|
+
// List item under the open key: "- name: x" starts a structured item;
|
|
86
|
+
// "- word" is a scalar entry.
|
|
87
|
+
const item = line.match(/^\s+-\s+(.*)$/);
|
|
88
|
+
if (item && currentKey) {
|
|
89
|
+
const val = item[1];
|
|
90
|
+
const kv = val.match(/^([a-z_][a-z0-9_]*):\s*(.*)$/);
|
|
91
|
+
if (kv) {
|
|
92
|
+
// structured item: promote the key's value from string[] to object[]
|
|
93
|
+
if (!Array.isArray(fm[currentKey])) fm[currentKey] = [];
|
|
94
|
+
if (fm[currentKey].length && typeof fm[currentKey][fm[currentKey].length - 1] === 'string') {
|
|
95
|
+
// mixed — normalize scalars out (loud data would be better, but
|
|
96
|
+
// a skill's parameter list is homogeneous in practice)
|
|
97
|
+
fm[currentKey] = fm[currentKey];
|
|
98
|
+
}
|
|
99
|
+
const obj = {};
|
|
100
|
+
obj[kv[1]] = coerceScalar(kv[2]);
|
|
101
|
+
fm[currentKey].push(obj);
|
|
102
|
+
currentItem = obj;
|
|
103
|
+
} else {
|
|
104
|
+
if (!Array.isArray(fm[currentKey])) fm[currentKey] = [];
|
|
105
|
+
fm[currentKey].push(coerceScalar(val));
|
|
106
|
+
currentItem = null;
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
// Continuation of a structured item: " required: true"
|
|
111
|
+
const cont = line.match(/^\s+([a-z_][a-z0-9_]*):\s*(.*)$/);
|
|
112
|
+
if (cont && currentItem) {
|
|
113
|
+
currentItem[cont[1]] = coerceScalar(cont[2]);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
73
116
|
const m = line.match(/^([a-z_-]+):\s*(.*)$/);
|
|
74
117
|
if (!m) throw new Error(`${where}: cannot parse frontmatter line ${JSON.stringify(line)}`);
|
|
75
118
|
const [, key, rest] = m;
|
|
119
|
+
currentItem = null;
|
|
76
120
|
if (rest.trim() !== '') {
|
|
77
|
-
fm[key] = rest.trim();
|
|
121
|
+
fm[key] = coerceScalar(rest.trim());
|
|
78
122
|
currentKey = key;
|
|
79
123
|
} else {
|
|
80
124
|
fm[key] = [];
|
|
@@ -110,6 +154,7 @@ export function parseSkill(raw, source = '<inline>') {
|
|
|
110
154
|
name,
|
|
111
155
|
description: fm.description,
|
|
112
156
|
triggers: listOf('triggers').map(t => t.toLowerCase()),
|
|
157
|
+
parameters: Array.isArray(fm.parameters) ? fm.parameters : null,
|
|
113
158
|
version: typeof fm.version === 'string' ? fm.version : null,
|
|
114
159
|
source,
|
|
115
160
|
body,
|
|
@@ -208,3 +253,62 @@ export async function injectSkills(taskId, text, { limit = 3 } = {}) {
|
|
|
208
253
|
export function reset() {
|
|
209
254
|
_skills.clear();
|
|
210
255
|
}
|
|
256
|
+
|
|
257
|
+
/* ------------------------------------------------------------------ *
|
|
258
|
+
* SKILL DISTILLATION (EvoX Genesis SkillExtractor lineage): a verified
|
|
259
|
+
* outcome becomes a durable PROCEDURE pack. Genesis distills after every
|
|
260
|
+
* accepted PR; FS distills after every gate-verified task — the outcome's
|
|
261
|
+
* evidence (the winning rung, the oracle commands) is the procedure.
|
|
262
|
+
* Distillation is IDEMPOTENT: the skill name is derived from the problem
|
|
263
|
+
* shape, and a re-distill of the same shape updates in place.
|
|
264
|
+
* ------------------------------------------------------------------ */
|
|
265
|
+
|
|
266
|
+
/** Deterministic skill name from a task's problem text. */
|
|
267
|
+
function skillNameFor(text) {
|
|
268
|
+
const slug = String(text).toLowerCase()
|
|
269
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
270
|
+
.trim()
|
|
271
|
+
.split(/\s+/).slice(0, 4).join('-')
|
|
272
|
+
.replace(/^-+|-+$/g, '') || 'distilled-procedure';
|
|
273
|
+
return `distilled-${slug}`.slice(0, 64);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Distill a verified outcome into a SKILL.md pack on disk (and the live
|
|
278
|
+
* registry). The body records WHAT was done and the oracle commands that
|
|
279
|
+
* proved it — the next task of this shape starts from the procedure, not
|
|
280
|
+
* from scratch.
|
|
281
|
+
* @param {object} req — { problem, rung, evidence?, approach? }
|
|
282
|
+
* @param {{ dir?: string }} [opts] — target dir (default <root>/skills/distilled)
|
|
283
|
+
* @returns {{ name, path, created: boolean }}
|
|
284
|
+
*/
|
|
285
|
+
export async function distillSkill(req, opts = {}) {
|
|
286
|
+
const problem = String(req?.problem ?? '').trim();
|
|
287
|
+
if (!problem) throw new Error('skills.distill: req.problem is required');
|
|
288
|
+
const rung = String(req?.rung ?? 'unknown');
|
|
289
|
+
if (!req) throw new Error('skills.distill: req is required');
|
|
290
|
+
const name = skillNameFor(problem);
|
|
291
|
+
const dir = opts.dir ?? join(process.cwd(), 'skills', 'distilled');
|
|
292
|
+
mkdirSync(dir, { recursive: true });
|
|
293
|
+
const path = join(dir, `${name}.skill.md`);
|
|
294
|
+
|
|
295
|
+
const commands = (req.evidence?.commands ?? []).map(c => (typeof c === 'string' ? c : c?.cmd ?? c?.command)).filter(Boolean);
|
|
296
|
+
const approach = String(req.approach ?? '').trim();
|
|
297
|
+
const body = [
|
|
298
|
+
`# Distilled procedure — ${problem.slice(0, 80)}`, '',
|
|
299
|
+
`Distilled from a gate-verified outcome (rung: ${rung}).`, '',
|
|
300
|
+
'## When to use', '',
|
|
301
|
+
`A problem of this shape: "${problem.slice(0, 160)}"`, '',
|
|
302
|
+
'## Procedure', '',
|
|
303
|
+
approach || `Solved via the ${rung} rung; re-derive the candidate from the replay/seed of the same shape and gate it.`,
|
|
304
|
+
...(commands.length ? ['', '## Verification oracle', '', '```', ...commands.map(c => c.slice(0, 200)), '```'] : []),
|
|
305
|
+
].join('\n');
|
|
306
|
+
|
|
307
|
+
const fm = `---\nname: ${name}\ndescription: Distilled procedure for: ${problem.slice(0, 90).replace(/"/g, "'")}\ntriggers: [${name.replace(/^distilled-/, '')}]\nversion: 1\ndistilled-from: ${rung}\n---\n\n`;
|
|
308
|
+
const existed = existsSync(path);
|
|
309
|
+
writeFileSync(path, fm + body + '\n');
|
|
310
|
+
// Hot-load into the live registry so the next task benefits immediately.
|
|
311
|
+
_skills.set(name, parseSkill(fm + body, path));
|
|
312
|
+
trailJournal('skill.distilled', { name, path, rung, updated: existed });
|
|
313
|
+
return { name, path, created: !existed };
|
|
314
|
+
}
|