futura-scion 0.2.4 → 0.2.6
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 +52 -5
- package/bin/scion.js +107 -1
- package/package.json +1 -1
- package/recipes/dotnet.yaml +8 -0
- package/recipes/go.yaml +13 -0
- package/recipes/jvm.yaml +10 -0
- package/recipes/rust.yaml +13 -0
- package/src/brain/db.js +19 -0
- package/src/config.js +22 -6
- package/src/gate/recipes.js +6 -0
- package/src/gate/verifier.js +40 -4
- package/src/kernel/policy.js +1 -0
- package/src/kernel/roles.js +4 -0
- package/src/kernel/safety.js +5 -1
- package/src/kernel/workflow.js +25 -1
- package/src/ladder.js +95 -6
- package/src/mind/lessons.js +109 -0
- package/src/mind/plan-fullstack.js +180 -0
- package/src/mind/scaffold.js +17 -1
- package/src/mind/schematic.js +559 -0
- package/src/mind/task-brief.js +190 -0
- package/src/swarm.js +16 -3
- package/src/worker.js +73 -13
- package/templates/dotnet-api.yaml +72 -0
- package/templates/go-service.yaml +143 -0
- package/templates/rust-cli.yaml +62 -0
package/README.md
CHANGED
|
@@ -531,11 +531,14 @@ signature. Read-only plans need no ceremony.
|
|
|
531
531
|
|
|
532
532
|
Gate verifier BUNDLES per stack ship in `recipes/` as pure data (YAML —
|
|
533
533
|
description, required tools, verifier commands): **node** (node --check +
|
|
534
|
-
npm test), **python** (py_compile + pytest), **
|
|
535
|
-
**
|
|
536
|
-
|
|
537
|
-
**
|
|
538
|
-
|
|
534
|
+
npm test), **python** (py_compile + pytest), **go** (gofmt discipline +
|
|
535
|
+
go vet + go test), **rust** (cargo check + clippy discipline + cargo test),
|
|
536
|
+
**dotnet** (dotnet build + test), **jvm** (wrapper-aware Maven/Gradle test),
|
|
537
|
+
**docker** (image build), **terraform** (fmt -check + init + validate —
|
|
538
|
+
deliberately read-only; plan/apply belong in senior-review-approved workflow
|
|
539
|
+
steps, never a gate), **xcode** (xcodebuild, tuned by `SCION_XCODE_*` env),
|
|
540
|
+
**gradle** (wrapper build + test). POSIX-pipeline verifiers declare
|
|
541
|
+
`shell: bash` and run under Git Bash on Windows. Enable with one line:
|
|
539
542
|
|
|
540
543
|
```yaml
|
|
541
544
|
gate:
|
|
@@ -777,6 +780,50 @@ With `learn: { auto: true }` in config, `scion serve` runs a cycle every
|
|
|
777
780
|
`learn.interval_ms` (default 6h) in-process — the harness improves itself
|
|
778
781
|
while it works. Zero-LLM by construction.
|
|
779
782
|
|
|
783
|
+
### Codebase schematics — single-pass understanding, incremental forever (`scion map`)
|
|
784
|
+
|
|
785
|
+
FS converts a complex codebase into a complete machine-navigable map — every
|
|
786
|
+
symbol (signature, kind, line, exported/async), file import edges AND
|
|
787
|
+
symbol-level call edges, entry points (bin scripts, HTTP route
|
|
788
|
+
registrations, handlers), the exported surface, per-layer fan-in/fan-out,
|
|
789
|
+
and deterministic pattern observations (factories, handlers, repositories).
|
|
790
|
+
Persisted in the brain with per-file content hashes.
|
|
791
|
+
|
|
792
|
+
The token contract: `mapFor()` re-parses ONLY files whose hash changed and
|
|
793
|
+
reuses persisted fragments for everything else — 89 files with 1 changed
|
|
794
|
+
costs 1 re-parse. Queries answer from the map with zero file reads:
|
|
795
|
+
impact (transitive blast radius of a symbol), who-imports/who-calls,
|
|
796
|
+
surface, entries.
|
|
797
|
+
|
|
798
|
+
```bash
|
|
799
|
+
scion map src # build-or-refresh + the one-screen digest
|
|
800
|
+
scion map src --impact=runTask # what breaks if this symbol changes
|
|
801
|
+
scion map src --who=enqueue # importers + callers, from the map
|
|
802
|
+
scion map src --entries # every door into the system
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
`digest()` renders the briefing a worker reads INSTEAD of source files;
|
|
806
|
+
subsequent passes re-read only what changed. First pass = understanding;
|
|
807
|
+
every pass after = surgical.
|
|
808
|
+
|
|
809
|
+
### The MoE retention discipline — ephemeral fuel, durable lessons
|
|
810
|
+
|
|
811
|
+
FS's memory mirrors a Mixture-of-Experts model's discipline: activate what
|
|
812
|
+
the current task needs, retain only what compounds.
|
|
813
|
+
|
|
814
|
+
- **Task working-set** (`scion working-set <taskId>`): gathered research
|
|
815
|
+
lives in an EPHEMERAL per-task brief — 64 KiB budget enforced by
|
|
816
|
+
oldest-first eviction, 1-hour TTL, swept at task end. Fuel, not furniture.
|
|
817
|
+
- **Earned research rung**: when every cheaper ladder rung abstains, the
|
|
818
|
+
worker researches the web (deterministic organ) into its working set and
|
|
819
|
+
escalates INFORMED — the reviewer sees findings and sources. Opt out
|
|
820
|
+
with `research: false`.
|
|
821
|
+
- **Lesson distiller**: exactly ONE durable lesson per gate verdict
|
|
822
|
+
(success / failure with the failing command / escalation), dedup-
|
|
823
|
+
reinforced. Lessons start at confidence 82 — just under the memory
|
|
824
|
+
rung's action threshold — so only REPETITION makes them actionable.
|
|
825
|
+
One occurrence is an observation; repetition is knowledge.
|
|
826
|
+
|
|
780
827
|
### FS Desktop — the standalone UI (chat / agent / plan / architect)
|
|
781
828
|
|
|
782
829
|
FS ships a real UI in two forms, both zero-build:
|
package/bin/scion.js
CHANGED
|
@@ -656,6 +656,28 @@ switch (cmd || '') {
|
|
|
656
656
|
else console.log(JSON.stringify(loop.cycle(), null, 2));
|
|
657
657
|
break;
|
|
658
658
|
}
|
|
659
|
+
case 'working-set': {
|
|
660
|
+
// Task working-set introspection (the ephemeral brief):
|
|
661
|
+
// scion working-set <taskId> what a task gathered + its budget posture
|
|
662
|
+
// scion working-set --sweep janitor: expire all past-TTL sets now
|
|
663
|
+
const tb = await import('../src/mind/task-brief.js');
|
|
664
|
+
if (arg === '--sweep') {
|
|
665
|
+
console.log(JSON.stringify(tb.briefSweep(), null, 2));
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
if (!arg) {
|
|
669
|
+
console.error('usage: scion working-set <taskId> | scion working-set --sweep');
|
|
670
|
+
process.exitCode = 2;
|
|
671
|
+
break;
|
|
672
|
+
}
|
|
673
|
+
const stats = tb.briefStats(arg);
|
|
674
|
+
const brief = tb.briefRead(arg, { limit: 12 });
|
|
675
|
+
console.log(`task ${arg}: ${stats.items} item(s), ${stats.bytes} bytes`);
|
|
676
|
+
for (const i of brief.items) {
|
|
677
|
+
console.log(` · [${i.source ?? 'no source'}] ${i.text.replace(/\s+/g, ' ').slice(0, 150)}`);
|
|
678
|
+
}
|
|
679
|
+
break;
|
|
680
|
+
}
|
|
659
681
|
case 'evolve': {
|
|
660
682
|
// The nightly harness-evolution pass (A4): mine weaknesses → propose
|
|
661
683
|
// harness edits → gate them (SICA utility) → apply the accepted. Bounded
|
|
@@ -769,6 +791,43 @@ switch (cmd || '') {
|
|
|
769
791
|
if (arch.violations.length > 0) process.exitCode = 1; // CI-friendly
|
|
770
792
|
break;
|
|
771
793
|
}
|
|
794
|
+
case 'map': {
|
|
795
|
+
// THE CODEBASE SCHEMATICS (single-pass understanding, incremental after):
|
|
796
|
+
// scion map [dir] build-or-refresh + digest (one-screen briefing)
|
|
797
|
+
// scion map <dir> --impact X transitive blast radius of symbol X
|
|
798
|
+
// scion map <dir> --who X who imports/calls X
|
|
799
|
+
// scion map <dir> --entries every entry point
|
|
800
|
+
// All queries answer from the persisted map — zero file re-reads beyond
|
|
801
|
+
// the changed files' hashes.
|
|
802
|
+
const sch = await import('../src/mind/schematic.js');
|
|
803
|
+
const target = arg || 'src';
|
|
804
|
+
const r = sch.mapFor(target);
|
|
805
|
+
const m = r.model;
|
|
806
|
+
const rel = (p) => String(p).split('\\').join('/').replace(/^src\//, '');
|
|
807
|
+
if (restArgs.includes('--entries')) {
|
|
808
|
+
for (const e of m.entries) console.log(`${e.kind.padEnd(8)} ${rel(e.file)}${e.symbol ? '#' + e.symbol : ''} (${e.detail})`);
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
const impactOf = restArgs.find((a) => a.startsWith('--impact='))?.split('=')[1];
|
|
812
|
+
if (impactOf) {
|
|
813
|
+
const im = sch.impact(m, impactOf);
|
|
814
|
+
console.log(`impact of ${impactOf} (defined in ${rel(m.symbols.find((s) => s.name === impactOf)?.file ?? '?')}):`);
|
|
815
|
+
console.log(` direct (${im.direct.length}): ${im.direct.map(rel).join(', ')}`);
|
|
816
|
+
console.log(` transitive (${im.transitive.length}): ${im.transitive.map(rel).join(', ')}`);
|
|
817
|
+
break;
|
|
818
|
+
}
|
|
819
|
+
const whoOf = restArgs.find((a) => a.startsWith('--who='))?.split('=')[1];
|
|
820
|
+
if (whoOf) {
|
|
821
|
+
const uniq = (a) => [...new Set(a)];
|
|
822
|
+
console.log(`importers: ${uniq(sch.queries.whoImports(m, sch.queries.fileFor(m, whoOf) ?? whoOf)).map(rel).join(', ') || '(none)'}`);
|
|
823
|
+
console.log(`callers: ${uniq(sch.queries.callersOf(m, whoOf)).map(rel).join(', ') || '(none)'}`);
|
|
824
|
+
break;
|
|
825
|
+
}
|
|
826
|
+
console.log(sch.digest(m));
|
|
827
|
+
console.log(`
|
|
828
|
+
refresh: ${r.reused} reused, ${r.changed.length} changed, ${r.added.length} added, ${r.removed.length} removed`);
|
|
829
|
+
break;
|
|
830
|
+
}
|
|
772
831
|
case 'curate': {
|
|
773
832
|
// Build + persist the codebase model (cortex curator lineage).
|
|
774
833
|
// scion curate <dir> build + persist + summary
|
|
@@ -805,10 +864,57 @@ switch (cmd || '') {
|
|
|
805
864
|
const positional = restArgs.filter((a) => !a.startsWith('--'));
|
|
806
865
|
const target = positional[0]; // output directory
|
|
807
866
|
if (!target) {
|
|
808
|
-
console.error('usage: scion scaffold <template|list> <target-dir> [--name x] [--
|
|
867
|
+
console.error('usage: scion scaffold <template|fullstack|list> <target-dir> [--name x] [--port N] [--concurrency N] [--no-verify] [--dry-run]');
|
|
809
868
|
process.exitCode = 2;
|
|
810
869
|
break;
|
|
811
870
|
}
|
|
871
|
+
if (arg === 'fullstack') {
|
|
872
|
+
// COMPOSABLE CONSTRUCTION: a workflow DAG that scaffolds backend,
|
|
873
|
+
// frontend, and CLI in parallel, then wires them into one repo.
|
|
874
|
+
const { planFullstack } = await import('../src/mind/plan-fullstack.js');
|
|
875
|
+
const params = {};
|
|
876
|
+
for (const f of flags) {
|
|
877
|
+
const m = f.match(/^--([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
878
|
+
if (m) params[m[1]] = m[2];
|
|
879
|
+
}
|
|
880
|
+
const swarm = flags.includes('--swarm');
|
|
881
|
+
const manifest = planFullstack(resolve(target), {
|
|
882
|
+
name: params.name,
|
|
883
|
+
...(params.port !== undefined ? { port: Number(params.port) } : {}),
|
|
884
|
+
...(swarm ? { swarm: true } : {}),
|
|
885
|
+
});
|
|
886
|
+
if (flags.includes('--dry-run')) {
|
|
887
|
+
console.log(`fullstack plan for ${target}:`);
|
|
888
|
+
for (const t of manifest.tasks) console.log(` ${t.id.padEnd(20)} deps: ${(t.depends_on ?? []).join(', ') || '(parallel)'}`);
|
|
889
|
+
console.log('\nrun with --approve-as <reviewer> to construct');
|
|
890
|
+
break;
|
|
891
|
+
}
|
|
892
|
+
const approveIdx = restArgs.indexOf('--approve-as');
|
|
893
|
+
const reviewer = approveIdx !== -1 ? restArgs[approveIdx + 1] : null;
|
|
894
|
+
if (!reviewer) {
|
|
895
|
+
console.error('fullstack construction is a mutating workflow — approve it: --approve-as <reviewer> (or inspect first with --dry-run)');
|
|
896
|
+
process.exitCode = 2;
|
|
897
|
+
break;
|
|
898
|
+
}
|
|
899
|
+
const { runWorkflow } = await import('../src/kernel/workflow.js');
|
|
900
|
+
const conc = Number(flags.find((f) => f.startsWith('--concurrency='))?.split('=')[1]) || 3;
|
|
901
|
+
// SWARM mode needs a builder worker: register the construct role and
|
|
902
|
+
// bind this process's worker so the claim transaction routes to it.
|
|
903
|
+
const swarmOpts = {};
|
|
904
|
+
if (swarm) {
|
|
905
|
+
const roles = await import('../src/kernel/roles.js');
|
|
906
|
+
roles.registerRole({ id: 'builder', capabilities: ['construct'] });
|
|
907
|
+
roles.bindWorker('cli-builder', 'builder');
|
|
908
|
+
swarmOpts.role = 'builder';
|
|
909
|
+
}
|
|
910
|
+
const r = await runWorkflow(manifest, { concurrency: conc, approval: { reviewer }, ...swarmOpts });
|
|
911
|
+
console.log(`fullstack ${r.status}: ${r.completed}/${r.total} steps (concurrency ${conc}${swarm ? ', swarm-routed' : ''})`);
|
|
912
|
+
for (const t of r.tasks ?? []) {
|
|
913
|
+
if (t.status !== 'completed') console.log(` ${t.status.toUpperCase()}: ${t.manifestId ?? t.id} ${t.error ?? ''}`);
|
|
914
|
+
}
|
|
915
|
+
if (r.status !== 'completed') process.exitCode = 1;
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
812
918
|
const tpl = loadTemplate(arg);
|
|
813
919
|
const params = {};
|
|
814
920
|
for (const f of flags) {
|
package/package.json
CHANGED
package/recipes/go.yaml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# gate.verifiers — the REAL Gate oracle for Go projects (modules).
|
|
2
|
+
# gofmt discipline (any unformatted file fails), go vet, go test — real
|
|
3
|
+
# exit codes, fail-fast. Pipeline runs under bash (Git Bash on Windows).
|
|
4
|
+
description: Go (modules)
|
|
5
|
+
requires: [go]
|
|
6
|
+
verifiers:
|
|
7
|
+
- cmd: "gofmt -l . | grep -q . && exit 1 || exit 0"
|
|
8
|
+
timeout_ms: 60000
|
|
9
|
+
shell: bash
|
|
10
|
+
- cmd: "go vet ./..."
|
|
11
|
+
timeout_ms: 300000
|
|
12
|
+
- cmd: "go test ./..."
|
|
13
|
+
timeout_ms: 600000
|
package/recipes/jvm.yaml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# gate.verifiers — the REAL Gate oracle for JVM projects (Maven or Gradle,
|
|
2
|
+
# whichever wrapper the project ships). Auto-detects the wrapper, falls
|
|
3
|
+
# back to the global tool. POSIX test syntax runs under bash (Git Bash on
|
|
4
|
+
# Windows).
|
|
5
|
+
description: JVM (Java / Kotlin via Maven or Gradle)
|
|
6
|
+
requires: [java]
|
|
7
|
+
verifiers:
|
|
8
|
+
- cmd: "if [ -f mvnw ]; then ./mvnw -q test; elif [ -f gradlew ]; then ./gradlew -q test; else mvn -q test 2>/dev/null || gradle -q test; fi"
|
|
9
|
+
timeout_ms: 600000
|
|
10
|
+
shell: bash
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# gate.verifiers — the REAL Gate oracle for Rust projects (cargo).
|
|
2
|
+
# Clippy is discipline, cargo test is truth. All judged by exit code.
|
|
3
|
+
# The clippy gate is a bash pipeline (Git Bash on Windows).
|
|
4
|
+
description: Rust (cargo)
|
|
5
|
+
requires: [cargo]
|
|
6
|
+
verifiers:
|
|
7
|
+
- cmd: "cargo check --quiet"
|
|
8
|
+
timeout_ms: 600000
|
|
9
|
+
- cmd: "cargo clippy --quiet 2>&1 | grep -E '^(warning|error)' && exit 1 || exit 0"
|
|
10
|
+
timeout_ms: 600000
|
|
11
|
+
shell: bash
|
|
12
|
+
- cmd: "cargo test --quiet"
|
|
13
|
+
timeout_ms: 600000
|
package/src/brain/db.js
CHANGED
|
@@ -256,6 +256,25 @@ export function ensureSchema(db) {
|
|
|
256
256
|
CREATE INDEX IF NOT EXISTS idx_req_status ON requirements(status);
|
|
257
257
|
`);
|
|
258
258
|
|
|
259
|
+
// ── Mind: task working-set (the EPHEMERAL brief) — gathered research
|
|
260
|
+
// held per-task for the task's duration, swept on completion. The MoE
|
|
261
|
+
// discipline: activate what THIS task needs, retain only the lessons.
|
|
262
|
+
// TTL (expires_at) and a size budget are enforced mechanically.
|
|
263
|
+
db.exec(`
|
|
264
|
+
CREATE TABLE IF NOT EXISTS task_working_set (
|
|
265
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
266
|
+
task_id TEXT NOT NULL,
|
|
267
|
+
kind TEXT NOT NULL DEFAULT 'finding',
|
|
268
|
+
text TEXT NOT NULL,
|
|
269
|
+
source TEXT,
|
|
270
|
+
bytes INTEGER NOT NULL DEFAULT 0,
|
|
271
|
+
expires_at TEXT,
|
|
272
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
273
|
+
);
|
|
274
|
+
CREATE INDEX IF NOT EXISTS idx_tws_task ON task_working_set(task_id);
|
|
275
|
+
CREATE INDEX IF NOT EXISTS idx_tws_expires ON task_working_set(expires_at);
|
|
276
|
+
`);
|
|
277
|
+
|
|
259
278
|
// ── Mind: replay index (Tier-0 learned decisions, now persistent) ──
|
|
260
279
|
db.exec(`
|
|
261
280
|
CREATE TABLE IF NOT EXISTS replay_index (
|
package/src/config.js
CHANGED
|
@@ -75,6 +75,19 @@ function dedentBlockLine(line, blockScalar) {
|
|
|
75
75
|
return line.slice(cut);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* YAML clip semantics: a literal/folded block scalar's value ends with a
|
|
80
|
+
* SINGLE trailing newline. Templates embed whole files in these blocks —
|
|
81
|
+
* gofmt, py_compile, and most linters require the final newline, and the
|
|
82
|
+
* old parser silently dropped it (a latent correctness bug the Go recipe's
|
|
83
|
+
* gofmt oracle caught first). Folded blocks join with spaces as before.
|
|
84
|
+
*/
|
|
85
|
+
function finalizeBlock(blockScalar) {
|
|
86
|
+
const text = blockScalar.text;
|
|
87
|
+
if (blockScalar.folded) return text.join(' ');
|
|
88
|
+
return text.length > 0 ? text.join('\n') + '\n' : '';
|
|
89
|
+
}
|
|
90
|
+
|
|
78
91
|
/**
|
|
79
92
|
* Minimal YAML-subset parser: nested maps by indentation, scalars, inline
|
|
80
93
|
* arrays, `#` comments. Deliberately NOT full YAML — unsupported syntax
|
|
@@ -84,6 +97,10 @@ export function parseYaml(text, source = CONFIG_PATH) {
|
|
|
84
97
|
const root = {};
|
|
85
98
|
const stack = [{ indent: -1, obj: root }];
|
|
86
99
|
const lines = text.split(/\r?\n/);
|
|
100
|
+
// A trailing newline is a line TERMINATOR, not a blank line — drop the
|
|
101
|
+
// phantom final empty element so block scalars don't swallow it as an
|
|
102
|
+
// extra blank content line (gofmt-grade oracles reject the result).
|
|
103
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
|
87
104
|
let blockScalar = null; // open "key: >" / "key: |" scalar being collected
|
|
88
105
|
|
|
89
106
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -103,8 +120,8 @@ export function parseYaml(text, source = CONFIG_PATH) {
|
|
|
103
120
|
&& line.match(/^\s*/)[0].length > (blockScalar.indent ?? 0);
|
|
104
121
|
const isListItem = /^\s*-\s/.test(line);
|
|
105
122
|
if (!isContinuation || isListItem) {
|
|
106
|
-
const { parent, key
|
|
107
|
-
parent[key] =
|
|
123
|
+
const { parent, key } = blockScalar;
|
|
124
|
+
parent[key] = finalizeBlock(blockScalar);
|
|
108
125
|
blockScalar = null;
|
|
109
126
|
}
|
|
110
127
|
}
|
|
@@ -207,11 +224,10 @@ export function parseYaml(text, source = CONFIG_PATH) {
|
|
|
207
224
|
} else {
|
|
208
225
|
parent[key] = parseScalar(rest);
|
|
209
226
|
}
|
|
210
|
-
}
|
|
211
|
-
// Close a block scalar still open at EOF.
|
|
227
|
+
} // Close a block scalar still open at EOF.
|
|
212
228
|
if (blockScalar !== null) {
|
|
213
|
-
const { parent, key
|
|
214
|
-
parent[key] =
|
|
229
|
+
const { parent, key } = blockScalar;
|
|
230
|
+
parent[key] = finalizeBlock(blockScalar);
|
|
215
231
|
}
|
|
216
232
|
return root;
|
|
217
233
|
}
|
package/src/gate/recipes.js
CHANGED
|
@@ -129,8 +129,14 @@ export function probeRecipes(names = null) {
|
|
|
129
129
|
if (toolCache.has(tool)) return toolCache.get(tool);
|
|
130
130
|
let ok = false;
|
|
131
131
|
try {
|
|
132
|
+
// Go prints its version to STDOUT via `go version` (`go --version` is
|
|
133
|
+
// a usage error); everything else speaks `--version`. Probe both.
|
|
132
134
|
const r = spawnSync(`${tool} --version`, { shell: true, windowsHide: true, timeout: 10000 });
|
|
133
135
|
ok = r.status === 0 || (r.error === undefined && typeof r.stdout === 'string' && r.stdout.length > 0);
|
|
136
|
+
if (!ok && tool === 'go') {
|
|
137
|
+
const g = spawnSync('go version', { shell: true, windowsHide: true, timeout: 10000 });
|
|
138
|
+
ok = g.status === 0;
|
|
139
|
+
}
|
|
134
140
|
} catch { ok = false; }
|
|
135
141
|
toolCache.set(tool, ok);
|
|
136
142
|
return ok;
|
package/src/gate/verifier.js
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
'use strict';
|
|
30
30
|
|
|
31
31
|
import { spawn } from 'node:child_process';
|
|
32
|
+
import { accessSync, constants } from 'node:fs';
|
|
32
33
|
import { resolve, isAbsolute } from 'node:path';
|
|
33
34
|
import { load as loadConfig } from '../config.js';
|
|
34
35
|
import { redact, assertCommandAllowed } from '../kernel/safety.js';
|
|
@@ -59,6 +60,31 @@ export function parseCommand(cmd) {
|
|
|
59
60
|
return argv;
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Locate a bash executable for portable POSIX recipes (shell:'bash').
|
|
65
|
+
* Order: SCION_BASH env override, then the well-known Git Bash paths on
|
|
66
|
+
* Windows, then PATH. Cached — a probe per command would be waste.
|
|
67
|
+
*/
|
|
68
|
+
let _bashPath = undefined;
|
|
69
|
+
function resolveBash() {
|
|
70
|
+
if (_bashPath !== undefined) return _bashPath;
|
|
71
|
+
const candidates = [
|
|
72
|
+
process.env.SCION_BASH,
|
|
73
|
+
'C:/Program Files/Git/bin/bash.exe',
|
|
74
|
+
'C:/Program Files (x86)/Git/bin/bash.exe',
|
|
75
|
+
].filter(Boolean);
|
|
76
|
+
if (process.platform !== 'win32') candidates.unshift('/bin/bash', '/usr/bin/bash');
|
|
77
|
+
for (const c of candidates) {
|
|
78
|
+
try {
|
|
79
|
+
accessSync(c, constants.X_OK);
|
|
80
|
+
_bashPath = c;
|
|
81
|
+
return c;
|
|
82
|
+
} catch { /* try next */ }
|
|
83
|
+
}
|
|
84
|
+
_bashPath = null;
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
62
88
|
/**
|
|
63
89
|
* Run one command and capture its outcome.
|
|
64
90
|
* @param {object} spec — { cmd, shell?, timeout_ms?, cwd?, env? }
|
|
@@ -74,6 +100,14 @@ export function runCommand(spec) {
|
|
|
74
100
|
const cfg = loadConfig();
|
|
75
101
|
const timeoutMs = spec.timeout_ms ?? cfg.gate?.timeout_ms ?? 120000;
|
|
76
102
|
const useShell = spec.shell === true;
|
|
103
|
+
// shell:'bash' — portable POSIX recipes (gofmt/clippy pipelines, [ -f ]
|
|
104
|
+
// tests). Windows resolves Git Bash; POSIX shells already speak this.
|
|
105
|
+
const useBash = spec.shell === 'bash';
|
|
106
|
+
const bash = useBash ? resolveBash() : null;
|
|
107
|
+
if (useBash && !bash) {
|
|
108
|
+
resolvePromise({ cmd: spec.cmd, exit_code: -1, verified: false, error: 'verifier: shell "bash" requested but no bash found (install Git Bash or set SCION_BASH)' });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
77
111
|
const cwd = spec.cwd ? resolve(spec.cwd) : process.cwd();
|
|
78
112
|
|
|
79
113
|
let argv;
|
|
@@ -85,10 +119,12 @@ export function runCommand(spec) {
|
|
|
85
119
|
return;
|
|
86
120
|
}
|
|
87
121
|
|
|
88
|
-
const file = useShell ? (process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : '/bin/sh') : argv[0];
|
|
89
|
-
const args =
|
|
90
|
-
?
|
|
91
|
-
:
|
|
122
|
+
const file = useBash ? bash : useShell ? (process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : '/bin/sh') : argv[0];
|
|
123
|
+
const args = useBash
|
|
124
|
+
? ['-c', spec.cmd]
|
|
125
|
+
: useShell
|
|
126
|
+
? (process.platform === 'win32' ? ['/d', '/s', '/c', spec.cmd] : ['-c', spec.cmd])
|
|
127
|
+
: argv.slice(1);
|
|
92
128
|
|
|
93
129
|
let child;
|
|
94
130
|
try {
|
package/src/kernel/policy.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
const DEFAULT_RULES = [
|
|
13
13
|
{ re: /^(os|shell|exec|rm|del)\b/i, action: 'deny', reason: 'os-level tools require the curated-command path' },
|
|
14
14
|
{ re: /^(read|list|glob|analyze|reason|search)\b/i, action: 'allow' },
|
|
15
|
+
{ re: /^construct\b/i, action: 'gate', reason: 'construction is a mutation: the transactional applier writes and the Gate verifies' },
|
|
15
16
|
{ re: /^(write|edit|fix|apply)\b/i, action: 'gate', reason: 'mutations pass through the evidence gate' },
|
|
16
17
|
];
|
|
17
18
|
|
package/src/kernel/roles.js
CHANGED
|
@@ -100,6 +100,9 @@ export function liveWorkers() {
|
|
|
100
100
|
* - An UNBOUND worker (no role) may claim only capability-less tasks —
|
|
101
101
|
* legacy behavior survives only where it is safe.
|
|
102
102
|
* - Otherwise: every required capability must be in the role's set.
|
|
103
|
+
* - The role capability '*' is the GENERALIST wildcard: it satisfies any
|
|
104
|
+
* requirement (the swarm's default binding; explicit is better than
|
|
105
|
+
* implicit unrestrictedness — a generalist role is a declared contract).
|
|
103
106
|
* Pure predicate — the queue's claim transaction calls this.
|
|
104
107
|
*/
|
|
105
108
|
export function canClaim(workerId, taskCapabilities) {
|
|
@@ -107,6 +110,7 @@ export function canClaim(workerId, taskCapabilities) {
|
|
|
107
110
|
if (required.length === 0) return true; // open task
|
|
108
111
|
const role = roleOf(workerId);
|
|
109
112
|
if (!role) return false;
|
|
113
|
+
if (role.capabilities.includes('*')) return true; // generalist wildcard
|
|
110
114
|
return required.every((c) => role.capabilities.includes(c));
|
|
111
115
|
}
|
|
112
116
|
|
package/src/kernel/safety.js
CHANGED
|
@@ -86,8 +86,12 @@ export function assertPathAllowed(p) {
|
|
|
86
86
|
throw new Error('safety: path must be a non-empty string');
|
|
87
87
|
}
|
|
88
88
|
const norm = p.replace(/\\/g, '/');
|
|
89
|
+
const base = norm.split('/').pop(); // final segment decides secrets, not substrings
|
|
89
90
|
for (const d of denylist()) {
|
|
90
|
-
|
|
91
|
+
// Segment-aware matching: `.env` denies the secrets file itself, not
|
|
92
|
+
// `.env.example` or `backend-service/` (substring matching blocked the
|
|
93
|
+
// scaffolded env CONTRACT — a template file, not a secret).
|
|
94
|
+
if (d === '.env' ? base === '.env' : norm.includes(d)) {
|
|
91
95
|
throw new Error(`safety: path denied by muscle.path_deny ("${d}"): ${p}`);
|
|
92
96
|
}
|
|
93
97
|
}
|
package/src/kernel/workflow.js
CHANGED
|
@@ -231,11 +231,35 @@ async function kernelCallable(fn) {
|
|
|
231
231
|
if (fn === 'ocrImage') {
|
|
232
232
|
return (args) => ocrImage(args?.image, { ...(args?.engine ? { engine: args.engine } : {}) });
|
|
233
233
|
}
|
|
234
|
+
if (fn === 'scaffold') {
|
|
235
|
+
return async (args) => {
|
|
236
|
+
const { scaffold } = await import('../mind/scaffold.js');
|
|
237
|
+
const r = await scaffold(args?.template, {
|
|
238
|
+
params: args?.params ?? {},
|
|
239
|
+
...(args?.cwd !== undefined ? { cwd: args.cwd } : {}),
|
|
240
|
+
...(args?.verify !== undefined ? { verify: args.verify } : {}),
|
|
241
|
+
});
|
|
242
|
+
if (!r.ok) throw new Error(r.error ?? `scaffold ${args?.template} failed (gate: ${r.gate?.verified})`);
|
|
243
|
+
return { template: r.template, files: r.files, gate: r.gate?.verified ?? null };
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
if (fn === 'scaffold.file') {
|
|
247
|
+
// Wiring step: write one glue file through the transactional applier
|
|
248
|
+
// (snapshot + rollback) with the same path guardrails as any mutation.
|
|
249
|
+
return async (args) => {
|
|
250
|
+
const { transactionalApplier } = await import('./apply.js');
|
|
251
|
+
const tx = transactionalApplier({
|
|
252
|
+
files: [{ file: args?.file, content: args?.content ?? '', description: args?.description ?? `wire ${args?.file}` }],
|
|
253
|
+
});
|
|
254
|
+
const description = tx.apply();
|
|
255
|
+
return { description, file: args?.file };
|
|
256
|
+
};
|
|
257
|
+
}
|
|
234
258
|
return null;
|
|
235
259
|
}
|
|
236
260
|
|
|
237
261
|
function callableSurface() {
|
|
238
|
-
return 'boostConsensus, ocrImage';
|
|
262
|
+
return 'boostConsensus, ocrImage, scaffold, scaffold.file';
|
|
239
263
|
}
|
|
240
264
|
|
|
241
265
|
async function execShellStep(step) {
|
package/src/ladder.js
CHANGED
|
@@ -65,6 +65,12 @@ function rollBudgetIfNeeded() {
|
|
|
65
65
|
*/
|
|
66
66
|
export async function decide(problem, opts = {}) {
|
|
67
67
|
let input = typeof problem === 'string' ? { text: problem } : problem;
|
|
68
|
+
// taskId is ROUTING metadata (the earned-research rung keys the ephemeral
|
|
69
|
+
// working set to it) — it must never leak into problem signatures, replay
|
|
70
|
+
// keys, or memory text, or identical problems stop replaying (t2 vs t3).
|
|
71
|
+
const { taskId: _routingTaskId, ...problemInput } = input;
|
|
72
|
+
input = problemInput;
|
|
73
|
+
const routingTaskId = _routingTaskId ?? 'unassigned';
|
|
68
74
|
|
|
69
75
|
// Rung A — ARCHITECTURE (declared-design violations): a task typed
|
|
70
76
|
// `arch:*` carries a MACHINE-VERIFIED design violation (mind/architecture
|
|
@@ -83,6 +89,36 @@ export async function decide(problem, opts = {}) {
|
|
|
83
89
|
});
|
|
84
90
|
}
|
|
85
91
|
|
|
92
|
+
// Rung C — CONSTRUCT (the construction lineage): a task that CARRIES a
|
|
93
|
+
// scaffold directive composes a project skeleton instead of analyzing a
|
|
94
|
+
// file. The candidate is the transactional multi-file applier shape
|
|
95
|
+
// ({ files: [...] }) — the worker applies it and the Gate verifies with
|
|
96
|
+
// the stack's real verifier recipe. Deterministic: pure template
|
|
97
|
+
// substitution, no generation. Capabilities gate WHO may construct.
|
|
98
|
+
if (input.construct?.template) {
|
|
99
|
+
try {
|
|
100
|
+
const { scaffold } = await import('./mind/scaffold.js');
|
|
101
|
+
const run = await scaffold(input.construct.template, {
|
|
102
|
+
params: input.construct.params ?? {},
|
|
103
|
+
...(input.construct.cwd !== undefined ? { cwd: input.construct.cwd } : {}),
|
|
104
|
+
composeOnly: true, // the WORKER applies + gates — the ladder only composes
|
|
105
|
+
});
|
|
106
|
+
if (run.ok) {
|
|
107
|
+
return journal('construct', {
|
|
108
|
+
description: `scaffold ${input.construct.template} → ${input.construct.cwd ?? 'cwd'}`,
|
|
109
|
+
files: run.fileCandidates, // applier candidates: { file, content, description }
|
|
110
|
+
fileStack: run.stack, // the gate recipe the verifier should run
|
|
111
|
+
postCommands: run.post, // dependency install / build, as verifier specs
|
|
112
|
+
template: input.construct.template,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
trail.journal('ladder.construct-failed', { error: run.error ?? 'composition failed' });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
trail.journal('ladder.construct-failed', { error: String(err?.message || err).slice(0, 200) });
|
|
118
|
+
}
|
|
119
|
+
// construction failure → fall through (escalation owns it)
|
|
120
|
+
}
|
|
121
|
+
|
|
86
122
|
// Rung 0 — IMAGE PRE-STEP (deterministic OCR, cortex perception lineage):
|
|
87
123
|
// an image problem carries no text, so before any rung can see it, its
|
|
88
124
|
// TEXT LAYER is extracted (tesseract CLI, exit-code judged). The extracted
|
|
@@ -213,15 +249,27 @@ export async function decide(problem, opts = {}) {
|
|
|
213
249
|
}
|
|
214
250
|
}
|
|
215
251
|
|
|
216
|
-
// Rung
|
|
217
|
-
//
|
|
218
|
-
//
|
|
252
|
+
// Rung 4b — REASONER INTEGRITY: an insight is only as good as its
|
|
253
|
+
// evidence. A synthesis drawn from LESSONS about unrelated problems is
|
|
254
|
+
// noise, not insight: if none of the memories the reasoner consulted
|
|
255
|
+
// overlap the problem's own terms, the mode is abstaining loudly by
|
|
256
|
+
// returning nothing. Guard here as well so rung order never matters.
|
|
219
257
|
const reasonTopic = input.topic || input.text || '';
|
|
220
258
|
if (reasonTopic.trim() !== '') {
|
|
221
259
|
const insights = brainReasoner.reason(reasonTopic, { project, scope: memoryScope });
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
260
|
+
const top = insights[0];
|
|
261
|
+
if (top && top.confidence >= minInsightConfidence) {
|
|
262
|
+
// Term-overlap check: at least one content word of the problem must
|
|
263
|
+
// appear in the insight's source context (the insight itself may be
|
|
264
|
+
// phrased differently). Pure lexical — no LLM anywhere.
|
|
265
|
+
const stop = new Set(['the','and','for','with','this','that','from','into','when','what','how','why','fix','make','add','resolve','explain','project','must','should']);
|
|
266
|
+
const topicWords = new Set(reasonTopic.toLowerCase().split(/\W+/).filter((w) => w.length > 3 && !stop.has(w)));
|
|
267
|
+
const sourceWords = new Set(String(top.sources ?? top.evidence ?? top.context ?? top.insight ?? '').toLowerCase().split(/\W+/));
|
|
268
|
+
const overlap = [...topicWords].some((w) => sourceWords.has(w));
|
|
269
|
+
if (topicWords.size === 0 || overlap) {
|
|
270
|
+
return journal('reasoner', { insight: top.insight, confidence: top.confidence, mode: top.mode });
|
|
271
|
+
}
|
|
272
|
+
trail.journal('ladder.reasoner-abstain', { reason: 'no term overlap between problem and insight evidence' });
|
|
225
273
|
}
|
|
226
274
|
}
|
|
227
275
|
|
|
@@ -280,6 +328,47 @@ export async function decide(problem, opts = {}) {
|
|
|
280
328
|
}
|
|
281
329
|
}
|
|
282
330
|
|
|
331
|
+
// Rung R2 — EARNED RESEARCH (the MoE discipline, the last rung before
|
|
332
|
+
// giving up): every cheaper rung abstained, so the generalist goes
|
|
333
|
+
// LOOKING — deterministic web research on the task text (webresearcher
|
|
334
|
+
// lineage). Findings land in the task's EPHEMERAL working set
|
|
335
|
+
// (mind/task-brief: TTL'd, budgeted, swept at task end) — never the
|
|
336
|
+
// brain; the brain keeps only lessons. The candidate is RESEARCH-SHAPED:
|
|
337
|
+
// the worker treats it as informed-escalation evidence, the operator
|
|
338
|
+
// sees what was found and from where. Explicitly opted-out tasks
|
|
339
|
+
// (research === false) skip straight to escalation — the operator said
|
|
340
|
+
// no, once is enough.
|
|
341
|
+
if (input.research !== false && String(input.text ?? '').trim() !== '') {
|
|
342
|
+
try {
|
|
343
|
+
const { runResearch } = await import('./mind/research/index.js');
|
|
344
|
+
const { briefAbsorb, briefRead } = await import('./mind/task-brief.js');
|
|
345
|
+
const taskId = routingTaskId;
|
|
346
|
+
const report = await runResearch(String(input.text).slice(0, 300), { persist: false });
|
|
347
|
+
const findings = (report.items ?? [])
|
|
348
|
+
.filter((it) => it.corroboration?.verdict !== 'CONFLICTING')
|
|
349
|
+
.map((it) => ({ text: `${it.text} [${it.source_url ?? 'unknown source'}]`, source: it.source_url ?? null }));
|
|
350
|
+
const absorb = briefAbsorb(taskId, findings, { source: 'research:' + report.recipe });
|
|
351
|
+
trail.journal('ladder.researched', {
|
|
352
|
+
task: taskId, question: String(input.text).slice(0, 120),
|
|
353
|
+
pages: report.pages, items: report.items?.length ?? 0, absorbed: absorb.absorbed,
|
|
354
|
+
gate: report.gate?.verdict ?? null,
|
|
355
|
+
});
|
|
356
|
+
const brief = briefRead(taskId, { limit: 12 });
|
|
357
|
+
return journal('research', {
|
|
358
|
+
description: `researched: ${report.pages} pages, ${report.items?.length ?? 0} items (${report.gate?.verdict ?? 'no gate'})`,
|
|
359
|
+
taskId,
|
|
360
|
+
findings: brief.items.map((i) => ({ text: i.text, source: i.source })),
|
|
361
|
+
pages: report.pages,
|
|
362
|
+
gate: report.gate,
|
|
363
|
+
summary: report.summary?.SYNTHESIS ?? null,
|
|
364
|
+
});
|
|
365
|
+
} catch (err) {
|
|
366
|
+
trail.journal('ladder.research-failed', { error: String(err?.message || err).slice(0, 200) });
|
|
367
|
+
// Research is a privilege, not a requirement — thin/failed research
|
|
368
|
+
// degrades to escalation, which still carries the attempt's evidence.
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
283
372
|
// Rung 7 — escalate
|
|
284
373
|
return journal('escalate', {
|
|
285
374
|
reason: 'ladder exhausted',
|