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 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
@@ -11,12 +11,8 @@ ladder:
11
11
  min_insight_confidence: 0.55 # reasoner rung admission floor
12
12
 
13
13
  llm:
14
- daily_tokens: 500
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "futura-scion",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "The fused scion of cortex-os-agent + persona: one zero-LLM-dependent agent stack — Mind proposes, Muscle executes, Gate disposes.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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
+ }
@@ -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
+ }