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 +60 -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/auto-decompose.js +172 -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/src/swarm.js +38 -3
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
|
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,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
|
+
}
|