@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,280 @@
1
+ import { resolve } from 'path'
2
+ import { BASE_URL, SESSIONS_DIR, TIMEOUT_MS, WS_URL } from './config'
3
+ import type { PhaseState } from './derive'
4
+ import { PHASES } from './phases'
5
+
6
+ const HEALTH_TIMEOUT_MS = 1000
7
+
8
+ export interface HealthInfo {
9
+ ok: boolean
10
+ version?: string
11
+ pid?: number
12
+ dataDir?: string
13
+ sessionsDir?: string
14
+ sessions?: number | string[]
15
+ }
16
+
17
+ function isRecord(value: unknown): value is Record<string, unknown> {
18
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
19
+ }
20
+
21
+ function optionalString(value: unknown): string | undefined {
22
+ return typeof value === 'string' ? value : undefined
23
+ }
24
+
25
+ function optionalNumber(value: unknown): number | undefined {
26
+ return typeof value === 'number' ? value : undefined
27
+ }
28
+
29
+ function optionalBoolean(value: unknown): boolean | undefined {
30
+ return typeof value === 'boolean' ? value : undefined
31
+ }
32
+
33
+ function stringArray(value: unknown): string[] | null {
34
+ if (!Array.isArray(value) || !value.every((item: unknown) => typeof item === 'string')) return null
35
+ return value
36
+ }
37
+
38
+ function parseHealthInfo(value: unknown): HealthInfo | null {
39
+ if (!isRecord(value) || value['ok'] !== true) return null
40
+ const sessions = typeof value['sessions'] === 'number' ? value['sessions'] : stringArray(value['sessions']) ?? undefined
41
+ return {
42
+ ok: true,
43
+ ...(optionalString(value['version']) === undefined ? {} : { version: optionalString(value['version']) }),
44
+ ...(optionalNumber(value['pid']) === undefined ? {} : { pid: optionalNumber(value['pid']) }),
45
+ ...(optionalString(value['dataDir']) === undefined ? {} : { dataDir: optionalString(value['dataDir']) }),
46
+ ...(optionalString(value['sessionsDir']) === undefined ? {} : { sessionsDir: optionalString(value['sessionsDir']) }),
47
+ ...(sessions === undefined ? {} : { sessions }),
48
+ }
49
+ }
50
+
51
+ function isPhaseId(value: unknown): value is PhaseState['current'] {
52
+ return typeof value === 'string' && PHASES.some((phase) => phase.id === value)
53
+ }
54
+
55
+ function parsePhaseState(value: unknown): PhaseState | null {
56
+ if (!isRecord(value) || !isPhaseId(value['current']) || typeof value['index'] !== 'number') return null
57
+ const rawCompleted = value['completed']
58
+ const completed = Array.isArray(rawCompleted) ? rawCompleted.filter(isPhaseId) : null
59
+ const reads = stringArray(value['reads'])
60
+ const produces = stringArray(value['produces'])
61
+ if (completed === null || !Array.isArray(rawCompleted) || completed.length !== rawCompleted.length || reads === null || produces === null) {
62
+ return null
63
+ }
64
+ if (typeof value['gate'] !== 'boolean' || typeof value['satisfied'] !== 'boolean') return null
65
+ return {
66
+ current: value['current'],
67
+ index: value['index'],
68
+ completed,
69
+ reads,
70
+ produces,
71
+ gate: value['gate'],
72
+ satisfied: value['satisfied'],
73
+ }
74
+ }
75
+
76
+ function stringRecord(value: unknown): Record<string, string> | undefined {
77
+ if (!isRecord(value)) return undefined
78
+ const result: Record<string, string> = {}
79
+ for (const [key, entry] of Object.entries(value)) {
80
+ if (typeof entry !== 'string') return undefined
81
+ result[key] = entry
82
+ }
83
+ return result
84
+ }
85
+
86
+ function parsePlanResult(value: unknown): PlanResult {
87
+ if (!isRecord(value) || typeof value['ok'] !== 'boolean') return { ok: false, error: 'Invalid daemon response' }
88
+ return {
89
+ ok: value['ok'],
90
+ ...(optionalString(value['browserUrl']) === undefined ? {} : { browserUrl: optionalString(value['browserUrl']) }),
91
+ ...(optionalNumber(value['revision']) === undefined ? {} : { revision: optionalNumber(value['revision']) }),
92
+ ...(optionalBoolean(value['isNew']) === undefined ? {} : { isNew: optionalBoolean(value['isNew']) }),
93
+ ...(stringArray(value['canvasFiles']) === null ? {} : { canvasFiles: stringArray(value['canvasFiles']) ?? [] }),
94
+ ...(optionalString(value['gate']) === undefined ? {} : { gate: optionalString(value['gate']) }),
95
+ ...(optionalString(value['error']) === undefined ? {} : { error: optionalString(value['error']) }),
96
+ ...(stringRecord(value['errors']) === undefined ? {} : { errors: stringRecord(value['errors']) }),
97
+ }
98
+ }
99
+
100
+ export async function health(): Promise<HealthInfo | null> {
101
+ try {
102
+ const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS) })
103
+ const data: unknown = await res.json()
104
+ return parseHealthInfo(data)
105
+ } catch {
106
+ return null
107
+ }
108
+ }
109
+
110
+ export async function isDaemonRunning(): Promise<boolean> {
111
+ const info = await health()
112
+ return info !== null && healthMatchesDataRoot(info)
113
+ }
114
+
115
+ export function healthMatchesDataRoot(
116
+ info: HealthInfo,
117
+ expectedSessionsDir: string = SESSIONS_DIR,
118
+ ): boolean {
119
+ if (!info.sessionsDir) return false
120
+ return comparablePath(info.sessionsDir) === comparablePath(expectedSessionsDir)
121
+ }
122
+
123
+ function comparablePath(path: string): string {
124
+ const absolute = resolve(path)
125
+ return process.platform === 'win32' ? absolute.toLowerCase() : absolute
126
+ }
127
+
128
+ export async function fetchPhase(fabrikaDir: string, sessionId?: string): Promise<PhaseState | null> {
129
+ try {
130
+ let url = `${BASE_URL}/api/phase?root=${encodeURIComponent(fabrikaDir)}`
131
+ if (sessionId) url += `&session=${encodeURIComponent(sessionId)}`
132
+ const res = await fetch(url, { signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS) })
133
+ if (!res.ok) return null
134
+ const data: unknown = await res.json()
135
+ return parsePhaseState(data)
136
+ } catch {
137
+ return null
138
+ }
139
+ }
140
+
141
+ export interface PlanResult {
142
+ ok: boolean
143
+ browserUrl?: string
144
+ revision?: number
145
+ isNew?: boolean
146
+ canvasFiles?: string[]
147
+ gate?: string
148
+ error?: string
149
+ errors?: Record<string, string>
150
+ }
151
+
152
+ export type PlanSource =
153
+ | { view: string; directory?: never }
154
+ | { directory: string; view?: never }
155
+
156
+ export async function pushPlan(
157
+ sessionId: string,
158
+ body: PlanSource & { projectRoot: string; label?: string; gate?: string },
159
+ ): Promise<PlanResult & { reachable: boolean }> {
160
+ try {
161
+ const res = await fetch(`${BASE_URL}/api/session/${sessionId}/plan`, {
162
+ method: 'POST',
163
+ headers: { 'Content-Type': 'application/json' },
164
+ body: JSON.stringify(body),
165
+ })
166
+ const data = parsePlanResult(await res.json())
167
+ return { ...data, reachable: true }
168
+ } catch (e) {
169
+ return { ok: false, reachable: false, error: String(e) }
170
+ }
171
+ }
172
+
173
+ export async function peekFeedback(sessionId: string): Promise<string | null> {
174
+ try {
175
+ const metaRes = await fetch(`${BASE_URL}/api/session/${sessionId}/meta`, {
176
+ signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
177
+ })
178
+ if (!metaRes.ok) return null
179
+ const meta: unknown = await metaRes.json()
180
+ if (!isRecord(meta) || !Array.isArray(meta['revisions'])) return null
181
+ const revisions = meta['revisions']
182
+ for (let i = revisions.length - 1; i >= 0; i--) {
183
+ const revision = revisions[i]
184
+ if (!isRecord(revision) || typeof revision['revision'] !== 'number') continue
185
+ if (revision['hasFeedback'] === true && revision['feedbackConsumed'] !== true) {
186
+ const fbRes = await fetch(`${BASE_URL}/api/session/${sessionId}/revision/${revision['revision']}/feedback`, {
187
+ signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
188
+ })
189
+ if (!fbRes.ok) return null
190
+ const data: unknown = await fbRes.json()
191
+ return isRecord(data) && typeof data['feedback'] === 'string' ? data['feedback'] : ''
192
+ }
193
+ }
194
+ return null
195
+ } catch {
196
+ return null
197
+ }
198
+ }
199
+
200
+ export async function consumeFeedback(sessionId: string): Promise<string | null> {
201
+ try {
202
+ const res = await fetch(`${BASE_URL}/api/session/${sessionId}/feedback/consume`, {
203
+ method: 'POST',
204
+ signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
205
+ })
206
+ const data: unknown = await res.json()
207
+ if (!isRecord(data) || data['found'] !== true) return null
208
+ return typeof data['feedback'] === 'string' ? data['feedback'] : ''
209
+ } catch {
210
+ return null
211
+ }
212
+ }
213
+
214
+ export interface BuildEvent {
215
+ issue?: string
216
+ status: string
217
+ message?: string
218
+ ts?: number
219
+ }
220
+
221
+ export async function postBuildEvent(event: BuildEvent): Promise<boolean> {
222
+ try {
223
+ const res = await fetch(`${BASE_URL}/api/build/event`, {
224
+ method: 'POST',
225
+ headers: { 'Content-Type': 'application/json' },
226
+ body: JSON.stringify(event),
227
+ signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
228
+ })
229
+ return res.ok
230
+ } catch {
231
+ return false
232
+ }
233
+ }
234
+
235
+ export function waitForFeedback(sessionId: string, timeoutMs: number = TIMEOUT_MS): Promise<string> {
236
+ return new Promise((resolveP, rejectP) => {
237
+ let settled = false
238
+ const ws = new WebSocket(`${WS_URL}/ws/wait/${sessionId}`)
239
+
240
+ const timer = setTimeout(() => {
241
+ if (settled) return
242
+ settled = true
243
+ try {
244
+ ws.close()
245
+ } catch {}
246
+ rejectP(new Error('timeout'))
247
+ }, timeoutMs)
248
+
249
+ ws.onmessage = (event: MessageEvent) => {
250
+ try {
251
+ const raw = typeof event.data === 'string' ? event.data : ''
252
+ const data: unknown = JSON.parse(raw)
253
+ if (isRecord(data) && data['type'] === 'submit') {
254
+ if (settled) return
255
+ settled = true
256
+ clearTimeout(timer)
257
+ try {
258
+ ws.close()
259
+ } catch {}
260
+ resolveP(typeof data['feedback'] === 'string' ? data['feedback'] : '')
261
+ }
262
+ } catch {
263
+ }
264
+ }
265
+
266
+ ws.onerror = () => {
267
+ if (settled) return
268
+ settled = true
269
+ clearTimeout(timer)
270
+ rejectP(new Error('connection-failed'))
271
+ }
272
+
273
+ ws.onclose = () => {
274
+ if (settled) return
275
+ settled = true
276
+ clearTimeout(timer)
277
+ rejectP(new Error('closed'))
278
+ }
279
+ })
280
+ }
@@ -0,0 +1,171 @@
1
+ import { spawn } from 'child_process'
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync } from 'fs'
3
+ import { dirname, join, resolve } from 'path'
4
+ import { fileURLToPath } from 'url'
5
+ import { DATA_DIR, PID_FILE, SESSIONS_DIR, TEMP_DIR } from './config'
6
+ import { health, type HealthInfo, healthMatchesDataRoot, isDaemonRunning } from './daemon-client'
7
+
8
+ const HERE = dirname(fileURLToPath(import.meta.url))
9
+
10
+ export function resolveDaemonServer(): string | null {
11
+ const candidates = [
12
+ resolve(HERE, '../../daemon/src/bin.ts'),
13
+ ]
14
+ for (const c of candidates) {
15
+ if (existsSync(c)) return c
16
+ }
17
+ return null
18
+ }
19
+
20
+ export class DaemonUnavailableError extends Error {
21
+ constructor(message: string) {
22
+ super(message)
23
+ this.name = 'DaemonUnavailableError'
24
+ }
25
+ }
26
+
27
+ export async function startDaemon(): Promise<void> {
28
+ const server = resolveDaemonServer()
29
+ if (!server) {
30
+ throw new DaemonUnavailableError(
31
+ 'fabrika builder daemon not found. '
32
+ + 'Build/start it manually, or run the CLI in offline mode.',
33
+ )
34
+ }
35
+
36
+ mkdirSync(TEMP_DIR, { recursive: true })
37
+ const startupLog = join(TEMP_DIR, 'daemon-startup.log')
38
+ const fd = openSync(startupLog, 'w')
39
+
40
+ const child = spawn('bun', ['run', server], {
41
+ detached: true,
42
+ stdio: ['ignore', 'ignore', fd],
43
+ cwd: dirname(dirname(server)),
44
+ env: daemonSpawnEnvironment(),
45
+ })
46
+ child.unref()
47
+ closeSync(fd)
48
+
49
+ for (let i = 0; i < 30; i++) {
50
+ await new Promise((r) => setTimeout(r, 100))
51
+ if (await isDaemonRunning()) return
52
+ }
53
+
54
+ let detail = ''
55
+ if (existsSync(startupLog)) {
56
+ const log = readFileSync(startupLog, 'utf-8').trim()
57
+ if (log) detail = '\nDaemon stderr:\n' + log
58
+ }
59
+ throw new DaemonUnavailableError('Daemon failed to start within 3 seconds.' + detail)
60
+ }
61
+
62
+ export function daemonSpawnEnvironment(
63
+ base: NodeJS.ProcessEnv = process.env,
64
+ dataDir: string = DATA_DIR,
65
+ ): NodeJS.ProcessEnv {
66
+ return { ...base, FABRIKA_DATA_DIR: resolve(dataDir) }
67
+ }
68
+
69
+ export async function ensureDaemon(opts: { quiet?: boolean } = {}): Promise<boolean> {
70
+ const active = await health()
71
+ if (active && healthMatchesDataRoot(active)) return true
72
+
73
+ if (active) {
74
+ const stopped = await stopMismatchedManagedDaemon(active, opts.quiet ?? false)
75
+ if (!stopped) return false
76
+ }
77
+
78
+ if (existsSync(PID_FILE)) {
79
+ const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10)
80
+ if (!isAlive(pid)) {
81
+ try {
82
+ unlinkSync(PID_FILE)
83
+ } catch {}
84
+ }
85
+ }
86
+
87
+ try {
88
+ await startDaemon()
89
+ return true
90
+ } catch (error: unknown) {
91
+ if (!opts.quiet) {
92
+ console.error(`[fabrika] ${error instanceof Error ? error.message : String(error)}`)
93
+ }
94
+ return false
95
+ }
96
+ }
97
+
98
+ async function stopMismatchedManagedDaemon(info: HealthInfo, quiet: boolean): Promise<boolean> {
99
+ const actual = info.sessionsDir ?? '<not reported by legacy daemon>'
100
+ const mismatch = `daemon data-root mismatch: active ${actual}, requested ${SESSIONS_DIR}`
101
+ if (!existsSync(PID_FILE)) {
102
+ if (!quiet) console.error(`[fabrika] ${mismatch}; no managed PID file, refusing to stop it.`)
103
+ return false
104
+ }
105
+
106
+ const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10)
107
+ if (!isAlive(pid) || info.pid === undefined || info.pid !== pid) {
108
+ if (!quiet) console.error(`[fabrika] ${mismatch}; daemon PID ownership could not be verified.`)
109
+ return false
110
+ }
111
+
112
+ if (!quiet) console.error(`[fabrika] ${mismatch}; restarting managed daemon ${pid}.`)
113
+ try {
114
+ process.kill(pid, 'SIGTERM')
115
+ } catch {
116
+ return false
117
+ }
118
+
119
+ for (let attempt = 0; attempt < 30; attempt++) {
120
+ await new Promise((resolveP) => setTimeout(resolveP, 100))
121
+ if ((await health()) === null) {
122
+ try {
123
+ unlinkSync(PID_FILE)
124
+ } catch {}
125
+ return true
126
+ }
127
+ }
128
+ if (!quiet) console.error(`[fabrika] mismatched daemon did not stop within 3 seconds.`)
129
+ return false
130
+ }
131
+
132
+ export function stopDaemon(): boolean {
133
+ if (!existsSync(PID_FILE)) return false
134
+ const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10)
135
+ try {
136
+ process.kill(pid, 'SIGTERM')
137
+ try {
138
+ unlinkSync(PID_FILE)
139
+ } catch {}
140
+ return true
141
+ } catch {
142
+ try {
143
+ unlinkSync(PID_FILE)
144
+ } catch {}
145
+ return false
146
+ }
147
+ }
148
+
149
+ function isAlive(pid: number): boolean {
150
+ if (!Number.isFinite(pid) || pid <= 0) return false
151
+ try {
152
+ process.kill(pid, 0)
153
+ return true
154
+ } catch {
155
+ return false
156
+ }
157
+ }
158
+
159
+ export function openBrowser(url: string): void {
160
+ const candidates = process.platform === 'darwin'
161
+ ? ['open']
162
+ : ['xdg-open', 'wslview', 'sensible-browser']
163
+ for (const cmd of candidates) {
164
+ const which = Bun.spawnSync(['which', cmd])
165
+ if (which.exitCode === 0) {
166
+ spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref()
167
+ return
168
+ }
169
+ }
170
+ console.error(`Open this URL in your browser: ${url}`)
171
+ }
@@ -0,0 +1,109 @@
1
+ import { existsSync, readdirSync, statSync } from 'fs'
2
+ import { basename, dirname, join, resolve } from 'path'
3
+ import { type Phase, type PhaseId, PHASES } from './phases'
4
+
5
+ export interface PhaseState {
6
+ current: PhaseId
7
+ index: number
8
+ completed: PhaseId[]
9
+ reads: string[]
10
+ produces: string[]
11
+ gate: boolean
12
+ satisfied: boolean
13
+ }
14
+
15
+ export type GateProbe = (phase: Phase) => boolean
16
+
17
+ export const offlineGateProbe: GateProbe = () => true
18
+
19
+ export function matchGlob(repoRoot: string, glob: string): string[] {
20
+ if (!glob.includes('*')) {
21
+ const abs = resolve(repoRoot, glob)
22
+ return existsSync(abs) ? [abs] : []
23
+ }
24
+
25
+ const dirPart = dirname(glob)
26
+ const filePart = basename(glob)
27
+ const absDir = resolve(repoRoot, dirPart)
28
+ if (!existsSync(absDir) || !statSync(absDir).isDirectory()) return []
29
+
30
+ const re = globSegmentToRegExp(filePart)
31
+ const out: string[] = []
32
+ for (const entry of readdirSync(absDir)) {
33
+ if (re.test(entry)) out.push(join(absDir, entry))
34
+ }
35
+ return out
36
+ }
37
+
38
+ function globSegmentToRegExp(seg: string): RegExp {
39
+ const escaped = seg.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')
40
+ return new RegExp(`^${escaped}$`)
41
+ }
42
+
43
+ export function isNonEmptyFile(p: string): boolean {
44
+ try {
45
+ const st = statSync(p)
46
+ return st.isFile() && st.size > 0
47
+ } catch {
48
+ return false
49
+ }
50
+ }
51
+
52
+ export function producesSatisfied(repoRoot: string, phase: Phase): boolean {
53
+ for (const glob of phase.produces) {
54
+ if (isPlaceholder(glob)) return false
55
+ const matches = matchGlob(repoRoot, glob).filter(isNonEmptyFile)
56
+ if (matches.length === 0) return false
57
+ }
58
+ return true
59
+ }
60
+
61
+ export function resolveReads(repoRoot: string, phase: Phase): string[] {
62
+ const out: string[] = []
63
+ for (const glob of phase.reads) {
64
+ if (isPlaceholder(glob)) {
65
+ out.push(glob) // keep the human hint, e.g. "<init brief>"
66
+ continue
67
+ }
68
+ const matches = matchGlob(repoRoot, glob)
69
+ if (matches.length > 0) {
70
+ out.push(glob)
71
+ }
72
+ }
73
+ return out
74
+ }
75
+
76
+ function isPlaceholder(glob: string): boolean {
77
+ return glob.startsWith('<')
78
+ }
79
+
80
+ export function derivePhase(repoRoot: string, gateProbe: GateProbe = offlineGateProbe): PhaseState {
81
+ const completed: PhaseId[] = []
82
+
83
+ for (const phase of PHASES) {
84
+ const produced = producesSatisfied(repoRoot, phase)
85
+ const gateOk = !produced ? false : phase.gate ? gateProbe(phase) : true
86
+
87
+ if (produced && gateOk) {
88
+ completed.push(phase.id)
89
+ continue
90
+ }
91
+ return toState(repoRoot, phase, completed, produced)
92
+ }
93
+
94
+ const build = PHASES.at(-1)
95
+ if (build === undefined) throw new Error('The builder has no phases')
96
+ return toState(repoRoot, build, completed.filter((id) => id !== build.id), true)
97
+ }
98
+
99
+ function toState(repoRoot: string, phase: Phase, completed: PhaseId[], satisfied: boolean): PhaseState {
100
+ return {
101
+ current: phase.id,
102
+ index: PHASES.findIndex((p) => p.id === phase.id),
103
+ completed,
104
+ reads: resolveReads(repoRoot, phase),
105
+ produces: phase.produces,
106
+ gate: phase.gate,
107
+ satisfied,
108
+ }
109
+ }
@@ -0,0 +1,38 @@
1
+ export { resolveCatalogRoot, resolveStackPointer, type StackPointer } from './catalog'
2
+ export {
3
+ BASE_URL,
4
+ DAEMON_PORT,
5
+ DATA_DIR,
6
+ FABRIKA_DIRNAME,
7
+ getSessionId,
8
+ hashRepoPath,
9
+ LEGACY_DATA_DIR,
10
+ NEUTRAL_DATA_DIR,
11
+ resolveDataDir,
12
+ TEMP_DIR,
13
+ WS_URL,
14
+ } from './config'
15
+ export { derivePhase, type GateProbe, isNonEmptyFile, matchGlob, offlineGateProbe, type PhaseState, producesSatisfied, resolveReads } from './derive'
16
+ export {
17
+ type AgentHost,
18
+ injectAgentSupport,
19
+ type InjectOptions,
20
+ type InjectResult,
21
+ injectSkills,
22
+ parseAgentHosts,
23
+ resolveSkillSource,
24
+ } from './inject-skills'
25
+ export {
26
+ configPath,
27
+ EXTERNAL_DIRS,
28
+ type FabrikaConfig,
29
+ fabrikaDir,
30
+ findRepoRoot,
31
+ type InitResult,
32
+ isInitialized,
33
+ LAYER_DIRS,
34
+ readConfig,
35
+ scaffold,
36
+ } from './layout'
37
+ export { type PacketJson, packetJson, PHASE_IDS, type RenderOpts, renderPacket } from './packet'
38
+ export { type Phase, PHASE_COUNT, phaseById, type PhaseId, phaseIndex, PHASES } from './phases'