@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.
Files changed (112) hide show
  1. package/BUILD-CONTRACT.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +100 -0
  4. package/cli/src/bin.ts +66 -0
  5. package/cli/src/build-status.ts +109 -0
  6. package/cli/src/catalog.ts +42 -0
  7. package/cli/src/commands/advance.ts +73 -0
  8. package/cli/src/commands/build.ts +215 -0
  9. package/cli/src/commands/ensure-daemon.ts +25 -0
  10. package/cli/src/commands/gate.ts +165 -0
  11. package/cli/src/commands/induce.ts +61 -0
  12. package/cli/src/commands/init.ts +91 -0
  13. package/cli/src/commands/next.ts +31 -0
  14. package/cli/src/config.ts +47 -0
  15. package/cli/src/context.ts +28 -0
  16. package/cli/src/daemon-client.ts +280 -0
  17. package/cli/src/daemon-lifecycle.ts +171 -0
  18. package/cli/src/derive.ts +109 -0
  19. package/cli/src/index.ts +38 -0
  20. package/cli/src/induce.ts +151 -0
  21. package/cli/src/inject-skills.ts +313 -0
  22. package/cli/src/issues.ts +75 -0
  23. package/cli/src/layout.ts +124 -0
  24. package/cli/src/packet.ts +108 -0
  25. package/cli/src/phase-state.ts +19 -0
  26. package/cli/src/phases.ts +131 -0
  27. package/cli/src/skeletons.ts +104 -0
  28. package/daemon/src/bin.ts +70 -0
  29. package/daemon/src/build-stream.ts +38 -0
  30. package/daemon/src/handlers.ts +472 -0
  31. package/daemon/src/index.ts +123 -0
  32. package/daemon/src/paths.ts +30 -0
  33. package/daemon/src/phases.ts +221 -0
  34. package/daemon/src/static.ts +72 -0
  35. package/decisions/README.md +133 -0
  36. package/decisions/patterns/app-deploy.md +55 -0
  37. package/decisions/patterns/auth-password-kv.md +45 -0
  38. package/decisions/patterns/count-store.md +44 -0
  39. package/decisions/patterns/di-container.md +47 -0
  40. package/decisions/patterns/feature-architecture.md +48 -0
  41. package/decisions/patterns/lib-publish.md +46 -0
  42. package/decisions/patterns/monorepo.md +47 -0
  43. package/decisions/patterns/named-seeds.md +45 -0
  44. package/decisions/patterns/observability.md +45 -0
  45. package/decisions/patterns/single-worker-spa.md +74 -0
  46. package/decisions/patterns/structured-logging.md +49 -0
  47. package/decisions/patterns/typed-worker-rpc.md +87 -0
  48. package/decisions/patterns/vozka-app-deploy.md +74 -0
  49. package/decisions/patterns/write-split.md +47 -0
  50. package/decisions/technologies/analytics-engine.md +44 -0
  51. package/decisions/technologies/bindx.md +55 -0
  52. package/decisions/technologies/bun.md +51 -0
  53. package/decisions/technologies/buzola.md +80 -0
  54. package/decisions/technologies/cloudflare-access.md +50 -0
  55. package/decisions/technologies/cloudflare-vite-plugin.md +43 -0
  56. package/decisions/technologies/cloudflare.md +50 -0
  57. package/decisions/technologies/contember.md +65 -0
  58. package/decisions/technologies/cron-triggers.md +46 -0
  59. package/decisions/technologies/d1.md +51 -0
  60. package/decisions/technologies/durable-objects.md +44 -0
  61. package/decisions/technologies/kv.md +42 -0
  62. package/decisions/technologies/lopata.md +55 -0
  63. package/decisions/technologies/nuasite.md +50 -0
  64. package/decisions/technologies/oblaka.md +48 -0
  65. package/decisions/technologies/opice.md +40 -0
  66. package/decisions/technologies/pletivo.md +53 -0
  67. package/decisions/technologies/postgres.md +47 -0
  68. package/decisions/technologies/preact.md +44 -0
  69. package/decisions/technologies/propustka.md +137 -0
  70. package/decisions/technologies/queue.md +55 -0
  71. package/decisions/technologies/r2.md +41 -0
  72. package/decisions/technologies/react-router.md +47 -0
  73. package/decisions/technologies/react.md +37 -0
  74. package/decisions/technologies/roj.md +51 -0
  75. package/decisions/technologies/sentry.md +49 -0
  76. package/decisions/technologies/tailwind.md +45 -0
  77. package/decisions/technologies/vite.md +47 -0
  78. package/decisions/technologies/vozka.md +122 -0
  79. package/decisions/technologies/workers-ai.md +59 -0
  80. package/decisions/technologies/wrangler.md +41 -0
  81. package/decisions/tooling/deptective.md +45 -0
  82. package/decisions/tooling/edvabe.md +45 -0
  83. package/index.ts +2 -0
  84. package/package.json +51 -0
  85. package/skill/SKILL.md +76 -0
  86. package/skill/agents/build-orchestrator.md +170 -0
  87. package/skill/agents/flows-author.md +65 -0
  88. package/skill/agents/implementer.md +37 -0
  89. package/skill/agents/openai.yaml +4 -0
  90. package/skill/agents/opice-skeleton-author.md +68 -0
  91. package/skill/gates/brief.jsx +20 -0
  92. package/skill/gates/build.jsx +20 -0
  93. package/skill/gates/design.jsx +22 -0
  94. package/skill/gates/flows.jsx +14 -0
  95. package/skill/gates/review-grid.jsx +32 -0
  96. package/skill/gates/tech.jsx +17 -0
  97. package/skill/gates/vision.jsx +27 -0
  98. package/stacks/README.md +44 -0
  99. package/stacks/cf-fat.md +64 -0
  100. package/stacks/cf-internal-app.md +125 -0
  101. package/stacks/cf-lightweight.md +54 -0
  102. package/stacks/contember-app.md +53 -0
  103. package/stacks/content-site.md +45 -0
  104. package/ui/dist/assets/annotations-runtime.js +1 -0
  105. package/ui/dist/assets/client.css +2 -0
  106. package/ui/dist/assets/client.fhjppkvr.js +214 -0
  107. package/ui/dist/assets/components.js +63 -0
  108. package/ui/dist/assets/preact-compat.js +1 -0
  109. package/ui/dist/assets/react-jsx-dev-runtime.js +3 -0
  110. package/ui/dist/assets/react-jsx-runtime.js +3 -0
  111. package/ui/dist/assets/runtime.js +1 -0
  112. 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
+ }