futura-scion 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -903,6 +903,66 @@ 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
+ - **Swarm auto-decomposition** (`src/kernel/auto-decompose.js`): a task
950
+ enqueued with `kind: 'decompose', payload: { large: true }` opens a
951
+ delegation run under a manager template and enqueues its declared
952
+ `subtasks` (plain objectives or `{ text, file }` targets) as child tasks —
953
+ chained sequentially by default, independent on request. Every child's
954
+ gate verdict ROLLS UP to its delegation node: accepted children advance
955
+ the tree; gate-failed or escalated children reject it. Fire-and-forget
956
+ parents complete on enqueue; awaiting parents escalate with the run id
957
+ so the reviewer reads the tree (`scion decompose tree <run>`). Verified
958
+ end-to-end: two file-targeted children patched two files through real
959
+ `node --check` oracles and rolled up `accepted, accepted`.
960
+
961
+ - **Operational hygiene** (`src/kernel/ops.js`, `scion ops`): per-task
962
+ scratch dirs reclaimed EVENT-DRIVEN at terminal status (containment-
963
+ guarded), and peak/off-peak concurrency windows (`ops.peak_windows`)
964
+ scale the swarm down during shared-machine hours.
965
+
906
966
  ### FS Desktop — the standalone UI (chat / agent / plan / architect)
907
967
 
908
968
  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.3.0",
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,172 @@
1
+ /**
2
+ * kernel/auto-decompose.js — SWARM AUTO-DECOMPOSITION (EvoX Genesis
3
+ * lineage, wired into runSwarm).
4
+ *
5
+ * The gap: decompose.js provides the delegation spine, but nothing AUTOMATIC
6
+ * routed large objectives into it. Here the swarm itself gains the Genesis
7
+ * behavior: a task flagged `large: true` (explicit, never inferred — the
8
+ * doctrine demands declared knobs) opens a delegation run under a MANAGER
9
+ * template; the decomposition plan (declared `subtasks` in the payload —
10
+ * the plan is data) enqueues CHILD TASKS on the same queue; every child's
11
+ * gate verdict ROLLS UP to its delegation node; the parent task completes
12
+ * only when all children are accepted.
13
+ *
14
+ * Rollup is the acceptance machinery: a child that gate-fails or escalates
15
+ * marks its node rejected, and the parent — visible to the reviewer through
16
+ * the delegation tree — carries the failure upward instead of completing.
17
+ * Transient agents, persistent world, only accepted results advance.
18
+ *
19
+ * maybeAutoDecompose(task, opts) → { run, parent, children } | null
20
+ * rollupChild(nodeId, childResult) → { run, allAccepted, accepted, total }
21
+ * runDecompositionTask(task, opts) → the swarm's handler for kind 'decompose'
22
+ *
23
+ * @module kernel/auto-decompose
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ import * as queue from './queue.js';
29
+ import * as decompose from './decompose.js';
30
+ import { getDb } from '../brain/db.js';
31
+ import { journal as trailJournal } from './trail.js';
32
+ import { get as getConfig } from '../config.js';
33
+
34
+ /** The manager template used when a task doesn't name one (shipped default). */
35
+ export function managerTemplateFor(task) {
36
+ return task?.payload?.manager_template
37
+ ?? (() => { try { return getConfig()?.decomposition?.manager_template; } catch { return undefined; } })()
38
+ ?? 'constructor';
39
+ }
40
+
41
+ function rowOf(nodeId) {
42
+ return getDb().prepare('SELECT * FROM delegations WHERE node_id = ?').get(nodeId);
43
+ }
44
+
45
+ function flatten(tree) {
46
+ const out = [];
47
+ const walk = (n) => { out.push(n); for (const c of n.children) walk(c); };
48
+ for (const r of tree.roots) walk(r);
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * Open a delegation run for a large task and enqueue its children.
54
+ * The decomposition plan comes from the task payload: `subtasks: [objective,
55
+ * ...]` (declared by the caller/enqueuer — the plan is data). Children
56
+ * enqueue onto the SAME queue (the swarm's existing workers drain them)
57
+ * with `delegation_node` stamped into their payloads for rollup.
58
+ *
59
+ * @returns {{ run, parent, children: Array<{ node, task }>} | null}
60
+ * null when the task is not flagged large.
61
+ */
62
+ export function maybeAutoDecompose(task, opts = {}) {
63
+ if (!task?.payload?.large) return null; // explicit flag only — never inferred
64
+ const managerTemplate = opts.managerTemplate ?? managerTemplateFor(task);
65
+ const subtasks = Array.isArray(task.payload.subtasks) && task.payload.subtasks.length > 0
66
+ ? task.payload.subtasks
67
+ : [String(task.payload.text ?? task.id)]; // single-leaf fallback: the objective itself
68
+
69
+ const root = decompose.decompose({
70
+ text: String(task.payload.text ?? task.id),
71
+ rootTemplate: managerTemplate,
72
+ repo: task.payload.repo ?? process.cwd(),
73
+ });
74
+
75
+ const children = [];
76
+ let prevTaskId = null;
77
+ for (const sub of subtasks) {
78
+ // A subtask is either a plain string objective or a declared object
79
+ // { text, file? } — a file-targeted child lets the analyzer rung derive
80
+ // a concrete patch; a text-only child researches and escalates.
81
+ const objective = typeof sub === 'string' ? { text: sub } : sub;
82
+ const node = decompose.delegate(root, opts.childTemplate ?? 'verifier', String(objective.text), {
83
+ repo: task.payload.repo ?? process.cwd(),
84
+ // Children work the PRIMARY repo — foreign writes stay root-only.
85
+ });
86
+ const t = queue.enqueue({
87
+ kind: task.payload.child_kind ?? 'fix',
88
+ payload: {
89
+ text: objective.text,
90
+ ...(objective.file ? { file: objective.file } : {}),
91
+ // File-targeted children get the analyzer's fresh-truth path; the
92
+ // earned-research rung would burn web budget on a subtask the tree
93
+ // already scopes. Text-only children keep research (opt-out is the
94
+ // operator's call, declared per task via payload.child_research).
95
+ ...(objective.file && task.payload.child_research !== true ? { research: false } : {}),
96
+ delegation_node: node.id,
97
+ delegation_run: root.id,
98
+ parent_task: task.id,
99
+ },
100
+ // Children chain sequentially by default (Genesis: writes to one repo
101
+ // are a manager's job, one at a time); `sequential: false` trusts the
102
+ // caller's declaration of independent subtasks.
103
+ ...(prevTaskId && task.payload.sequential !== false ? { depends_on: [prevTaskId] } : {}),
104
+ ...(task.payload.child_capabilities ? { capabilities: task.payload.child_capabilities } : {}),
105
+ });
106
+ children.push({ node, task: t });
107
+ prevTaskId = t.id;
108
+ }
109
+ trailJournal('autodecompose.opened', {
110
+ task: task.id, run: root.id, manager: managerTemplate,
111
+ children: children.length, sequential: task.payload.sequential !== false,
112
+ });
113
+ return { run: root.id, parent: root, children };
114
+ }
115
+
116
+ /**
117
+ * Roll a child's terminal verdict up to its delegation node.
118
+ * @param {string} nodeId — the child's delegation_node
119
+ * @param {{ ok: boolean, rung?: string, error?: string }} childResult
120
+ * @returns {{ run, allAccepted: boolean, accepted: number, total: number }}
121
+ */
122
+ export function rollupChild(nodeId, childResult) {
123
+ const row = rowOf(nodeId);
124
+ if (!row) throw new Error(`auto-decompose.rollup: unknown delegation node ${JSON.stringify(nodeId)}`);
125
+ const accepted = childResult?.ok === true;
126
+ decompose.completeDelegation(nodeId, {
127
+ ok: accepted,
128
+ ...(childResult?.error ? { error: String(childResult.error).slice(0, 300) } : {}),
129
+ ...(childResult?.rung ? { rung: childResult.rung } : {}),
130
+ });
131
+ const tree = decompose.delegationTree(row.task_id);
132
+ const nodes = flatten(tree);
133
+ const childNodes = nodes.filter(n => n.status !== 'root');
134
+ const acceptedCount = childNodes.filter(n => n.status === 'accepted').length;
135
+ const allAccepted = childNodes.length > 0 && acceptedCount === childNodes.length;
136
+ trailJournal('autodecompose.rollup', { node: nodeId, ok: accepted, accepted: acceptedCount, total: childNodes.length, allAccepted });
137
+ return { run: row.task_id, allAccepted, accepted: acceptedCount, total: childNodes.length };
138
+ }
139
+
140
+ /** Whether every child of a run is terminal (accepted/rejected/closed). */
141
+ export function runComplete(runId) {
142
+ const nodes = flatten(decompose.delegationTree(runId)).filter(n => n.status !== 'root');
143
+ return nodes.length > 0 && nodes.every(n => n.status !== 'pending');
144
+ }
145
+
146
+ /**
147
+ * The swarm's handler for a `decompose` task: open the run, enqueue
148
+ * children, and park THIS task on the children's completion — the parent
149
+ * task's own lifecycle closes when the last child rolls up. Implemented as:
150
+ * enqueue children with depends_on the parent is released by; the parent
151
+ * fails-soft as 'awaiting-children' (an escalation-shaped verdict so the
152
+ * review lane can see the tree) OR, when opts.awaitParent is false, the
153
+ * parent completes immediately after enqueueing (fire-and-forget mode).
154
+ */
155
+ export async function runDecompositionTask(task, opts = {}) {
156
+ const opened = maybeAutoDecompose(task, opts);
157
+ if (!opened) {
158
+ // Flagged decompose but not large: loud misconfiguration.
159
+ throw new Error(`auto-decompose: task ${task.id} has kind 'decompose' but payload.large is not set — the trigger is explicit, never inferred`);
160
+ }
161
+ // awaitParent is the TASK's declaration first (payload.awaitParent), with
162
+ // the caller's opts as the operator override. Fire-and-forget: the parent
163
+ // completes when the children are enqueued — the delegation tree carries
164
+ // the outcomes, and the reviewer reads it (scion decompose tree).
165
+ const awaitParent = task.payload?.awaitParent ?? opts.awaitParent ?? true;
166
+ if (awaitParent === false) {
167
+ return { ok: true, rung: 'decompose', decision: { rung: 'decompose', candidate: { description: `delegated ${opened.children.length} subtask(s)`, run: opened.run } } };
168
+ }
169
+ // Default: the parent waits — fail-soft with the run visible in the tree.
170
+ const failResult = queue.fail(task.id, `awaiting children of run ${opened.run} (see: scion decompose tree ${opened.run})`, { escalate: true });
171
+ return { ok: false, escalated: true, rung: 'decompose', awaitingRun: opened.run, ...failResult };
172
+ }
@@ -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
+ }