diff --git a/.changeset/20333-create-objectstack-wire-barrels.md b/.changeset/20333-create-objectstack-wire-barrels.md
new file mode 100644
index 00000000000..63c9cfb3782
--- /dev/null
+++ b/.changeset/20333-create-objectstack-wire-barrels.md
@@ -0,0 +1,15 @@
+---
+'create-objectstack': patch
+---
+
+fix(create-objectstack): the blank starter wires every directory `os generate` writes into
+
+`npm create objectstack` scaffolded an `objectstack.config.ts` that imported `./src/objects` alone. `os g view`, `action`, `flow`, `dashboard`, `app` and `skill` each wrote a file and a barrel `index.ts` that nothing imported, and `os validate` then exited 0 printing `Logic: 0 Flows`: the generated metadata was never loaded.
+
+**What a new blank project now ships** is the wiring `os init` writes:
+
+- `objectstack.config.ts` imports every directory `os generate` writes into (`src/objects`, `src/views`, `src/actions`, `src/flows`, `src/dashboards`, `src/apps`, `src/skills`) and hands each barrel's exports to `defineStack` under its key (`objects`, `views`, …). A file `os g` writes there is part of the stack with no edit to the config. The keys read the barrels through a small `exportsOf` helper declared in the config, because `Object.values` on an empty barrel does not type-check against `defineStack`'s collection types.
+- An `index.ts` containing only `export {};` in each of those directories except `src/objects`, which keeps the sample object.
+- `requires: ['automation', 'triggers']`. `automation` was already there for the three connector plugins. `triggers` fires a flow that starts on a record change, the kind `os g flow` writes, and without it the config stops loading as soon as it holds one. A project with no flow boots as before.
+
+**Projects scaffolded by an earlier release** keep their config. `os g` says when a file it wrote is not wired, and prints the lines to add.
diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx
index 247bfeadca4..7b518e78565 100644
--- a/content/docs/deployment/cli.mdx
+++ b/content/docs/deployment/cli.mdx
@@ -33,10 +33,15 @@ This scaffolds a working project with `objectstack.config.ts`, a sample object,
```bash
os generate object customer # Add a Customer object
-os generate action approve # Add an action
-os generate flow onboarding # Add an automation flow
+os generate flow customer # Add an automation flow on it
+os generate action customer # Add an action on it that runs the flow
```
+Each command writes a file and its export line, and the starter's config already
+wires every directory they write into, so all three are part of the stack. The
+flow and the action bind to the object named like them, which is why the object
+comes first: an action bound to an object the stack does not declare is refused.
+
### Launch the dev server
```bash
@@ -739,8 +744,8 @@ the view and the action listed under
renamed out of that page's `support_desk_` namespace into `my_app_`. That page's `support`
app is **not** added, and a zero count is never printed, which is why the `UI:` row reads
`1 Views 1 Actions` with no `Apps`. The walkthrough's `os generate` commands are **not**
-part of the fixture — run those as well and the summary gains `my_app_customer` and a
-`Logic:` row. Timings are machine identity, and the rule count and artifact size track the
+part of the fixture — run those as well and the summary gains `my_app_customer`, a second
+action and a `Logic:` row. Timings are machine identity, and the rule count and artifact size track the
CLI version; everything else is fixture identity and reproduces.
@@ -1410,7 +1415,8 @@ else a scaffold names (an action's, flow's or app's own `name`) is prefixed.
**Every scaffold reaches the stack, or the command says it does not.** The
`objectstack.config.ts` that `os init` writes for the `app` and `plugin`
-templates wires every directory in the table below: it imports each
+templates, and the one the `npm create objectstack` starter ships, wires every
+directory in the table below: it imports each
`src/
/index.ts` barrel and hands its exports to `defineStack` under the key
in the **Collected as** column, so a file `os g` writes there is part of the
stack with no edit to the config. It also declares
@@ -1421,8 +1427,8 @@ After writing, `os g` loads the config again and says which of these holds:
checks it.
- **Not wired**: the config loads and its stack does not carry the item, or
there is no config. This is what happens with a config that imports
- `./src/objects` alone, as `os init` projects from earlier releases and the
- `npm create objectstack` starter do. The file is written, the config is left
+ `./src/objects` alone, as projects that `os init` and `npm create objectstack`
+ scaffolded in earlier releases do. The file is written, the config is left
as it was, and the command prints the import and the `defineStack` key that
wire the directory.
- **Cannot run**: the stack carries a flow, and its `requires` lacks
diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx
index 9b1da864799..1640287c80b 100644
--- a/content/docs/getting-started/build-with-claude-code.mdx
+++ b/content/docs/getting-started/build-with-claude-code.mdx
@@ -223,12 +223,16 @@ export const SupportApp = defineApp({
});
```
-The agent also **wires the new files into `objectstack.config.ts`** — the object
-through the `src/objects/index.ts` barrel, and the action, view and app via the
-`actions:` / `views:` / `apps:` arrays in `defineStack()`. There is no
-filename-suffix magic: metadata exists in the app only if the config imports it,
-so if a freshly-authored action doesn't show up, the wiring is the first thing to
-check.
+The agent also **exports each new file from its directory's barrel** — the object
+from `src/objects/index.ts`, and the action, view and app from
+`src/actions/index.ts`, `src/views/index.ts` and `src/apps/index.ts`. The
+starter's `objectstack.config.ts` already hands every barrel to `defineStack()`
+through `exportsOf()`, so that export line is the whole wiring and the config is
+not edited; adding an `actions:` / `views:` / `apps:` key for the file instead
+would duplicate the key and replace that barrel's wiring. There is no
+filename-suffix magic: metadata exists in the app only if its barrel exports it,
+so if a freshly-authored action doesn't show up, its export line is the first
+thing to check.
What it does **not** touch is the starter `note` object the `blank` template
scaffolded in step 1 (`src/objects/note.object.ts`, two fields). Nothing asked
diff --git a/content/docs/getting-started/your-first-project.mdx b/content/docs/getting-started/your-first-project.mdx
index 8d228472f36..b3f62dbbf13 100644
--- a/content/docs/getting-started/your-first-project.mdx
+++ b/content/docs/getting-started/your-first-project.mdx
@@ -91,22 +91,41 @@ my-app/
├── tsconfig.json
├── AGENTS.md # conventions for coding agents
└── src/
- └── objects/
- ├── index.ts # barrel — re-exports every object
- └── note.object.ts # a sample object
+ ├── objects/
+ │ ├── index.ts # barrel — re-exports every object
+ │ └── note.object.ts # a sample object
+ ├── views/index.ts # an empty barrel for each directory
+ ├── actions/index.ts # `os generate` writes into, already
+ ├── flows/index.ts # wired into objectstack.config.ts
+ ├── dashboards/index.ts
+ ├── apps/index.ts
+ └── skills/index.ts
```
Two files matter most:
**`objectstack.config.ts`** wires everything together. There is no
-filename-suffix magic — metadata exists in the app only if it is imported here:
+filename-suffix magic — metadata exists in the app only if it is imported here.
+Every directory `os generate` writes into is already imported, so a generated
+view, action or flow adds a file and one export line to its directory's
+`index.ts` and is part of the stack with no edit to this file:
```typescript title="objectstack.config.ts"
import { defineStack } from '@objectstack/spec';
import { ConnectorRestPlugin } from '@objectstack/connector-rest';
import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi';
import { ConnectorMcpPlugin } from '@objectstack/connector-mcp';
-import * as objects from './src/objects/index.js';
+import * as objects from './src/objects';
+import * as views from './src/views';
+import * as actions from './src/actions';
+import * as flows from './src/flows';
+import * as dashboards from './src/dashboards';
+import * as apps from './src/apps';
+import * as skills from './src/skills';
+
+// Object.values, typed by what the barrel exports, so an empty barrel still
+// type-checks.
+const exportsOf = (barrel: M): M[keyof M][] => Object.values(barrel);
export default defineStack({
manifest: {
@@ -120,14 +139,22 @@ export default defineStack({
},
// `automation` runs flows and, per ADR-0097, materializes declarative
// `connectors:` entries at boot. The three generic executors below register
- // their `rest` / `openapi` / `mcp` provider factories with it.
- requires: ['automation'],
+ // their `rest` / `openapi` / `mcp` provider factories with it. `triggers`
+ // fires a flow that starts on a record change, the kind `os generate flow`
+ // writes.
+ requires: ['automation', 'triggers'],
plugins: [
new ConnectorRestPlugin(),
new ConnectorOpenApiPlugin(),
new ConnectorMcpPlugin(),
],
- objects: Object.values(objects),
+ objects: exportsOf(objects),
+ views: exportsOf(views),
+ actions: exportsOf(actions),
+ flows: exportsOf(flows),
+ dashboards: exportsOf(dashboards),
+ apps: exportsOf(apps),
+ skills: exportsOf(skills),
});
```
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 176024d636b..a57d5b003a4 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -132,6 +132,9 @@
"better-sqlite3": "^13.0.3"
},
"devDependencies": {
+ "@objectstack/connector-mcp": "workspace:*",
+ "@objectstack/connector-openapi": "workspace:*",
+ "@objectstack/connector-rest": "workspace:*",
"@objectstack/driver-turso": "workspace:*",
"@objectstack/plugin-dev": "workspace:*",
"@oclif/plugin-help": "^6.2.58",
diff --git a/packages/cli/test/create-objectstack-stack-reach.test.ts b/packages/cli/test/create-objectstack-stack-reach.test.ts
new file mode 100644
index 00000000000..abac07a894a
--- /dev/null
+++ b/packages/cli/test/create-objectstack-stack-reach.test.ts
@@ -0,0 +1,164 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * PIN (#20333) — `npm create objectstack` → `os g flow` → `os validate`
+ * counts the flow, with `os g object` as the control.
+ *
+ * ## What was measured before the fix
+ *
+ * On `origin/main` `c74de10a9`, a project scaffolded by the on-ramp's real
+ * `bin/` entry, then `os g object order_line` and `os g flow order_line`:
+ *
+ * os g object exit 0, "Reaches the stack" (objects was always wired)
+ * os g flow exit 0, "Not wired: … is not part of the stack"
+ * os validate exit 0, `Data: 2 Objects` and `Logic: 0 Flows`
+ *
+ * The blank starter's config imported `./src/objects` alone, so the flow was
+ * written and never loaded. The control is what makes the red readable: the
+ * same chain counted the generated object, so a 0 for the flow is the wiring
+ * and not a harness that counts nothing.
+ *
+ * ## The chain, through the real commands
+ *
+ * node create-objectstack/bin/create-objectstack.js my-app --skip-install --skip-skills
+ * os g object order_line → exit 0, reaches the stack
+ * os g flow order_line → exit 0, reaches the stack, no wiring lines to add
+ * os validate → exit 0, `Data: 2 Objects`, `Logic: 1 Flows`
+ *
+ * Asserted: exit statuses, the named subjects, the absence of the wiring
+ * lines `os g` prints for a scaffold that did not arrive (code an author
+ * pastes), and the counts `os validate` prints. Prose is not pinned. The item
+ * names are read off the generator roster, not written down.
+ *
+ * ## Why here, why a child process, and why this file is NOT named `.e2e`
+ *
+ * `os g` and `os validate` are this package's commands, and it already
+ * depends on `create-objectstack`, so `@objectstack/cli#test`'s `^build`
+ * builds the on-ramp's `dist/` — the tree its `bin/` copies from. The bin and
+ * the blank template are declared cross-package inputs of this package
+ * (scripts/cross-package-test-inputs.mjs, mirrored into turbo.json).
+ *
+ * The project lives under this package's `node_modules`, so the scaffolded
+ * config's imports resolve to workspace copies without an install. Besides
+ * `@objectstack/spec`, the blank config imports three connector packages;
+ * they are this package's devDependencies for that reason alone, which is
+ * also what puts them in this suite's build closure.
+ *
+ * An exit status is the contract, and `process.exit` inside a vitest worker
+ * is not one, so the commands are spawned (the `integration` project). The
+ * name keeps it in the per-PR run. The structural half — the blank config
+ * wires exactly what `os init` wires — is `create-objectstack-wiring-parity.test.ts`.
+ */
+
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { execFile } from 'node:child_process';
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
+import { join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { GENERATOR_SCAFFOLD_TARGETS } from '../src/commands/generate.js';
+import { childEnv } from './helpers/serve-process.js';
+
+const HERE = resolve(fileURLToPath(import.meta.url), '..');
+const CLI = resolve(HERE, '../bin/run-dev.js');
+const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
+
+// One `resolve(HERE, …)` call per line: `check:cross-package-test-inputs`
+// reconstructs this read by SOURCE SCAN.
+const ON_RAMP_BIN = resolve(HERE, '../../..', 'packages/create-objectstack/bin/create-objectstack.js');
+
+/** One plain-node scaffold, then three oclif + tsx cold starts, sequential. */
+const RUN_TIMEOUT_MS = 240_000;
+
+const PROJECT = 'my-app';
+const NS = 'my_app';
+const STEM = 'order_line';
+
+interface Run {
+ code: number;
+ stdout: string;
+ stderr: string;
+}
+
+function run(file: string, args: string[], cwd: string): Promise {
+ return new Promise((resolvePromise) => {
+ execFile(
+ file,
+ args,
+ { cwd, maxBuffer: 8 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
+ (err, stdout, stderr) => {
+ resolvePromise({
+ // `err.code` is the real exit status; a signalled child has none and
+ // is reported as 1, never as 0.
+ code: err
+ ? typeof (err as { code?: unknown }).code === 'number'
+ ? (err as unknown as { code: number }).code
+ : 1
+ : 0,
+ stdout: String(stdout),
+ stderr: String(stderr),
+ });
+ },
+ );
+ });
+}
+
+const os = (args: string[], cwd: string) => run(TSX, [CLI, ...args], cwd);
+const out = (r: Run) => r.stdout + r.stderr;
+
+const target = (type: string) => {
+ const t = GENERATOR_SCAFFOLD_TARGETS.find((g) => g.type === type);
+ if (!t) throw new Error(`no '${type}' generator in the roster`);
+ return t;
+};
+const OBJECT = target('object');
+const FLOW = target('flow');
+
+let root: string;
+let project: string;
+let scaffold: Run;
+let genObject: Run;
+let genFlow: Run;
+let validate: Run;
+
+beforeAll(async () => {
+ root = mkdtempSync(join(HERE, '..', 'node_modules', '.create-objectstack-reach-'));
+ project = join(root, PROJECT);
+ scaffold = await run(process.execPath, [ON_RAMP_BIN, PROJECT, '--skip-install', '--skip-skills'], root);
+ // Sequential on purpose: the object first, so the flow binds to something
+ // declared, and cold starts in a container several agents share.
+ genObject = await os(['g', 'object', STEM], project);
+ genFlow = await os(['g', 'flow', STEM], project);
+ validate = await os(['validate'], project);
+}, RUN_TIMEOUT_MS);
+
+afterAll(() => {
+ if (root) rmSync(root, { recursive: true, force: true });
+});
+
+describe('[#20333] `npm create objectstack` → `os g flow` → `os validate`', () => {
+ it('the on-ramp scaffolded the project, under the namespace this file assumes', () => {
+ expect(scaffold.code, out(scaffold)).toBe(0);
+ expect(readFileSync(join(project, 'objectstack.config.ts'), 'utf-8')).toContain(`namespace: '${NS}'`);
+ });
+
+ it('CONTROL: `os g object` reaches the stack', () => {
+ expect(genObject.code, out(genObject)).toBe(0);
+ expect(genObject.stdout).toContain(`'${OBJECT.itemName(STEM, NS)}'`);
+ expect(genObject.stdout).not.toContain(`import * as ${OBJECT.stackKey}`);
+ });
+
+ it('`os g flow` reaches the stack, with no wiring lines to add', () => {
+ expect(genFlow.code, out(genFlow)).toBe(0);
+ expect(genFlow.stdout).toContain(`'${FLOW.itemName(STEM, NS)}'`);
+ expect(genFlow.stdout).not.toContain(`import * as ${FLOW.stackKey}`);
+ // Nor a `requires` line: the starter already declares what a flow runs on.
+ expect(genFlow.stdout).not.toContain('requires: [');
+ });
+
+ it('`os validate` exits 0 and counts the generated flow beside the control', () => {
+ expect(validate.code, out(validate)).toBe(0);
+ // The starter's own object plus the generated one.
+ expect(validate.stdout).toMatch(/\bData: 2 Objects\b/);
+ expect(validate.stdout).toMatch(/\bLogic: 1 Flows\b/);
+ });
+});
diff --git a/packages/cli/test/create-objectstack-wiring-parity.test.ts b/packages/cli/test/create-objectstack-wiring-parity.test.ts
new file mode 100644
index 00000000000..f228853c33d
--- /dev/null
+++ b/packages/cli/test/create-objectstack-wiring-parity.test.ts
@@ -0,0 +1,141 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * PIN (#20333) — `npm create objectstack`'s blank starter wires exactly the
+ * barrels `os init` wires, in the lines `os init` renders.
+ *
+ * ## The defect
+ *
+ * The blank starter's `objectstack.config.ts` imported `./src/objects` alone.
+ * `os g view|action|flow|dashboard|app|skill` wrote a scaffold and a barrel
+ * that nothing imported, and `os validate` exited 0 with `Logic: 0 Flows`.
+ * `os init` had the same config and was fixed by wiring every generator
+ * barrel (#20215): `SCAFFOLD_WIRED_BARRELS`, derived from the generator
+ * roster, rendered through `exportsOf` over `export {};` barrels, with
+ * `SCAFFOLD_WIRED_REQUIRES` in `requires`.
+ *
+ * ## Why a parity pin, and why it lives in this package
+ *
+ * `create-objectstack` cannot import that roster. The dependency edge runs
+ * the other way (this package depends on it for `created-summary`), and the
+ * npx entry point must not pull the CLI's closure — the same boundary
+ * `scripts/sync-scaffold-emission-policy.mjs` documents. Its template is a
+ * static file tree copied byte for byte, so its wiring is a COPY of what
+ * `os init` renders. A copy with nothing binding it to the source is a second
+ * wiring rule, and it decays: the next generator added to the roster would be
+ * wired by `os init` and silently not by the on-ramp.
+ *
+ * This file is the binding. Every expected value below is READ off the CLI —
+ * `TEMPLATES.app`'s rendered config and barrels, and the two exported
+ * rosters — and none is written down here, so a roster change, a renderer
+ * change or a hand edit of the template reddens this file until the two
+ * scaffolders agree again. Only this package can call the renderer; the
+ * template side is a static file, so reading it IS reading its producer.
+ *
+ * What is compared is code, verbatim: the barrel imports, the `exportsOf`
+ * helper, the stack-key lines, the `requires` tokens, and each empty barrel's
+ * bytes. The prose comments around them are not compared — the starter keeps
+ * its own explanation of `automation` (its connectors need it too).
+ *
+ * The behaviour this wiring buys is pinned through the real commands in
+ * `create-objectstack-stack-reach.test.ts` (`npm create objectstack` →
+ * `os g flow` → `os validate`).
+ */
+
+import { describe, expect, it } from 'vitest';
+import { existsSync, readFileSync } from 'node:fs';
+import { join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { SCAFFOLD_WIRED_BARRELS, SCAFFOLD_WIRED_REQUIRES, TEMPLATES } from '../src/commands/init.js';
+
+const HERE = resolve(fileURLToPath(import.meta.url), '..');
+
+// One `resolve(HERE, …)` call per line: `check:cross-package-test-inputs`
+// reconstructs these reads by SOURCE SCAN. The blank template is declared as a
+// cross-package input of `@objectstack/cli` (scripts/cross-package-test-inputs.mjs,
+// mirrored into turbo.json), so a template-only diff re-runs this file.
+const BLANK = resolve(HERE, '../../create-objectstack/src/templates/blank');
+
+const blankConfig = readFileSync(join(BLANK, 'objectstack.config.ts'), 'utf8');
+const initConfig = TEMPLATES.app.configContent('my-app', 'my_app');
+
+/** Every line of `text` matching `re`, sorted — order is not the contract. */
+const linesMatching = (text: string, re: RegExp): string[] =>
+ text.split('\n').filter((line) => re.test(line)).sort();
+
+/** `import * as from './src/';` — one per wired barrel. */
+const BARREL_IMPORT = /^import \* as \w+ from '\.\/src\/[\w-]+';$/;
+/** ` : exportsOf(),` — one per wired barrel, inside `defineStack`. */
+const STACK_KEY = /^\s+\w+: exportsOf\(\w+\),$/;
+/** The helper both configs read their barrels through. */
+const HELPER = /^const exportsOf = /;
+
+/** A barrel whose only code is `export {};` — comments aside. */
+const isEmptyBarrel = (source: string): boolean =>
+ source
+ .split('\n')
+ .filter((line) => line.trim() !== '' && !line.trimStart().startsWith('//'))
+ .join('\n') === 'export {};';
+
+describe('[#20333] the roster this file reads is the real one', () => {
+ it('wires more than objects, and includes flows', () => {
+ // Vacuity guard: an empty or objects-only roster would make every
+ // comparison below agree with the defect.
+ expect(SCAFFOLD_WIRED_BARRELS.length).toBeGreaterThan(1);
+ expect(SCAFFOLD_WIRED_BARRELS.map((b) => b.stackKey)).toContain('flows');
+ expect(SCAFFOLD_WIRED_REQUIRES.length).toBeGreaterThan(0);
+ });
+
+ it('the patterns below find one line per wired barrel in the CLI render', () => {
+ expect(linesMatching(initConfig, BARREL_IMPORT)).toHaveLength(SCAFFOLD_WIRED_BARRELS.length);
+ expect(linesMatching(initConfig, STACK_KEY)).toHaveLength(SCAFFOLD_WIRED_BARRELS.length);
+ expect(linesMatching(initConfig, HELPER)).toHaveLength(1);
+ });
+});
+
+describe('[#20333] the blank config wires what `os init` wires, in its lines', () => {
+ it('imports every wired barrel, and nothing else under ./src', () => {
+ expect(linesMatching(blankConfig, BARREL_IMPORT)).toEqual(linesMatching(initConfig, BARREL_IMPORT));
+ });
+
+ it('reads the barrels through the same `exportsOf` helper', () => {
+ expect(linesMatching(blankConfig, HELPER)).toEqual(linesMatching(initConfig, HELPER));
+ });
+
+ it('hands every wired barrel to its stack key', () => {
+ expect(linesMatching(blankConfig, STACK_KEY)).toEqual(linesMatching(initConfig, STACK_KEY));
+ });
+
+ it('declares every capability the scaffolds need to run', () => {
+ const declared = [...blankConfig.matchAll(/^\s+requires: \[([^\]]*)\],$/gm)];
+ expect(declared, 'exactly one top-level `requires` line').toHaveLength(1);
+ const tokens = declared[0][1].split(',').map((t) => t.trim().replace(/^'|'$/g, ''));
+ // A superset, not an equality: the starter's connectors need `automation`
+ // whether or not a scaffold does.
+ for (const token of SCAFFOLD_WIRED_REQUIRES) expect(tokens, token).toContain(token);
+ });
+});
+
+describe('[#20333] the blank template ships every wired barrel', () => {
+ const barrels = SCAFFOLD_WIRED_BARRELS.map((b) => {
+ const file = `${b.dir}/index.ts`;
+ const render = TEMPLATES.app.srcFiles[file];
+ return { ...b, file, init: render ? render('my-app', 'my_app') : undefined };
+ });
+
+ it.each(barrels.map((b) => [b.file, b] as const))('%s exists', (_file, b) => {
+ expect(existsSync(join(BLANK, b.file))).toBe(true);
+ });
+
+ const empty = barrels.filter((b) => b.init !== undefined && isEmptyBarrel(b.init));
+
+ it('`os init` writes an empty barrel for every wired directory but objects', () => {
+ // Vacuity guard for the byte comparison below.
+ expect(empty.map((b) => b.stackKey).sort())
+ .toEqual(SCAFFOLD_WIRED_BARRELS.map((b) => b.stackKey).filter((k) => k !== 'objects').sort());
+ });
+
+ it.each(empty.map((b) => [b.file, b] as const))('%s is byte-identical to the one `os init` writes', (_file, b) => {
+ expect(readFileSync(join(BLANK, b.file), 'utf8')).toBe(b.init);
+ });
+});
diff --git a/packages/create-objectstack/README.md b/packages/create-objectstack/README.md
index 30683ea1c11..2e9c0f976f5 100644
--- a/packages/create-objectstack/README.md
+++ b/packages/create-objectstack/README.md
@@ -77,9 +77,15 @@ my-app/
├── README.md
├── AGENTS.md # conventions for coding agents
└── src/
- └── objects/
- ├── index.ts
- └── note.object.ts
+ ├── objects/
+ │ ├── index.ts
+ │ └── note.object.ts
+ ├── views/index.ts # an empty barrel for each directory
+ ├── actions/index.ts # `objectstack generate` writes into,
+ ├── flows/index.ts # already wired into objectstack.config.ts
+ ├── dashboards/index.ts
+ ├── apps/index.ts
+ └── skills/index.ts
```
Next steps inside the project:
diff --git a/packages/create-objectstack/src/starter-comments-self-contained.test.ts b/packages/create-objectstack/src/starter-comments-self-contained.test.ts
index 1427f6702f9..436bf308eb5 100644
--- a/packages/create-objectstack/src/starter-comments-self-contained.test.ts
+++ b/packages/create-objectstack/src/starter-comments-self-contained.test.ts
@@ -142,7 +142,7 @@ const MONOREPO_ONLY = [
// "the shallowest point a path reaches"). In THIS population the two give
// the same answer, for a measured reason: every relative reference the
// shipped tree carries is `./`-anchored and points DOWNWARD from the file
- // that writes it (`./note.object.js`, `./src/objects/index.js`), so the
+ // that writes it (`./note.object.js`, `./src/objects`), so the
// count of `../` here — escaping or merely climbing — is zero. The bare
// anchor therefore has no correct text to redden. The leading lookbehind is
// what keeps it that way: it refuses a `..` that is itself part of a longer
diff --git a/packages/create-objectstack/src/templates/AGENTS.md b/packages/create-objectstack/src/templates/AGENTS.md
index 55324f2ecdc..944cb0e4720 100644
--- a/packages/create-objectstack/src/templates/AGENTS.md
+++ b/packages/create-objectstack/src/templates/AGENTS.md
@@ -46,7 +46,11 @@ Run `npm run build` when you need the compiled `dist/objectstack.json` artifact.
1. **Zod First** — All schema definitions start with Zod. Types are derived via `z.infer<>`.
2. `defineStack()` is the single configuration entry point in `objectstack.config.ts`.
-3. Use `Object.values()` barrel pattern for metadata arrays.
+3. Metadata reaches the stack through barrels: each `src/*/index.ts` exports what its
+ directory holds, and `objectstack.config.ts` already hands every barrel to
+ `defineStack()` through `exportsOf()` — a typed `Object.values()` that still
+ type-checks while a barrel is empty, so keep it. Export a new file from its barrel
+ rather than adding a key for it.
4. Import from `@objectstack/spec` — never use relative paths into the spec package.
5. **Predicates are CEL** — `visible`, `disabled`, `requiredWhen`, validation rules,
flow conditions and sharing rules reference record fields as `record.`,
diff --git a/packages/create-objectstack/src/templates/blank/README.md b/packages/create-objectstack/src/templates/blank/README.md
index f1dc70ba54c..5830ac33994 100644
--- a/packages/create-objectstack/src/templates/blank/README.md
+++ b/packages/create-objectstack/src/templates/blank/README.md
@@ -38,8 +38,10 @@ the whole time — the `curl` above returns it, and an MCP client can read and
write it. What it has no route into is the Console's navigation.
**An object appears in Console navigation only when an app lists it.** Add an
-`*.app.ts` under `src/apps/` (plus the views it points at), and the Console
-renders it after the next `pnpm dev` rebuild. The `objectstack-ui` skill covers
+`*.app.ts` under `src/apps/` (plus the views it points at), export each one from
+its directory's `index.ts`, and the Console renders it after the next `pnpm dev`
+rebuild. `objectstack generate app NAME` and `objectstack generate view NAME`
+write the file and its export line together. The `objectstack-ui` skill covers
the shape; describing the app you want to your coding agent is the intended
path.
@@ -63,8 +65,15 @@ for OAuth, API keys, and which objects/actions become tools.
## Layout
-- `objectstack.config.ts` — environment manifest (objects, API, plugins)
+- `objectstack.config.ts` — environment manifest (objects, API, plugins), and
+ the wiring for every directory below
- `src/objects/` — object definitions (one file per object)
+- `src/views/`, `src/actions/`, `src/flows/`, `src/dashboards/`, `src/apps/`,
+ `src/skills/` — empty to start. Each directory's `index.ts` exports what it
+ holds and `objectstack.config.ts` hands those exports to the stack, so
+ `objectstack generate flow NAME` (or `view`, `action`, …) adds a file and one
+ export line, and the item is loaded, and counted by `pnpm validate`, with no
+ edit to the config
## Connectors (default providers)
diff --git a/packages/create-objectstack/src/templates/blank/objectstack.config.ts b/packages/create-objectstack/src/templates/blank/objectstack.config.ts
index 84a65033903..17e29830585 100644
--- a/packages/create-objectstack/src/templates/blank/objectstack.config.ts
+++ b/packages/create-objectstack/src/templates/blank/objectstack.config.ts
@@ -2,8 +2,24 @@ import { defineStack } from '@objectstack/spec';
import { ConnectorRestPlugin } from '@objectstack/connector-rest';
import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi';
import { ConnectorMcpPlugin } from '@objectstack/connector-mcp';
-import * as objects from './src/objects/index.js';
+import * as objects from './src/objects';
+import * as views from './src/views';
+import * as actions from './src/actions';
+import * as flows from './src/flows';
+import * as dashboards from './src/dashboards';
+import * as apps from './src/apps';
+import * as skills from './src/skills';
+// Every value a barrel exports, as the list a stack key takes: typed by what
+// the barrel exports, and an empty list while it exports nothing yet.
+const exportsOf = (barrel: M): M[keyof M][] => Object.values(barrel);
+
+// This file is a MODULE, and the whole module is the stack: the default
+// export below is the base, and every NAMED export is merged onto it as a
+// top-level stack key under its own name. A helper exported from here is
+// therefore read as a stack key and the build refuses it — keep helpers in a
+// sibling module and import them. Only names the stack schema declares
+// (onEnable, functions, the collections) belong here as named exports.
export default defineStack({
manifest: {
id: 'com.example.blank',
@@ -27,7 +43,12 @@ export default defineStack({
// connector executors below register their provider factories with it —
// without `automation` loaded they have nowhere to register and boot fails,
// so keep this capability whenever `plugins:` lists a connector.
- requires: ['automation'],
+ //
+ // `triggers` fires a flow that starts on a record change, the kind
+ // `objectstack generate flow NAME` writes: without it this config stops
+ // loading once it holds such a flow. It can go if this project will never
+ // hold one.
+ requires: ['automation', 'triggers'],
// Generic connector executors, default-present so you can add a `connectors:`
// entry naming `provider: 'rest' | 'openapi' | 'mcp'` and have it materialize
@@ -43,5 +64,17 @@ export default defineStack({
new ConnectorMcpPlugin(),
],
- objects: Object.values(objects),
+ // Every directory `objectstack generate` writes into is wired here: its
+ // index.ts exports what the directory holds, and each list below hands
+ // those exports to the stack. `objectstack generate view NAME` adds a file
+ // and one export line, and the view is part of this stack with no edit to
+ // this file. A directory that is not wired here is never loaded, and
+ // `objectstack validate` neither counts nor checks what it holds.
+ objects: exportsOf(objects),
+ views: exportsOf(views),
+ actions: exportsOf(actions),
+ flows: exportsOf(flows),
+ dashboards: exportsOf(dashboards),
+ apps: exportsOf(apps),
+ skills: exportsOf(skills),
});
diff --git a/packages/create-objectstack/src/templates/blank/src/actions/index.ts b/packages/create-objectstack/src/templates/blank/src/actions/index.ts
new file mode 100644
index 00000000000..9ad0888f28b
--- /dev/null
+++ b/packages/create-objectstack/src/templates/blank/src/actions/index.ts
@@ -0,0 +1,5 @@
+// The actions in this directory. `objectstack generate action NAME` writes one
+// here and adds its export line below. objectstack.config.ts hands every
+// export of this file to the stack, so a action exported here is part of it,
+// and a action file this index does not export is never loaded.
+export {};
diff --git a/packages/create-objectstack/src/templates/blank/src/apps/index.ts b/packages/create-objectstack/src/templates/blank/src/apps/index.ts
new file mode 100644
index 00000000000..535c2b56f6b
--- /dev/null
+++ b/packages/create-objectstack/src/templates/blank/src/apps/index.ts
@@ -0,0 +1,5 @@
+// The apps in this directory. `objectstack generate app NAME` writes one
+// here and adds its export line below. objectstack.config.ts hands every
+// export of this file to the stack, so a app exported here is part of it,
+// and a app file this index does not export is never loaded.
+export {};
diff --git a/packages/create-objectstack/src/templates/blank/src/dashboards/index.ts b/packages/create-objectstack/src/templates/blank/src/dashboards/index.ts
new file mode 100644
index 00000000000..c053abecb0b
--- /dev/null
+++ b/packages/create-objectstack/src/templates/blank/src/dashboards/index.ts
@@ -0,0 +1,5 @@
+// The dashboards in this directory. `objectstack generate dashboard NAME` writes one
+// here and adds its export line below. objectstack.config.ts hands every
+// export of this file to the stack, so a dashboard exported here is part of it,
+// and a dashboard file this index does not export is never loaded.
+export {};
diff --git a/packages/create-objectstack/src/templates/blank/src/flows/index.ts b/packages/create-objectstack/src/templates/blank/src/flows/index.ts
new file mode 100644
index 00000000000..51262047af5
--- /dev/null
+++ b/packages/create-objectstack/src/templates/blank/src/flows/index.ts
@@ -0,0 +1,5 @@
+// The flows in this directory. `objectstack generate flow NAME` writes one
+// here and adds its export line below. objectstack.config.ts hands every
+// export of this file to the stack, so a flow exported here is part of it,
+// and a flow file this index does not export is never loaded.
+export {};
diff --git a/packages/create-objectstack/src/templates/blank/src/skills/index.ts b/packages/create-objectstack/src/templates/blank/src/skills/index.ts
new file mode 100644
index 00000000000..013da9c6bf6
--- /dev/null
+++ b/packages/create-objectstack/src/templates/blank/src/skills/index.ts
@@ -0,0 +1,5 @@
+// The skills in this directory. `objectstack generate skill NAME` writes one
+// here and adds its export line below. objectstack.config.ts hands every
+// export of this file to the stack, so a skill exported here is part of it,
+// and a skill file this index does not export is never loaded.
+export {};
diff --git a/packages/create-objectstack/src/templates/blank/src/views/index.ts b/packages/create-objectstack/src/templates/blank/src/views/index.ts
new file mode 100644
index 00000000000..51c27851ae0
--- /dev/null
+++ b/packages/create-objectstack/src/templates/blank/src/views/index.ts
@@ -0,0 +1,5 @@
+// The views in this directory. `objectstack generate view NAME` writes one
+// here and adds its export line below. objectstack.config.ts hands every
+// export of this file to the stack, so a view exported here is part of it,
+// and a view file this index does not export is never loaded.
+export {};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 980f23e80db..bf69c2837b1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -616,6 +616,15 @@ importers:
specifier: ^4.6.1
version: 4.6.1
devDependencies:
+ '@objectstack/connector-mcp':
+ specifier: workspace:*
+ version: link:../connectors/connector-mcp
+ '@objectstack/connector-openapi':
+ specifier: workspace:*
+ version: link:../connectors/connector-openapi
+ '@objectstack/connector-rest':
+ specifier: workspace:*
+ version: link:../connectors/connector-rest
'@objectstack/driver-turso':
specifier: workspace:*
version: link:../drivers/driver-turso
diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs
index 830ab47d042..431ed76ab95 100644
--- a/scripts/cross-package-test-inputs.mjs
+++ b/scripts/cross-package-test-inputs.mjs
@@ -624,7 +624,31 @@ export const CROSS_PACKAGE_TEST_INPUTS = {
// that file DEFINES the `FieldType` enum the pin asserts totality over --
// a member added there is exactly the change that must re-run this suite.
'packages/spec/src/data/field.zod.ts',
+ // The blank starter's config and its `src/` tree, the third pair of the
+ // two-scaffolder shape above (#20333). The starter cannot import the
+ // wiring `os init` renders (the dependency edge runs the other way), so
+ // its config and barrels are a static COPY of that render, and
+ // test/create-objectstack-wiring-parity.test.ts holds the copy to it:
+ // every expected line is read off `TEMPLATES.app` and the two exported
+ // rosters. test/create-objectstack-stack-reach.test.ts drives the
+ // on-ramp's `bin/` into a project and counts what `os validate` loads,
+ // the starter's own object included. A template-only diff therefore
+ // changes what both measure, and without these globs
+ // `@objectstack/cli#test` would hash the same and replay a cached green
+ // over the drift the parity pin exists to catch. `src/**` rather than
+ // the barrels by name: the pin reaches them through a join over the
+ // roster, which is why the glob carries a witness below.
+ 'packages/create-objectstack/src/templates/blank/objectstack.config.ts',
+ 'packages/create-objectstack/src/templates/blank/src/**',
],
+ heldBy: {
+ // The parity pin joins each barrel path from `SCAFFOLD_WIRED_BARRELS`, a
+ // roster the scan cannot fold, so no rostered path lands in this glob;
+ // the witness keeps it attributed to the read that needs it.
+ 'packages/create-objectstack/src/templates/blank/src/**': [
+ 'packages/cli/test/create-objectstack-wiring-parity.test.ts',
+ ],
+ },
},
'@objectstack/client': {
// The first entry this gate DERIVED from import specifiers rather than from
diff --git a/turbo.json b/turbo.json
index 9b149402f9b..42a3e1c2316 100644
--- a/turbo.json
+++ b/turbo.json
@@ -144,7 +144,9 @@
"$TURBO_ROOT$/scripts/sync-scaffold-emission-policy.mjs",
"$TURBO_ROOT$/packages/drivers/driver-sql/src/sql-driver.ts",
"$TURBO_ROOT$/packages/drivers/driver-sql/src/schema-drift.ts",
- "$TURBO_ROOT$/packages/spec/src/data/field.zod.ts"
+ "$TURBO_ROOT$/packages/spec/src/data/field.zod.ts",
+ "$TURBO_ROOT$/packages/create-objectstack/src/templates/blank/objectstack.config.ts",
+ "$TURBO_ROOT$/packages/create-objectstack/src/templates/blank/src/**"
]
},
"@objectstack/client#test": {