futura-scion 0.2.5 → 0.2.7

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:
@@ -589,6 +592,40 @@ with the violation evidence attached — the human chooses refactor, move,
589
592
  or amend the declaration, via the same `scion review` loop as every other
590
593
  escalation.
591
594
 
595
+ #### Hub rules — shape, not just edges (`architecture.hubs`)
596
+
597
+ Edges police direction; HUBS police concentration, checked against the
598
+ schematic's measured fan-in/fan-out (`scion map` design data — curator
599
+ models derive the same numbers from edges, so no map build is required):
600
+
601
+ ```yaml
602
+ architecture:
603
+ hubs:
604
+ max_fan_in: 100 # no file imported by more than N (change magnets)
605
+ max_fan_out: 20 # no file importing more than N (God modules)
606
+ max_layer_fan_in: 300 # no layer funneling more inbound edges than N
607
+ ignore: [vendor, gen] # path prefixes exempt from hub accounting
608
+ ```
609
+
610
+ Hub violations are analyzer-shaped **warnings** (`arch-hub-fan-in`,
611
+ `arch-hub-fan-out`, `arch-hub-layer-fan-in`) carrying the measured number,
612
+ the declared ceiling, and the file — a design smell escalates to the human
613
+ arch lane, never machine-patched. All thresholds are opt-in (empty = not
614
+ checked); malformed or typo'd keys fail loud. Live on FS's own source at
615
+ diagnostic thresholds: `kernel/trail.js` flagged at 173 inbound edges
616
+ (change magnet), `daemon.js` at 22 imports (God module).
617
+
618
+ #### Map-first THINK (`schematic.enabled`)
619
+
620
+ Every ladder decision attaches the schematic digest — layers, entries,
621
+ hubs, exported surface — as context for all downstream rungs (generator
622
+ prompts, analyzer reasoning, research queries). Served from a warm cache
623
+ (`schematic.digest_ttl_ms`, default 30000) so latency-bounded scans are
624
+ never starved: one refresh per TTL window, amortized across every worker
625
+ and daemon scan. `taskId` and `mapContext` are ambient keys — stripped
626
+ before signature hashing, so identical problems replay identically across
627
+ task ids and map refreshes.
628
+
592
629
  ### Command over tools — the research rung (`scion tools`)
593
630
 
594
631
  FS's rungs answer from what it knows. The RESEARCH rung gives it hands:
@@ -777,6 +814,50 @@ With `learn: { auto: true }` in config, `scion serve` runs a cycle every
777
814
  `learn.interval_ms` (default 6h) in-process — the harness improves itself
778
815
  while it works. Zero-LLM by construction.
779
816
 
817
+ ### Codebase schematics — single-pass understanding, incremental forever (`scion map`)
818
+
819
+ FS converts a complex codebase into a complete machine-navigable map — every
820
+ symbol (signature, kind, line, exported/async), file import edges AND
821
+ symbol-level call edges, entry points (bin scripts, HTTP route
822
+ registrations, handlers), the exported surface, per-layer fan-in/fan-out,
823
+ and deterministic pattern observations (factories, handlers, repositories).
824
+ Persisted in the brain with per-file content hashes.
825
+
826
+ The token contract: `mapFor()` re-parses ONLY files whose hash changed and
827
+ reuses persisted fragments for everything else — 89 files with 1 changed
828
+ costs 1 re-parse. Queries answer from the map with zero file reads:
829
+ impact (transitive blast radius of a symbol), who-imports/who-calls,
830
+ surface, entries.
831
+
832
+ ```bash
833
+ scion map src # build-or-refresh + the one-screen digest
834
+ scion map src --impact=runTask # what breaks if this symbol changes
835
+ scion map src --who=enqueue # importers + callers, from the map
836
+ scion map src --entries # every door into the system
837
+ ```
838
+
839
+ `digest()` renders the briefing a worker reads INSTEAD of source files;
840
+ subsequent passes re-read only what changed. First pass = understanding;
841
+ every pass after = surgical.
842
+
843
+ ### The MoE retention discipline — ephemeral fuel, durable lessons
844
+
845
+ FS's memory mirrors a Mixture-of-Experts model's discipline: activate what
846
+ the current task needs, retain only what compounds.
847
+
848
+ - **Task working-set** (`scion working-set <taskId>`): gathered research
849
+ lives in an EPHEMERAL per-task brief — 64 KiB budget enforced by
850
+ oldest-first eviction, 1-hour TTL, swept at task end. Fuel, not furniture.
851
+ - **Earned research rung**: when every cheaper ladder rung abstains, the
852
+ worker researches the web (deterministic organ) into its working set and
853
+ escalates INFORMED — the reviewer sees findings and sources. Opt out
854
+ with `research: false`.
855
+ - **Lesson distiller**: exactly ONE durable lesson per gate verdict
856
+ (success / failure with the failing command / escalation), dedup-
857
+ reinforced. Lessons start at confidence 82 — just under the memory
858
+ rung's action threshold — so only REPETITION makes them actionable.
859
+ One occurrence is an observation; repetition is knowledge.
860
+
780
861
  ### FS Desktop — the standalone UI (chat / agent / plan / architect)
781
862
 
782
863
  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
@@ -818,9 +877,11 @@ switch (cmd || '') {
818
877
  const m = f.match(/^--([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
819
878
  if (m) params[m[1]] = m[2];
820
879
  }
880
+ const swarm = flags.includes('--swarm');
821
881
  const manifest = planFullstack(resolve(target), {
822
882
  name: params.name,
823
883
  ...(params.port !== undefined ? { port: Number(params.port) } : {}),
884
+ ...(swarm ? { swarm: true } : {}),
824
885
  });
825
886
  if (flags.includes('--dry-run')) {
826
887
  console.log(`fullstack plan for ${target}:`);
@@ -837,8 +898,17 @@ switch (cmd || '') {
837
898
  }
838
899
  const { runWorkflow } = await import('../src/kernel/workflow.js');
839
900
  const conc = Number(flags.find((f) => f.startsWith('--concurrency='))?.split('=')[1]) || 3;
840
- const r = await runWorkflow(manifest, { concurrency: conc, approval: { reviewer } });
841
- console.log(`fullstack ${r.status}: ${r.completed}/${r.total} steps (concurrency ${conc})`);
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' : ''})`);
842
912
  for (const t of r.tasks ?? []) {
843
913
  if (t.status !== 'completed') console.log(` ${t.status.toUpperCase()}: ${t.manifestId ?? t.id} ${t.error ?? ''}`);
844
914
  }
@@ -11,8 +11,12 @@ ladder:
11
11
  min_insight_confidence: 0.55 # reasoner rung admission floor
12
12
 
13
13
  llm:
14
- daily_tokens: 0 # 0 = LLM rung off (zero-LLM doctrine)
14
+ daily_tokens: 500
15
15
 
16
+ apiKey: null
17
+ model: "qwen2.5-coder:7b"
18
+ baseUrl: "http://localhost:11434/v1"
19
+ provider: "ollama"
16
20
  bounds:
17
21
  max_turns: 25
18
22
  timeout_ms: 300000
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "futura-scion",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
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
@@ -27,7 +27,8 @@ export const DEFAULTS = Object.freeze({
27
27
  forge: { min_support: 3, min_precision: 0.7, max_trigger_terms: 3, max_seeds_per_run: 5 },
28
28
  queue: { max_attempts: 3 },
29
29
  analyzer: { enabled: true },
30
- architecture: { rules: [] }, // declared layer/dependency-direction rules (mind/architecture.js)
30
+ schematic: { enabled: true, digest_ttl_ms: 30000 }, // map-first THINK: inject the codebase digest into every ladder decide (warm-cache TTL)
31
+ 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)
31
32
  daemon: { auto_fix: false, max_fixes_per_scan: 10 },
32
33
  muscle: { path_deny: [], path_allow: [] }, // empty deny = shipped defaults
33
34
  api: { token: null, port: 5107, host: '127.0.0.1' },
@@ -75,6 +76,19 @@ function dedentBlockLine(line, blockScalar) {
75
76
  return line.slice(cut);
76
77
  }
77
78
 
79
+ /**
80
+ * YAML clip semantics: a literal/folded block scalar's value ends with a
81
+ * SINGLE trailing newline. Templates embed whole files in these blocks —
82
+ * gofmt, py_compile, and most linters require the final newline, and the
83
+ * old parser silently dropped it (a latent correctness bug the Go recipe's
84
+ * gofmt oracle caught first). Folded blocks join with spaces as before.
85
+ */
86
+ function finalizeBlock(blockScalar) {
87
+ const text = blockScalar.text;
88
+ if (blockScalar.folded) return text.join(' ');
89
+ return text.length > 0 ? text.join('\n') + '\n' : '';
90
+ }
91
+
78
92
  /**
79
93
  * Minimal YAML-subset parser: nested maps by indentation, scalars, inline
80
94
  * arrays, `#` comments. Deliberately NOT full YAML — unsupported syntax
@@ -84,6 +98,10 @@ export function parseYaml(text, source = CONFIG_PATH) {
84
98
  const root = {};
85
99
  const stack = [{ indent: -1, obj: root }];
86
100
  const lines = text.split(/\r?\n/);
101
+ // A trailing newline is a line TERMINATOR, not a blank line — drop the
102
+ // phantom final empty element so block scalars don't swallow it as an
103
+ // extra blank content line (gofmt-grade oracles reject the result).
104
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
87
105
  let blockScalar = null; // open "key: >" / "key: |" scalar being collected
88
106
 
89
107
  for (let i = 0; i < lines.length; i++) {
@@ -103,8 +121,8 @@ export function parseYaml(text, source = CONFIG_PATH) {
103
121
  && line.match(/^\s*/)[0].length > (blockScalar.indent ?? 0);
104
122
  const isListItem = /^\s*-\s/.test(line);
105
123
  if (!isContinuation || isListItem) {
106
- const { parent, key, folded, text } = blockScalar;
107
- parent[key] = folded ? text.join(' ') : text.join('\n');
124
+ const { parent, key } = blockScalar;
125
+ parent[key] = finalizeBlock(blockScalar);
108
126
  blockScalar = null;
109
127
  }
110
128
  }
@@ -207,11 +225,10 @@ export function parseYaml(text, source = CONFIG_PATH) {
207
225
  } else {
208
226
  parent[key] = parseScalar(rest);
209
227
  }
210
- }
211
- // Close a block scalar still open at EOF.
228
+ } // Close a block scalar still open at EOF.
212
229
  if (blockScalar !== null) {
213
- const { parent, key, folded, text } = blockScalar;
214
- parent[key] = folded ? text.join(' ') : text.join('\n');
230
+ const { parent, key } = blockScalar;
231
+ parent[key] = finalizeBlock(blockScalar);
215
232
  }
216
233
  return root;
217
234
  }
@@ -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