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 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), **docker** (image build),
535
- **terraform** (fmt -check + init + validate — deliberately read-only;
536
- plan/apply belong in senior-review-approved workflow steps, never a gate),
537
- **xcode** (xcodebuild, tuned by `SCION_XCODE_*` env), **gradle** (wrapper
538
- build + test). Enable with one line:
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] [--title y] [--port N] [--param k=v] [--no-verify]');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "futura-scion",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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",
@@ -0,0 +1,8 @@
1
+ # gate.verifiers — the REAL Gate oracle for .NET projects (dotnet CLI).
2
+ description: .NET (C# / F#)
3
+ requires: [dotnet]
4
+ verifiers:
5
+ - cmd: "dotnet build --nologo -v q"
6
+ timeout_ms: 600000
7
+ - cmd: "dotnet test --nologo -v q"
8
+ timeout_ms: 600000
@@ -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
@@ -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, folded, text } = blockScalar;
107
- parent[key] = folded ? text.join(' ') : text.join('\n');
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, folded, text } = blockScalar;
214
- parent[key] = folded ? text.join(' ') : text.join('\n');
229
+ const { parent, key } = blockScalar;
230
+ parent[key] = finalizeBlock(blockScalar);
215
231
  }
216
232
  return root;
217
233
  }
@@ -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;
@@ -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 = useShell
90
- ? (process.platform === 'win32' ? ['/d', '/s', '/c', spec.cmd] : ['-c', spec.cmd])
91
- : argv.slice(1);
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 {
@@ -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
 
@@ -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
 
@@ -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
- if (norm.includes(d)) {
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
  }
@@ -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 4 — reasoner (full 6-mode brain inference). An image problem whose
217
- // OCR was refused carries no text at all — skip the reasoner (an empty
218
- // topic is not an insight request) and let escalation own it.
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
- if (insights.length && insights[0].confidence >= minInsightConfidence) {
223
- const top = insights[0];
224
- return journal('reasoner', { insight: top.insight, confidence: top.confidence, mode: top.mode });
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',