@crewhaus/context-bundle 0.1.0

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/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@crewhaus/context-bundle",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Builds a single-markdown manifest of CrewHaus docs + recipes + schema. Consumed by `crewhaus context --bundle` and the cloud demo prompt builder.",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {},
15
+ "license": "Apache-2.0",
16
+ "author": {
17
+ "name": "Max Meier",
18
+ "email": "max@studiomax.io",
19
+ "url": "https://studiomax.io"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/crewhaus/factory.git",
24
+ "directory": "packages/context-bundle"
25
+ },
26
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/context-bundle#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/crewhaus/factory/issues"
29
+ },
30
+ "publishConfig": {
31
+ "access": "restricted"
32
+ },
33
+ "files": [
34
+ "src",
35
+ "README.md",
36
+ "LICENSE",
37
+ "NOTICE"
38
+ ]
39
+ }
@@ -0,0 +1,147 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { buildContextBundle, discoverRoots } from "./index";
6
+
7
+ function makeFixtureTree(): {
8
+ factoryRoot: string;
9
+ docsRoot: string;
10
+ demosRoot: string;
11
+ monorepoRoot: string;
12
+ } {
13
+ const monorepoRoot = join(
14
+ tmpdir(),
15
+ `crewhaus-context-bundle-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
16
+ );
17
+ const factoryRoot = join(monorepoRoot, "factory");
18
+ const docsRoot = join(monorepoRoot, "docs");
19
+ const demosRoot = join(monorepoRoot, "demos");
20
+
21
+ mkdirSync(join(factoryRoot, "packages/spec/src"), { recursive: true });
22
+ mkdirSync(docsRoot, { recursive: true });
23
+ mkdirSync(join(demosRoot, "recipes"), { recursive: true });
24
+
25
+ writeFileSync(
26
+ join(factoryRoot, "packages/spec/src/index.ts"),
27
+ "export const SPEC_VERSION = 0;\nconst _x = z.discriminatedUnion('target', []);\n",
28
+ );
29
+ writeFileSync(
30
+ join(docsRoot, "GETTING-STARTED.md"),
31
+ "# Getting started\n\nWelcome to the fixture.\n",
32
+ );
33
+ writeFileSync(
34
+ join(docsRoot, "MODULE-CATALOG.md"),
35
+ "# Module catalog\n\n## Layer A — Foundations\n\nbody\n\n### A1 — IR\n\nbody\n",
36
+ );
37
+ writeFileSync(
38
+ join(demosRoot, "recipes/INDEX.md"),
39
+ [
40
+ "# Recipes",
41
+ "",
42
+ "preamble paragraph",
43
+ "",
44
+ "## Pick a recipe — diagnostic decision tree",
45
+ "",
46
+ "1. First option",
47
+ "2. Second option",
48
+ "",
49
+ "## Recipes by part",
50
+ "",
51
+ "more content here",
52
+ ].join("\n"),
53
+ );
54
+ writeFileSync(join(demosRoot, "recipes/01-cli-coding-agent.md"), "# CLI Coding Agent\n\nbody\n");
55
+ writeFileSync(
56
+ join(demosRoot, "recipes/02-sequential-workflow.md"),
57
+ "# Sequential Workflow\n\nbody\n",
58
+ );
59
+
60
+ return { factoryRoot, docsRoot, demosRoot, monorepoRoot };
61
+ }
62
+
63
+ describe("buildContextBundle", () => {
64
+ test("produces a bundle with the major sections", () => {
65
+ const { factoryRoot, docsRoot, demosRoot } = makeFixtureTree();
66
+ const { markdown, sources } = buildContextBundle({
67
+ factoryRoot,
68
+ docsRoot,
69
+ demosRoot,
70
+ bundledAt: "2026-05-21T00:00:00Z",
71
+ });
72
+
73
+ expect(markdown).toContain("# CrewHaus Context Bundle");
74
+ expect(markdown).toContain("Bundled at: 2026-05-21T00:00:00Z");
75
+ expect(markdown).toContain("## Quickstart");
76
+ expect(markdown).toContain("## Spec schema");
77
+ expect(markdown).toContain("z.discriminatedUnion('target', [])");
78
+ expect(markdown).toContain("## Picking a recipe");
79
+ expect(markdown).toContain("1. First option");
80
+ expect(markdown).not.toContain("more content here");
81
+ expect(markdown).toContain("## Recipe index");
82
+ expect(markdown).toContain("01-cli-coding-agent.md");
83
+ expect(markdown).toContain("CLI Coding Agent");
84
+ expect(markdown).toContain("## Module catalog — layer index");
85
+ expect(markdown).toContain("## Layer A — Foundations");
86
+ expect(markdown).not.toContain("body\n");
87
+ expect(markdown).toContain("## Getting started");
88
+ expect(markdown).toContain("Welcome to the fixture");
89
+
90
+ const paths = sources.map((s) => s.path);
91
+ expect(paths.some((p) => p.endsWith("packages/spec/src/index.ts"))).toBe(true);
92
+ expect(paths.some((p) => p.endsWith("recipes/INDEX.md"))).toBe(true);
93
+ expect(paths.some((p) => p.endsWith("GETTING-STARTED.md"))).toBe(true);
94
+ expect(paths.some((p) => p.endsWith("MODULE-CATALOG.md"))).toBe(true);
95
+ });
96
+
97
+ test("is deterministic for a fixed bundledAt and tree", () => {
98
+ const { factoryRoot, docsRoot, demosRoot } = makeFixtureTree();
99
+ const first = buildContextBundle({
100
+ factoryRoot,
101
+ docsRoot,
102
+ demosRoot,
103
+ bundledAt: "2026-05-21T00:00:00Z",
104
+ });
105
+ const second = buildContextBundle({
106
+ factoryRoot,
107
+ docsRoot,
108
+ demosRoot,
109
+ bundledAt: "2026-05-21T00:00:00Z",
110
+ });
111
+ expect(first.markdown).toBe(second.markdown);
112
+ });
113
+ });
114
+
115
+ describe("discoverRoots", () => {
116
+ test("walks up to find sibling factory/docs/demos directories", () => {
117
+ const { factoryRoot, docsRoot, demosRoot, monorepoRoot } = makeFixtureTree();
118
+ const nested = join(monorepoRoot, "factory/packages/spec/src");
119
+
120
+ const roots = discoverRoots({ startDir: nested, env: {} });
121
+
122
+ expect(roots.factoryRoot).toBe(resolve(factoryRoot));
123
+ expect(roots.docsRoot).toBe(resolve(docsRoot));
124
+ expect(roots.demosRoot).toBe(resolve(demosRoot));
125
+ });
126
+
127
+ test("env overrides take precedence", () => {
128
+ const { factoryRoot, docsRoot, demosRoot } = makeFixtureTree();
129
+
130
+ const roots = discoverRoots({
131
+ startDir: "/tmp",
132
+ env: {
133
+ CREWHAUS_FACTORY_ROOT: factoryRoot,
134
+ CREWHAUS_DOCS_ROOT: docsRoot,
135
+ CREWHAUS_DEMOS_ROOT: demosRoot,
136
+ },
137
+ });
138
+
139
+ expect(roots.factoryRoot).toBe(resolve(factoryRoot));
140
+ expect(roots.docsRoot).toBe(resolve(docsRoot));
141
+ expect(roots.demosRoot).toBe(resolve(demosRoot));
142
+ });
143
+
144
+ test("throws when roots cannot be located", () => {
145
+ expect(() => discoverRoots({ startDir: tmpdir(), env: {} })).toThrow(/Could not locate/);
146
+ });
147
+ });
package/src/index.ts ADDED
@@ -0,0 +1,232 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { basename, join, resolve } from "node:path";
3
+
4
+ export type BuildContextBundleOpts = {
5
+ readonly docsRoot: string;
6
+ readonly demosRoot: string;
7
+ readonly factoryRoot: string;
8
+ readonly bundledAt?: string;
9
+ };
10
+
11
+ export type ContextBundleSource = {
12
+ readonly path: string;
13
+ readonly bytes: number;
14
+ };
15
+
16
+ export type ContextBundle = {
17
+ readonly markdown: string;
18
+ readonly sources: readonly ContextBundleSource[];
19
+ };
20
+
21
+ const HEADER = `# CrewHaus Context Bundle
22
+
23
+ > A single-file orientation manifest for AI agents working with CrewHaus.
24
+ > CrewHaus is a meta-harness compiler: write one spec, emit many runtime shapes.
25
+ > Live docs: https://crewhaus.ai/docs · Source of truth for schema: \`packages/spec/src/index.ts\`
26
+ `;
27
+
28
+ const QUICKSTART = `## Quickstart
29
+
30
+ \`\`\`bash
31
+ crewhaus init my-agent
32
+ crewhaus compile my-agent/crewhaus.yaml -o ./dist
33
+ crewhaus run ./dist
34
+ \`\`\`
35
+
36
+ The \`target:\` field in \`crewhaus.yaml\` selects the runtime shape. Currently supported:
37
+
38
+ | target | shape |
39
+ |-------------|----------------------------------------|
40
+ | \`cli\` | interactive coding-agent loop |
41
+ | \`workflow\` | sequence of steps |
42
+ | \`channel\` | Slack/Discord/Telegram/iMessage daemon|
43
+ | \`graph\` | stateful graph with HITL checkpoints |
44
+ | \`managed\` | Anthropic Managed Agents |
45
+ | \`pipeline\` | RAG / extract-transform pipeline |
46
+ | \`crew\` | multi-agent role-based crew |
47
+ | \`research\` | autonomous long-horizon research |
48
+ | \`batch\` | queue worker |
49
+ | \`voice\` | voice agent |
50
+ | \`browser\` | browser agent |
51
+ | \`eval\` | eval harness bundle |
52
+ `;
53
+
54
+ export function buildContextBundle(opts: BuildContextBundleOpts): ContextBundle {
55
+ const bundledAt = opts.bundledAt ?? new Date().toISOString();
56
+ const sources: ContextBundleSource[] = [];
57
+
58
+ const sections: string[] = [];
59
+ sections.push(HEADER);
60
+ sections.push(`> Bundled at: ${bundledAt}`);
61
+ sections.push(QUICKSTART);
62
+
63
+ sections.push(
64
+ readSection("Spec schema (source of truth)", "ts", () =>
65
+ readTracked(sources, resolve(opts.factoryRoot, "packages/spec/src/index.ts")),
66
+ ),
67
+ );
68
+
69
+ sections.push(
70
+ readSection("Picking a recipe — diagnostic decision tree", "md", () =>
71
+ sliceDecisionTree(readTracked(sources, resolve(opts.demosRoot, "recipes/INDEX.md"))),
72
+ ),
73
+ );
74
+
75
+ sections.push(`## Recipe index\n\n${listRecipes(opts.demosRoot, sources)}`);
76
+
77
+ sections.push(
78
+ readSection("Module catalog — layer index", "md", () =>
79
+ extractHeadings(readTracked(sources, resolve(opts.docsRoot, "MODULE-CATALOG.md"))),
80
+ ),
81
+ );
82
+
83
+ sections.push(
84
+ readSection("Getting started", "md", () =>
85
+ readTracked(sources, resolve(opts.docsRoot, "GETTING-STARTED.md")),
86
+ ),
87
+ );
88
+
89
+ const markdown = `${sections.join("\n\n")}\n`;
90
+ return { markdown, sources };
91
+ }
92
+
93
+ function readTracked(sources: ContextBundleSource[], path: string): string {
94
+ const text = readFileSync(path, "utf-8");
95
+ sources.push({ path, bytes: Buffer.byteLength(text, "utf-8") });
96
+ return text;
97
+ }
98
+
99
+ function readSection(title: string, fence: "ts" | "md", body: () => string): string {
100
+ const content = body();
101
+ if (fence === "ts") {
102
+ return `## ${title}\n\n\`\`\`ts\n${content}\n\`\`\``;
103
+ }
104
+ return `## ${title}\n\n${content}`;
105
+ }
106
+
107
+ export function sliceDecisionTree(indexMarkdown: string): string {
108
+ const startMarker = "## Pick a recipe";
109
+ const endMarker = "## Recipes by part";
110
+ const start = indexMarkdown.indexOf(startMarker);
111
+ if (start === -1) return indexMarkdown;
112
+ const end = indexMarkdown.indexOf(endMarker, start);
113
+ if (end === -1) return indexMarkdown.slice(start);
114
+ return indexMarkdown.slice(start, end).trim();
115
+ }
116
+
117
+ export function sliceSpecSchemaExcerpt(specSource: string): string {
118
+ const marker = specSource.indexOf("discriminatedUnion");
119
+ if (marker === -1) return specSource.slice(0, 8000);
120
+ const start = Math.max(0, marker - 800);
121
+ const end = Math.min(specSource.length, marker + 5000);
122
+ return specSource.slice(start, end);
123
+ }
124
+
125
+ function extractHeadings(catalog: string): string {
126
+ const lines = catalog.split("\n");
127
+ const headings = lines.filter((line) => /^#{1,3}\s/.test(line));
128
+ return headings.join("\n");
129
+ }
130
+
131
+ function listRecipes(demosRoot: string, sources: ContextBundleSource[]): string {
132
+ const recipesDir = resolve(demosRoot, "recipes");
133
+ if (!existsSync(recipesDir)) return "_(recipes directory not found)_";
134
+ const files = readdirSync(recipesDir)
135
+ .filter((name) => name.endsWith(".md") && name !== "INDEX.md")
136
+ .sort();
137
+ const rows: string[] = [];
138
+ for (const file of files) {
139
+ const path = join(recipesDir, file);
140
+ const text = readFileSync(path, "utf-8");
141
+ sources.push({ path, bytes: Buffer.byteLength(text, "utf-8") });
142
+ const title = firstHeading(text) ?? basename(file, ".md");
143
+ rows.push(`- [${file}](demos/walkthroughs/${file}) — ${title}`);
144
+ }
145
+ return rows.join("\n");
146
+ }
147
+
148
+ function firstHeading(markdown: string): string | undefined {
149
+ for (const rawLine of markdown.split("\n")) {
150
+ const line = rawLine.trim();
151
+ if (line.startsWith("# ")) return line.slice(2).trim();
152
+ }
153
+ return undefined;
154
+ }
155
+
156
+ export type DiscoverRootsOpts = {
157
+ readonly startDir?: string;
158
+ readonly env?: NodeJS.ProcessEnv;
159
+ };
160
+
161
+ export type DiscoveredRoots = {
162
+ readonly factoryRoot: string;
163
+ readonly docsRoot: string;
164
+ readonly demosRoot: string;
165
+ };
166
+
167
+ export function discoverRoots(opts: DiscoverRootsOpts = {}): DiscoveredRoots {
168
+ const env = opts.env ?? process.env;
169
+ const factoryRoot = resolveRoot(
170
+ env["CREWHAUS_FACTORY_ROOT"],
171
+ "factory",
172
+ ["packages/spec/src/index.ts"],
173
+ opts.startDir,
174
+ );
175
+ const docsRoot = resolveRoot(
176
+ env["CREWHAUS_DOCS_ROOT"],
177
+ "docs",
178
+ ["GETTING-STARTED.md", "MODULE-CATALOG.md"],
179
+ opts.startDir,
180
+ );
181
+ const demosRoot = resolveRoot(
182
+ env["CREWHAUS_DEMOS_ROOT"],
183
+ "demos",
184
+ ["recipes/INDEX.md"],
185
+ opts.startDir,
186
+ );
187
+ return { factoryRoot, docsRoot, demosRoot };
188
+ }
189
+
190
+ function resolveRoot(
191
+ envValue: string | undefined,
192
+ siblingName: string,
193
+ markers: readonly string[],
194
+ startDir: string | undefined,
195
+ ): string {
196
+ if (envValue !== undefined && envValue !== "") {
197
+ const abs = resolve(envValue);
198
+ assertMarkers(abs, markers, `${envValue} (from env)`);
199
+ return abs;
200
+ }
201
+ const found = walkUpForSibling(startDir ?? process.cwd(), siblingName, markers);
202
+ if (found !== undefined) return found;
203
+ throw new Error(
204
+ `Could not locate ${siblingName}/ with markers ${markers.join(", ")}. ` +
205
+ `Set CREWHAUS_${siblingName.toUpperCase()}_ROOT or run from inside a CrewHaus checkout.`,
206
+ );
207
+ }
208
+
209
+ function assertMarkers(root: string, markers: readonly string[], label: string): void {
210
+ for (const marker of markers) {
211
+ if (!existsSync(resolve(root, marker))) {
212
+ throw new Error(`${label} is missing required file: ${marker}`);
213
+ }
214
+ }
215
+ }
216
+
217
+ function walkUpForSibling(
218
+ from: string,
219
+ siblingName: string,
220
+ markers: readonly string[],
221
+ ): string | undefined {
222
+ let current = resolve(from);
223
+ while (true) {
224
+ const candidate = resolve(current, siblingName);
225
+ if (existsSync(candidate) && markers.every((m) => existsSync(resolve(candidate, m)))) {
226
+ return candidate;
227
+ }
228
+ const parent = resolve(current, "..");
229
+ if (parent === current) return undefined;
230
+ current = parent;
231
+ }
232
+ }