futura-scion 0.2.3 → 0.2.5

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/bin/scion.js CHANGED
@@ -791,6 +791,134 @@ switch (cmd || '') {
791
791
  console.log(JSON.stringify(out, null, 2));
792
792
  break;
793
793
  }
794
+ case 'scaffold': {
795
+ // CONSTRUCTION: compose a template into a project skeleton, gated by
796
+ // the stack's real verifier recipe before success is reported.
797
+ const { scaffold, listTemplates, loadTemplate } = await import('../src/mind/scaffold.js');
798
+ if (!arg || arg === 'list' || arg === '--list') {
799
+ const tpls = listTemplates();
800
+ if (tpls.length === 0) { console.log('no templates in templates/ — add YAML manifests'); break; }
801
+ for (const t of tpls) console.log(`${t.id.padEnd(16)} ${(t.stack ?? '-').toString().padEnd(8)} ${t.description}`);
802
+ break;
803
+ }
804
+ const flags = restArgs.filter((a) => a.startsWith('--'));
805
+ const positional = restArgs.filter((a) => !a.startsWith('--'));
806
+ const target = positional[0]; // output directory
807
+ if (!target) {
808
+ console.error('usage: scion scaffold <template|fullstack|list> <target-dir> [--name x] [--port N] [--concurrency N] [--no-verify] [--dry-run]');
809
+ process.exitCode = 2;
810
+ break;
811
+ }
812
+ if (arg === 'fullstack') {
813
+ // COMPOSABLE CONSTRUCTION: a workflow DAG that scaffolds backend,
814
+ // frontend, and CLI in parallel, then wires them into one repo.
815
+ const { planFullstack } = await import('../src/mind/plan-fullstack.js');
816
+ const params = {};
817
+ for (const f of flags) {
818
+ const m = f.match(/^--([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
819
+ if (m) params[m[1]] = m[2];
820
+ }
821
+ const manifest = planFullstack(resolve(target), {
822
+ name: params.name,
823
+ ...(params.port !== undefined ? { port: Number(params.port) } : {}),
824
+ });
825
+ if (flags.includes('--dry-run')) {
826
+ console.log(`fullstack plan for ${target}:`);
827
+ for (const t of manifest.tasks) console.log(` ${t.id.padEnd(20)} deps: ${(t.depends_on ?? []).join(', ') || '(parallel)'}`);
828
+ console.log('\nrun with --approve-as <reviewer> to construct');
829
+ break;
830
+ }
831
+ const approveIdx = restArgs.indexOf('--approve-as');
832
+ const reviewer = approveIdx !== -1 ? restArgs[approveIdx + 1] : null;
833
+ if (!reviewer) {
834
+ console.error('fullstack construction is a mutating workflow — approve it: --approve-as <reviewer> (or inspect first with --dry-run)');
835
+ process.exitCode = 2;
836
+ break;
837
+ }
838
+ const { runWorkflow } = await import('../src/kernel/workflow.js');
839
+ 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})`);
842
+ for (const t of r.tasks ?? []) {
843
+ if (t.status !== 'completed') console.log(` ${t.status.toUpperCase()}: ${t.manifestId ?? t.id} ${t.error ?? ''}`);
844
+ }
845
+ if (r.status !== 'completed') process.exitCode = 1;
846
+ break;
847
+ }
848
+ const tpl = loadTemplate(arg);
849
+ const params = {};
850
+ for (const f of flags) {
851
+ const m = f.match(/^--([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
852
+ if (m) params[m[1]] = m[2];
853
+ }
854
+ // Default missing params to sensible template-agnostic values instead
855
+ // of a raw throw — the CLI user should see WHICH param is missing in a
856
+ // friendly line, not a stack trace.
857
+ for (const req of ['name', 'title', 'port', 'description']) {
858
+ if (!(req in params)) {
859
+ if (req === 'port') params.port = '3000';
860
+ else if (req === 'title') params.title = params.name ?? arg;
861
+ else if (req === 'name') params.name = params.name ?? 'app';
862
+ else params.description ??= `built with FS scaffold (${arg})`;
863
+ }
864
+ }
865
+ let r;
866
+ try {
867
+ r = await scaffold(arg, { params, cwd: resolve(target), verify: !flags.includes('--no-verify') });
868
+ } catch (err) {
869
+ console.error(`scaffold failed: ${err.message}`);
870
+ process.exitCode = 1;
871
+ break;
872
+ }
873
+ if (r.ok === false && r.error) {
874
+ console.error(`scaffold failed: ${r.error}`);
875
+ process.exitCode = 1;
876
+ break;
877
+ }
878
+ console.log(`scaffold ${r.template} → ${target}`);
879
+ for (const f of r.files) console.log(` + ${f}`);
880
+ if (r.gate) {
881
+ console.log(`gate: ${r.gate.verified ? 'VERIFIED' : 'FAILED'} — [${r.gate.evidence.exit_codes.join(',')}]`);
882
+ if (!r.gate.verified) process.exitCode = 1;
883
+ } else {
884
+ console.log('gate: skipped (no stack recipe)');
885
+ }
886
+ break;
887
+ }
888
+ case 'research': {
889
+ // The research rung, driven from the CLI (webresearcher lineage):
890
+ // deterministic-first web research with a completion gate.
891
+ const q = [arg, ...restArgs.filter((a) => !a.startsWith('--'))].filter(Boolean).join(' ').trim();
892
+ const flags = restArgs.filter((a) => a.startsWith('--')) || [];
893
+ const { runResearch } = await import('../src/mind/research/index.js');
894
+ if (!q) {
895
+ console.error('usage: scion research "<question>" [--no-persist] [--pages N] [--deep] [--learn]');
896
+ process.exitCode = 2;
897
+ break;
898
+ }
899
+ const recipe = flags.includes('--deep')
900
+ ? { id: 'deep-dive', parameters: { max_pages: 16 }, verify: { min_pages: 2, min_items: 5, min_corroborated: 1 } }
901
+ : undefined;
902
+ const report = await runResearch(q, {
903
+ recipe,
904
+ persist: !flags.includes('--no-persist'),
905
+ max_pages: Number((flags.find((f) => f.startsWith('--pages')) ?? '').split('=')[1]) || (flags.includes('--pages') ? Number(restArgs[restArgs.indexOf('--pages') + 1]) : undefined) || undefined,
906
+ });
907
+ console.log(`research: ${report.pages} pages → ${report.items.length} items ` +
908
+ `(${report.census.SUPPORTED} corroborated, ${report.census.CONFLICTING} conflicting)`);
909
+ console.log(`gate: ${report.gate.verdict} — ${report.gate.checks.map((c) => `${c.check}:${c.pass ? 'ok' : 'fail'}(${c.actual})`).join(' ')}`);
910
+ for (const f of report.summary.KEY_FACT.slice(0, 5)) console.log(` ✓ ${f.slice(0, 140)}`);
911
+ for (const c of report.summary.CONTRADICTION.slice(0, 3)) console.log(` ⚡ ${c.slice(0, 140)}`);
912
+ for (const e of report.summary.EVIDENCE.slice(0, 3)) console.log(` · ${e.slice(0, 140)}`);
913
+ if (report.fallback) console.log(` [llm-fallback] ${report.fallback.synthesis.slice(0, 200)}`);
914
+ if (flags.includes('--learn')) {
915
+ const { learnResearchLoops } = await import('../src/mind/research/loop-learner.js');
916
+ const learned = learnResearchLoops();
917
+ console.log(`loop-learner: ${learned.length} lesson(s) persisted`);
918
+ }
919
+ process.exitCode = report.gate.verdict === 'PASS' ? 0 : 1;
920
+ break;
921
+ }
794
922
  case 'conventions': {
795
923
  // Measure + persist project conventions (cortex convention-detector
796
924
  // lineage, as EVIDENCE): discovered, never enforced.
@@ -884,6 +1012,10 @@ switch (cmd || '') {
884
1012
  leaseMs, pollMs,
885
1013
  primaryUrl: followerMode || primaryUrl ? (primaryUrl || null) : null,
886
1014
  });
1015
+ // Lever 3: the capability tools (web-search, scholar-search, scaffold-*)
1016
+ // register at boot so serve/UI/MCP all share the widened hands.
1017
+ const { registerBuiltinCapabilities } = await import('../src/mind/builtins-register.js');
1018
+ registerBuiltinCapabilities();
887
1019
  const api = await startApi({ port: Number(arg) || undefined, leader });
888
1020
  leader.start();
889
1021
  // FS Desktop support: provider-backed generator (rung G) from config,
@@ -11,12 +11,8 @@ ladder:
11
11
  min_insight_confidence: 0.55 # reasoner rung admission floor
12
12
 
13
13
  llm:
14
- daily_tokens: 500
14
+ daily_tokens: 0 # 0 = LLM rung off (zero-LLM doctrine)
15
15
 
16
- apiKey: null
17
- model: "qwen2.5-coder:7b"
18
- baseUrl: "http://localhost:11434/v1"
19
- provider: "ollama"
20
16
  bounds:
21
17
  max_turns: 25
22
18
  timeout_ms: 300000
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "futura-scion",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
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",
@@ -13,6 +13,7 @@
13
13
  "bin/",
14
14
  "src/",
15
15
  "recipes/",
16
+ "templates/",
16
17
  "config/",
17
18
  "knowledge/",
18
19
  "README.md",
@@ -368,6 +368,30 @@ export function list(opts = {}) {
368
368
  return rows.map(r => ({ ...r, tags: JSON.parse(r.tags || '[]') }));
369
369
  }
370
370
 
371
+ /**
372
+ * reinforce — DEDUP-REINFORCE (webresearcher cortex_bridge lineage): when
373
+ * new evidence matches an existing memory, STRENGTHEN it instead of
374
+ * duplicating. Confidence rises by delta (capped at 100), importance to at
375
+ * least the floor, and the access counter ticks. Repeated corroboration is
376
+ * a trust signal — the memory-graph weights read it on every recall.
377
+ * @param {number} id — existing memory id
378
+ * @param {object} [opts] — { confidence_delta=5, importance_floor=null }
379
+ * @returns {{ id, confidence, importance, reinforced: true }}
380
+ */
381
+ export function reinforce(id, opts = {}) {
382
+ const db = getDb();
383
+ const row = db.prepare('SELECT confidence, importance FROM memories WHERE id = ?').get(id);
384
+ if (!row) throw new Error(`memory.reinforce: no memory ${id}`);
385
+ const delta = opts.confidence_delta ?? 5;
386
+ const confidence = Math.min(100, row.confidence + delta);
387
+ const importance = opts.importance_floor != null
388
+ ? Math.max(row.importance, opts.importance_floor)
389
+ : Math.min(10, row.importance + (delta >= 5 ? 1 : 0));
390
+ db.prepare('UPDATE memories SET confidence = ?, importance = ?, access_count = access_count + 1 WHERE id = ?')
391
+ .run(confidence, importance, id);
392
+ return { id, confidence, importance, reinforced: true };
393
+ }
394
+
371
395
  export function stats() {
372
396
  const db = getDb();
373
397
  const row = db.prepare(
package/src/config.js CHANGED
@@ -59,6 +59,22 @@ function parseScalar(raw) {
59
59
  return v.replace(/^['"]|['"]$/g, '');
60
60
  }
61
61
 
62
+ /**
63
+ * Strip a block scalar's BASE indentation while preserving relative
64
+ * indentation (Python code in templates lives or dies by its indents).
65
+ * The base is the indentation of the FIRST content line; subsequent lines
66
+ * are dedented by the same amount. `|` literal blocks are exact; `>` folded
67
+ * blocks joined with spaces downstream, so per-line dedent is harmless.
68
+ */
69
+ function dedentBlockLine(line, blockScalar) {
70
+ if (blockScalar.folded) return line.trim();
71
+ if (blockScalar.baseIndent === undefined) {
72
+ blockScalar.baseIndent = line.match(/^\s*/)[0].length;
73
+ }
74
+ const cut = Math.min(blockScalar.baseIndent, line.match(/^\s*/)[0].length);
75
+ return line.slice(cut);
76
+ }
77
+
62
78
  /**
63
79
  * Minimal YAML-subset parser: nested maps by indentation, scalars, inline
64
80
  * arrays, `#` comments. Deliberately NOT full YAML — unsupported syntax
@@ -144,11 +160,23 @@ export function parseYaml(text, source = CONFIG_PATH) {
144
160
  if (!match) {
145
161
  // Continuation line of a block scalar ("key: >" folded text)?
146
162
  if (blockScalar !== null && /^\s+\S/.test(line)) {
147
- blockScalar.text.push(line.trim());
163
+ blockScalar.text.push(dedentBlockLine(line, blockScalar));
148
164
  continue;
149
165
  }
150
166
  throw new Error(`config: ${source}:${lineno} unsupported syntax — ${JSON.stringify(line.trim())}`);
151
167
  }
168
+ // A block scalar is OPEN and this line is deeper-indented than its key:
169
+ // it is SCALAR TEXT, never a new key — even when it looks like
170
+ // "word: value" (template manifests embed whole files in block scalars;
171
+ // their `port: 3000`-shaped lines are content, not structure). Leading
172
+ // indentation is PRESERVED relative to the block's first content line —
173
+ // Python files in templates live or die by their indentation.
174
+ if (blockScalar !== null
175
+ && match[1].length > (blockScalar.indent ?? 0)
176
+ && !/^\s*-\s/.test(line)) {
177
+ blockScalar.text.push(dedentBlockLine(line, blockScalar));
178
+ continue;
179
+ }
152
180
  const [, ws, key, rest] = match;
153
181
  const indent = ws.length;
154
182
 
@@ -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) {
@@ -0,0 +1,88 @@
1
+ /**
2
+ * mind/builtins-register.js — LEVER 3 (tool-calling breadth): registers the
3
+ * network + construction capabilities as FIRST-CLASS registry tools so the
4
+ * research rung (`research()`) and the swarm can invoke them by name like
5
+ * any shell tool. Import once at boot/serve; registration is idempotent.
6
+ *
7
+ * These are ASYNC capability tools (functions, not argv templates) — the
8
+ * registry stores their runner and runTool dispatches to it. Same rails
9
+ * apply: journaled, redacted, bounded.
10
+ *
11
+ * @module mind/builtins-register
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ import { registerTool } from './tools.js';
17
+ import { searchWeb } from './research/search.js';
18
+ import { listTemplates, loadTemplate } from './scaffold.js';
19
+
20
+ let registered = false;
21
+
22
+ /** Idempotently register the capability tools. */
23
+ export function registerBuiltinCapabilities() {
24
+ if (registered) return listTemplates();
25
+ registered = true;
26
+
27
+ registerTool({
28
+ name: 'web-search',
29
+ description: 'search the web (DuckDuckGo keyless / Tavily keyed); {topic} is the query',
30
+ tags: ['web', 'research', 'network'],
31
+ capability: async (opts = {}) => {
32
+ const r = await searchWeb(opts.topic ?? '', { max_results: opts.max_results ?? 5 });
33
+ return {
34
+ ok: r.results.length > 0,
35
+ stdout: r.results.map((x) => `- ${x.title}\n ${x.url}\n ${String(x.snippet ?? '').slice(0, 160)}`).join('\n').slice(0, 8000),
36
+ meta: { provider: r.provider, count: r.results.length, attempts: r.attempts.length },
37
+ };
38
+ },
39
+ });
40
+
41
+ registerTool({
42
+ name: 'scholar-search',
43
+ description: 'search scholarship (arXiv/Semantic Scholar/CrossRef); {topic} is the query',
44
+ tags: ['academic', 'research', 'network'],
45
+ capability: async (opts = {}) => {
46
+ const { searchScholar } = await import('./research/academic.js');
47
+ const papers = await searchScholar(opts.topic ?? '', { max_results: 5 });
48
+ return {
49
+ ok: papers.length > 0,
50
+ stdout: papers.map((p) => `- ${p.title} (${p.year ?? 'n.d.'}, cites: ${p.citation_count ?? '?'})\n ${p.url}`).join('\n').slice(0, 8000),
51
+ meta: { count: papers.length },
52
+ };
53
+ },
54
+ });
55
+
56
+ registerTool({
57
+ name: 'scaffold-list',
58
+ description: 'list constructable project templates (what FS can BUILD)',
59
+ tags: ['construction'],
60
+ capability: async () => {
61
+ const tpls = listTemplates();
62
+ return {
63
+ ok: tpls.length > 0,
64
+ stdout: tpls.map((t) => `${t.id.padEnd(16)} ${(t.stack ?? '-').padEnd(8)} ${t.description}`).join('\n'),
65
+ meta: { count: tpls.length },
66
+ };
67
+ },
68
+ });
69
+
70
+ registerTool({
71
+ name: 'scaffold-inspect',
72
+ description: 'show a template manifest (files, post commands, stack) for {topic}=template id',
73
+ tags: ['construction'],
74
+ capability: async (opts = {}) => {
75
+ try {
76
+ const t = loadTemplate(opts.topic ?? '');
77
+ return {
78
+ ok: true,
79
+ stdout: `${t.name} (stack: ${t.stack ?? 'none'})\n${t.description}\nfiles:\n${t.files.map((f) => ` + ${f.path}`).join('\n')}\npost:\n${(t.post ?? []).map((c) => ` $ ${c}`).join('\n') || ' (none)'}`,
80
+ };
81
+ } catch (err) {
82
+ return { ok: false, error: String(err?.message || err).slice(0, 200) };
83
+ }
84
+ },
85
+ });
86
+
87
+ return listTemplates();
88
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * mind/plan-fullstack.js — COMPOSABLE CONSTRUCTION: a fullstack scaffold is
3
+ * a WORKFLOW DAG (the plan-fix lineage applied to building).
4
+ *
5
+ * Wave 1 (parallel): scaffold the backend, the frontend, and the CLI into
6
+ * their subdirectories — three independent `scaffold` kernel calls.
7
+ * Wave 2 (wiring, after all of wave 1): generate the cross-component glue
8
+ * that only makes sense once all three exist:
9
+ *
10
+ * - frontend/src/api.js — a typed-ish fetch client for the backend
11
+ * - README.md — the repo-level map of all three components
12
+ * - .env.example — shared env contract (PORT for the backend)
13
+ *
14
+ * The wiring steps are shell-independent: they are emitted as additional
15
+ * `scaffold`-adjacent `call` steps writing files through the same
16
+ * transactional path the Gate judges. Resumability inherits from the
17
+ * workflow engine: a failed wiring step can be re-driven; completed
18
+ * scaffolds never re-run.
19
+ *
20
+ * @module mind/plan-fullstack
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ import * as trail from '../kernel/trail.js';
26
+
27
+ /**
28
+ * Generate the fullstack workflow manifest.
29
+ * @param {string} targetDir — repo root to construct into
30
+ * @param {object} [opts] — { name?, backend?, frontend?, cli?, port? }
31
+ * backend/frontend/cli: { template, params } overrides; defaults build
32
+ * node-api + static-web + node-cli.
33
+ * @returns {object} a workflow manifest ({ name, tasks })
34
+ */
35
+ export function planFullstack(targetDir, opts = {}) {
36
+ if (!targetDir || typeof targetDir !== 'string') {
37
+ throw new Error('planFullstack: targetDir is required');
38
+ }
39
+ const name = opts.name ?? targetDir.split(/[\\/]/).pop() ?? 'fullstack-app';
40
+ const port = String(opts.port ?? 3000);
41
+ const backend = { template: 'node-api', dir: 'backend', params: { name: `${name}-api`, port }, ...(opts.backend ?? {}) };
42
+ const frontend = { template: 'static-web', dir: 'frontend', params: { name: `${name}-web`, title: name }, ...(opts.frontend ?? {}) };
43
+ const cli = { template: 'node-cli', dir: 'cli', params: { name: `${name}-cli` }, ...(opts.cli ?? {}) };
44
+ const base = String(targetDir).replace(/\\/g, '/').replace(/\/$/, '');
45
+
46
+ const tasks = [];
47
+
48
+ // ── Wave 1: the three scaffolds, mutually independent → parallel ──
49
+ tasks.push(
50
+ { id: 'scaffold-backend', type: 'call', fn: 'scaffold', args: { template: backend.template, params: backend.params, cwd: `${base}/${backend.dir}` } },
51
+ { id: 'scaffold-frontend', type: 'call', fn: 'scaffold', args: { template: frontend.template, params: frontend.params, cwd: `${base}/${frontend.dir}` } },
52
+ { id: 'scaffold-cli', type: 'call', fn: 'scaffold', args: { template: cli.template, params: cli.params, cwd: `${base}/${cli.dir}` } },
53
+ );
54
+
55
+ // ── Wave 2: wiring — depends on ALL of wave 1 ──
56
+ tasks.push(
57
+ {
58
+ id: 'wire-frontend-api',
59
+ type: 'call',
60
+ fn: 'scaffold.file',
61
+ args: {
62
+ file: `${base}/${frontend.dir}/js/api.js`,
63
+ content: apiClientModule(port),
64
+ description: 'frontend API client for the backend',
65
+ },
66
+ depends_on: ['scaffold-backend', 'scaffold-frontend'],
67
+ },
68
+ {
69
+ id: 'wire-env-contract',
70
+ type: 'call',
71
+ fn: 'scaffold.file',
72
+ args: {
73
+ file: `${base}/.env.example`,
74
+ content: envContract(port),
75
+ description: 'shared env contract',
76
+ },
77
+ depends_on: ['scaffold-backend'],
78
+ },
79
+ {
80
+ id: 'wire-root-readme',
81
+ type: 'call',
82
+ fn: 'scaffold.file',
83
+ args: {
84
+ file: `${base}/README.md`,
85
+ content: rootReadme(name, { backend, frontend, cli, port }),
86
+ description: 'repo-level map of the three components',
87
+ },
88
+ depends_on: ['scaffold-backend', 'scaffold-frontend', 'scaffold-cli'],
89
+ },
90
+ );
91
+
92
+ const manifest = {
93
+ name: `fullstack-${name}`,
94
+ description: `Fullstack construct: ${backend.template} backend + ${frontend.template} frontend + ${cli.template} CLI, wired into one repo`,
95
+ tasks,
96
+ };
97
+ trail.journal('scaffold.fullstack-plan', { name, target: base, tasks: tasks.length });
98
+ return manifest;
99
+ }
100
+
101
+ /** The frontend's generated API client — fetch wrapper over the backend. */
102
+ function apiClientModule(port) {
103
+ return `/**
104
+ * api.js — generated by FS fullstack scaffold. The ONLY place the frontend
105
+ * knows the backend's address; change API_URL here and nowhere else.
106
+ */
107
+ const API_URL = 'http://localhost:${port}';
108
+
109
+ async function request(path, opts = {}) {
110
+ const res = await fetch(\`\${API_URL}\${path}\`, {
111
+ headers: { 'content-type': 'application/json' },
112
+ ...opts,
113
+ });
114
+ const body = await res.json().catch(() => ({}));
115
+ if (!res.ok) throw new Error(body.error ?? \`HTTP \${res.status}\`);
116
+ return body;
117
+ }
118
+
119
+ export const api = {
120
+ health: () => request('/health'),
121
+ listItems: () => request('/items'),
122
+ addItem: (title) => request('/items', { method: 'POST', body: JSON.stringify({ title }) }),
123
+ };
124
+ `;
125
+ }
126
+
127
+ /** The shared env contract. */
128
+ function envContract(port) {
129
+ return `# Generated by FS fullstack scaffold — the env contract shared by all components.
130
+ PORT=${port}
131
+ NODE_ENV=development
132
+ `;
133
+ }
134
+
135
+ /** The repo-level README. */
136
+ function rootReadme(name, { backend, frontend, cli, port }) {
137
+ return `# ${name}
138
+
139
+ Fullstack repo constructed by FS scaffold — three gate-verified components
140
+ wired into one tree. Every file passed the stack's verifier recipe (real
141
+ exit codes) before this run reported success.
142
+
143
+ ## Layout
144
+
145
+ | Component | Template | Location |
146
+ |---|---|---|
147
+ | Backend API | \`${backend.template}\` | \`${backend.dir}/\` — serves on :${port} |
148
+ | Frontend | \`${frontend.template}\` | \`${frontend.dir}/\` — open \`index.html\` |
149
+ | CLI | \`${cli.template}\` | \`${cli.dir}/\` |
150
+
151
+ ## Run
152
+
153
+ \`\`\`bash
154
+ cd ${backend.dir} && npm install && npm start # API on :${port}
155
+ cd ${frontend.dir} # open index.html (js/api.js talks to :${port})
156
+ cd ${cli.dir} && npm install && npm test
157
+ \`\`\`
158
+
159
+ ## Contract
160
+
161
+ - The frontend's \`${frontend.dir}/js/api.js\` is the single seam to the backend.
162
+ - \`.env.example\` is the shared environment contract.
163
+ `;
164
+ }