@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,41 @@
|
|
|
1
|
+
# Builder contract
|
|
2
|
+
|
|
3
|
+
## Package boundary
|
|
4
|
+
|
|
5
|
+
`@fabrika/builder` owns one complete guided-build feature:
|
|
6
|
+
|
|
7
|
+
- deterministic phase and issue logic under `cli/`
|
|
8
|
+
- the local daemon under `daemon/`
|
|
9
|
+
- the packaged dashboard under `ui/dist/`
|
|
10
|
+
- installed agent support under `skill/`
|
|
11
|
+
- the public stack and technology catalog under `stacks/` and `decisions/`
|
|
12
|
+
|
|
13
|
+
`@fabrika/cli` is the only public `fabrika` executable. It routes arguments after `fabrika builder` to `runBuilderCli()`.
|
|
14
|
+
|
|
15
|
+
The builder CLI and dashboard must not import daemon implementation modules. They communicate through HTTP and WebSocket endpoints. The daemon is the only builder layer that imports `@fabrika/canvas-kernel/server`.
|
|
16
|
+
|
|
17
|
+
## Phase model
|
|
18
|
+
|
|
19
|
+
The ordered phases are intake, vision, flows, technology, design, skeletons, issues, and build. A phase is complete when every required output exists and its review gate, when present, has submitted and consumed feedback.
|
|
20
|
+
|
|
21
|
+
The phase is always derived from application files and gate state. No mutable phase counter is authoritative.
|
|
22
|
+
|
|
23
|
+
## Application state
|
|
24
|
+
|
|
25
|
+
`fabrika builder init` creates `.fabrika/config.json`, layered artifact directories, and `tests/browser/`. It installs agent support without overwriting user-edited generated files.
|
|
26
|
+
|
|
27
|
+
The stable session ID identifies dashboard revisions. Persistent daemon state defaults to `~/.fabrika` and can be moved with `FABRIKA_DATA_DIR`.
|
|
28
|
+
|
|
29
|
+
## Gate protocol
|
|
30
|
+
|
|
31
|
+
1. `gate open` sends one view or a directory of views to `POST /api/session/:id/plan`.
|
|
32
|
+
2. The daemon compiles the selected canvas files and creates a revision.
|
|
33
|
+
3. The dashboard submits feedback over `/ws/session/:id`.
|
|
34
|
+
4. `gate wait` observes feedback without consuming it.
|
|
35
|
+
5. `advance` validates outputs and consumes the latest feedback.
|
|
36
|
+
|
|
37
|
+
Only `advance` consumes feedback. This keeps retries and re-opened gates pending until the agent explicitly advances.
|
|
38
|
+
|
|
39
|
+
## Release artifact
|
|
40
|
+
|
|
41
|
+
The release packer runs `release:prepare`, which builds the dashboard into `ui/dist/`. The npm package includes runtime source, dashboard assets, skills, decisions, and stack presets. Tests and development-only source are excluded.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Contember Limited
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# @fabrika/builder
|
|
2
|
+
|
|
3
|
+
`@fabrika/builder` guides a coding agent from an initial application brief to a reviewed acceptance contract and an assisted implementation.
|
|
4
|
+
|
|
5
|
+
The builder runs eight deterministic phases:
|
|
6
|
+
|
|
7
|
+
1. intake
|
|
8
|
+
2. vision
|
|
9
|
+
3. flows
|
|
10
|
+
4. technology
|
|
11
|
+
5. design
|
|
12
|
+
6. opice test skeletons
|
|
13
|
+
7. implementation issues
|
|
14
|
+
8. build
|
|
15
|
+
|
|
16
|
+
The current phase is derived from files in `.fabrika/`; it is not stored as a mutable counter. Review gates use a local dashboard with persistent annotations and responses.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
Install the public CLI and initialize the builder in an application repository:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
bun add --global @fabrika/cli
|
|
24
|
+
fabrika builder init
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Initialization installs the Fabrika skill for Claude Code and Codex, starts the local daemon, and opens the dashboard. The coding agent then drives the phase loop.
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
fabrika builder next
|
|
31
|
+
do the emitted work
|
|
32
|
+
fabrika builder gate open <phase> --view <canvas.jsx>
|
|
33
|
+
fabrika builder gate wait
|
|
34
|
+
fabrika builder advance
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
All commands:
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
fabrika builder init [dir] [--profile <stack>] [--agent all|claude|codex]
|
|
41
|
+
fabrika builder next [--json]
|
|
42
|
+
fabrika builder advance [--force]
|
|
43
|
+
fabrika builder gate open <name> --view <file.jsx>
|
|
44
|
+
fabrika builder gate wait [--gate <revision>] [--timeout <ms>]
|
|
45
|
+
fabrika builder ensure-daemon
|
|
46
|
+
fabrika builder induce [--force]
|
|
47
|
+
fabrika builder build [--issue <ID>] [--assisted]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Use `--profile cf-internal-app` for the full Buzola, Propustka, and Vozka internal-app stack. The default is `cf-lightweight`.
|
|
51
|
+
|
|
52
|
+
## Generated application state
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
.fabrika/
|
|
56
|
+
config.json
|
|
57
|
+
agent-support.json
|
|
58
|
+
intent/
|
|
59
|
+
principles/
|
|
60
|
+
architecture/
|
|
61
|
+
issues/
|
|
62
|
+
.claude/
|
|
63
|
+
.agents/skills/fabrika/
|
|
64
|
+
.codex/agents/
|
|
65
|
+
tests/browser/*.test.ts
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The opice skeletons under `tests/browser/` are the executable acceptance contract. The generated issues form the implementation dependency graph.
|
|
69
|
+
|
|
70
|
+
## Environment
|
|
71
|
+
|
|
72
|
+
| Variable | Purpose | Default |
|
|
73
|
+
| -------------------- | --------------------------------- | ----------------------------------- |
|
|
74
|
+
| `FABRIKA_PORT` | Local daemon port | `19410` |
|
|
75
|
+
| `FABRIKA_SESSION_ID` | Session override | Stable hash of the application path |
|
|
76
|
+
| `FABRIKA_DATA_DIR` | Persistent local state | `~/.fabrika` |
|
|
77
|
+
| `FABRIKA_TIMEOUT` | Gate wait timeout in milliseconds | `3600000` |
|
|
78
|
+
| `FABRIKA_UI_DIST` | Dashboard build override | Packaged `ui/dist` |
|
|
79
|
+
|
|
80
|
+
## Package contents
|
|
81
|
+
|
|
82
|
+
- `cli/` owns phase derivation, task packets, issue induction, and builder commands.
|
|
83
|
+
- `daemon/` owns HTTP/WebSocket endpoints and persistent canvas sessions.
|
|
84
|
+
- `ui/` owns the local dashboard.
|
|
85
|
+
- `skill/` contains the agent-neutral workflow and specialist roles.
|
|
86
|
+
- `decisions/` and `stacks/` contain the public technology catalog.
|
|
87
|
+
|
|
88
|
+
The public `@fabrika/cli` package exposes this package under `fabrika builder …`. CLI and dashboard clients communicate with the daemon only over HTTP and WebSocket.
|
|
89
|
+
|
|
90
|
+
## Development
|
|
91
|
+
|
|
92
|
+
From the `fabrika-platform` repository root:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
bun run --filter @fabrika/builder typecheck
|
|
96
|
+
bun run --filter @fabrika/builder test
|
|
97
|
+
bun run --filter @fabrika/builder build
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
See [`BUILD-CONTRACT.md`](./BUILD-CONTRACT.md) for the package boundary and wire contract.
|
package/cli/src/bin.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { cmdAdvance } from './commands/advance'
|
|
4
|
+
import { cmdBuild } from './commands/build'
|
|
5
|
+
import { cmdEnsureDaemon } from './commands/ensure-daemon'
|
|
6
|
+
import { cmdGate } from './commands/gate'
|
|
7
|
+
import { cmdInduce } from './commands/induce'
|
|
8
|
+
import { cmdInit } from './commands/init'
|
|
9
|
+
import { cmdNext } from './commands/next'
|
|
10
|
+
|
|
11
|
+
export const BUILDER_USAGE = `fabrika builder — guided application builder
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
fabrika builder init [dir] [--profile <stack>] [--agent all|claude|codex]
|
|
15
|
+
Scaffold .fabrika/, install agent support, open dashboard
|
|
16
|
+
fabrika builder next [--json] Print the task packet for the current derived phase
|
|
17
|
+
fabrika builder advance [--force] Validate outputs and gate, then print the next phase
|
|
18
|
+
fabrika builder gate open <name> --view <f.jsx> Push a canvas gate and open the dashboard
|
|
19
|
+
fabrika builder gate wait [--gate <n>] [--timeout] Wait for submitted gate feedback
|
|
20
|
+
fabrika builder ensure-daemon Start the daemon if it is not running
|
|
21
|
+
fabrika builder induce [--force] Derive issues from opice skeleton metadata
|
|
22
|
+
fabrika builder build [--issue <ID>] [--assisted] Run the assisted per-issue build driver
|
|
23
|
+
|
|
24
|
+
Environment:
|
|
25
|
+
FABRIKA_PORT Daemon port (default 19410)
|
|
26
|
+
FABRIKA_SESSION_ID Session id (fallback: stable hash of repo abs path)
|
|
27
|
+
FABRIKA_DATA_DIR Persistent daemon/session data (default ~/.fabrika; legacy auto-detected)
|
|
28
|
+
FABRIKA_TIMEOUT gate wait timeout in ms (default 3600000)`
|
|
29
|
+
|
|
30
|
+
export async function runBuilderCli(argv: readonly string[]): Promise<number> {
|
|
31
|
+
const command = argv[0]
|
|
32
|
+
const rest = argv.slice(1)
|
|
33
|
+
|
|
34
|
+
if (!command || command === '--help' || command === '-h' || command === 'help') {
|
|
35
|
+
console.info(BUILDER_USAGE)
|
|
36
|
+
return command ? 0 : 1
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
switch (command) {
|
|
40
|
+
case 'init':
|
|
41
|
+
return cmdInit(rest)
|
|
42
|
+
case 'next':
|
|
43
|
+
return cmdNext(rest)
|
|
44
|
+
case 'advance':
|
|
45
|
+
return cmdAdvance(rest)
|
|
46
|
+
case 'gate':
|
|
47
|
+
return cmdGate(rest)
|
|
48
|
+
case 'ensure-daemon':
|
|
49
|
+
return cmdEnsureDaemon(rest)
|
|
50
|
+
case 'induce':
|
|
51
|
+
return cmdInduce(rest)
|
|
52
|
+
case 'build':
|
|
53
|
+
return cmdBuild(rest)
|
|
54
|
+
default:
|
|
55
|
+
console.error(`Unknown builder command: ${command}`)
|
|
56
|
+
console.info(BUILDER_USAGE)
|
|
57
|
+
return 1
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (import.meta.main) {
|
|
62
|
+
runBuilderCli(process.argv.slice(2)).then((code) => process.exit(code)).catch((error: unknown) => {
|
|
63
|
+
console.error(error instanceof Error ? error.stack ?? error.message : String(error))
|
|
64
|
+
process.exit(1)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
2
|
+
import { dirname, join } from 'path'
|
|
3
|
+
import type { Issue } from './issues'
|
|
4
|
+
|
|
5
|
+
export type IssueStatus = 'done' | 'blocked'
|
|
6
|
+
|
|
7
|
+
export interface BuildRecord {
|
|
8
|
+
status: IssueStatus
|
|
9
|
+
reason?: string
|
|
10
|
+
at: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type BuildBoard = Record<string, BuildRecord>
|
|
14
|
+
|
|
15
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
16
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseBoard(value: unknown): BuildBoard | null {
|
|
20
|
+
if (!isRecord(value)) return null
|
|
21
|
+
const board: BuildBoard = {}
|
|
22
|
+
for (const [id, candidate] of Object.entries(value)) {
|
|
23
|
+
if (!isRecord(candidate)) return null
|
|
24
|
+
const status = candidate['status']
|
|
25
|
+
const at = candidate['at']
|
|
26
|
+
const reason = candidate['reason']
|
|
27
|
+
if ((status !== 'done' && status !== 'blocked') || typeof at !== 'string') return null
|
|
28
|
+
if (reason !== undefined && typeof reason !== 'string') return null
|
|
29
|
+
board[id] = { status, at, ...(reason === undefined ? {} : { reason }) }
|
|
30
|
+
}
|
|
31
|
+
return board
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function boardPath(fabrikaDir: string): string {
|
|
35
|
+
return join(fabrikaDir, 'build', 'status.json')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function readBoard(fabrikaDir: string): BuildBoard {
|
|
39
|
+
const p = boardPath(fabrikaDir)
|
|
40
|
+
if (!existsSync(p)) return {}
|
|
41
|
+
try {
|
|
42
|
+
const board = parseBoard(JSON.parse(readFileSync(p, 'utf-8')))
|
|
43
|
+
return board ?? {}
|
|
44
|
+
} catch {
|
|
45
|
+
return {}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function writeBoard(fabrikaDir: string, board: BuildBoard): void {
|
|
50
|
+
const p = boardPath(fabrikaDir)
|
|
51
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
52
|
+
writeFileSync(p, JSON.stringify(board, null, 2) + '\n')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function record(
|
|
56
|
+
fabrikaDir: string,
|
|
57
|
+
id: string,
|
|
58
|
+
status: IssueStatus,
|
|
59
|
+
reason: string | undefined,
|
|
60
|
+
at: string,
|
|
61
|
+
): BuildBoard {
|
|
62
|
+
const board = readBoard(fabrikaDir)
|
|
63
|
+
board[id] = { status, ...(reason ? { reason } : {}), at }
|
|
64
|
+
writeBoard(fabrikaDir, board)
|
|
65
|
+
return board
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface BuildPlan {
|
|
69
|
+
done: Issue[]
|
|
70
|
+
blocked: Issue[]
|
|
71
|
+
ready: Issue[]
|
|
72
|
+
waiting: Issue[]
|
|
73
|
+
next: Issue | null
|
|
74
|
+
complete: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function planBuild(issues: Issue[], board: BuildBoard): BuildPlan {
|
|
78
|
+
const statusOf = (id: string): IssueStatus | undefined => board[id]?.status
|
|
79
|
+
const doneIds = new Set(issues.filter((i) => statusOf(i.id) === 'done').map((i) => i.id))
|
|
80
|
+
|
|
81
|
+
const done: Issue[] = []
|
|
82
|
+
const blocked: Issue[] = []
|
|
83
|
+
const ready: Issue[] = []
|
|
84
|
+
const waiting: Issue[] = []
|
|
85
|
+
|
|
86
|
+
for (const issue of issues) {
|
|
87
|
+
const s = statusOf(issue.id)
|
|
88
|
+
if (s === 'done') {
|
|
89
|
+
done.push(issue)
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if (s === 'blocked') {
|
|
93
|
+
blocked.push(issue)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
const depsDone = issue.dependsOn.every((d) => doneIds.has(d))
|
|
97
|
+
if (depsDone) ready.push(issue)
|
|
98
|
+
else waiting.push(issue)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
done,
|
|
103
|
+
blocked,
|
|
104
|
+
ready,
|
|
105
|
+
waiting,
|
|
106
|
+
next: ready[0] ?? null,
|
|
107
|
+
complete: ready.length === 0 && waiting.length === 0 && done.length + blocked.length === issues.length && issues.length > 0,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'fs'
|
|
2
|
+
import { dirname, join, resolve } from 'path'
|
|
3
|
+
import { fileURLToPath } from 'url'
|
|
4
|
+
|
|
5
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
6
|
+
|
|
7
|
+
export function resolveCatalogRoot(): string | null {
|
|
8
|
+
const candidates = [resolve(HERE, '../..')]
|
|
9
|
+
for (const c of candidates) {
|
|
10
|
+
if (isDir(join(c, 'stacks')) && isDir(join(c, 'decisions'))) return c
|
|
11
|
+
}
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface StackPointer {
|
|
16
|
+
profile: string
|
|
17
|
+
root: string
|
|
18
|
+
stackFile: string
|
|
19
|
+
stackFileExists: boolean
|
|
20
|
+
decisionsDir: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveStackPointer(profile: string): StackPointer | null {
|
|
24
|
+
const root = resolveCatalogRoot()
|
|
25
|
+
if (!root) return null
|
|
26
|
+
const stackFile = join(root, 'stacks', `${profile}.md`)
|
|
27
|
+
return {
|
|
28
|
+
profile,
|
|
29
|
+
root,
|
|
30
|
+
stackFile,
|
|
31
|
+
stackFileExists: existsSync(stackFile) && statSync(stackFile).isFile(),
|
|
32
|
+
decisionsDir: join(root, 'decisions'),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isDir(p: string): boolean {
|
|
37
|
+
try {
|
|
38
|
+
return statSync(p).isDirectory()
|
|
39
|
+
} catch {
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { parseArgs } from 'util'
|
|
2
|
+
import { resolveContext } from '../context'
|
|
3
|
+
import { consumeFeedback, isDaemonRunning } from '../daemon-client'
|
|
4
|
+
import { isNonEmptyFile, matchGlob, producesSatisfied } from '../derive'
|
|
5
|
+
import { resolvePhaseState } from '../phase-state'
|
|
6
|
+
import { PHASE_COUNT, phaseById, phaseIndex, PHASES } from '../phases'
|
|
7
|
+
|
|
8
|
+
export async function cmdAdvance(argv: string[]): Promise<number> {
|
|
9
|
+
const { values } = parseArgs({
|
|
10
|
+
args: argv,
|
|
11
|
+
options: {
|
|
12
|
+
force: { type: 'boolean', default: false },
|
|
13
|
+
dir: { type: 'string' },
|
|
14
|
+
session: { type: 'string' },
|
|
15
|
+
},
|
|
16
|
+
allowPositionals: true,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const ctx = resolveContext({ dir: values.dir, session: values.session })
|
|
20
|
+
if (!ctx.initialized) {
|
|
21
|
+
console.error(`[fabrika] no .fabrika/ found at or above ${ctx.repoRoot}. Run \`fabrika builder init\` first.`)
|
|
22
|
+
return 1
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { state } = await resolvePhaseState(ctx.repoRoot, ctx.fabrikaDir, ctx.sessionId)
|
|
26
|
+
const phase = phaseById(state.current)
|
|
27
|
+
const problems: string[] = []
|
|
28
|
+
|
|
29
|
+
if (!producesSatisfied(ctx.repoRoot, phase)) {
|
|
30
|
+
for (const glob of phase.produces) {
|
|
31
|
+
if (glob.startsWith('<')) {
|
|
32
|
+
problems.push(`missing produce: ${glob} (manual/terminal — cannot auto-validate)`)
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
const matches = matchGlob(ctx.repoRoot, glob).filter(isNonEmptyFile)
|
|
36
|
+
if (matches.length === 0) problems.push(`missing produce: ${glob}`)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (phase.gate && !values.force) {
|
|
41
|
+
const daemonUp = await isDaemonRunning()
|
|
42
|
+
if (!daemonUp) {
|
|
43
|
+
problems.push(
|
|
44
|
+
`gate "${phase.id}" cannot be validated offline (daemon down). `
|
|
45
|
+
+ `Open the gate + submit feedback, or pass --force.`,
|
|
46
|
+
)
|
|
47
|
+
} else {
|
|
48
|
+
const feedback = await consumeFeedback(ctx.sessionId)
|
|
49
|
+
if (feedback === null) {
|
|
50
|
+
problems.push(
|
|
51
|
+
`gate "${phase.id}" has no submitted feedback to consume. `
|
|
52
|
+
+ `Run: fabrika builder gate open ${phase.id} --view ${phase.gateView ?? phase.id + '.jsx'} && fabrika builder gate wait`,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (problems.length > 0) {
|
|
59
|
+
console.error(`[fabrika] cannot advance from phase ${phase.number}/${PHASE_COUNT} (${phase.id}):`)
|
|
60
|
+
for (const p of problems) console.error(` - ${p}`)
|
|
61
|
+
return 1
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const idx = phaseIndex(phase.id)
|
|
65
|
+
const next = idx + 1 < PHASES.length ? PHASES[idx + 1] : null
|
|
66
|
+
if (next) {
|
|
67
|
+
console.log(`OK: phase ${phase.number}/${PHASE_COUNT} (${phase.id}) satisfied.`)
|
|
68
|
+
console.log(`Next -> phase ${next.number}/${PHASE_COUNT} - ${next.title}. Run \`fabrika builder next\`.`)
|
|
69
|
+
} else {
|
|
70
|
+
console.log(`OK: all ${PHASE_COUNT} phases satisfied. Run \`fabrika builder build\`.`)
|
|
71
|
+
}
|
|
72
|
+
return 0
|
|
73
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { join } from 'path'
|
|
2
|
+
import { parseArgs } from 'util'
|
|
3
|
+
import { type BuildPlan, planBuild, readBoard, record } from '../build-status'
|
|
4
|
+
import { resolveStackPointer } from '../catalog'
|
|
5
|
+
import { resolveContext } from '../context'
|
|
6
|
+
import { postBuildEvent } from '../daemon-client'
|
|
7
|
+
import { type Issue, readIssues } from '../issues'
|
|
8
|
+
|
|
9
|
+
export async function cmdBuild(argv: string[]): Promise<number> {
|
|
10
|
+
const { values, positionals } = parseArgs({
|
|
11
|
+
args: argv,
|
|
12
|
+
options: {
|
|
13
|
+
reason: { type: 'string' },
|
|
14
|
+
issue: { type: 'string' },
|
|
15
|
+
dir: { type: 'string' },
|
|
16
|
+
session: { type: 'string' },
|
|
17
|
+
assisted: { type: 'boolean', default: false },
|
|
18
|
+
},
|
|
19
|
+
allowPositionals: true,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const sub = positionals[0] ?? 'next'
|
|
23
|
+
const idArg = positionals[1] ?? values.issue
|
|
24
|
+
|
|
25
|
+
const ctx = resolveContext({ dir: values.dir, session: values.session })
|
|
26
|
+
if (!ctx.initialized) {
|
|
27
|
+
console.error(`[fabrika] no .fabrika/ found at or above ${ctx.repoRoot}. Run \`fabrika builder init\` first.`)
|
|
28
|
+
return 1
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const issues = readIssues(ctx.fabrikaDir)
|
|
32
|
+
if (issues.length === 0) {
|
|
33
|
+
console.error(
|
|
34
|
+
`[fabrika] no issues in ${ctx.fabrikaDir}/issues/. Run \`fabrika builder induce\` (phase 7) first.`,
|
|
35
|
+
)
|
|
36
|
+
return 1
|
|
37
|
+
}
|
|
38
|
+
const byId = (id: string) => issues.find((i) => i.id === id) ?? null
|
|
39
|
+
|
|
40
|
+
switch (sub) {
|
|
41
|
+
case 'status':
|
|
42
|
+
printBoard(planBuild(issues, readBoard(ctx.fabrikaDir)))
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
case 'done': {
|
|
46
|
+
if (!idArg || !byId(idArg)) {
|
|
47
|
+
console.error(`[fabrika] build done <ISSUE-ID> — unknown or missing issue id.`)
|
|
48
|
+
return 1
|
|
49
|
+
}
|
|
50
|
+
record(ctx.fabrikaDir, idArg, 'done', undefined, new Date().toISOString())
|
|
51
|
+
await postBuildEvent({ issue: idArg, status: 'passed', message: 'issue green (bun test passed)' })
|
|
52
|
+
console.log(`[fabrika] ✓ ${idArg} marked done.`)
|
|
53
|
+
return await reportProgress(issues, ctx.fabrikaDir)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
case 'block': {
|
|
57
|
+
if (!idArg || !byId(idArg)) {
|
|
58
|
+
console.error(`[fabrika] build block <ISSUE-ID> --reason "…" — unknown or missing issue id.`)
|
|
59
|
+
return 1
|
|
60
|
+
}
|
|
61
|
+
const reason = values.reason ?? 'blocked (no reason given)'
|
|
62
|
+
record(ctx.fabrikaDir, idArg, 'blocked', reason, new Date().toISOString())
|
|
63
|
+
await postBuildEvent({ issue: idArg, status: 'blocked', message: reason })
|
|
64
|
+
console.log(`[fabrika] ⚠ ${idArg} blocked: ${reason}`)
|
|
65
|
+
console.log(`[fabrika] continuing to independent issues; collect blocked at final review.`)
|
|
66
|
+
return await reportProgress(issues, ctx.fabrikaDir)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
case 'next':
|
|
70
|
+
default: {
|
|
71
|
+
if (values.issue && byId(values.issue)) {
|
|
72
|
+
await postBuildEvent({ issue: values.issue, status: 'running', message: 'building (re-selected)' })
|
|
73
|
+
process.stdout.write('\n' + renderBuildPacket(byId(values.issue)!, ctx.repoRoot, ctx.profile))
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
return await emitBrief(issues, ctx.fabrikaDir, ctx.repoRoot, ctx.profile)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function emitBrief(
|
|
82
|
+
issues: Issue[],
|
|
83
|
+
fabrikaDir: string,
|
|
84
|
+
repoRoot: string,
|
|
85
|
+
profile: string,
|
|
86
|
+
): Promise<number> {
|
|
87
|
+
const plan = planBuild(issues, readBoard(fabrikaDir))
|
|
88
|
+
printSummary(plan)
|
|
89
|
+
|
|
90
|
+
if (plan.complete) {
|
|
91
|
+
return await reportComplete(plan, issues.length)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await postBuildEvent({
|
|
95
|
+
status: 'info',
|
|
96
|
+
message: `build: ${plan.done.length}/${issues.length} done, ${plan.ready.length} ready`,
|
|
97
|
+
})
|
|
98
|
+
process.stdout.write('\n' + renderOrchestrationBrief(plan, repoRoot, profile))
|
|
99
|
+
return 0
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function reportProgress(issues: Issue[], fabrikaDir: string): Promise<number> {
|
|
103
|
+
const plan = planBuild(issues, readBoard(fabrikaDir))
|
|
104
|
+
if (plan.complete) return await reportComplete(plan, issues.length)
|
|
105
|
+
printBoard(plan)
|
|
106
|
+
if (!plan.next && plan.waiting.length) {
|
|
107
|
+
console.error(`\n[fabrika] nothing buildable: ${plan.waiting.length} waiting on blocked deps (see final review).`)
|
|
108
|
+
}
|
|
109
|
+
return 0
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function reportComplete(plan: BuildPlan, total: number): Promise<number> {
|
|
113
|
+
await postBuildEvent({ status: 'info', message: `build complete: ${plan.done.length}/${total} green` })
|
|
114
|
+
console.log(
|
|
115
|
+
`\n[fabrika] ✓ build complete — ${plan.done.length}/${total} issues green`
|
|
116
|
+
+ (plan.blocked.length ? `, ${plan.blocked.length} blocked (see final review).` : '.'),
|
|
117
|
+
)
|
|
118
|
+
if (plan.blocked.length) {
|
|
119
|
+
console.log(`\nBlocked (final review):`)
|
|
120
|
+
for (const b of plan.blocked) console.log(` ⚠ ${b.id} ${b.goal}`)
|
|
121
|
+
}
|
|
122
|
+
return 0
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function printSummary(plan: BuildPlan): void {
|
|
126
|
+
const total = plan.done.length + plan.blocked.length + plan.ready.length + plan.waiting.length
|
|
127
|
+
console.log(
|
|
128
|
+
`build: ${plan.done.length}/${total} done · ${plan.ready.length} ready · `
|
|
129
|
+
+ `${plan.waiting.length} waiting · ${plan.blocked.length} blocked`,
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function printBoard(plan: BuildPlan): void {
|
|
134
|
+
printSummary(plan)
|
|
135
|
+
const line = (label: string, xs: Issue[]) => xs.length && console.log(`\n${label}:\n` + xs.map((i) => ` ${i.id} ${i.goal}`).join('\n'))
|
|
136
|
+
line('✓ done', plan.done)
|
|
137
|
+
line('→ ready', plan.ready)
|
|
138
|
+
line('… waiting', plan.waiting)
|
|
139
|
+
line('⚠ blocked', plan.blocked)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function stackBriefLines(profile: string): string[] {
|
|
143
|
+
const p = resolveStackPointer(profile)
|
|
144
|
+
if (!p) return []
|
|
145
|
+
if (!p.stackFileExists) {
|
|
146
|
+
return [
|
|
147
|
+
`Stack: ${profile} — preset NOT found; pick one of ${join(p.root, 'stacks')}/*.md (decisions under ${p.decisionsDir}/)`,
|
|
148
|
+
]
|
|
149
|
+
}
|
|
150
|
+
return [
|
|
151
|
+
`Stack: ${profile} -> ${p.stackFile}`,
|
|
152
|
+
` resolve its \`decisions:\` ids under ${p.decisionsDir}/ and read the live \`sources\` they pin`,
|
|
153
|
+
]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function renderOrchestrationBrief(plan: BuildPlan, repoRoot: string, profile: string): string {
|
|
157
|
+
const marker = new Map<string, string>()
|
|
158
|
+
for (const i of plan.done) marker.set(i.id, '✓')
|
|
159
|
+
for (const i of plan.blocked) marker.set(i.id, '⚠')
|
|
160
|
+
for (const i of plan.ready) marker.set(i.id, '→')
|
|
161
|
+
for (const i of plan.waiting) marker.set(i.id, '…')
|
|
162
|
+
const all = [...plan.done, ...plan.ready, ...plan.waiting, ...plan.blocked].sort((a, b) => a.id.localeCompare(b.id))
|
|
163
|
+
const doneIds = new Set(plan.done.map((i) => i.id))
|
|
164
|
+
|
|
165
|
+
const dag = all.map((i) => {
|
|
166
|
+
const deps = i.dependsOn.length ? ` deps: ${i.dependsOn.join(', ')}` : ''
|
|
167
|
+
const note = marker.get(i.id) === '…'
|
|
168
|
+
? ` (waiting on ${i.dependsOn.filter((d) => !doneIds.has(d)).join(', ')})`
|
|
169
|
+
: ''
|
|
170
|
+
return ` ${marker.get(i.id) ?? '?'} ${i.id} ${i.goal}${deps}${note}`
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
return [
|
|
174
|
+
`## Build — ${all.length} issues`,
|
|
175
|
+
`Apply the bundled **agents/build-orchestrator.md** playbook in the MAIN session.`,
|
|
176
|
+
`Do not spawn a nested orchestrator. SUPERVISE and fan out one direct`,
|
|
177
|
+
`implementer subagent per issue (use the fabrika-implementer role; worktree-isolated`,
|
|
178
|
+
`where they run in parallel). Do NOT hand-code the whole DAG yourself.`,
|
|
179
|
+
``,
|
|
180
|
+
`Repo: ${repoRoot}`,
|
|
181
|
+
``,
|
|
182
|
+
`Issue DAG (foundation first; build independents in parallel):`,
|
|
183
|
+
...dag,
|
|
184
|
+
``,
|
|
185
|
+
`Per issue: implement → opice-author (selectors vs the running app) → \`bun test\` green.`,
|
|
186
|
+
`Acceptance: the issue's \`satisfies\` scenarios + FROZEN invariants must pass.`,
|
|
187
|
+
` Never weaken an invariant statement to go green — adversarially verify it.`,
|
|
188
|
+
...stackBriefLines(profile),
|
|
189
|
+
`Scaffold: NO templates — read the stack preset + its decisions + the live sources`,
|
|
190
|
+
` they point to, and scaffold from current APIs (your memory is likely stale).`,
|
|
191
|
+
`Record: fabrika builder build done <ID> | fabrika builder build block <ID> --reason "…"`,
|
|
192
|
+
` (both write status.json + stream a BuildEvent to the dashboard)`,
|
|
193
|
+
`Detail: fabrika builder build --issue <ID> (re-emit one issue's full packet)`,
|
|
194
|
+
].join('\n') + '\n'
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function renderBuildPacket(issue: Issue, repoRoot: string, profile: string): string {
|
|
198
|
+
const lines: string[] = []
|
|
199
|
+
lines.push(`## Build issue ${issue.id}`)
|
|
200
|
+
lines.push(`Goal: ${issue.goal || '(no goal recorded)'}`)
|
|
201
|
+
lines.push(`Satisfies: ${issue.satisfies.length ? issue.satisfies.join(', ') : '(none)'}`)
|
|
202
|
+
lines.push(`Depends: ${issue.dependsOn.length ? issue.dependsOn.join(', ') : '(none)'}`)
|
|
203
|
+
lines.push(`Touches: ${issue.touches.length ? issue.touches.join(', ') : '(none)'}`)
|
|
204
|
+
const sp = resolveStackPointer(profile)
|
|
205
|
+
if (sp?.stackFileExists) {
|
|
206
|
+
lines.push(`Stack: ${profile} -> ${sp.stackFile} (decisions under ${sp.decisionsDir}/)`)
|
|
207
|
+
}
|
|
208
|
+
lines.push(`Pipeline: implement -> opice-author (selectors vs running app) -> bun test (steps + invariants)`)
|
|
209
|
+
lines.push(`Done when: green \`bun test\` for the satisfied scenarios/invariants.`)
|
|
210
|
+
lines.push(`Repo: ${repoRoot}`)
|
|
211
|
+
lines.push(``)
|
|
212
|
+
lines.push(`After green: fabrika builder build done ${issue.id} (records it + emits the next packet)`)
|
|
213
|
+
lines.push(`If stuck: fabrika builder build block ${issue.id} --reason "…" (escalate, continue)`)
|
|
214
|
+
return lines.join('\n') + '\n'
|
|
215
|
+
}
|