@fabrika/builder 0.2.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/BUILD-CONTRACT.md +41 -0
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/cli/src/bin.ts +66 -0
- package/cli/src/build-status.ts +109 -0
- package/cli/src/catalog.ts +42 -0
- package/cli/src/commands/advance.ts +73 -0
- package/cli/src/commands/build.ts +215 -0
- package/cli/src/commands/ensure-daemon.ts +25 -0
- package/cli/src/commands/gate.ts +165 -0
- package/cli/src/commands/induce.ts +61 -0
- package/cli/src/commands/init.ts +91 -0
- package/cli/src/commands/next.ts +31 -0
- package/cli/src/config.ts +47 -0
- package/cli/src/context.ts +28 -0
- package/cli/src/daemon-client.ts +280 -0
- package/cli/src/daemon-lifecycle.ts +171 -0
- package/cli/src/derive.ts +109 -0
- package/cli/src/index.ts +38 -0
- package/cli/src/induce.ts +151 -0
- package/cli/src/inject-skills.ts +313 -0
- package/cli/src/issues.ts +75 -0
- package/cli/src/layout.ts +124 -0
- package/cli/src/packet.ts +108 -0
- package/cli/src/phase-state.ts +19 -0
- package/cli/src/phases.ts +131 -0
- package/cli/src/skeletons.ts +104 -0
- package/daemon/src/bin.ts +70 -0
- package/daemon/src/build-stream.ts +38 -0
- package/daemon/src/handlers.ts +472 -0
- package/daemon/src/index.ts +123 -0
- package/daemon/src/paths.ts +30 -0
- package/daemon/src/phases.ts +221 -0
- package/daemon/src/static.ts +72 -0
- package/decisions/README.md +133 -0
- package/decisions/patterns/app-deploy.md +55 -0
- package/decisions/patterns/auth-password-kv.md +45 -0
- package/decisions/patterns/count-store.md +44 -0
- package/decisions/patterns/di-container.md +47 -0
- package/decisions/patterns/feature-architecture.md +48 -0
- package/decisions/patterns/lib-publish.md +46 -0
- package/decisions/patterns/monorepo.md +47 -0
- package/decisions/patterns/named-seeds.md +45 -0
- package/decisions/patterns/observability.md +45 -0
- package/decisions/patterns/single-worker-spa.md +74 -0
- package/decisions/patterns/structured-logging.md +49 -0
- package/decisions/patterns/typed-worker-rpc.md +87 -0
- package/decisions/patterns/vozka-app-deploy.md +74 -0
- package/decisions/patterns/write-split.md +47 -0
- package/decisions/technologies/analytics-engine.md +44 -0
- package/decisions/technologies/bindx.md +55 -0
- package/decisions/technologies/bun.md +51 -0
- package/decisions/technologies/buzola.md +80 -0
- package/decisions/technologies/cloudflare-access.md +50 -0
- package/decisions/technologies/cloudflare-vite-plugin.md +43 -0
- package/decisions/technologies/cloudflare.md +50 -0
- package/decisions/technologies/contember.md +65 -0
- package/decisions/technologies/cron-triggers.md +46 -0
- package/decisions/technologies/d1.md +51 -0
- package/decisions/technologies/durable-objects.md +44 -0
- package/decisions/technologies/kv.md +42 -0
- package/decisions/technologies/lopata.md +55 -0
- package/decisions/technologies/nuasite.md +50 -0
- package/decisions/technologies/oblaka.md +48 -0
- package/decisions/technologies/opice.md +40 -0
- package/decisions/technologies/pletivo.md +53 -0
- package/decisions/technologies/postgres.md +47 -0
- package/decisions/technologies/preact.md +44 -0
- package/decisions/technologies/propustka.md +137 -0
- package/decisions/technologies/queue.md +55 -0
- package/decisions/technologies/r2.md +41 -0
- package/decisions/technologies/react-router.md +47 -0
- package/decisions/technologies/react.md +37 -0
- package/decisions/technologies/roj.md +51 -0
- package/decisions/technologies/sentry.md +49 -0
- package/decisions/technologies/tailwind.md +45 -0
- package/decisions/technologies/vite.md +47 -0
- package/decisions/technologies/vozka.md +122 -0
- package/decisions/technologies/workers-ai.md +59 -0
- package/decisions/technologies/wrangler.md +41 -0
- package/decisions/tooling/deptective.md +45 -0
- package/decisions/tooling/edvabe.md +45 -0
- package/index.ts +2 -0
- package/package.json +51 -0
- package/skill/SKILL.md +76 -0
- package/skill/agents/build-orchestrator.md +170 -0
- package/skill/agents/flows-author.md +65 -0
- package/skill/agents/implementer.md +37 -0
- package/skill/agents/openai.yaml +4 -0
- package/skill/agents/opice-skeleton-author.md +68 -0
- package/skill/gates/brief.jsx +20 -0
- package/skill/gates/build.jsx +20 -0
- package/skill/gates/design.jsx +22 -0
- package/skill/gates/flows.jsx +14 -0
- package/skill/gates/review-grid.jsx +32 -0
- package/skill/gates/tech.jsx +17 -0
- package/skill/gates/vision.jsx +27 -0
- package/stacks/README.md +44 -0
- package/stacks/cf-fat.md +64 -0
- package/stacks/cf-internal-app.md +125 -0
- package/stacks/cf-lightweight.md +54 -0
- package/stacks/contember-app.md +53 -0
- package/stacks/content-site.md +45 -0
- package/ui/dist/assets/annotations-runtime.js +1 -0
- package/ui/dist/assets/client.css +2 -0
- package/ui/dist/assets/client.fhjppkvr.js +214 -0
- package/ui/dist/assets/components.js +63 -0
- package/ui/dist/assets/preact-compat.js +1 -0
- package/ui/dist/assets/react-jsx-dev-runtime.js +3 -0
- package/ui/dist/assets/react-jsx-runtime.js +3 -0
- package/ui/dist/assets/runtime.js +1 -0
- package/ui/dist/index.html +36 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { StackPointer } from './catalog'
|
|
2
|
+
import type { Skeleton } from './skeletons'
|
|
3
|
+
|
|
4
|
+
export interface InducedIssue {
|
|
5
|
+
id: string
|
|
6
|
+
goal: string
|
|
7
|
+
satisfies: string[]
|
|
8
|
+
dependsOn: string[]
|
|
9
|
+
touches: string[]
|
|
10
|
+
body: string
|
|
11
|
+
kind: 'foundation' | 'feature'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const FOUNDATION_SCAFFOLD = 'ISSUE-001'
|
|
15
|
+
const FOUNDATION_DATA = 'ISSUE-002'
|
|
16
|
+
const FOUNDATION_AUTH = 'ISSUE-003'
|
|
17
|
+
|
|
18
|
+
export interface InduceOpts {
|
|
19
|
+
stack?: StackPointer
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function induceIssues(skeletons: Skeleton[], opts: InduceOpts = {}): InducedIssue[] {
|
|
23
|
+
if (skeletons.length === 0) return []
|
|
24
|
+
const issues: InducedIssue[] = []
|
|
25
|
+
|
|
26
|
+
const allSeeds = uniq(skeletons.flatMap((s) => s.seeds))
|
|
27
|
+
const allRoles = uniq(skeletons.flatMap((s) => s.roles))
|
|
28
|
+
const allUrls = uniq(skeletons.map((s) => s.url).filter((u): u is string => !!u))
|
|
29
|
+
|
|
30
|
+
const stackRef = opts.stack && opts.stack.stackFileExists
|
|
31
|
+
? [
|
|
32
|
+
`Stack preset: ${opts.stack.stackFile}`,
|
|
33
|
+
`Decisions: ${opts.stack.decisionsDir}/ (resolve the preset's \`decisions:\` ids; read their live \`sources\`)`,
|
|
34
|
+
'',
|
|
35
|
+
]
|
|
36
|
+
: []
|
|
37
|
+
|
|
38
|
+
issues.push({
|
|
39
|
+
id: FOUNDATION_SCAFFOLD,
|
|
40
|
+
kind: 'foundation',
|
|
41
|
+
goal: 'Stand up the running app + the routes every scenario navigates to.',
|
|
42
|
+
satisfies: [],
|
|
43
|
+
dependsOn: [],
|
|
44
|
+
touches: ['app scaffold', 'routing', ...allUrls.map((u) => `route: ${u}`)],
|
|
45
|
+
body: [
|
|
46
|
+
...stackRef,
|
|
47
|
+
'Foundation. Scaffold the running app per the active stack preset — read its',
|
|
48
|
+
'decisions + the live sources they point to, and scaffold from current APIs (do',
|
|
49
|
+
'NOT copy templates). Register a route for every URL a scenario targets so the',
|
|
50
|
+
'dev server serves them.',
|
|
51
|
+
'',
|
|
52
|
+
'Routes that must exist (from skeleton `url` metadata). The URL is the contract;',
|
|
53
|
+
'the file location is your call per the stack conventions:',
|
|
54
|
+
...(allUrls.length ? allUrls.map((u) => `- \`${u}\``) : ['- (none declared)']),
|
|
55
|
+
].join('\n'),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
if (allSeeds.length) {
|
|
59
|
+
issues.push({
|
|
60
|
+
id: FOUNDATION_DATA,
|
|
61
|
+
kind: 'foundation',
|
|
62
|
+
goal: 'Data layer: schema migrations + the named idempotent seeds scenarios rely on.',
|
|
63
|
+
satisfies: [],
|
|
64
|
+
dependsOn: [FOUNDATION_SCAFFOLD],
|
|
65
|
+
touches: ['schema/migrations', ...allSeeds.map((s) => `seed: ${s}`)],
|
|
66
|
+
body: [
|
|
67
|
+
'Foundation. Define the schema (idempotent migrations) and one named',
|
|
68
|
+
'idempotent seed per `seeds:` value, keyed on stable ids.',
|
|
69
|
+
'',
|
|
70
|
+
'Named seeds to provide (union of skeleton `seeds`):',
|
|
71
|
+
...allSeeds.map((s) => `- \`${s}\``),
|
|
72
|
+
].join('\n'),
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (allRoles.length) {
|
|
77
|
+
issues.push({
|
|
78
|
+
id: FOUNDATION_AUTH,
|
|
79
|
+
kind: 'foundation',
|
|
80
|
+
goal: 'Auth: the roles scenarios act as, and the means to act as each.',
|
|
81
|
+
satisfies: [],
|
|
82
|
+
dependsOn: [FOUNDATION_SCAFFOLD],
|
|
83
|
+
touches: ['auth', 'identities'],
|
|
84
|
+
body: [
|
|
85
|
+
'Foundation. Provide auth for every acting identity in the skeletons.',
|
|
86
|
+
'',
|
|
87
|
+
'Roles to support (union of skeleton `roles`):',
|
|
88
|
+
...allRoles.map((r) => `- \`${r}\``),
|
|
89
|
+
].join('\n'),
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let n = 101
|
|
94
|
+
for (const s of skeletons) {
|
|
95
|
+
const deps = [FOUNDATION_SCAFFOLD]
|
|
96
|
+
if (s.seeds.length && allSeeds.length) deps.push(FOUNDATION_DATA)
|
|
97
|
+
if (s.roles.length && allRoles.length) deps.push(FOUNDATION_AUTH)
|
|
98
|
+
|
|
99
|
+
const satisfies = [s.feature ?? s.name, ...s.invariants]
|
|
100
|
+
|
|
101
|
+
issues.push({
|
|
102
|
+
id: `ISSUE-${n}`,
|
|
103
|
+
kind: 'feature',
|
|
104
|
+
goal: `Implement the vertical slice for "${s.name}"${s.url ? ` at ${s.url}` : ''}.`,
|
|
105
|
+
satisfies: uniq(satisfies),
|
|
106
|
+
dependsOn: deps,
|
|
107
|
+
touches: uniq([s.url ? `route: ${s.url}` : 'feature route/UI', ...s.seeds.map((sd) => `seed: ${sd}`)]),
|
|
108
|
+
body: [
|
|
109
|
+
`Feature slice satisfying the scenario **${s.name}**` + (s.feature ? ` (\`${s.feature}\`)` : '') + '.',
|
|
110
|
+
'',
|
|
111
|
+
`- Route + data: serve URL \`${
|
|
112
|
+
s.url ?? '(unspecified)'
|
|
113
|
+
}\` — the URL is a contract (must match the skeleton); choose the file per the stack conventions.`,
|
|
114
|
+
s.seeds.length ? `- Seeds: ${s.seeds.map((x) => `\`${x}\``).join(', ')}.` : '- Seeds: none.',
|
|
115
|
+
s.roles.length ? `- Roles: ${s.roles.map((x) => `\`${x}\``).join(', ')}.` : '- Roles: none.',
|
|
116
|
+
'- Semantic, accessible markup so opice-author finds stable selectors.',
|
|
117
|
+
s.invariants.length ? '\nInvariants to satisfy (frozen — do not weaken):' : '',
|
|
118
|
+
...s.invariants.map((iv) => `- ${iv}`),
|
|
119
|
+
`\nAcceptance: \`${relTest(s.file)}\` goes green (\`bun test\`).`,
|
|
120
|
+
].filter((x) => x !== '').join('\n'),
|
|
121
|
+
})
|
|
122
|
+
n++
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return issues
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function renderIssue(issue: InducedIssue): string {
|
|
129
|
+
const fm = [
|
|
130
|
+
'---',
|
|
131
|
+
`goal: ${issue.goal}`,
|
|
132
|
+
`satisfies: [${issue.satisfies.map(q).join(', ')}]`,
|
|
133
|
+
`depends_on: [${issue.dependsOn.map(q).join(', ')}]`,
|
|
134
|
+
`touches: [${issue.touches.map(q).join(', ')}]`,
|
|
135
|
+
'---',
|
|
136
|
+
].join('\n')
|
|
137
|
+
return `${fm}\n\n# ${issue.id} — ${issue.goal}\n\n${issue.body}\n`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function relTest(file: string): string {
|
|
141
|
+
const i = file.indexOf('tests/browser/')
|
|
142
|
+
return i !== -1 ? file.slice(i) : file
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function uniq<T>(xs: T[]): T[] {
|
|
146
|
+
return [...new Set(xs)]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function q(s: string): string {
|
|
150
|
+
return `"${s.replace(/"/g, '\\"')}"`
|
|
151
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { createHash } from 'crypto'
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
|
|
3
|
+
import { dirname, join, relative, resolve } from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
|
|
6
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
const MANIFEST_VERSION = 1
|
|
8
|
+
const MANIFEST_RELATIVE_PATH = join('.fabrika', 'agent-support.json')
|
|
9
|
+
|
|
10
|
+
export type AgentHost = 'claude' | 'codex'
|
|
11
|
+
|
|
12
|
+
export interface InjectOptions {
|
|
13
|
+
hosts?: AgentHost[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
17
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isAgentHost(value: string): value is AgentHost {
|
|
21
|
+
return value === 'claude' || value === 'codex'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface InjectResult {
|
|
25
|
+
sourceDir: string | null
|
|
26
|
+
hosts: AgentHost[]
|
|
27
|
+
written: string[]
|
|
28
|
+
updated: string[]
|
|
29
|
+
skipped: string[]
|
|
30
|
+
preserved: string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ManagedManifest {
|
|
34
|
+
version: number
|
|
35
|
+
files: Record<string, { installedHash: string; host: AgentHost }>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface DesiredFile {
|
|
39
|
+
relativePath: string
|
|
40
|
+
content: string
|
|
41
|
+
host: AgentHost
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface AgentSource {
|
|
45
|
+
filename: string
|
|
46
|
+
name: string
|
|
47
|
+
description: string
|
|
48
|
+
body: string
|
|
49
|
+
raw: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const LEGACY_MANAGED_HASHES: Record<string, Set<string>> = {
|
|
53
|
+
[join('.claude', 'skills', 'fabrika', 'SKILL.md')]: new Set([
|
|
54
|
+
'd06c8508d47ab36222bcb33b964fd928e3fbcff383db8c257ef68dfd956dd428',
|
|
55
|
+
]),
|
|
56
|
+
[join('.claude', 'agents', 'build-orchestrator.md')]: new Set([
|
|
57
|
+
'31c436e08ffac217d32c71ec56d1612f565b0b2828df9c067ff1e812768339f1',
|
|
58
|
+
]),
|
|
59
|
+
[join('.claude', 'agents', 'flows-author.md')]: new Set([
|
|
60
|
+
'1e55cce06f0210b65ce6ec2d9cd1d4bb5e0e8bcff11e06eb4f2ee405914dd322',
|
|
61
|
+
]),
|
|
62
|
+
[join('.claude', 'agents', 'opice-skeleton-author.md')]: new Set([
|
|
63
|
+
'8644461c1acdc236a886b648ae97984cbd54bc0d59872f7b310ce2b5783e4378',
|
|
64
|
+
]),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function resolveSkillSource(): string | null {
|
|
68
|
+
const candidates = [resolve(HERE, '../../skill')]
|
|
69
|
+
for (const candidate of candidates) {
|
|
70
|
+
if (existsSync(join(candidate, 'SKILL.md'))) return candidate
|
|
71
|
+
}
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function parseAgentHosts(value?: string): AgentHost[] {
|
|
76
|
+
if (!value || value === 'all') return ['claude', 'codex']
|
|
77
|
+
const requested = [...new Set(value.split(',').map((item) => item.trim()).filter(Boolean))]
|
|
78
|
+
if (!requested.every(isAgentHost) || requested.length === 0) {
|
|
79
|
+
throw new Error(`Invalid --agent value: ${value}. Use claude, codex, or all.`)
|
|
80
|
+
}
|
|
81
|
+
return requested
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function injectAgentSupport(repoRoot: string, opts: InjectOptions = {}): InjectResult {
|
|
85
|
+
const sourceDir = resolveSkillSource()
|
|
86
|
+
const hosts = normalizeHosts(opts.hosts)
|
|
87
|
+
const result: InjectResult = {
|
|
88
|
+
sourceDir,
|
|
89
|
+
hosts,
|
|
90
|
+
written: [],
|
|
91
|
+
updated: [],
|
|
92
|
+
skipped: [],
|
|
93
|
+
preserved: [],
|
|
94
|
+
}
|
|
95
|
+
if (!sourceDir) return result
|
|
96
|
+
|
|
97
|
+
const root = resolve(repoRoot)
|
|
98
|
+
const desired = renderDesiredFiles(sourceDir, hosts)
|
|
99
|
+
const manifestPath = join(root, MANIFEST_RELATIVE_PATH)
|
|
100
|
+
const manifest = readManifest(manifestPath)
|
|
101
|
+
|
|
102
|
+
for (const file of desired) {
|
|
103
|
+
installManagedFile(root, file, manifest, result)
|
|
104
|
+
}
|
|
105
|
+
writeManifest(manifestPath, manifest)
|
|
106
|
+
return result
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function injectSkills(repoRoot: string): InjectResult {
|
|
110
|
+
return injectAgentSupport(repoRoot, { hosts: ['claude'] })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeHosts(hosts?: AgentHost[]): AgentHost[] {
|
|
114
|
+
const selected: AgentHost[] = hosts?.length ? hosts : ['claude', 'codex']
|
|
115
|
+
return [...new Set(selected)].sort()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function renderDesiredFiles(sourceDir: string, hosts: AgentHost[]): DesiredFile[] {
|
|
119
|
+
const skill = readFileSync(join(sourceDir, 'SKILL.md'), 'utf-8')
|
|
120
|
+
const agents = readAgentSources(join(sourceDir, 'agents'))
|
|
121
|
+
const resources = readSkillResources(sourceDir)
|
|
122
|
+
const files: DesiredFile[] = []
|
|
123
|
+
|
|
124
|
+
for (const host of hosts) {
|
|
125
|
+
const skillRoot = host === 'claude'
|
|
126
|
+
? join('.claude', 'skills', 'fabrika')
|
|
127
|
+
: join('.agents', 'skills', 'fabrika')
|
|
128
|
+
files.push({
|
|
129
|
+
relativePath: join(skillRoot, 'SKILL.md'),
|
|
130
|
+
content: host === 'claude' ? renderClaudeSkill(skill) : skill,
|
|
131
|
+
host,
|
|
132
|
+
})
|
|
133
|
+
for (const resource of resources) {
|
|
134
|
+
if (host === 'claude' && resource.relativePath === join('agents', 'openai.yaml')) continue
|
|
135
|
+
files.push({
|
|
136
|
+
relativePath: join(skillRoot, resource.relativePath),
|
|
137
|
+
content: resource.content,
|
|
138
|
+
host,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (hosts.includes('claude')) {
|
|
144
|
+
for (const agent of agents) {
|
|
145
|
+
files.push({
|
|
146
|
+
relativePath: join('.claude', 'agents', agent.filename),
|
|
147
|
+
content: agent.raw,
|
|
148
|
+
host: 'claude',
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (hosts.includes('codex')) {
|
|
153
|
+
for (const agent of agents) {
|
|
154
|
+
files.push({
|
|
155
|
+
relativePath: join('.codex', 'agents', agent.filename.replace(/\.md$/, '.toml')),
|
|
156
|
+
content: renderCodexAgent(agent),
|
|
157
|
+
host: 'codex',
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return files.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readSkillResources(sourceDir: string): Array<{ relativePath: string; content: string }> {
|
|
166
|
+
const resources: Array<{ relativePath: string; content: string }> = []
|
|
167
|
+
for (const directory of ['agents', 'gates']) {
|
|
168
|
+
const absoluteDir = join(sourceDir, directory)
|
|
169
|
+
if (!existsSync(absoluteDir)) continue
|
|
170
|
+
for (const entry of readdirSync(absoluteDir, { withFileTypes: true })) {
|
|
171
|
+
if (!entry.isFile()) continue
|
|
172
|
+
if (directory === 'gates' && !entry.name.endsWith('.jsx')) continue
|
|
173
|
+
if (directory === 'agents' && !entry.name.endsWith('.md') && entry.name !== 'openai.yaml') continue
|
|
174
|
+
resources.push({
|
|
175
|
+
relativePath: join(directory, entry.name),
|
|
176
|
+
content: readFileSync(join(absoluteDir, entry.name), 'utf-8'),
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return resources
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function readAgentSources(agentsDir: string): AgentSource[] {
|
|
184
|
+
if (!existsSync(agentsDir)) return []
|
|
185
|
+
return readdirSync(agentsDir)
|
|
186
|
+
.filter((entry) => entry.endsWith('.md'))
|
|
187
|
+
.sort()
|
|
188
|
+
.map((filename) => parseAgentSource(filename, readFileSync(join(agentsDir, filename), 'utf-8')))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function parseAgentSource(filename: string, raw: string): AgentSource {
|
|
192
|
+
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
|
|
193
|
+
if (!match) throw new Error(`Agent source ${filename} has no valid frontmatter.`)
|
|
194
|
+
const metadata = match[1]!
|
|
195
|
+
const name = metadata.match(/^name:\s*(.+)$/m)?.[1]?.trim()
|
|
196
|
+
const metadataLines = metadata.split('\n')
|
|
197
|
+
const descriptionIndex = metadataLines.findIndex((line) => line.startsWith('description:'))
|
|
198
|
+
let description: string | undefined
|
|
199
|
+
if (descriptionIndex >= 0) {
|
|
200
|
+
const inline = metadataLines[descriptionIndex]!.slice('description:'.length).trim()
|
|
201
|
+
if (inline === '>' || inline === '|') {
|
|
202
|
+
const descriptionLines: string[] = []
|
|
203
|
+
for (let index = descriptionIndex + 1; index < metadataLines.length; index++) {
|
|
204
|
+
const line = metadataLines[index]!
|
|
205
|
+
if (line.length > 0 && !/^\s/.test(line)) break
|
|
206
|
+
descriptionLines.push(line.trim())
|
|
207
|
+
}
|
|
208
|
+
description = descriptionLines.filter(Boolean).join(' ')
|
|
209
|
+
} else {
|
|
210
|
+
description = inline
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (!name || !description) throw new Error(`Agent source ${filename} is missing name or description.`)
|
|
214
|
+
return { filename, name, description, body: match[2]!.trim() + '\n', raw }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function renderClaudeSkill(canonical: string): string {
|
|
218
|
+
return canonical.replace(
|
|
219
|
+
/\n---\n/,
|
|
220
|
+
'\nallowed-tools: Bash(bunx fabrika:*), Bash(bun:*), Read, Write, Edit, Glob, Grep, Skill, Agent\n---\n',
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function renderCodexAgent(agent: AgentSource): string {
|
|
225
|
+
return [
|
|
226
|
+
`name = ${tomlString(agent.name)}`,
|
|
227
|
+
`description = ${tomlString(agent.description)}`,
|
|
228
|
+
`developer_instructions = ${tomlMultiline(agent.body)}`,
|
|
229
|
+
'',
|
|
230
|
+
].join('\n')
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function tomlString(value: string): string {
|
|
234
|
+
return JSON.stringify(value)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function tomlMultiline(value: string): string {
|
|
238
|
+
const escaped = value.replace(/\\/g, '\\\\').replace(/\"\"\"/g, '\\"\\"\\"')
|
|
239
|
+
return `\"\"\"\n${escaped}\"\"\"`
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function installManagedFile(
|
|
243
|
+
root: string,
|
|
244
|
+
desired: DesiredFile,
|
|
245
|
+
manifest: ManagedManifest,
|
|
246
|
+
result: InjectResult,
|
|
247
|
+
): void {
|
|
248
|
+
const destination = join(root, desired.relativePath)
|
|
249
|
+
const desiredHash = hash(desired.content)
|
|
250
|
+
const existing = existsSync(destination) ? readFileSync(destination, 'utf-8') : null
|
|
251
|
+
const existingHash = existing === null ? null : hash(existing)
|
|
252
|
+
const recorded = manifest.files[desired.relativePath]
|
|
253
|
+
|
|
254
|
+
if (existing === null) {
|
|
255
|
+
writeAtomic(destination, desired.content)
|
|
256
|
+
result.written.push(destination)
|
|
257
|
+
} else if (existingHash === desiredHash) {
|
|
258
|
+
result.skipped.push(destination)
|
|
259
|
+
} else if (
|
|
260
|
+
existingHash === recorded?.installedHash
|
|
261
|
+
|| LEGACY_MANAGED_HASHES[desired.relativePath]?.has(existingHash ?? '')
|
|
262
|
+
) {
|
|
263
|
+
writeAtomic(destination, desired.content)
|
|
264
|
+
result.updated.push(destination)
|
|
265
|
+
} else {
|
|
266
|
+
result.preserved.push(destination)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
manifest.files[desired.relativePath] = { installedHash: desiredHash, host: desired.host }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function readManifest(path: string): ManagedManifest {
|
|
274
|
+
if (!existsSync(path)) return { version: MANIFEST_VERSION, files: {} }
|
|
275
|
+
try {
|
|
276
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8'))
|
|
277
|
+
if (!isRecord(parsed) || !isRecord(parsed['files'])) return { version: MANIFEST_VERSION, files: {} }
|
|
278
|
+
const files: ManagedManifest['files'] = {}
|
|
279
|
+
for (const [relativePath, candidate] of Object.entries(parsed['files'])) {
|
|
280
|
+
if (!isRecord(candidate)) continue
|
|
281
|
+
const installedHash = candidate['installedHash']
|
|
282
|
+
const host = candidate['host']
|
|
283
|
+
if (typeof installedHash === 'string' && typeof host === 'string' && isAgentHost(host)) {
|
|
284
|
+
files[relativePath] = { installedHash, host }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
version: MANIFEST_VERSION,
|
|
289
|
+
files,
|
|
290
|
+
}
|
|
291
|
+
} catch {
|
|
292
|
+
return { version: MANIFEST_VERSION, files: {} }
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function writeManifest(path: string, manifest: ManagedManifest): void {
|
|
297
|
+
writeAtomic(path, JSON.stringify(manifest, null, 2) + '\n')
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function writeAtomic(path: string, content: string): void {
|
|
301
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
302
|
+
const temp = `${path}.${process.pid}.tmp`
|
|
303
|
+
writeFileSync(temp, content)
|
|
304
|
+
renameSync(temp, path)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function hash(content: string): string {
|
|
308
|
+
return createHash('sha256').update(content).digest('hex')
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function manifestRelativePath(repoRoot: string): string {
|
|
312
|
+
return relative(resolve(repoRoot), join(resolve(repoRoot), MANIFEST_RELATIVE_PATH))
|
|
313
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
2
|
+
import { basename, join } from 'path'
|
|
3
|
+
|
|
4
|
+
export interface Issue {
|
|
5
|
+
id: string
|
|
6
|
+
path: string
|
|
7
|
+
goal: string
|
|
8
|
+
satisfies: string[]
|
|
9
|
+
dependsOn: string[]
|
|
10
|
+
touches: string[]
|
|
11
|
+
body: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function readIssues(fabrikaDir: string): Issue[] {
|
|
15
|
+
const dir = join(fabrikaDir, 'issues')
|
|
16
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return []
|
|
17
|
+
const out: Issue[] = []
|
|
18
|
+
for (const entry of readdirSync(dir)) {
|
|
19
|
+
if (!/^ISSUE-.*\.md$/i.test(entry)) continue
|
|
20
|
+
const p = join(dir, entry)
|
|
21
|
+
if (!statSync(p).isFile() || statSync(p).size === 0) continue
|
|
22
|
+
out.push(parseIssue(p))
|
|
23
|
+
}
|
|
24
|
+
out.sort((a, b) => a.id.localeCompare(b.id))
|
|
25
|
+
return out
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseIssue(path: string): Issue {
|
|
29
|
+
const raw = readFileSync(path, 'utf-8')
|
|
30
|
+
const fm = extractFrontMatter(raw)
|
|
31
|
+
return {
|
|
32
|
+
id: basename(path).replace(/\.md$/i, ''),
|
|
33
|
+
path,
|
|
34
|
+
goal: scalar(fm.body['goal']) ?? '',
|
|
35
|
+
satisfies: list(fm.body['satisfies']),
|
|
36
|
+
dependsOn: list(fm.body['depends_on']),
|
|
37
|
+
touches: list(fm.body['touches']),
|
|
38
|
+
body: fm.rest,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isReady(issue: Issue, knownIds: Set<string>): boolean {
|
|
43
|
+
return issue.dependsOn.every((dep) => knownIds.has(dep))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface FrontMatter {
|
|
47
|
+
body: Record<string, string>
|
|
48
|
+
rest: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function extractFrontMatter(raw: string): FrontMatter {
|
|
52
|
+
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
|
|
53
|
+
if (!m) return { body: {}, rest: raw }
|
|
54
|
+
const body: Record<string, string> = {}
|
|
55
|
+
for (const line of (m[1] ?? '').split(/\r?\n/)) {
|
|
56
|
+
const kv = line.match(/^\s*([A-Za-z0-9_-]+)\s*:\s*(.*)$/)
|
|
57
|
+
if (kv && kv[1]) body[kv[1]] = (kv[2] ?? '').trim()
|
|
58
|
+
}
|
|
59
|
+
return { body, rest: (m[2] ?? '').trim() }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function scalar(v: string | undefined): string | undefined {
|
|
63
|
+
if (v === undefined) return undefined
|
|
64
|
+
return v.replace(/^["']|["']$/g, '')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function list(v: string | undefined): string[] {
|
|
68
|
+
if (!v) return []
|
|
69
|
+
const inner = v.replace(/^\[/, '').replace(/\]$/, '')
|
|
70
|
+
if (inner.trim() === '') return []
|
|
71
|
+
return inner
|
|
72
|
+
.split(',')
|
|
73
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ''))
|
|
74
|
+
.filter((s) => s.length > 0)
|
|
75
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs'
|
|
2
|
+
import { join, resolve } from 'path'
|
|
3
|
+
import { FABRIKA_DIRNAME, hashRepoPath } from './config'
|
|
4
|
+
|
|
5
|
+
export const LAYER_DIRS = [
|
|
6
|
+
'intent',
|
|
7
|
+
'principles',
|
|
8
|
+
'architecture',
|
|
9
|
+
'architecture/flows',
|
|
10
|
+
'architecture/spec',
|
|
11
|
+
'pages',
|
|
12
|
+
'issues',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
export const EXTERNAL_DIRS = ['tests/browser']
|
|
16
|
+
|
|
17
|
+
export interface FabrikaConfig {
|
|
18
|
+
profile: string
|
|
19
|
+
sessionId: string
|
|
20
|
+
createdAt: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseConfig(value: unknown): FabrikaConfig | null {
|
|
24
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
|
|
25
|
+
if (!('profile' in value) || !('sessionId' in value) || !('createdAt' in value)) return null
|
|
26
|
+
const { profile, sessionId, createdAt } = value
|
|
27
|
+
if (typeof profile !== 'string' || typeof sessionId !== 'string' || typeof createdAt !== 'string') return null
|
|
28
|
+
return { profile, sessionId, createdAt }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function fabrikaDir(repoRoot: string): string {
|
|
32
|
+
return join(resolve(repoRoot), FABRIKA_DIRNAME)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function configPath(repoRoot: string): string {
|
|
36
|
+
return join(fabrikaDir(repoRoot), 'config.json')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isInitialized(repoRoot: string): boolean {
|
|
40
|
+
return existsSync(configPath(repoRoot))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function readConfig(repoRoot: string): FabrikaConfig | null {
|
|
44
|
+
const p = configPath(repoRoot)
|
|
45
|
+
if (!existsSync(p)) return null
|
|
46
|
+
try {
|
|
47
|
+
return parseConfig(JSON.parse(readFileSync(p, 'utf-8')))
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface InitResult {
|
|
54
|
+
repoRoot: string
|
|
55
|
+
fabrikaDir: string
|
|
56
|
+
config: FabrikaConfig
|
|
57
|
+
createdDirs: string[]
|
|
58
|
+
createdConfig: boolean
|
|
59
|
+
alreadyInitialized: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function scaffold(
|
|
63
|
+
repoRoot: string,
|
|
64
|
+
opts: { profile?: string; sessionId?: string } = {},
|
|
65
|
+
): InitResult {
|
|
66
|
+
const root = resolve(repoRoot)
|
|
67
|
+
const fdir = fabrikaDir(root)
|
|
68
|
+
const profile = opts.profile ?? 'cf-lightweight'
|
|
69
|
+
const createdDirs: string[] = []
|
|
70
|
+
|
|
71
|
+
const alreadyInitialized = isInitialized(root)
|
|
72
|
+
|
|
73
|
+
for (const dir of ['.', ...LAYER_DIRS]) {
|
|
74
|
+
const abs = dir === '.' ? fdir : join(fdir, dir)
|
|
75
|
+
if (!existsSync(abs)) {
|
|
76
|
+
mkdirSync(abs, { recursive: true })
|
|
77
|
+
createdDirs.push(abs)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const dir of EXTERNAL_DIRS) {
|
|
82
|
+
const abs = join(root, dir)
|
|
83
|
+
if (!existsSync(abs)) {
|
|
84
|
+
mkdirSync(abs, { recursive: true })
|
|
85
|
+
createdDirs.push(abs)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let config: FabrikaConfig
|
|
90
|
+
let createdConfig = false
|
|
91
|
+
const existing = readConfig(root)
|
|
92
|
+
if (existing) {
|
|
93
|
+
config = existing
|
|
94
|
+
} else {
|
|
95
|
+
config = {
|
|
96
|
+
profile,
|
|
97
|
+
sessionId: opts.sessionId ?? hashRepoPath(root),
|
|
98
|
+
createdAt: new Date().toISOString(),
|
|
99
|
+
}
|
|
100
|
+
writeFileSync(configPath(root), JSON.stringify(config, null, 2) + '\n')
|
|
101
|
+
createdConfig = true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
repoRoot: root,
|
|
106
|
+
fabrikaDir: fdir,
|
|
107
|
+
config,
|
|
108
|
+
createdDirs,
|
|
109
|
+
createdConfig,
|
|
110
|
+
alreadyInitialized,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function findRepoRoot(start: string = process.cwd()): string | null {
|
|
115
|
+
let dir = resolve(start)
|
|
116
|
+
while (true) {
|
|
117
|
+
if (existsSync(join(dir, FABRIKA_DIRNAME)) && statSync(join(dir, FABRIKA_DIRNAME)).isDirectory()) {
|
|
118
|
+
return dir
|
|
119
|
+
}
|
|
120
|
+
const parent = resolve(dir, '..')
|
|
121
|
+
if (parent === dir) return null
|
|
122
|
+
dir = parent
|
|
123
|
+
}
|
|
124
|
+
}
|