matrix-builder-bot commited on
Commit
66d0c75
·
1 Parent(s): 4cea3e5

deploy: matrix-builder@ae902df6985a

Browse files

Source: https://github.com/agent-matrix/matrix-builder/commit/ae902df6985a7d7a550b94dc5634e76db0109a45

Dockerfile CHANGED
@@ -30,7 +30,10 @@ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONPATH=/app/services/api \
30
  AGENT_GENERATOR_MODE=sdk MATRIX_DEFINITIONS_MODE=sdk \
31
  # Public, absolute API base so the coder prompt's "Fetch the Matrix Bundle" link is a real,
32
  # fetchable signed URL (build.matrixhub.io proxies /api/builder/* to this backend).
33
- PUBLIC_API_BASE_URL=https://build.matrixhub.io/api/builder/api/v1
 
 
 
34
  WORKDIR /app
35
  RUN apt-get update && apt-get install -y --no-install-recommends python3 python3-venv \
36
  && rm -rf /var/lib/apt/lists/* \
 
30
  AGENT_GENERATOR_MODE=sdk MATRIX_DEFINITIONS_MODE=sdk \
31
  # Public, absolute API base so the coder prompt's "Fetch the Matrix Bundle" link is a real,
32
  # fetchable signed URL (build.matrixhub.io proxies /api/builder/* to this backend).
33
+ PUBLIC_API_BASE_URL=https://build.matrixhub.io/api/builder/api/v1 \
34
+ # Matrix Designer (the brain) runs as an internal service in this same container (started by
35
+ # start.sh). The control plane calls it here and falls back to deterministic generation if down.
36
+ MATRIX_DESIGNER_PORT=8077 MATRIX_DESIGNER_URL=http://127.0.0.1:8077
37
  WORKDIR /app
38
  RUN apt-get update && apt-get install -y --no-install-recommends python3 python3-venv \
39
  && rm -rf /var/lib/apt/lists/* \
requirements.txt CHANGED
@@ -36,3 +36,10 @@ httpx>=0.27
36
  jsonschema>=4.22
37
  ruff>=0.6
38
  mypy>=1.11
 
 
 
 
 
 
 
 
36
  jsonschema>=4.22
37
  ruff>=0.6
38
  mypy>=1.11
39
+
40
+ # --- Matrix Designer (the brain) — installed alongside on Hugging Face ---
41
+ # When this image is built (HF Space / Docker), Matrix Designer is installed and its
42
+ # internal HTTP service is started by start.sh; the control plane reaches it at
43
+ # MATRIX_DESIGNER_URL (default http://127.0.0.1:8077) and falls back to deterministic
44
+ # generation if it's unavailable.
45
+ matrix-designer[service] @ git+https://github.com/agent-matrix/matrix-designer.git
services/api/app/api/blueprints.py CHANGED
@@ -1,13 +1,23 @@
1
  from __future__ import annotations
2
 
3
- from fastapi import APIRouter, Depends
4
 
 
5
  from app.schemas.blueprint import (
6
  BlueprintCandidateResponse,
 
 
 
7
  BlueprintGenerationRequest,
8
  BlueprintResult,
 
 
 
 
9
  )
10
  from app.schemas.idea import IdeaRequest
 
 
11
  from app.services.matrix_builder_service import MatrixBuilderService
12
  from app.dependencies import get_matrix_builder_service
13
 
@@ -36,3 +46,67 @@ def generate_selected_blueprint(
36
  service: MatrixBuilderService = Depends(get_matrix_builder_service),
37
  ) -> BlueprintResult:
38
  return service.generate_blueprint(payload.idea_request, candidate_id=payload.candidate_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ from fastapi import APIRouter, Depends, Query
4
 
5
+ from app.core.auth import optional_user_id
6
  from app.schemas.blueprint import (
7
  BlueprintCandidateResponse,
8
+ BlueprintChatRequest,
9
+ BlueprintChatResponse,
10
+ BlueprintDetails,
11
  BlueprintGenerationRequest,
12
  BlueprintResult,
13
+ BlueprintSavedResponse,
14
+ BlueprintSaveRequest,
15
+ DesignerCandidate,
16
+ DesignerCandidatesResponse,
17
  )
18
  from app.schemas.idea import IdeaRequest
19
+ from app.services.designer_client import DesignerClient, get_designer_client
20
+ from app.services.design_bundle_store import DesignBundleStore, get_design_bundle_store
21
  from app.services.matrix_builder_service import MatrixBuilderService
22
  from app.dependencies import get_matrix_builder_service
23
 
 
46
  service: MatrixBuilderService = Depends(get_matrix_builder_service),
47
  ) -> BlueprintResult:
48
  return service.generate_blueprint(payload.idea_request, candidate_id=payload.candidate_id)
49
+
50
+
51
+ # --- Matrix Designer: Blueprint Details page (fail-open via designer_client) -----------
52
+ @router.get("/designer-candidates", response_model=DesignerCandidatesResponse)
53
+ def designer_candidates(
54
+ idea: str = Query(..., description="The build idea to design the 3 blueprints for."),
55
+ designer: DesignerClient = Depends(get_designer_client),
56
+ ) -> DesignerCandidatesResponse:
57
+ data = designer.blueprints(idea)
58
+ return DesignerCandidatesResponse(
59
+ candidates=[DesignerCandidate.model_validate(c) for c in data.get("candidates", [])]
60
+ )
61
+
62
+
63
+ @router.get("/{candidate_id}/details", response_model=BlueprintDetails)
64
+ def blueprint_details(
65
+ candidate_id: str,
66
+ idea: str = Query(..., description="The build idea this blueprint is for."),
67
+ designer: DesignerClient = Depends(get_designer_client),
68
+ ) -> BlueprintDetails:
69
+ return BlueprintDetails.model_validate(designer.details(idea, candidate_id))
70
+
71
+
72
+ @router.post("/{candidate_id}/chat", response_model=BlueprintChatResponse)
73
+ def blueprint_chat(
74
+ candidate_id: str,
75
+ payload: BlueprintChatRequest,
76
+ designer: DesignerClient = Depends(get_designer_client),
77
+ ) -> BlueprintChatResponse:
78
+ out = designer.refine(payload.idea or "", payload.message, candidate_id)
79
+ details = out["details"].get(candidate_id) or out["details"].get("standard") or {}
80
+ return BlueprintChatResponse(reply=out.get("reply", ""), details=BlueprintDetails.model_validate(details))
81
+
82
+
83
+ @router.post("/{candidate_id}/save", response_model=BlueprintDetails)
84
+ def blueprint_save(
85
+ candidate_id: str,
86
+ payload: BlueprintSaveRequest,
87
+ owner_id: str = Depends(optional_user_id),
88
+ store: DesignBundleStore = Depends(get_design_bundle_store),
89
+ ) -> BlueprintDetails:
90
+ # batch-10 — persist the edited blueprint + chat for this build, owner-scoped (RLS), so
91
+ # reopening the build restores it. The contract only changes when the user saves.
92
+ if payload.build_id:
93
+ store.save(
94
+ owner_id=owner_id, build_id=payload.build_id, candidate_id=candidate_id,
95
+ idea=payload.idea or "", details=payload.details.model_dump(),
96
+ chat_history=[m.model_dump() for m in payload.details.chat_history],
97
+ )
98
+ return payload.details
99
+
100
+
101
+ @router.get("/{candidate_id}/saved", response_model=BlueprintSavedResponse)
102
+ def blueprint_saved(
103
+ candidate_id: str,
104
+ build_id: str = Query(..., description="The build to restore the saved blueprint for."),
105
+ owner_id: str = Depends(optional_user_id),
106
+ store: DesignBundleStore = Depends(get_design_bundle_store),
107
+ ) -> BlueprintSavedResponse:
108
+ rec = store.get(owner_id, build_id, candidate_id)
109
+ if not rec:
110
+ return BlueprintSavedResponse(found=False)
111
+ return BlueprintSavedResponse(found=True, idea=rec.get("idea", ""),
112
+ details=BlueprintDetails.model_validate(rec["details"]))
services/api/app/db/orm.py CHANGED
@@ -39,6 +39,7 @@ RLS_TABLES: tuple[str, ...] = (
39
  "artifacts",
40
  "run_events",
41
  "gitpilot_runs",
 
42
  )
43
 
44
 
@@ -265,6 +266,28 @@ class GitPilotRun(Base):
265
  )
266
 
267
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  __all__ = [
269
  "Base",
270
  "RLS_TABLES",
@@ -279,4 +302,5 @@ __all__ = [
279
  "RunEvent",
280
  "Artifact",
281
  "GitPilotRun",
 
282
  ]
 
39
  "artifacts",
40
  "run_events",
41
  "gitpilot_runs",
42
+ "design_bundles",
43
  )
44
 
45
 
 
266
  )
267
 
268
 
269
+ class DesignBundleRecord(Base):
270
+ """A saved Matrix Designer Blueprint Details + chat for a build (batch-10).
271
+
272
+ Owner-scoped like every other workflow table. Keyed by (owner, build, candidate)
273
+ so reopening a build restores the same blueprint and Talk-to-blueprint history.
274
+ """
275
+
276
+ __tablename__ = "design_bundles"
277
+ id: Mapped[str] = _pk()
278
+ owner_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
279
+ build_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
280
+ candidate_id: Mapped[str] = mapped_column(String(32), nullable=False)
281
+ idea: Mapped[str] = mapped_column(Text, default="")
282
+ details: Mapped[dict] = mapped_column(JSONB, default=dict)
283
+ chat_history: Mapped[list] = mapped_column(JSONB, default=list)
284
+ created_at: Mapped[datetime] = _ts()
285
+ updated_at: Mapped[datetime] = mapped_column(
286
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
287
+ )
288
+ __table_args__ = (UniqueConstraint("owner_id", "build_id", "candidate_id", name="uq_design_bundle_owner_build_cand"),)
289
+
290
+
291
  __all__ = [
292
  "Base",
293
  "RLS_TABLES",
 
302
  "RunEvent",
303
  "Artifact",
304
  "GitPilotRun",
305
+ "DesignBundleRecord",
306
  ]
services/api/app/schemas/blueprint.py CHANGED
@@ -66,3 +66,91 @@ class BlueprintResult(StrictModel):
66
  tasks: list[BlueprintTask] = Field(default_factory=list)
67
  acceptance_commands: list[str] = Field(default_factory=list)
68
  standards_lock_ref: str = "MATRIX_STANDARDS.lock"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  tasks: list[BlueprintTask] = Field(default_factory=list)
67
  acceptance_commands: list[str] = Field(default_factory=list)
68
  standards_lock_ref: str = "MATRIX_STANDARDS.lock"
69
+
70
+
71
+ # --- Matrix Designer: Blueprint Details (the dashboard the Details page renders) -------
72
+ # Lenient models (ignore extra keys) so designer output maps cleanly even as it evolves.
73
+ from pydantic import BaseModel # noqa: E402
74
+
75
+
76
+ class DesignerArchitectureNode(BaseModel):
77
+ name: str
78
+ description: str = ""
79
+ dependencies: list[str] = Field(default_factory=list)
80
+
81
+
82
+ class DesignerFilePlanItem(BaseModel):
83
+ path: str
84
+ description: str = ""
85
+
86
+
87
+ class DesignerBatch(BaseModel):
88
+ id: str
89
+ name: str
90
+ purpose: str = ""
91
+ tasks: list[str] = Field(default_factory=list)
92
+ allowed_files: list[str] = Field(default_factory=list)
93
+ depends_on: list[str] = Field(default_factory=list)
94
+ acceptance_criteria: list[str] = Field(default_factory=list)
95
+ validation_checks: list[str] = Field(default_factory=list)
96
+ must_not_change: list[str] = Field(default_factory=list)
97
+
98
+
99
+ class DesignerChatMessage(BaseModel):
100
+ id: str = ""
101
+ role: str = "blueprint"
102
+ content: str = ""
103
+ timestamp: str = ""
104
+
105
+
106
+ class BlueprintDetails(BaseModel):
107
+ candidate_id: str
108
+ overview: str = ""
109
+ architecture: list[DesignerArchitectureNode] = Field(default_factory=list)
110
+ batches: list[DesignerBatch] = Field(default_factory=list)
111
+ file_plan: list[DesignerFilePlanItem] = Field(default_factory=list)
112
+ matrix_rules: list[str] = Field(default_factory=list)
113
+ acceptance_criteria: list[str] = Field(default_factory=list)
114
+ validation_plan: list[str] = Field(default_factory=list)
115
+ risks: list[str] = Field(default_factory=list)
116
+ assumptions: list[str] = Field(default_factory=list)
117
+ design_brain: str | None = None
118
+ chat_history: list[DesignerChatMessage] = Field(default_factory=list)
119
+
120
+
121
+ class BlueprintChatRequest(BaseModel):
122
+ message: str
123
+ idea: str | None = None
124
+
125
+
126
+ class BlueprintChatResponse(BaseModel):
127
+ reply: str
128
+ details: BlueprintDetails
129
+
130
+
131
+ class BlueprintSaveRequest(BaseModel):
132
+ idea: str | None = None
133
+ build_id: str | None = None
134
+ details: BlueprintDetails
135
+
136
+
137
+ class BlueprintSavedResponse(BaseModel):
138
+ found: bool = False
139
+ idea: str = ""
140
+ details: BlueprintDetails | None = None
141
+
142
+
143
+ class DesignerCandidate(BaseModel):
144
+ id: str
145
+ tier: str
146
+ title: str
147
+ summary: str = ""
148
+ file_count: int = 1
149
+ difficulty: str = "Medium"
150
+ estimate: str = "unknown"
151
+ stack: list[str] = Field(default_factory=list)
152
+ recommended: bool = False
153
+
154
+
155
+ class DesignerCandidatesResponse(BaseModel):
156
+ candidates: list[DesignerCandidate] = Field(default_factory=list)
services/api/app/services/design_bundle_store.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persistence for saved Matrix Designer blueprints + chat (batch-10).
2
+
3
+ Stores the chosen blueprint's Details and Talk-to-blueprint chat per build, owner-scoped,
4
+ so reopening a build restores the same state across devices.
5
+
6
+ Backed by Postgres when ``DATABASE_URL`` is configured AND the ``design_bundles`` table
7
+ exists (RLS-isolated per user); falls back to an in-memory store otherwise. Persistence is
8
+ best-effort and must never break the Details page, so a missing table degrades to memory.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import threading
15
+ import uuid
16
+ from datetime import datetime, timezone
17
+ from typing import Any
18
+
19
+ from sqlalchemy import select
20
+ from sqlalchemy.exc import SQLAlchemyError
21
+
22
+ from app.db.engine import is_configured, session_scope
23
+ from app.db.orm import DesignBundleRecord
24
+
25
+ logger = logging.getLogger("matrix_builder.design_bundle_store")
26
+
27
+ # In-memory fallback, keyed by (owner, build, candidate) so isolation matches the DB's RLS.
28
+ _MEM: dict[tuple[str, str, str], dict[str, Any]] = {}
29
+ _LOCK = threading.Lock()
30
+ _DB_OK: bool | None = None
31
+
32
+
33
+ def _db_usable() -> bool:
34
+ global _DB_OK
35
+ if not is_configured():
36
+ return False
37
+ if _DB_OK is None:
38
+ try:
39
+ with session_scope() as session:
40
+ session.execute(select(DesignBundleRecord.id).limit(1))
41
+ _DB_OK = True
42
+ except SQLAlchemyError:
43
+ logger.warning("design_bundles table unavailable (migration not applied?); using in-memory store")
44
+ _DB_OK = False
45
+ return _DB_OK
46
+
47
+
48
+ class DesignBundleStore:
49
+ def save(self, owner_id: str, build_id: str, candidate_id: str, idea: str,
50
+ details: dict[str, Any], chat_history: list[Any]) -> dict[str, Any]:
51
+ record = {
52
+ "owner_id": owner_id, "build_id": build_id, "candidate_id": candidate_id,
53
+ "idea": idea, "details": details, "chat_history": chat_history,
54
+ "updated_at": datetime.now(timezone.utc).isoformat(),
55
+ }
56
+ if _db_usable():
57
+ try:
58
+ with session_scope(user_id=owner_id) as session:
59
+ existing = session.execute(
60
+ select(DesignBundleRecord).where(
61
+ DesignBundleRecord.build_id == build_id,
62
+ DesignBundleRecord.candidate_id == candidate_id,
63
+ )
64
+ ).scalar_one_or_none()
65
+ if existing is None:
66
+ session.add(DesignBundleRecord(
67
+ id=str(uuid.uuid4()), owner_id=owner_id, build_id=build_id,
68
+ candidate_id=candidate_id, idea=idea, details=details, chat_history=chat_history,
69
+ ))
70
+ else:
71
+ existing.idea = idea
72
+ existing.details = details
73
+ existing.chat_history = chat_history
74
+ return record
75
+ except SQLAlchemyError:
76
+ logger.exception("design bundle save failed; falling back to memory")
77
+ with _LOCK:
78
+ _MEM[(owner_id, build_id, candidate_id)] = record
79
+ return record
80
+
81
+ def get(self, owner_id: str, build_id: str, candidate_id: str) -> dict[str, Any] | None:
82
+ if _db_usable():
83
+ try:
84
+ with session_scope(user_id=owner_id) as session:
85
+ row = session.execute(
86
+ select(DesignBundleRecord).where(
87
+ DesignBundleRecord.build_id == build_id,
88
+ DesignBundleRecord.candidate_id == candidate_id,
89
+ )
90
+ ).scalar_one_or_none()
91
+ if row is not None:
92
+ return {"owner_id": row.owner_id, "build_id": row.build_id, "candidate_id": row.candidate_id,
93
+ "idea": row.idea, "details": row.details, "chat_history": row.chat_history}
94
+ return None
95
+ except SQLAlchemyError:
96
+ logger.exception("design bundle get failed; falling back to memory")
97
+ with _LOCK:
98
+ return _MEM.get((owner_id, build_id, candidate_id))
99
+
100
+
101
+ _STORE = DesignBundleStore()
102
+
103
+
104
+ def get_design_bundle_store() -> DesignBundleStore:
105
+ return _STORE
services/api/app/services/designer_client.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Designer client — bridges Matrix Builder's control plane to Matrix Designer.
2
+
3
+ Resolution order (fail-open, never 500):
4
+ 1. HTTP service at ``MATRIX_DESIGNER_URL`` (the matrix-designer FastAPI service)
5
+ 2. the ``matrix_designer`` package if importable in-process
6
+ 3. a built-in deterministic fallback, so the Details page always has data even when
7
+ the brain is unavailable or the Matrix Designer toggle is off.
8
+
9
+ All three return the same shape: ``{candidates, details, matrix_rules}`` where ``details``
10
+ maps candidate_id → a Blueprint Details dict (the page's dashboard data).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ DESIGNER_URL = os.environ.get("MATRIX_DESIGNER_URL", "").rstrip("/")
18
+ RMD_RULES = [
19
+ "RMD-101: AI coders are workers, not architects.",
20
+ "RMD-103: Control files are protected.",
21
+ "RMD-111: Acceptance criteria are law.",
22
+ ]
23
+ _TIERS = [
24
+ ("minimal", "Minimal", "Easy", "a weekend", 0.55, False),
25
+ ("standard", "Standard", "Medium", "about one week", 1.0, True),
26
+ ("production", "Production", "Hard", "about three weeks", 1.6, False),
27
+ ]
28
+
29
+
30
+ class DesignerClient:
31
+ def __init__(self, url: str = DESIGNER_URL) -> None:
32
+ self.url = url
33
+
34
+ # -- public API ---------------------------------------------------------------------
35
+ def blueprints(self, idea: str, references=None, constraints=None) -> Dict[str, Any]:
36
+ return self._call("/design/blueprints", {"idea": idea, "references": references, "constraints": constraints},
37
+ fallback=lambda: self._fallback_blueprints(idea))
38
+
39
+ def details(self, idea: str, candidate_id: str) -> Dict[str, Any]:
40
+ data = self.blueprints(idea)
41
+ return data["details"].get(candidate_id) or data["details"].get("standard") or {}
42
+
43
+ def refine(self, idea: str, message: str, candidate_id: str = "standard") -> Dict[str, Any]:
44
+ return self._call("/design/refine", {"idea": idea, "message": message, "candidate_id": candidate_id},
45
+ fallback=lambda: self._fallback_refine(idea, message, candidate_id))
46
+
47
+ # -- resolution ---------------------------------------------------------------------
48
+ def _call(self, path: str, payload: Dict[str, Any], fallback) -> Dict[str, Any]:
49
+ if self.url:
50
+ try:
51
+ import httpx # local import keeps it optional
52
+ r = httpx.post(f"{self.url}{path}", json=payload, timeout=8.0)
53
+ if r.status_code == 200:
54
+ data = r.json()
55
+ if not data.get("error"):
56
+ return data
57
+ except Exception:
58
+ pass # fall through to in-process / deterministic
59
+ try:
60
+ return self._via_import(path, payload)
61
+ except Exception:
62
+ return fallback()
63
+
64
+ def _via_import(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
65
+ from matrix_designer.graph import design_blueprints, refine, run_design # type: ignore
66
+ if path.endswith("blueprints"):
67
+ out = design_blueprints(payload["idea"], payload.get("references"), payload.get("constraints"))
68
+ out.pop("_state", None)
69
+ return out
70
+ state = run_design(payload["idea"])
71
+ out = refine(state, payload["message"], payload.get("candidate_id", "standard"))
72
+ out.pop("_state", None)
73
+ return out
74
+
75
+ # -- deterministic fallback (self-contained) ---------------------------------------
76
+ def _fallback_blueprints(self, idea: str) -> Dict[str, Any]:
77
+ domain = self._domain(idea)
78
+ stack = self._stack(domain)
79
+ full = self._roadmap(domain)
80
+ candidates: List[Dict[str, Any]] = []
81
+ details: Dict[str, Any] = {}
82
+ for cid, tier, diff, est, scale, rec in _TIERS:
83
+ n = max(3, round(len(full) * scale))
84
+ batches = full[:n] if n <= len(full) else full + self._extra(len(full), n - len(full))
85
+ candidates.append({
86
+ "id": cid, "tier": tier,
87
+ "title": f"{tier} {'Matrix Bundle' if cid == 'standard' else 'controlled blueprint'}",
88
+ "summary": f"{'Recommended ' if rec else ''}controlled blueprint for: {idea.strip()[:90]}",
89
+ "file_count": max(8, round(len(self._files(domain)) * (2 + scale * 4))),
90
+ "difficulty": diff, "estimate": est, "stack": stack, "recommended": rec,
91
+ })
92
+ details[cid] = {
93
+ "candidate_id": cid,
94
+ "overview": f"This {tier.lower()} blueprint builds {idea.strip()[:120]}. Delivered in ordered, validated batches.",
95
+ "architecture": self._architecture(domain) if cid != "minimal" else self._architecture(domain)[:2],
96
+ "batches": batches,
97
+ "file_plan": self._files(domain),
98
+ "matrix_rules": RMD_RULES,
99
+ "acceptance_criteria": ["builds and runs", "core flow works with tests", "Matrix validation approves the build"],
100
+ "validation_plan": ["lint", "typecheck", "unit tests", "allowed-file check", "Matrix commit check"],
101
+ "risks": ["external credentials may be required"],
102
+ "assumptions": ["original assets only" if domain == "web-game" else "auth required if data is sensitive"],
103
+ "design_brain": f"Deterministic fallback · {len(batches)} batches · tier {tier}.",
104
+ "chat_history": [],
105
+ }
106
+ return {"candidates": candidates, "details": details, "matrix_rules": RMD_RULES, "violations": []}
107
+
108
+ def _fallback_refine(self, idea: str, message: str, candidate_id: str) -> Dict[str, Any]:
109
+ data = self._fallback_blueprints(idea)
110
+ det = data["details"].get(candidate_id, data["details"]["standard"])
111
+ m = message.lower()
112
+ if any(k in m for k in ("add", "boss", "level", "analytic", "audit", "auth", "dashboard")):
113
+ nid = f"batch-{len(det['batches']) + 1:02d}"
114
+ name = "Boss encounter" if "boss" in m else "Refinement"
115
+ det["batches"].append({"id": nid, "name": name, "purpose": f"Added from chat: {message.strip()}",
116
+ "tasks": [message.strip()], "allowed_files": ["src/**"], "depends_on": [],
117
+ "acceptance_criteria": ["feature works", "no runtime errors"], "validation_checks": ["unit tests"], "must_not_change": []})
118
+ reply = f"Added {nid} — {name}. Review and Save latest edits."
119
+ elif any(k in m for k in ("reduce", "simpler", "remove", "drop")) and len(det["batches"]) > 3:
120
+ dropped = det["batches"].pop()
121
+ reply = f"Reduced scope — removed {dropped['name']}."
122
+ else:
123
+ reply = "Noted. Save latest edits to keep this."
124
+ return {"reply": reply, "details": data["details"], "candidates": data["candidates"]}
125
+
126
+ # -- tiny domain heuristics --------------------------------------------------------
127
+ @staticmethod
128
+ def _domain(idea: str) -> str:
129
+ s = idea.lower()
130
+ if any(k in s for k in ("game", "platformer", "phaser", "arcade")):
131
+ return "web-game"
132
+ if any(k in s for k in ("api", "fastapi", "service", "endpoint")):
133
+ return "api"
134
+ return "web-app"
135
+
136
+ @staticmethod
137
+ def _stack(domain: str) -> List[str]:
138
+ return {"web-game": ["Phaser 3", "TypeScript", "Vite"], "api": ["FastAPI", "PostgreSQL", "Docker"]}.get(
139
+ domain, ["Next.js", "FastAPI", "PostgreSQL", "Docker"])
140
+
141
+ @staticmethod
142
+ def _architecture(domain: str) -> List[Dict[str, Any]]:
143
+ if domain == "web-game":
144
+ return [{"name": "Web Client (Phaser)", "description": "Canvas game", "dependencies": []},
145
+ {"name": "Asset pipeline", "description": "Original art", "dependencies": ["Web Client (Phaser)"]},
146
+ {"name": "Level engine", "description": "Data-driven levels", "dependencies": ["Web Client (Phaser)"]},
147
+ {"name": "CI / Pages", "description": "Build + deploy", "dependencies": []}]
148
+ return [{"name": "Web App", "description": "Frontend", "dependencies": []},
149
+ {"name": "API", "description": "Business logic", "dependencies": ["Web App"]},
150
+ {"name": "Database", "description": "Storage", "dependencies": ["API"]},
151
+ {"name": "Worker / Queue", "description": "Background jobs", "dependencies": ["API"]},
152
+ {"name": "Admin Dashboard", "description": "Ops & analytics", "dependencies": ["Web App", "API"]}]
153
+
154
+ @staticmethod
155
+ def _files(domain: str) -> List[Dict[str, str]]:
156
+ if domain == "web-game":
157
+ return [{"path": p, "description": d} for p, d in [
158
+ ("src/scenes", "Boot/Preload/Game/…"), ("src/levels", "level data + builder"),
159
+ ("src/entities", "Player, enemies, Coin"), ("public/assets", "original art"), ("README.md", "overview")]]
160
+ return [{"path": p, "description": d} for p, d in [
161
+ ("apps/web", "Web app and dashboard"), ("services/api", "API and business logic"),
162
+ ("services/worker", "Background jobs"), ("packages/shared", "Shared types"), ("db", "Migrations"), ("README.md", "overview")]]
163
+
164
+ @staticmethod
165
+ def _roadmap(domain: str) -> List[Dict[str, Any]]:
166
+ if domain == "web-game":
167
+ spec = [("Game foundation", ["vite.config.ts", "src/main.ts"]), ("Asset pipeline", ["scripts/gen_assets.py", "public/assets/**"]),
168
+ ("Tilemap world", ["src/levels/**"]), ("Player & controls", ["src/entities/Player.ts"]),
169
+ ("Enemies & power-ups", ["src/entities/**"]), ("HUD & gate", ["src/ui/HUD.ts"]),
170
+ ("Campaign & boss", ["src/levels/episodes.ts"]), ("Polish & release", [".github/workflows/deploy.yml"])]
171
+ else:
172
+ spec = [("Foundation", ["apps/web/**", "services/api/**"]), ("Integrations", ["services/api/**"]),
173
+ ("Workflow & logic", ["services/api/**"]), ("Admin & analytics", ["apps/web/**"]),
174
+ ("Hardening & deploy", ["docker-compose.yml", ".github/**"]), ("Validation & release", ["tests/**"])]
175
+ out = []
176
+ for i, (name, allowed) in enumerate(spec, 1):
177
+ out.append({"id": f"batch-{i:02d}", "name": name, "purpose": f"{name}.", "tasks": [name],
178
+ "allowed_files": allowed, "depends_on": [f"batch-{i-1:02d}"] if i > 1 else [],
179
+ "acceptance_criteria": ["builds", "feature works", "no runtime errors"],
180
+ "validation_checks": ["lint", "typecheck", "tests"], "must_not_change": []})
181
+ return out
182
+
183
+ @staticmethod
184
+ def _extra(start: int, count: int) -> List[Dict[str, Any]]:
185
+ names = ["Observability", "Performance pass", "Security review", "Docs & onboarding", "Accessibility"]
186
+ out = []
187
+ for k in range(count):
188
+ i = start + k + 1
189
+ out.append({"id": f"batch-{i:02d}", "name": names[k % len(names)], "purpose": "Production hardening.",
190
+ "tasks": ["hardening"], "allowed_files": ["**"], "depends_on": [f"batch-{i-1:02d}"],
191
+ "acceptance_criteria": ["meets the production bar"], "validation_checks": ["tests"], "must_not_change": []})
192
+ return out
193
+
194
+
195
+ def get_designer_client() -> DesignerClient:
196
+ return DesignerClient()
services/api/migrations/versions/0006_design_bundles.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Saved Matrix Designer Blueprint Details + chat per build (batch-10).
2
+
3
+ Owner-scoped table that persists the chosen blueprint's details and the
4
+ Talk-to-blueprint chat history for a build, so reopening it restores the same
5
+ state across devices — RLS-isolated per user like every other workflow table.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from alembic import op
11
+
12
+ revision = "0006_design_bundles"
13
+ down_revision = "0005_gitpilot_runs"
14
+ branch_labels = None
15
+ depends_on = None
16
+
17
+ _TABLE_SQL = """
18
+ CREATE TABLE IF NOT EXISTS design_bundles (
19
+ id text PRIMARY KEY,
20
+ owner_id text NOT NULL,
21
+ build_id text NOT NULL,
22
+ candidate_id text NOT NULL,
23
+ idea text NOT NULL DEFAULT '',
24
+ details jsonb NOT NULL DEFAULT '{}'::jsonb,
25
+ chat_history jsonb NOT NULL DEFAULT '[]'::jsonb,
26
+ created_at timestamptz NOT NULL DEFAULT now(),
27
+ updated_at timestamptz NOT NULL DEFAULT now(),
28
+ CONSTRAINT uq_design_bundle_owner_build_cand UNIQUE (owner_id, build_id, candidate_id)
29
+ )
30
+ """
31
+
32
+
33
+ def upgrade() -> None:
34
+ op.execute(_TABLE_SQL)
35
+ op.execute("CREATE INDEX IF NOT EXISTS ix_design_bundles_owner ON design_bundles(owner_id)")
36
+ op.execute(
37
+ "CREATE INDEX IF NOT EXISTS ix_design_bundles_owner_build "
38
+ "ON design_bundles(owner_id, build_id)"
39
+ )
40
+ op.execute("ALTER TABLE design_bundles ENABLE ROW LEVEL SECURITY")
41
+ op.execute("ALTER TABLE design_bundles FORCE ROW LEVEL SECURITY")
42
+ op.execute(
43
+ "CREATE POLICY design_bundles_owner ON design_bundles "
44
+ "USING (owner_id = app_current_user()) WITH CHECK (owner_id = app_current_user())"
45
+ )
46
+
47
+
48
+ def downgrade() -> None:
49
+ op.execute("DROP TABLE IF EXISTS design_bundles CASCADE")
start.sh CHANGED
@@ -1,7 +1,20 @@
1
  #!/usr/bin/env sh
2
  set -e
 
 
 
 
 
 
 
 
 
 
 
 
3
  # Backend (internal, same-origin target of the Next.js /api/builder rewrite).
4
  python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --proxy-headers &
 
5
  # Frontend (public). Next standalone honors PORT/HOSTNAME.
6
  cd /app/web
7
  export PORT="${PORT:-7860}" HOSTNAME=0.0.0.0
 
1
  #!/usr/bin/env sh
2
  set -e
3
+ # Matrix Designer (the brain) — internal HTTP service the control plane calls to design the
4
+ # blueprints/batches. Best-effort: if it fails to boot, the control plane falls back to its
5
+ # deterministic generator, so the app still works. Bound to localhost; never public.
6
+ export MATRIX_DESIGNER_PORT="${MATRIX_DESIGNER_PORT:-8077}"
7
+ export MATRIX_DESIGNER_URL="${MATRIX_DESIGNER_URL:-http://127.0.0.1:${MATRIX_DESIGNER_PORT}}"
8
+ if python -c "import matrix_designer" 2>/dev/null; then
9
+ echo "-> starting Matrix Designer service on 127.0.0.1:${MATRIX_DESIGNER_PORT}"
10
+ ( cd /tmp && python -m matrix_designer.service ) &
11
+ else
12
+ echo "-> Matrix Designer not installed; control plane will use the deterministic generator"
13
+ fi
14
+
15
  # Backend (internal, same-origin target of the Next.js /api/builder rewrite).
16
  python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --proxy-headers &
17
+
18
  # Frontend (public). Next standalone honors PORT/HOSTNAME.
19
  cd /app/web
20
  export PORT="${PORT:-7860}" HOSTNAME=0.0.0.0
web/src/app/matrix-builder/MatrixBuilderClient.tsx CHANGED
@@ -12,6 +12,11 @@ import { createBundleFiles } from "@/lib/matrix-bundle";
12
  import { createBlueprintCandidates } from "@/lib/matrix-demo-data";
13
  import { toUiBundleFiles, toUiCandidates } from "@/lib/engine-map";
14
  import { briefToIdea, enhanceProjectBrief, enrichBlueprintCandidates, explainValidationFindings, isOllaBridgeAssistAvailable, type EnrichmentMap } from "@/lib/ai-provider-manager";
 
 
 
 
 
15
  import UploadExistingPlanModal, { type UploadSelection } from "@/components/matrix-builder/UploadExistingPlanModal";
16
  import {
17
  downloadBundleZip,
@@ -54,7 +59,7 @@ import type { ProjectBriefContract } from "@/lib/workflow-types";
54
  import type { CoderId } from "@/types/coder";
55
  import type { ValidationReportContract } from "@/types/contracts";
56
 
57
- type Phase = "hero" | "scanning" | "candidates" | "bundle" | "submit" | "running" | "validation" | "timeline" | "complete";
58
 
59
  // Paths the AI coder reports as changed (parsed from the pasted result) → validated against the
60
  // contract. status defaults to "modified".
@@ -736,7 +741,7 @@ function LandingHero({ idea, setIdea, generate, onUpload }: { idea: string; setI
736
  );
737
  }
738
 
739
- function CandidateCard({ candidate, choose, enrichment }: { candidate: BlueprintCandidate; choose: (candidate: BlueprintCandidate) => void; enrichment?: { displayName?: string; displaySummary?: string } }) {
740
  // Display-only: the deterministic candidate still drives bundle generation. AI may only soften
741
  // the visible name/summary; an explicit badge tells the user the wording was AI-assisted.
742
  const name = enrichment?.displayName ?? candidate.name;
@@ -755,7 +760,19 @@ function CandidateCard({ candidate, choose, enrichment }: { candidate: Blueprint
755
  <span className="mb-tag n">{candidate.time}</span>
756
  </div>
757
  <div className="cand-stack">{candidate.stack.map((stack) => <span className="mb-tag" key={stack}>{stack}</span>)}</div>
758
- <button className="cand-choose" type="button">Choose this <MatrixIcon size={16}>{icons.arrow}</MatrixIcon></button>
 
 
 
 
 
 
 
 
 
 
 
 
759
  </article>
760
  );
761
  }
@@ -1584,12 +1601,280 @@ function BuildComplete({
1584
  );
1585
  }
1586
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1587
  export default function MatrixBuilderClient({ initialBuild }: { initialBuild?: InitialBuild } = {}) {
1588
  // When reopened from My Builds we land directly on the active build screen, reconstructed
1589
  // from the persisted build, so the user keeps full context (no state is lost).
1590
  const [phase, setPhase] = useState<Phase>(initialBuild ? "bundle" : "hero");
1591
  const [idea, setIdea] = useState(initialBuild?.idea ?? "");
1592
  const [scanIndex, setScanIndex] = useState(0);
 
 
 
 
1593
  const [candidates, setCandidates] = useState<BlueprintCandidate[]>([]);
1594
  const [candidatesLoaded, setCandidatesLoaded] = useState(false);
1595
  // Optional Internal AI: display-only copy overrides keyed by candidate id; the deterministic
@@ -1689,6 +1974,11 @@ export default function MatrixBuilderClient({ initialBuild }: { initialBuild?: I
1689
  }
1690
  setCandidates(list);
1691
  setCandidatesLoaded(true);
 
 
 
 
 
1692
  // Optional Internal AI assist (fail-open): improve display copy only, after candidates show.
1693
  void enrichBlueprintCandidates(
1694
  ideaText,
@@ -1712,6 +2002,14 @@ export default function MatrixBuilderClient({ initialBuild }: { initialBuild?: I
1712
  }, [phase, scanIndex, candidatesLoaded]);
1713
 
1714
  // Step 2 — generate the chosen candidate's Matrix Bundle on the engine (the real file manifest).
 
 
 
 
 
 
 
 
1715
  const choose = (candidate: BlueprintCandidate) => {
1716
  const localId = generateBundleId();
1717
  setChosen(candidate);
@@ -2098,12 +2396,28 @@ export default function MatrixBuilderClient({ initialBuild }: { initialBuild?: I
2098
  </details>
2099
  )}
2100
  <div className="cand-grid stag">
2101
- {candidates.map((candidate) => <CandidateCard key={candidate.id} candidate={candidate} choose={choose} enrichment={enrichedById[candidate.id]} />)}
2102
  </div>
2103
  </div>
2104
  </>
2105
  )}
2106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2107
  {phase === "bundle" && chosen && (
2108
  <BundleResult
2109
  candidate={chosen}
 
12
  import { createBlueprintCandidates } from "@/lib/matrix-demo-data";
13
  import { toUiBundleFiles, toUiCandidates } from "@/lib/engine-map";
14
  import { briefToIdea, enhanceProjectBrief, enrichBlueprintCandidates, explainValidationFindings, isOllaBridgeAssistAvailable, type EnrichmentMap } from "@/lib/ai-provider-manager";
15
+ import { isMatrixDesignerEnabled } from "@/lib/ai-settings-store";
16
+ import { fetchBlueprintDetails, saveBlueprintDetails, type DesignerCandidate } from "@/lib/blueprint-client";
17
+ import { getCapabilities } from "@/lib/capabilities";
18
+ import { generateCandidates } from "@/lib/blueprint-engine";
19
+ import { useBlueprintWorkspace } from "@/lib/blueprint-store";
20
  import UploadExistingPlanModal, { type UploadSelection } from "@/components/matrix-builder/UploadExistingPlanModal";
21
  import {
22
  downloadBundleZip,
 
59
  import type { CoderId } from "@/types/coder";
60
  import type { ValidationReportContract } from "@/types/contracts";
61
 
62
+ type Phase = "hero" | "scanning" | "candidates" | "details" | "bundle" | "submit" | "running" | "validation" | "timeline" | "complete";
63
 
64
  // Paths the AI coder reports as changed (parsed from the pasted result) → validated against the
65
  // contract. status defaults to "modified".
 
741
  );
742
  }
743
 
744
+ function CandidateCard({ candidate, choose, details, enrichment }: { candidate: BlueprintCandidate; choose: (candidate: BlueprintCandidate) => void; details: (candidate: BlueprintCandidate) => void; enrichment?: { displayName?: string; displaySummary?: string } }) {
745
  // Display-only: the deterministic candidate still drives bundle generation. AI may only soften
746
  // the visible name/summary; an explicit badge tells the user the wording was AI-assisted.
747
  const name = enrichment?.displayName ?? candidate.name;
 
760
  <span className="mb-tag n">{candidate.time}</span>
761
  </div>
762
  <div className="cand-stack">{candidate.stack.map((stack) => <span className="mb-tag" key={stack}>{stack}</span>)}</div>
763
+ {/* Secondary action: inspect the full plan before choosing. Primary stays "Choose this". */}
764
+ <div className="cand-actions">
765
+ <button
766
+ className="cand-details"
767
+ type="button"
768
+ onClick={(e) => { e.stopPropagation(); details(candidate); }}
769
+ >
770
+ Details
771
+ </button>
772
+ <button className="cand-choose" type="button" onClick={(e) => { e.stopPropagation(); choose(candidate); }}>
773
+ Choose this <MatrixIcon size={16}>{icons.arrow}</MatrixIcon>
774
+ </button>
775
+ </div>
776
  </article>
777
  );
778
  }
 
1601
  );
1602
  }
1603
 
1604
+ // ---------------------------------------------------------------------------------------------
1605
+ // Blueprint Details — a calm planning room for one blueprint. Cards stay simple; this page answers
1606
+ // "what exactly will this path build?". All sections are derived client-side from the deterministic
1607
+ // candidate + the idea (no backend route yet); the Matrix contract is never changed here.
1608
+ // ---------------------------------------------------------------------------------------------
1609
+
1610
+ // Map a Matrix Designer candidate card onto the UI's BlueprintCandidate shape.
1611
+ function designerToUiCandidate(dc: DesignerCandidate): BlueprintCandidate {
1612
+ return {
1613
+ id: dc.id as BlueprintCandidate["id"],
1614
+ tier: dc.tier as BlueprintCandidate["tier"],
1615
+ name: dc.title,
1616
+ summary: dc.summary,
1617
+ stack: dc.stack,
1618
+ files: dc.file_count,
1619
+ difficulty: dc.difficulty as BlueprintCandidate["difficulty"],
1620
+ time: dc.estimate,
1621
+ standards: [],
1622
+ recommended: dc.recommended,
1623
+ };
1624
+ }
1625
+
1626
+ // Map an architecture-node name to one of the existing line icons.
1627
+ function archIcon(name: string): IconDefinition {
1628
+ const s = name.toLowerCase();
1629
+ if (/api|backend|service/.test(s)) return icons.cpu;
1630
+ if (/data|db|postgres|mysql|storage|mongo/.test(s)) return icons.cube;
1631
+ if (/worker|queue|job/.test(s)) return icons.refresh;
1632
+ if (/deploy|ci|pages|docker|kubernetes/.test(s)) return icons.shield;
1633
+ if (/dashboard|admin|ui|doc/.test(s)) return icons.doc;
1634
+ return icons.layers;
1635
+ }
1636
+
1637
+ function BlueprintDetails({ candidate, subject, idea, designerOn, aiAssist, onBack, onChoose, onNotice }: {
1638
+ candidate: BlueprintCandidate; subject: string; idea: string; designerOn: boolean; aiAssist: boolean;
1639
+ onBack: () => void; onChoose: () => void; onNotice: (message: string) => void;
1640
+ }) {
1641
+ // C0/C1/C2 — the workspace is a single client state object mutated by the in-browser engine.
1642
+ // No fetch on the render path: generate + chat run locally, so the page is instant and works
1643
+ // fully offline. (The server/LLM are optional enhancements, layered on via ws.setData later.)
1644
+ const ws = useBlueprintWorkspace(candidate.id, idea);
1645
+ const { data, messages, busy, dirty } = ws;
1646
+ const [draft, setDraft] = useState("");
1647
+ const [chatTab, setChatTab] = useState<"chat" | "activity">("chat");
1648
+
1649
+ // C5 — optional server sync behind a capability check. With no backend (offline / static
1650
+ // hosting) this is a no-op and the UX is identical; with a backend it syncs the richer output.
1651
+ useEffect(() => {
1652
+ let active = true;
1653
+ void getCapabilities().then((cap) => {
1654
+ if (!active || !cap.server) return; // no server → stay fully local
1655
+ if (ws.data.chat_history.length > 0) return; // don't clobber local edits
1656
+ void fetchBlueprintDetails(candidate.id, idea, { useDesigner: designerOn })
1657
+ .then((d) => { if (active) ws.setData(d); })
1658
+ .catch(() => undefined);
1659
+ });
1660
+ return () => { active = false; };
1661
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1662
+ }, [candidate.id, idea, designerOn]);
1663
+
1664
+ function send() {
1665
+ const text = draft.trim();
1666
+ if (!text) return;
1667
+ ws.applyInstruction(text); // instant, local, no network — sections patch from the engine
1668
+ setDraft("");
1669
+ }
1670
+
1671
+ function save() {
1672
+ if (!dirty) { onNotice("No changes to save."); return; }
1673
+ // Local state is already saved (localStorage). Server persistence is gated on a capability
1674
+ // check so offline makes zero requests.
1675
+ void getCapabilities().then((cap) => {
1676
+ if (cap.server) void saveBlueprintDetails(candidate.id, idea, data).catch(() => undefined);
1677
+ });
1678
+ ws.markSaved();
1679
+ onNotice("Blueprint updated.");
1680
+ }
1681
+
1682
+ // Activity feed derived from the live state — what the engine changed.
1683
+ const activity = [
1684
+ `${data.batches.length} build batches planned`,
1685
+ `${data.architecture.length} architecture components`,
1686
+ `${data.file_plan.length} planned files`,
1687
+ ...messages.filter((m) => m.role === "blueprint").map((m) => m.content),
1688
+ ];
1689
+
1690
+ const batches = data.batches;
1691
+ const architecture = data.architecture;
1692
+ const filePlan = data.file_plan;
1693
+
1694
+ return (
1695
+ <div className="bd-surface">
1696
+ <div className="l-wrap mb-page bd-page">
1697
+ <div className="bd-crumb reveal">
1698
+ <span className="bd-step">Step 1 · Choose a blueprint</span>
1699
+ <span className="bd-sep">/</span><span>{candidate.tier}</span>
1700
+ <span className="bd-sep">/</span><span className="bd-cur">Details</span>
1701
+ </div>
1702
+
1703
+ <div className="bd-head reveal">
1704
+ <div className="bd-head-main">
1705
+ <h1 className="bd-h1">{candidate.name}{candidate.recommended && <span className="bd-rec">Recommended</span>}</h1>
1706
+ <p className="bd-lede">{candidate.summary}</p>
1707
+ <div className="bd-pills">
1708
+ <span className="mb-tag">Stack: {candidate.stack.join(", ")}</span>
1709
+ <span className="mb-tag n">{candidate.files} files</span>
1710
+ <span className="mb-tag n">{candidate.difficulty}</span>
1711
+ <span className="mb-tag n">{candidate.time}</span>
1712
+ {aiAssist && <span className="mb-tag bd-ai">AI assist</span>}
1713
+ <span className="mb-tag bd-lock"><MatrixIcon size={12}>{icons.shield}</MatrixIcon> RMD locked</span>
1714
+ </div>
1715
+ </div>
1716
+ </div>
1717
+
1718
+ <div className="bd-grid">
1719
+ {/* LEFT — overview, batches, chat (history + bottom composer) */}
1720
+ <div className="bd-col">
1721
+ <section className="bd-card reveal">
1722
+ <div className="bd-card-h"><MatrixIcon size={16}>{icons.doc}</MatrixIcon><h3>Blueprint overview</h3></div>
1723
+ <div className="bd-ov-split">
1724
+ <p className="bd-p">{data.overview}</p>
1725
+ <div className="bd-ov">
1726
+ <div className="bd-ov-scope"><span className="bd-ov-k">Scope</span><span className="bd-ov-v">{idea}</span></div>
1727
+ <div className="bd-ov-row">
1728
+ <div><span className="bd-ov-k">Quality</span><span className="bd-ov-v">{candidate.tier}</span></div>
1729
+ <div><span className="bd-ov-k">Outcome</span><span className="bd-ov-v">{data.acceptance_criteria[0] ?? "builds and runs"}</span></div>
1730
+ </div>
1731
+ </div>
1732
+ </div>
1733
+ </section>
1734
+
1735
+ <section className="bd-card reveal">
1736
+ <div className="bd-card-h"><MatrixIcon size={16}>{icons.layers}</MatrixIcon><h3>Build batches</h3>
1737
+ <span className="bd-card-sub">{`${batches.length} controlled batches · built in order`}</span></div>
1738
+ <ol className="bd-batches">
1739
+ {batches.map((b, i) => (
1740
+ <li className="bd-batch" key={b.id}>
1741
+ <span className="bd-batch-n">{String(i + 1).padStart(2, "0")}</span>
1742
+ <div className="bd-batch-body">
1743
+ <div className="bd-batch-top">
1744
+ <span className="bd-batch-name">{b.name}</span>
1745
+ <span className="bd-batch-desc">{b.purpose}</span>
1746
+ </div>
1747
+ <div className="bd-batch-meta">
1748
+ <span className="bd-batch-tag">Allowed: {b.allowed_files.slice(0, 2).join(", ")}{b.allowed_files.length > 2 ? "…" : ""}</span>
1749
+ {b.acceptance_criteria[0] && <span className="bd-batch-tag">Accept: {b.acceptance_criteria[0]}</span>}
1750
+ </div>
1751
+ </div>
1752
+ <span className="bd-batch-status"><span className="bd-status-dot" />Planned</span>
1753
+ </li>
1754
+ ))}
1755
+ </ol>
1756
+ </section>
1757
+
1758
+ {/* Chat — history + always-visible bottom composer (ChatGPT/Claude style) */}
1759
+ <section className="bd-card reveal bd-chat">
1760
+ <div className="bd-chat-head">
1761
+ <div className="bd-chat-tabs">
1762
+ <button className={`bd-tab${chatTab === "chat" ? " on" : ""}`} type="button" onClick={() => setChatTab("chat")}>Chat</button>
1763
+ <button className={`bd-tab${chatTab === "activity" ? " on" : ""}`} type="button" onClick={() => setChatTab("activity")}>Activity</button>
1764
+ </div>
1765
+ {dirty && <span className="bd-unsaved" title="You have unsaved edits">Unsaved</span>}
1766
+ <button className={`bd-save${dirty ? " on" : ""}`} type="button" onClick={save} title="Save latest edits" aria-label="Save latest edits">
1767
+ <MatrixIcon size={15}>{icons.download}</MatrixIcon>
1768
+ </button>
1769
+ </div>
1770
+
1771
+ <div className="bd-chat-log">
1772
+ {chatTab === "chat" ? (
1773
+ messages.length === 0 ? (
1774
+ <p className="bd-chat-empty">Ask Blueprints to refine the architecture, batches, or scope — the plan updates live.</p>
1775
+ ) : (
1776
+ messages.map((m) => (
1777
+ <div className={`bd-msg ${m.role}`} key={m.id}>
1778
+ <span className="bd-msg-av">{m.role === "user" ? "M" : <MatrixIcon size={14}>{icons.cube}</MatrixIcon>}</span>
1779
+ <div className="bd-msg-body">
1780
+ <div className="bd-msg-head"><span className="bd-msg-role">{m.role === "user" ? "You" : "Matrix Builder"}</span>{m.time && <span className="bd-msg-time">{m.time}</span>}</div>
1781
+ <div className="bd-msg-text">{m.content}</div>
1782
+ </div>
1783
+ </div>
1784
+ ))
1785
+ )
1786
+ ) : (
1787
+ <ul className="bd-activity">
1788
+ {activity.map((a, i) => (
1789
+ <li className="bd-activity-row" key={`${i}-${a}`}><span className="bd-activity-dot" />{a}</li>
1790
+ ))}
1791
+ </ul>
1792
+ )}
1793
+ {busy && chatTab === "chat" && (
1794
+ <div className="bd-msg blueprint"><span className="bd-msg-av"><MatrixIcon size={14}>{icons.cube}</MatrixIcon></span>
1795
+ <div className="bd-msg-body"><div className="bd-msg-head"><span className="bd-msg-role">Matrix Builder</span></div><div className="bd-msg-text bd-typing">thinking…</div></div></div>
1796
+ )}
1797
+ </div>
1798
+
1799
+ <div className="bd-composer">
1800
+ <button className="bd-composer-add" type="button" aria-label="Attach" title="Attach"><MatrixIcon size={16}>{icons.plus}</MatrixIcon></button>
1801
+ <input className="bd-composer-input" value={draft} placeholder="Describe the change you want…" disabled={busy}
1802
+ onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") send(); }} aria-label="Describe the change you want" />
1803
+ <span className="bd-kbd" aria-hidden="true">⌘↵</span>
1804
+ <button className="bd-composer-send" type="button" onClick={send} aria-label="Send" disabled={busy || !draft.trim()}>
1805
+ <MatrixIcon size={16}>{icons.send}</MatrixIcon>
1806
+ </button>
1807
+ </div>
1808
+ </section>
1809
+ </div>
1810
+
1811
+ {/* RIGHT — sticky sidebar: architecture, file plan, matrix rules, design brain, actions */}
1812
+ <aside className="bd-col bd-aside">
1813
+ <section className="bd-card reveal">
1814
+ <div className="bd-card-h"><MatrixIcon size={16}>{icons.git}</MatrixIcon><h3>Architecture</h3></div>
1815
+ <div className="bd-arch">
1816
+ {architecture.map((n) => (
1817
+ <div className="bd-arch-node" key={n.name}>
1818
+ <span className="bd-arch-top"><span className="bd-arch-ic"><MatrixIcon size={15}>{archIcon(n.name)}</MatrixIcon></span><span className="bd-arch-label">{n.name}</span></span>
1819
+ <span className="bd-arch-sub">{n.description}</span>
1820
+ </div>
1821
+ ))}
1822
+ </div>
1823
+ <button className="bd-card-link" type="button" onClick={() => onNotice("Architecture detail view coming soon.")}>View details of Architecture <MatrixIcon size={13}>{icons.arrow}</MatrixIcon></button>
1824
+ </section>
1825
+
1826
+ <section className="bd-card reveal">
1827
+ <div className="bd-card-h"><MatrixIcon size={16}>{icons.doc}</MatrixIcon><h3>File plan</h3></div>
1828
+ <ul className="bd-files">
1829
+ {filePlan.map((f) => (
1830
+ <li className="bd-file" key={f.path}><code className="bd-file-path">{f.path}</code><span className="bd-file-why">{f.description}</span></li>
1831
+ ))}
1832
+ </ul>
1833
+ </section>
1834
+
1835
+ <section className="bd-card reveal">
1836
+ <div className="bd-card-h"><MatrixIcon size={16}>{icons.shield}</MatrixIcon><h3>Matrix rules</h3></div>
1837
+ <ul className="bd-rules">
1838
+ {data.matrix_rules.map((rule) => (
1839
+ <li key={rule}><MatrixIcon size={14}>{icons.check}</MatrixIcon> {rule.replace(/^RMD-\d+:\s*/, "")}</li>
1840
+ ))}
1841
+ </ul>
1842
+ </section>
1843
+
1844
+ {designerOn && (
1845
+ <section className="bd-card reveal bd-brain">
1846
+ <div className="bd-card-h"><span className="bd-brain-ic">🧠</span><h3>Design Brain</h3>
1847
+ <span className="bd-card-sub">Matrix Designer</span></div>
1848
+ <span className="bd-brain-k">Product intent</span>
1849
+ <p className="bd-brain-intent">{subject} — designed before batching so every batch fits the whole.</p>
1850
+ {(data.risks.length > 0 || data.assumptions.length > 0) && (
1851
+ <p className="bd-brain-note">{[...data.risks, ...data.assumptions].slice(0, 2).join(" · ")}</p>
1852
+ )}
1853
+ <button className="bd-card-link" type="button" onClick={() => setChatTab("activity")}>View details <MatrixIcon size={13}>{icons.arrow}</MatrixIcon></button>
1854
+ </section>
1855
+ )}
1856
+
1857
+ <div className="bd-actions">
1858
+ <button className="bd-back" type="button" onClick={onBack}><MatrixIcon size={15}>{icons.back}</MatrixIcon> Back to blueprints</button>
1859
+ <button className="bd-choose" type="button" onClick={onChoose}>Choose this blueprint <MatrixIcon size={16}>{icons.arrow}</MatrixIcon></button>
1860
+ </div>
1861
+ </aside>
1862
+ </div>
1863
+ </div>
1864
+ </div>
1865
+ );
1866
+ }
1867
+
1868
  export default function MatrixBuilderClient({ initialBuild }: { initialBuild?: InitialBuild } = {}) {
1869
  // When reopened from My Builds we land directly on the active build screen, reconstructed
1870
  // from the persisted build, so the user keeps full context (no state is lost).
1871
  const [phase, setPhase] = useState<Phase>(initialBuild ? "bundle" : "hero");
1872
  const [idea, setIdea] = useState(initialBuild?.idea ?? "");
1873
  const [scanIndex, setScanIndex] = useState(0);
1874
+ // Blueprint Details view — the candidate being inspected, and whether the Matrix Designer
1875
+ // enhancement is on (read from settings when the user opens Details, so it reflects the toggle).
1876
+ const [detailsCandidate, setDetailsCandidate] = useState<BlueprintCandidate | null>(null);
1877
+ const [designerOn, setDesignerOn] = useState(false);
1878
  const [candidates, setCandidates] = useState<BlueprintCandidate[]>([]);
1879
  const [candidatesLoaded, setCandidatesLoaded] = useState(false);
1880
  // Optional Internal AI: display-only copy overrides keyed by candidate id; the deterministic
 
1974
  }
1975
  setCandidates(list);
1976
  setCandidatesLoaded(true);
1977
+ // Matrix Designer toggle ON: source the 3 cards from the in-browser engine (no network).
1978
+ // The server is an optional enhancement; the client engine is the primary, instant path.
1979
+ if (isMatrixDesignerEnabled()) {
1980
+ setCandidates(generateCandidates(ideaText).map(designerToUiCandidate));
1981
+ }
1982
  // Optional Internal AI assist (fail-open): improve display copy only, after candidates show.
1983
  void enrichBlueprintCandidates(
1984
  ideaText,
 
2002
  }, [phase, scanIndex, candidatesLoaded]);
2003
 
2004
  // Step 2 — generate the chosen candidate's Matrix Bundle on the engine (the real file manifest).
2005
+ // Open the premium Details view for a candidate (read the Designer toggle fresh each time).
2006
+ const openDetails = (candidate: BlueprintCandidate) => {
2007
+ setDetailsCandidate(candidate);
2008
+ setDesignerOn(isMatrixDesignerEnabled());
2009
+ setPhase("details");
2010
+ window.scrollTo({ top: 0, behavior: "smooth" });
2011
+ };
2012
+
2013
  const choose = (candidate: BlueprintCandidate) => {
2014
  const localId = generateBundleId();
2015
  setChosen(candidate);
 
2396
  </details>
2397
  )}
2398
  <div className="cand-grid stag">
2399
+ {candidates.map((candidate) => <CandidateCard key={candidate.id} candidate={candidate} choose={choose} details={openDetails} enrichment={enrichedById[candidate.id]} />)}
2400
  </div>
2401
  </div>
2402
  </>
2403
  )}
2404
 
2405
+ {phase === "details" && detailsCandidate && (
2406
+ <>
2407
+ <BuilderBar onNew={reset} onNotice={showToast} />
2408
+ <BlueprintDetails
2409
+ candidate={detailsCandidate}
2410
+ subject={headingSubject}
2411
+ idea={effectiveIdea}
2412
+ designerOn={designerOn}
2413
+ aiAssist={isOllaBridgeAssistAvailable()}
2414
+ onBack={() => setPhase("candidates")}
2415
+ onChoose={() => choose(detailsCandidate)}
2416
+ onNotice={showToast}
2417
+ />
2418
+ </>
2419
+ )}
2420
+
2421
  {phase === "bundle" && chosen && (
2422
  <BundleResult
2423
  candidate={chosen}
web/src/components/settings/AiConfigurationSection.tsx CHANGED
@@ -57,6 +57,16 @@ export default function AiConfigurationSection() {
57
  setDefaultCoder(next);
58
  }
59
 
 
 
 
 
 
 
 
 
 
 
60
  const ob = settings.ollabridge;
61
  const localhost = isLocalhostLike(ob.baseUrl);
62
 
@@ -188,6 +198,28 @@ export default function AiConfigurationSection() {
188
  <p className="ai-help">AI assist is off. Matrix Builder will run deterministically.</p>
189
  )}
190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  {settings.provider === "ollabridge" && (
192
  <>
193
  {/* Assisted-mode toggle */}
 
57
  setDefaultCoder(next);
58
  }
59
 
60
+ // Optional Matrix Designer enhancement — persist immediately, like the default-coder preference.
61
+ function onToggleMatrixDesigner(enabled: boolean) {
62
+ setSettings((s) => {
63
+ const next = { ...s, matrixDesigner: { ...s.matrixDesigner, enabled } };
64
+ saveAISettings(next);
65
+ return next;
66
+ });
67
+ setSaveNote(null);
68
+ }
69
+
70
  const ob = settings.ollabridge;
71
  const localhost = isLocalhostLike(ob.baseUrl);
72
 
 
198
  <p className="ai-help">AI assist is off. Matrix Builder will run deterministically.</p>
199
  )}
200
 
201
+ {/* Optional enhancement: Matrix Designer — the design brain. Default OFF. */}
202
+ <div className="ai-divider" />
203
+ <div className="settings-field-group">
204
+ <label className="ai-toggle md-toggle">
205
+ <input
206
+ type="checkbox"
207
+ checked={settings.matrixDesigner.enabled}
208
+ onChange={(e) => onToggleMatrixDesigner(e.target.checked)}
209
+ />
210
+ <span>
211
+ 🧠 Matrix Designer <span className="md-badge">optional</span>
212
+ <em className="ai-toggle-hint">
213
+ {settings.matrixDesigner.enabled
214
+ ? settings.provider === "ollabridge" && settings.mode === "assisted"
215
+ ? "On — uses your Internal AI (OllaBridge) to design the full batch plan and coder prompts for the next stages. The Matrix contract is unchanged."
216
+ : "On — but it stays deterministic until Internal AI (OllaBridge) assist above is enabled; then it designs the batch plan with AI."
217
+ : "Off — the design brain that plans every batch before you build. Turn on to inspect a full plan on each blueprint’s Details page."}
218
+ </em>
219
+ </span>
220
+ </label>
221
+ </div>
222
+
223
  {settings.provider === "ollabridge" && (
224
  <>
225
  {/* Assisted-mode toggle */}
web/src/lib/__tests__/blueprint-engine.test.ts ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // C1 — the in-browser blueprint engine is pure and deterministic (no network).
2
+ //
3
+ // Run with: npm run test. These cover the local orchestrator the workspace uses on every chat
4
+ // instruction, so the Details page mutates instantly and works fully offline.
5
+
6
+ import assert from "node:assert/strict";
7
+ import { describe, it } from "node:test";
8
+
9
+ import { apply, generate, generateCandidates } from "@/lib/blueprint-engine";
10
+
11
+ describe("blueprint-engine.generate", () => {
12
+ it("builds a game blueprint for a Phaser idea", () => {
13
+ const d = generate("standard", "an 8-episode Phaser platformer on GitHub Pages");
14
+ assert.equal(d.candidate_id, "standard");
15
+ assert.ok(d.batches.length >= 6);
16
+ assert.ok(d.architecture.some((n) => /phaser/i.test(n.name)));
17
+ assert.ok(d.batches.every((b) => b.allowed_files.length && b.acceptance_criteria.length));
18
+ });
19
+
20
+ it("ramps minimal < standard < production", () => {
21
+ const idea = "A SaaS dashboard with auth";
22
+ const n = (id: string) => generate(id, idea).batches.length;
23
+ assert.ok(n("minimal") < n("standard"));
24
+ assert.ok(n("standard") < n("production"));
25
+ });
26
+
27
+ it("generateCandidates returns the three tiers", () => {
28
+ const cs = generateCandidates("a platformer game");
29
+ assert.deepEqual(cs.map((c) => c.id), ["minimal", "standard", "production"]);
30
+ assert.ok(cs.some((c) => c.recommended));
31
+ });
32
+ });
33
+
34
+ describe("blueprint-engine.apply (local orchestrator)", () => {
35
+ const base = () => generate("standard", "an 8-episode Phaser platformer");
36
+
37
+ it("adds a boss batch", () => {
38
+ const before = base();
39
+ const res = apply(before, "add a boss level");
40
+ assert.equal(res.data.batches.length, before.batches.length + 1);
41
+ assert.match(res.data.batches.at(-1)!.name, /boss/i);
42
+ assert.ok(res.updatedSections.includes("batches"));
43
+ // purity: the input is untouched
44
+ assert.equal(before.batches.length, base().batches.length);
45
+ });
46
+
47
+ it("reduces scope", () => {
48
+ const before = base();
49
+ const res = apply(before, "make this smaller");
50
+ assert.equal(res.data.batches.length, before.batches.length - 1);
51
+ });
52
+
53
+ it("splits a batch into two", () => {
54
+ const before = base();
55
+ const res = apply(before, "split batch 3 into two");
56
+ assert.equal(res.data.batches.length, before.batches.length + 1);
57
+ assert.ok(res.data.batches.some((b) => /part 1/.test(b.name)));
58
+ assert.ok(res.data.batches.some((b) => /part 2/.test(b.name)));
59
+ // batches stay renumbered batch-01..NN
60
+ res.data.batches.forEach((b, i) => assert.equal(b.id, `batch-${String(i + 1).padStart(2, "0")}`));
61
+ });
62
+
63
+ it("switches the stack (architecture + file plan)", () => {
64
+ const res = apply(generate("standard", "a web app"), "use FastAPI instead of Express");
65
+ assert.ok(res.updatedSections.includes("architecture"));
66
+ assert.ok(res.updatedSections.includes("filePlan"));
67
+ assert.ok(res.data.architecture.some((n) => /fastapi/i.test(n.description)));
68
+ });
69
+
70
+ it("adds an architecture component (worker/queue)", () => {
71
+ const res = apply(generate("minimal", "a web app"), "add a worker queue");
72
+ assert.ok(res.data.architecture.some((n) => /worker/i.test(n.name)));
73
+ assert.ok(res.updatedSections.includes("architecture"));
74
+ });
75
+ });
web/src/lib/__tests__/blueprint-persistence.test.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // C4 — localStorage-first persistence round-trips the workspace; C3 — AI refine is opt-in.
2
+ //
3
+ // Run with: npm run test. These prove a reload restores the workspace with no server, and that
4
+ // the optional AI enhancement stays off (deterministic, no network) unless explicitly enabled.
5
+
6
+ import assert from "node:assert/strict";
7
+ import { afterEach, beforeEach, describe, it } from "node:test";
8
+
9
+ import { generate } from "@/lib/blueprint-engine";
10
+ import { clearWorkspace, loadWorkspace, saveWorkspace, workspaceKey } from "@/lib/blueprint-persistence";
11
+ import { refineWithAI } from "@/lib/ai-refine";
12
+
13
+ function installFakeWindow(): Map<string, string> {
14
+ const store = new Map<string, string>();
15
+ (globalThis as Record<string, unknown>).window = {
16
+ localStorage: {
17
+ getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
18
+ setItem: (k: string, v: string) => void store.set(k, String(v)),
19
+ removeItem: (k: string) => void store.delete(k),
20
+ },
21
+ };
22
+ return store;
23
+ }
24
+
25
+ beforeEach(() => installFakeWindow());
26
+ afterEach(() => { delete (globalThis as Record<string, unknown>).window; });
27
+
28
+ describe("C4 persistence", () => {
29
+ it("save → load restores the workspace (reload with no server)", () => {
30
+ const idea = "an 8-episode Phaser platformer";
31
+ const key = workspaceKey(idea, "standard");
32
+ const data = generate("standard", idea);
33
+ data.chat_history = [{ id: "u1", role: "user", content: "add a boss level", timestamp: "" }];
34
+ saveWorkspace(key, data);
35
+
36
+ const restored = loadWorkspace(key);
37
+ assert.ok(restored);
38
+ assert.equal(restored!.candidate_id, "standard");
39
+ assert.equal(restored!.batches.length, data.batches.length);
40
+ assert.equal(restored!.chat_history[0].content, "add a boss level");
41
+ });
42
+
43
+ it("keys are stable per idea + candidate and isolated across them", () => {
44
+ assert.equal(workspaceKey("idea A", "standard"), workspaceKey("idea A", "standard"));
45
+ assert.notEqual(workspaceKey("idea A", "standard"), workspaceKey("idea B", "standard"));
46
+ assert.notEqual(workspaceKey("idea A", "standard"), workspaceKey("idea A", "minimal"));
47
+ });
48
+
49
+ it("clear removes the saved workspace", () => {
50
+ const key = workspaceKey("x", "standard");
51
+ saveWorkspace(key, generate("standard", "x"));
52
+ clearWorkspace(key);
53
+ assert.equal(loadWorkspace(key), null);
54
+ });
55
+
56
+ it("missing / corrupt entries load as null (never throw)", () => {
57
+ assert.equal(loadWorkspace(workspaceKey("never-saved", "standard")), null);
58
+ const ls = (globalThis as unknown as { window: { localStorage: Storage } }).window.localStorage;
59
+ ls.setItem(workspaceKey("bad", "standard"), "{not json");
60
+ assert.equal(loadWorkspace(workspaceKey("bad", "standard")), null);
61
+ });
62
+ });
63
+
64
+ describe("C3 AI refine (optional, fail-open)", () => {
65
+ it("returns null when assist is unavailable — deterministic result stands", async () => {
66
+ // No OllaBridge settings / no signed-in user in the test env → assist is off.
67
+ const out = await refineWithAI(generate("standard", "a web app"), "add audit logging");
68
+ assert.equal(out, null);
69
+ });
70
+ });
web/src/lib/__tests__/offline-workspace.test.ts ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // C6 — Proof: the Blueprint workspace is fully offline.
2
+ //
3
+ // With the network blocked, generate → edit → persist → reload all succeed and the data path
4
+ // makes ZERO requests. The capability probe degrades to {server:false} without throwing, so no
5
+ // per-interaction request is ever attempted. This is the unit-level equivalent of an
6
+ // airplane-mode browser e2e (the app has no Playwright harness).
7
+
8
+ import assert from "node:assert/strict";
9
+ import { afterEach, beforeEach, describe, it } from "node:test";
10
+
11
+ import { apply, generate } from "@/lib/blueprint-engine";
12
+ import { loadWorkspace, saveWorkspace, workspaceKey } from "@/lib/blueprint-persistence";
13
+ import { getCapabilities, resetCapabilities } from "@/lib/capabilities";
14
+
15
+ let fetchCalls = 0;
16
+ const realFetch = globalThis.fetch;
17
+
18
+ function installOfflineWindow(): void {
19
+ const store = new Map<string, string>();
20
+ (globalThis as Record<string, unknown>).window = {
21
+ localStorage: {
22
+ getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
23
+ setItem: (k: string, v: string) => void store.set(k, String(v)),
24
+ removeItem: (k: string) => void store.delete(k),
25
+ },
26
+ };
27
+ // Network is blocked: every request rejects (like airplane mode / refused connection).
28
+ (globalThis as Record<string, unknown>).fetch = (..._args: unknown[]) => {
29
+ fetchCalls++;
30
+ return Promise.reject(new Error("ERR_NETWORK_BLOCKED"));
31
+ };
32
+ }
33
+
34
+ beforeEach(() => { fetchCalls = 0; resetCapabilities(); installOfflineWindow(); });
35
+ afterEach(() => {
36
+ delete (globalThis as Record<string, unknown>).window;
37
+ (globalThis as Record<string, unknown>).fetch = realFetch;
38
+ });
39
+
40
+ describe("C6 — fully offline workspace", () => {
41
+ it("generate → edit → persist → reload, with ZERO network on the data path", () => {
42
+ const idea = "an 8-episode Phaser platformer on GitHub Pages";
43
+ const key = workspaceKey(idea, "standard");
44
+
45
+ // generate (local)
46
+ let data = generate("standard", idea);
47
+ const before = data.batches.length;
48
+
49
+ // edit (local engine) — add a boss
50
+ const res = apply(data, "add a boss level");
51
+ data = res.data;
52
+ assert.equal(data.batches.length, before + 1);
53
+ assert.match(data.batches.at(-1)!.name, /boss/i);
54
+
55
+ // persist (localStorage) + reload (new read)
56
+ saveWorkspace(key, data);
57
+ const reloaded = loadWorkspace(key);
58
+ assert.ok(reloaded);
59
+ assert.equal(reloaded!.batches.length, before + 1);
60
+ assert.match(reloaded!.batches.at(-1)!.name, /boss/i);
61
+
62
+ // the entire data path touched the network zero times
63
+ assert.equal(fetchCalls, 0);
64
+ });
65
+
66
+ it("capability probe degrades to {server:false} without throwing", async () => {
67
+ const cap = await getCapabilities({ timeoutMs: 50 });
68
+ assert.equal(cap.server, false);
69
+ assert.equal(fetchCalls, 1); // exactly one probe, caught — not per interaction
70
+ // cached: a second call does not probe again
71
+ const again = await getCapabilities({ timeoutMs: 50 });
72
+ assert.equal(again.server, false);
73
+ assert.equal(fetchCalls, 1);
74
+ });
75
+
76
+ it("a second session restores the same workspace from localStorage", () => {
77
+ const idea = "a SaaS dashboard with auth";
78
+ const key = workspaceKey(idea, "production");
79
+ const data = apply(generate("production", idea), "add audit logging").data;
80
+ saveWorkspace(key, data);
81
+
82
+ // "reload": a fresh read of the same key returns the identical plan
83
+ const restored = loadWorkspace(key);
84
+ assert.deepEqual(restored!.batches.map((b) => b.name), data.batches.map((b) => b.name));
85
+ assert.equal(fetchCalls, 0);
86
+ });
87
+ });
web/src/lib/ai-refine.ts ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // C3 — Optional AI enhancement for the Blueprint workspace.
2
+ //
3
+ // After the in-browser engine has ALREADY applied a chat instruction (instant, governed), this
4
+ // makes ONE provider-agnostic call (through OllaBridge, which can bridge to any model) to refine
5
+ // only the EXPLANATION — `overview` / `designBrain` prose and a short `reply`. It is:
6
+ // • optional — returns null when assist is off / no user (deterministic result stands)
7
+ // • bounded — a hard timeout; a slow model never blocks the UI
8
+ // • fail-open — any error/parse-failure returns null
9
+ // • safe — it can never change architecture, batches, or files (governance unchanged)
10
+
11
+ import { isOllaBridgeAssistAvailable, sendAIMessage } from "@/lib/ai-provider-manager";
12
+ import type { BlueprintDetailsData } from "@/types/blueprint-state";
13
+
14
+ export interface AIRefinement {
15
+ reply?: string;
16
+ overview?: string;
17
+ designBrain?: string;
18
+ }
19
+
20
+ const SYSTEM =
21
+ "You refine the EXPLANATION of a software blueprint after a change was applied. You may improve " +
22
+ "the 'overview' and 'designBrain' prose and write a short 'reply' confirming the change. You MUST " +
23
+ "NOT invent or change architecture, batches, files, or stack. Return ONLY compact JSON of the form " +
24
+ '{"reply":"...","overview":"...","designBrain":"..."} with no extra text.';
25
+
26
+ export async function refineWithAI(
27
+ data: BlueprintDetailsData,
28
+ instruction: string,
29
+ opts: { timeoutMs?: number } = {},
30
+ ): Promise<AIRefinement | null> {
31
+ if (!isOllaBridgeAssistAvailable()) return null;
32
+ const timeoutMs = opts.timeoutMs ?? 6000;
33
+ const user =
34
+ `Instruction just applied: ${instruction}\n` +
35
+ `Current overview: ${data.overview}\n` +
36
+ `Batches: ${data.batches.map((b) => b.name).join(", ")}\n` +
37
+ `Architecture: ${data.architecture.map((n) => n.name).join(", ")}`;
38
+ try {
39
+ const text = await withTimeout(
40
+ sendAIMessage([
41
+ { role: "system", content: SYSTEM },
42
+ { role: "user", content: user },
43
+ ]),
44
+ timeoutMs,
45
+ );
46
+ const json = extractJson(text);
47
+ if (!json) return null;
48
+ const out: AIRefinement = {};
49
+ if (typeof json.reply === "string" && json.reply.trim()) out.reply = json.reply.trim();
50
+ if (typeof json.overview === "string" && json.overview.trim()) out.overview = json.overview.trim();
51
+ if (typeof json.designBrain === "string" && json.designBrain.trim()) out.designBrain = json.designBrain.trim();
52
+ return Object.keys(out).length ? out : null;
53
+ } catch {
54
+ return null; // fail-open: the deterministic local result stands
55
+ }
56
+ }
57
+
58
+ function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
59
+ return new Promise<T>((resolve, reject) => {
60
+ const t = setTimeout(() => reject(new Error("ai-refine timeout")), ms);
61
+ p.then((v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); });
62
+ });
63
+ }
64
+
65
+ function extractJson(text: string): Record<string, unknown> | null {
66
+ const m = text.match(/\{[\s\S]*\}/);
67
+ if (!m) return null;
68
+ try {
69
+ return JSON.parse(m[0]) as Record<string, unknown>;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
web/src/lib/ai-settings-store.ts CHANGED
@@ -22,6 +22,7 @@ export function mergeAISettings(raw: unknown): MatrixAISettings {
22
  const mode = r.mode === "assisted" ? "assisted" : "deterministic";
23
  const authMode =
24
  ob.authMode === "api_key" || ob.authMode === "local-trust" ? ob.authMode : "pairing";
 
25
  return {
26
  provider,
27
  mode,
@@ -33,9 +34,17 @@ export function mergeAISettings(raw: unknown): MatrixAISettings {
33
  pairToken: typeof ob.pairToken === "string" ? ob.pairToken : "",
34
  deviceId: typeof ob.deviceId === "string" ? ob.deviceId : "",
35
  },
 
 
 
36
  };
37
  }
38
 
 
 
 
 
 
39
  export function getAISettings(): MatrixAISettings {
40
  if (typeof window === "undefined") return { ...DEFAULT_AI_SETTINGS };
41
  const raw = window.localStorage.getItem(AI_SETTINGS_STORAGE_KEY);
 
22
  const mode = r.mode === "assisted" ? "assisted" : "deterministic";
23
  const authMode =
24
  ob.authMode === "api_key" || ob.authMode === "local-trust" ? ob.authMode : "pairing";
25
+ const md = (r.matrixDesigner ?? {}) as Record<string, unknown>;
26
  return {
27
  provider,
28
  mode,
 
34
  pairToken: typeof ob.pairToken === "string" ? ob.pairToken : "",
35
  deviceId: typeof ob.deviceId === "string" ? ob.deviceId : "",
36
  },
37
+ matrixDesigner: {
38
+ enabled: md.enabled === true,
39
+ },
40
  };
41
  }
42
 
43
+ // Convenience: is the optional Matrix Designer enhancement turned on?
44
+ export function isMatrixDesignerEnabled(): boolean {
45
+ return getAISettings().matrixDesigner.enabled;
46
+ }
47
+
48
  export function getAISettings(): MatrixAISettings {
49
  if (typeof window === "undefined") return { ...DEFAULT_AI_SETTINGS };
50
  const raw = window.localStorage.getItem(AI_SETTINGS_STORAGE_KEY);
web/src/lib/blueprint-client.ts ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Optional server bridge for the Blueprint Details workspace.
2
+ //
3
+ // The workspace is driven entirely by the in-browser engine + store (C0/C1/C2) — these fetch
4
+ // wrappers are the OPTIONAL enhancement path: when a control plane is configured they sync /
5
+ // fetch real designer output, and on any failure they fall back to the local engine. Nothing on
6
+ // the render path depends on them.
7
+
8
+ import { apiBaseUrl } from "./api-client";
9
+ import { authHeaders } from "./auth-token";
10
+ import * as engine from "@/lib/blueprint-engine";
11
+
12
+ export type {
13
+ ArchitectureNode,
14
+ BlueprintDetailsData,
15
+ DesignerBatch,
16
+ DesignerCandidate,
17
+ DesignerChatMessage,
18
+ FilePlanItem,
19
+ } from "@/types/blueprint-state";
20
+
21
+ import type { BlueprintDetailsData, DesignerCandidate } from "@/types/blueprint-state";
22
+
23
+ // Local engine derivations re-exported for back-compat with existing callers.
24
+ export const deriveLocalDetails = engine.generate;
25
+ export const deriveLocalCandidates = engine.generateCandidates;
26
+
27
+ /** The 3 blueprint cards. `useDesigner=false` (toggle off) / offline → local engine. */
28
+ export async function fetchDesignerCandidates(
29
+ idea: string,
30
+ opts: { useDesigner?: boolean } = {},
31
+ ): Promise<DesignerCandidate[]> {
32
+ if (opts.useDesigner === false) return engine.generateCandidates(idea);
33
+ try {
34
+ const url = `${apiBaseUrl}/api/v1/blueprints/designer-candidates?idea=${encodeURIComponent(idea)}`;
35
+ const res = await fetch(url, { headers: { ...authHeaders() } });
36
+ if (!res.ok) throw new Error(`designer-candidates ${res.status}`);
37
+ const body = (await res.json()) as { candidates?: DesignerCandidate[] };
38
+ return body.candidates && body.candidates.length ? body.candidates : engine.generateCandidates(idea);
39
+ } catch {
40
+ return engine.generateCandidates(idea);
41
+ }
42
+ }
43
+
44
+ /** Optional: fetch real designer Details. Offline / toggle off → local engine. */
45
+ export async function fetchBlueprintDetails(
46
+ candidateId: string,
47
+ idea: string,
48
+ opts: { useDesigner?: boolean } = {},
49
+ ): Promise<BlueprintDetailsData> {
50
+ if (opts.useDesigner === false) return engine.generate(candidateId, idea);
51
+ try {
52
+ const url = `${apiBaseUrl}/api/v1/blueprints/${candidateId}/details?idea=${encodeURIComponent(idea)}`;
53
+ const res = await fetch(url, { headers: { ...authHeaders() } });
54
+ if (!res.ok) throw new Error(`details ${res.status}`);
55
+ return (await res.json()) as BlueprintDetailsData;
56
+ } catch {
57
+ return engine.generate(candidateId, idea);
58
+ }
59
+ }
60
+
61
+ /** Optional server refinement; the workspace already applied the local engine result. */
62
+ export async function sendBlueprintChat(
63
+ candidateId: string,
64
+ idea: string,
65
+ message: string,
66
+ ): Promise<{ reply: string; details: BlueprintDetailsData }> {
67
+ try {
68
+ const url = `${apiBaseUrl}/api/v1/blueprints/${candidateId}/chat`;
69
+ const res = await fetch(url, {
70
+ method: "POST",
71
+ headers: { "Content-Type": "application/json", ...authHeaders() },
72
+ body: JSON.stringify({ idea, message }),
73
+ });
74
+ if (!res.ok) throw new Error(`chat ${res.status}`);
75
+ return (await res.json()) as { reply: string; details: BlueprintDetailsData };
76
+ } catch {
77
+ const res = engine.apply(engine.generate(candidateId, idea), message);
78
+ return { reply: res.reply, details: res.data };
79
+ }
80
+ }
81
+
82
+ export async function saveBlueprintDetails(
83
+ candidateId: string,
84
+ idea: string,
85
+ details: BlueprintDetailsData,
86
+ buildId?: string,
87
+ ): Promise<BlueprintDetailsData> {
88
+ try {
89
+ const url = `${apiBaseUrl}/api/v1/blueprints/${candidateId}/save`;
90
+ const res = await fetch(url, {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/json", ...authHeaders() },
93
+ body: JSON.stringify({ idea, build_id: buildId, details }),
94
+ });
95
+ if (!res.ok) throw new Error(`save ${res.status}`);
96
+ return (await res.json()) as BlueprintDetailsData;
97
+ } catch {
98
+ return details; // offline: kept locally (localStorage persistence is C4)
99
+ }
100
+ }
101
+
102
+ /** Restore a previously-saved blueprint + chat for a build (owner-scoped). */
103
+ export async function fetchSavedBlueprint(
104
+ candidateId: string,
105
+ buildId: string,
106
+ ): Promise<BlueprintDetailsData | null> {
107
+ try {
108
+ const url = `${apiBaseUrl}/api/v1/blueprints/${candidateId}/saved?build_id=${encodeURIComponent(buildId)}`;
109
+ const res = await fetch(url, { headers: { ...authHeaders() } });
110
+ if (!res.ok) throw new Error(`saved ${res.status}`);
111
+ const body = (await res.json()) as { found: boolean; details: BlueprintDetailsData | null };
112
+ return body.found ? body.details : null;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
web/src/lib/blueprint-engine.ts ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // C1 — Browser blueprint-engine: deterministic, pure, no network.
2
+ //
3
+ // `generate(idea)` builds the initial blueprint; `apply(state, instruction)` is the local
4
+ // orchestrator that mutates the plan from a chat instruction (add / reduce / split batches,
5
+ // switch stack, add components) and returns the patched state + a reply + which sections
6
+ // changed. This is the primary path — the LLM (C3) only *refines* this result, optionally.
7
+
8
+ import {
9
+ type ArchitectureNode,
10
+ type BlueprintDetailsData,
11
+ type BlueprintSection,
12
+ type DesignerBatch,
13
+ type DesignerCandidate,
14
+ type FilePlanItem,
15
+ RMD_RULES,
16
+ } from "@/types/blueprint-state";
17
+
18
+ type Domain = "web-game" | "api" | "web-app";
19
+ type Tier = { id: string; difficulty: string; scale: number };
20
+
21
+ const TIERS: Tier[] = [
22
+ { id: "minimal", difficulty: "Easy", scale: 0.55 },
23
+ { id: "standard", difficulty: "Medium", scale: 1.0 },
24
+ { id: "production", difficulty: "Hard", scale: 1.6 },
25
+ ];
26
+
27
+ // ---- generate -------------------------------------------------------------------------
28
+ export function generate(candidateId: string, idea: string): BlueprintDetailsData {
29
+ const tier = TIERS.find((t) => t.id === candidateId) ?? TIERS[1];
30
+ const domain = inferDomain(idea);
31
+ const full = roadmap(domain);
32
+ const n = Math.max(3, Math.round(full.length * tier.scale));
33
+ const batches = n <= full.length ? full.slice(0, n) : full.concat(extra(full.length, n - full.length));
34
+ return {
35
+ candidate_id: candidateId,
36
+ overview: `This ${candidateId} blueprint builds ${idea.trim().slice(0, 120)}. Delivered in ordered, validated batches.`,
37
+ architecture: candidateId === "minimal" ? architecture(domain).slice(0, 2) : architecture(domain),
38
+ batches,
39
+ file_plan: filePlan(domain),
40
+ matrix_rules: RMD_RULES,
41
+ acceptance_criteria: ["builds and runs", "core flow works with tests", "Matrix validation approves the build"],
42
+ validation_plan: ["lint", "typecheck", "unit tests", "allowed-file check", "Matrix commit check"],
43
+ risks: ["external credentials may be required"],
44
+ assumptions: [domain === "web-game" ? "original assets only" : "auth required if data is sensitive"],
45
+ design_brain: `Client engine · ${batches.length} batches · ${candidateId}.`,
46
+ chat_history: [],
47
+ };
48
+ }
49
+
50
+ export function generateCandidates(idea: string): DesignerCandidate[] {
51
+ const domain = inferDomain(idea);
52
+ const stack = stackFor(domain);
53
+ const meta: [string, string, string, string, boolean][] = [
54
+ ["minimal", "Minimal", "Easy", "a weekend", false],
55
+ ["standard", "Standard", "Medium", "about one week", true],
56
+ ["production", "Production", "Hard", "about three weeks", false],
57
+ ];
58
+ return meta.map(([id, tier, difficulty, estimate, recommended]) => ({
59
+ id, tier, difficulty, estimate, recommended, stack,
60
+ title: `${tier} ${id === "standard" ? "Matrix Bundle" : "controlled blueprint"}`,
61
+ summary: `${recommended ? "Recommended " : ""}controlled blueprint for: ${idea.trim().slice(0, 90)}`,
62
+ file_count: generate(id, idea).batches.length * (id === "production" ? 7 : 5),
63
+ }));
64
+ }
65
+
66
+ // ---- apply — the local orchestrator ---------------------------------------------------
67
+ export interface ApplyResult {
68
+ data: BlueprintDetailsData;
69
+ reply: string;
70
+ updatedSections: BlueprintSection[];
71
+ }
72
+
73
+ export function apply(state: BlueprintDetailsData, instruction: string): ApplyResult {
74
+ const text = instruction.trim();
75
+ const m = text.toLowerCase();
76
+ const data = clone(state);
77
+ const sections = new Set<BlueprintSection>();
78
+ let reply = "Noted. Save latest edits to keep this.";
79
+
80
+ const nextId = () => `batch-${String(data.batches.length + 1).padStart(2, "0")}`;
81
+
82
+ // 1) split a batch into two
83
+ if (/\bsplit\b/.test(m)) {
84
+ const idx = pickBatchIndex(data, m);
85
+ if (idx >= 0) {
86
+ const b = data.batches[idx];
87
+ const partB: DesignerBatch = {
88
+ ...clone(b), id: nextId(), name: `${b.name} (part 2)`, depends_on: [b.id],
89
+ };
90
+ data.batches[idx] = { ...b, name: `${b.name} (part 1)` };
91
+ data.batches.splice(idx + 1, 0, partB);
92
+ renumber(data);
93
+ sections.add("batches");
94
+ reply = `Split “${b.name}” into two batches. Review and Save latest edits.`;
95
+ return finish(data, sections, reply);
96
+ }
97
+ }
98
+
99
+ // 2) reduce / remove scope
100
+ if (/\b(reduce|smaller|simpler|remove|drop|trim)\b/.test(m)) {
101
+ if (data.batches.length > 3) {
102
+ const dropped = data.batches.pop()!;
103
+ sections.add("batches");
104
+ reply = `Reduced scope — removed “${dropped.name}”.`;
105
+ } else {
106
+ reply = "Already at the minimal batch set.";
107
+ }
108
+ return finish(data, sections, reply);
109
+ }
110
+
111
+ // 3) switch stack / framework (architecture + file plan)
112
+ const stackHit = detectStack(m);
113
+ if (stackHit) {
114
+ applyStack(data, stackHit);
115
+ sections.add("architecture");
116
+ sections.add("filePlan");
117
+ reply = `Switched to ${stackHit.label}. Updated architecture and file plan.`;
118
+ return finish(data, sections, reply);
119
+ }
120
+
121
+ // 4) add an architecture component
122
+ const comp = detectComponent(m);
123
+ if (comp) {
124
+ if (!data.architecture.some((n) => n.name === comp.name)) {
125
+ data.architecture.push(comp);
126
+ sections.add("architecture");
127
+ }
128
+ // also add a batch to build it
129
+ data.batches.push(makeBatch(nextId(), comp.batchName, comp.allowed, text));
130
+ renumber(data);
131
+ sections.add("batches");
132
+ reply = `Added ${comp.name} (architecture + a build batch).`;
133
+ return finish(data, sections, reply);
134
+ }
135
+
136
+ // 5) add a feature / level / boss → a scoped batch
137
+ if (/\b(add|include|introduce|boss|level|enemy|feature|analytic|metric|audit|auth|dashboard|observab)\b/.test(m)) {
138
+ const name = featureName(m);
139
+ data.batches.push(makeBatch(nextId(), name, ["src/**"], text));
140
+ renumber(data);
141
+ sections.add("batches");
142
+ reply = `Added ${data.batches[data.batches.length - 1].id} — ${name}. Review and Save latest edits.`;
143
+ return finish(data, sections, reply);
144
+ }
145
+
146
+ return finish(data, sections, reply);
147
+ }
148
+
149
+ // ---- helpers --------------------------------------------------------------------------
150
+ function finish(data: BlueprintDetailsData, sections: Set<BlueprintSection>, reply: string): ApplyResult {
151
+ if (sections.has("batches")) {
152
+ data.design_brain = `Client engine · ${data.batches.length} batches · ${data.candidate_id}.`;
153
+ sections.add("designBrain");
154
+ }
155
+ return { data, reply, updatedSections: [...sections] };
156
+ }
157
+
158
+ function makeBatch(id: string, name: string, allowed: string[], from: string): DesignerBatch {
159
+ return {
160
+ id, name, purpose: `Added from chat: ${from}`, tasks: [from],
161
+ allowed_files: allowed, depends_on: [], acceptance_criteria: ["feature works", "no runtime errors"],
162
+ validation_checks: ["unit tests"], must_not_change: [],
163
+ };
164
+ }
165
+
166
+ function renumber(data: BlueprintDetailsData): void {
167
+ data.batches.forEach((b, i) => { b.id = `batch-${String(i + 1).padStart(2, "0")}`; });
168
+ }
169
+
170
+ function pickBatchIndex(data: BlueprintDetailsData, m: string): number {
171
+ const num = m.match(/\b(\d{1,2})\b/);
172
+ if (num) {
173
+ const i = parseInt(num[1], 10) - 1;
174
+ if (i >= 0 && i < data.batches.length) return i;
175
+ }
176
+ return data.batches.length - 1;
177
+ }
178
+
179
+ function featureName(m: string): string {
180
+ if (/boss/.test(m)) return "Boss encounter";
181
+ if (/level/.test(m)) return "Extra level";
182
+ if (/analytic|metric/.test(m)) return "Analytics & metrics";
183
+ if (/audit/.test(m)) return "Audit logging";
184
+ if (/auth/.test(m)) return "Auth & access";
185
+ if (/dashboard/.test(m)) return "Admin dashboard";
186
+ if (/observab/.test(m)) return "Observability";
187
+ return "Refinement";
188
+ }
189
+
190
+ function detectStack(m: string): { label: string; nodes: ArchitectureNode[]; files: FilePlanItem[] } | null {
191
+ if (/next\.?js|nextjs/.test(m)) {
192
+ return { label: "Next.js", nodes: [{ name: "Web App", description: "Next.js", dependencies: [] }],
193
+ files: [{ path: "apps/web", description: "Next.js app" }] };
194
+ }
195
+ if (/fastapi/.test(m)) {
196
+ return { label: "FastAPI", nodes: [{ name: "API", description: "FastAPI", dependencies: ["Web App"] }],
197
+ files: [{ path: "services/api", description: "FastAPI service" }] };
198
+ }
199
+ if (/\bexpress\b|\bnode\b/.test(m) && /(use|switch|instead)/.test(m)) {
200
+ return { label: "Express", nodes: [{ name: "API", description: "Express", dependencies: ["Web App"] }],
201
+ files: [{ path: "services/api", description: "Express service" }] };
202
+ }
203
+ return null;
204
+ }
205
+
206
+ function applyStack(data: BlueprintDetailsData, hit: { nodes: ArchitectureNode[]; files: FilePlanItem[] }): void {
207
+ for (const node of hit.nodes) {
208
+ const i = data.architecture.findIndex((n) => n.name === node.name);
209
+ if (i >= 0) data.architecture[i] = node; else data.architecture.push(node);
210
+ }
211
+ for (const f of hit.files) {
212
+ const i = data.file_plan.findIndex((p) => p.path === f.path);
213
+ if (i >= 0) data.file_plan[i] = f; else data.file_plan.unshift(f);
214
+ }
215
+ }
216
+
217
+ function detectComponent(m: string): (ArchitectureNode & { batchName: string; allowed: string[] }) | null {
218
+ if (/worker|queue/.test(m)) {
219
+ return { name: "Worker / Queue", description: "Background jobs", dependencies: ["API"], batchName: "Worker & queue", allowed: ["services/worker/**"] };
220
+ }
221
+ if (/redis|cache/.test(m)) {
222
+ return { name: "Cache (Redis)", description: "Caching layer", dependencies: ["API"], batchName: "Caching layer", allowed: ["services/api/**"] };
223
+ }
224
+ if (/database|postgres|\bdb\b/.test(m) && /(add|use)/.test(m)) {
225
+ return { name: "Database", description: "Persistent storage", dependencies: ["API"], batchName: "Data model & migrations", allowed: ["db/**"] };
226
+ }
227
+ return null;
228
+ }
229
+
230
+ function clone<T>(v: T): T {
231
+ return JSON.parse(JSON.stringify(v)) as T;
232
+ }
233
+
234
+ // ---- domain knowledge -----------------------------------------------------------------
235
+ function inferDomain(idea: string): Domain {
236
+ const s = idea.toLowerCase();
237
+ if (/(game|platformer|phaser|arcade|canvas)/.test(s)) return "web-game";
238
+ if (/(api|fastapi|service|endpoint)/.test(s)) return "api";
239
+ return "web-app";
240
+ }
241
+
242
+ function stackFor(domain: Domain): string[] {
243
+ return domain === "web-game" ? ["Phaser 3", "TypeScript", "Vite"] : ["Next.js", "FastAPI", "PostgreSQL", "Docker"];
244
+ }
245
+
246
+ function architecture(domain: Domain): ArchitectureNode[] {
247
+ if (domain === "web-game") {
248
+ return [
249
+ { name: "Web Client (Phaser)", description: "Canvas game", dependencies: [] },
250
+ { name: "Asset pipeline", description: "Original art", dependencies: ["Web Client (Phaser)"] },
251
+ { name: "Level engine", description: "Data-driven levels", dependencies: ["Web Client (Phaser)"] },
252
+ { name: "CI / Pages", description: "Build + deploy", dependencies: [] },
253
+ ];
254
+ }
255
+ return [
256
+ { name: "Web App", description: "Frontend", dependencies: [] },
257
+ { name: "API", description: "Business logic", dependencies: ["Web App"] },
258
+ { name: "Database", description: "Storage", dependencies: ["API"] },
259
+ { name: "Worker / Queue", description: "Background jobs", dependencies: ["API"] },
260
+ { name: "Admin Dashboard", description: "Ops & analytics", dependencies: ["Web App", "API"] },
261
+ ];
262
+ }
263
+
264
+ function filePlan(domain: Domain): FilePlanItem[] {
265
+ if (domain === "web-game") {
266
+ return [
267
+ { path: "src/scenes", description: "Boot/Preload/Game/…" },
268
+ { path: "src/levels", description: "level data + builder" },
269
+ { path: "src/entities", description: "Player, enemies, Coin" },
270
+ { path: "public/assets", description: "original art" },
271
+ { path: "README.md", description: "overview" },
272
+ ];
273
+ }
274
+ return [
275
+ { path: "apps/web", description: "Web app and dashboard" },
276
+ { path: "services/api", description: "API and business logic" },
277
+ { path: "services/worker", description: "Background jobs" },
278
+ { path: "packages/shared", description: "Shared types" },
279
+ { path: "db", description: "Migrations" },
280
+ { path: "README.md", description: "overview" },
281
+ ];
282
+ }
283
+
284
+ function roadmap(domain: Domain): DesignerBatch[] {
285
+ const spec: [string, string[]][] =
286
+ domain === "web-game"
287
+ ? [
288
+ ["Game foundation", ["vite.config.ts", "src/main.ts"]],
289
+ ["Asset pipeline", ["scripts/gen_assets.py", "public/assets/**"]],
290
+ ["Tilemap world", ["src/levels/**"]],
291
+ ["Player & controls", ["src/entities/Player.ts"]],
292
+ ["Enemies & power-ups", ["src/entities/**"]],
293
+ ["HUD & gate", ["src/ui/HUD.ts"]],
294
+ ["Campaign & boss", ["src/levels/episodes.ts"]],
295
+ ["Polish & release", [".github/workflows/deploy.yml"]],
296
+ ]
297
+ : [
298
+ ["Foundation", ["apps/web/**", "services/api/**"]],
299
+ ["Integrations", ["services/api/**"]],
300
+ ["Workflow & logic", ["services/api/**"]],
301
+ ["Admin & analytics", ["apps/web/**"]],
302
+ ["Hardening & deploy", ["docker-compose.yml", ".github/**"]],
303
+ ["Validation & release", ["tests/**"]],
304
+ ];
305
+ return spec.map(([name, allowed], i): DesignerBatch => ({
306
+ id: `batch-${String(i + 1).padStart(2, "0")}`,
307
+ name,
308
+ purpose: `${name}.`,
309
+ tasks: [name],
310
+ allowed_files: allowed,
311
+ depends_on: i > 0 ? [`batch-${String(i).padStart(2, "0")}`] : [],
312
+ acceptance_criteria: ["builds", "feature works", "no runtime errors"],
313
+ validation_checks: ["lint", "typecheck", "tests"],
314
+ must_not_change: [],
315
+ }));
316
+ }
317
+
318
+ function extra(start: number, count: number): DesignerBatch[] {
319
+ const names = ["Observability", "Performance pass", "Security review", "Docs & onboarding", "Accessibility"];
320
+ const out: DesignerBatch[] = [];
321
+ for (let k = 0; k < count; k++) {
322
+ const i = start + k + 1;
323
+ out.push({
324
+ id: `batch-${String(i).padStart(2, "0")}`,
325
+ name: names[k % names.length],
326
+ purpose: "Production hardening.",
327
+ tasks: ["hardening"],
328
+ allowed_files: ["**"],
329
+ depends_on: [`batch-${String(i - 1).padStart(2, "0")}`],
330
+ acceptance_criteria: ["meets the production bar"],
331
+ validation_checks: ["tests"],
332
+ must_not_change: [],
333
+ });
334
+ }
335
+ return out;
336
+ }
web/src/lib/blueprint-persistence.ts ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // C4 — localStorage-first persistence for the Blueprint workspace.
2
+ //
3
+ // The workspace autosaves its single state object (which already embeds the chat history) to
4
+ // localStorage on every edit, keyed per idea + candidate. On mount the store rehydrates from it,
5
+ // so a reload restores the workspace with no server. All access is guarded + best-effort: a
6
+ // quota error or private-mode block degrades silently to in-memory only.
7
+
8
+ import type { BlueprintDetailsData } from "@/types/blueprint-state";
9
+
10
+ const PREFIX = "matrix-builder:blueprint:v1";
11
+
12
+ function hash(s: string): string {
13
+ let h = 5381;
14
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
15
+ return (h >>> 0).toString(36);
16
+ }
17
+
18
+ export function workspaceKey(idea: string, candidateId: string): string {
19
+ return `${PREFIX}:${hash(idea.trim())}:${candidateId}`;
20
+ }
21
+
22
+ export function saveWorkspace(key: string, data: BlueprintDetailsData): void {
23
+ if (typeof window === "undefined") return;
24
+ try {
25
+ window.localStorage.setItem(key, JSON.stringify(data));
26
+ } catch {
27
+ /* quota / private mode — keep working in memory */
28
+ }
29
+ }
30
+
31
+ export function loadWorkspace(key: string): BlueprintDetailsData | null {
32
+ if (typeof window === "undefined") return null;
33
+ try {
34
+ const raw = window.localStorage.getItem(key);
35
+ if (!raw) return null;
36
+ const data = JSON.parse(raw) as BlueprintDetailsData;
37
+ return data && Array.isArray(data.batches) ? data : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ export function clearWorkspace(key: string): void {
44
+ if (typeof window === "undefined") return;
45
+ try {
46
+ window.localStorage.removeItem(key);
47
+ } catch {
48
+ /* ignore */
49
+ }
50
+ }
web/src/lib/blueprint-store.ts ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // C0 — Blueprint workspace store: one live state object, mutated in the browser.
2
+ //
3
+ // `useBlueprintWorkspace` is the single source of truth for the Details page. It generates the
4
+ // initial blueprint and applies chat instructions entirely via `blueprint-engine` (no network),
5
+ // so the workspace is instant and works fully offline. Two optional enhancements layer on top:
6
+ // • C4 persistence — the state autosaves to localStorage and rehydrates on reload (no server).
7
+ // • C3 AI refine — one bounded, fail-open call may improve the explanation prose afterward.
8
+
9
+ import { useCallback, useEffect, useRef, useState } from "react";
10
+
11
+ import * as engine from "@/lib/blueprint-engine";
12
+ import { refineWithAI } from "@/lib/ai-refine";
13
+ import { loadWorkspace, saveWorkspace, workspaceKey } from "@/lib/blueprint-persistence";
14
+ import type { BlueprintDetailsData, ChatMessage } from "@/types/blueprint-state";
15
+
16
+ const now = () => new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
17
+
18
+ function messagesFrom(data: BlueprintDetailsData): ChatMessage[] {
19
+ return (data.chat_history ?? []).map((m, i) => ({ id: m.id || `h-${i}`, role: m.role, content: m.content }));
20
+ }
21
+
22
+ export interface BlueprintWorkspace {
23
+ data: BlueprintDetailsData;
24
+ messages: ChatMessage[];
25
+ dirty: boolean;
26
+ busy: boolean;
27
+ /** Apply a chat instruction locally (instant, no network) and patch the affected sections. */
28
+ applyInstruction: (text: string) => void;
29
+ /** Replace state with a refined version (optional AI/server enhancement). */
30
+ setData: (next: BlueprintDetailsData) => void;
31
+ markSaved: () => void;
32
+ }
33
+
34
+ export function useBlueprintWorkspace(candidateId: string, idea: string): BlueprintWorkspace {
35
+ const [data, setDataState] = useState<BlueprintDetailsData>(() => engine.generate(candidateId, idea));
36
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
37
+ const [dirty, setDirty] = useState(false);
38
+ const [busy, setBusy] = useState(false);
39
+
40
+ const dataRef = useRef(data); // latest state, so applyInstruction never reads a stale closure
41
+ dataRef.current = data;
42
+ const keyRef = useRef(workspaceKey(idea, candidateId));
43
+
44
+ // C4 — rehydrate from localStorage if present, else generate locally. No fetch on the render path.
45
+ useEffect(() => {
46
+ const key = workspaceKey(idea, candidateId);
47
+ keyRef.current = key;
48
+ const saved = loadWorkspace(key);
49
+ const next = saved ?? engine.generate(candidateId, idea);
50
+ dataRef.current = next;
51
+ setDataState(next);
52
+ setMessages(saved ? messagesFrom(saved) : []);
53
+ setDirty(false);
54
+ setBusy(false);
55
+ }, [candidateId, idea]);
56
+
57
+ const setData = useCallback((next: BlueprintDetailsData) => {
58
+ dataRef.current = next;
59
+ setDataState(next);
60
+ setMessages(messagesFrom(next));
61
+ saveWorkspace(keyRef.current, next);
62
+ }, []);
63
+
64
+ const applyInstruction = useCallback((text: string) => {
65
+ const clean = text.trim();
66
+ if (!clean || busy) return;
67
+ setMessages((m) => [...m, { id: `u-${Date.now()}`, role: "user", content: clean, time: now() }]);
68
+ setBusy(true);
69
+ // Defer a tick so the "thinking" affordance can paint — still zero network for the result.
70
+ window.setTimeout(() => {
71
+ const res = engine.apply(dataRef.current, clean);
72
+ const stamp = new Date().toISOString();
73
+ res.data.chat_history = [
74
+ ...(dataRef.current.chat_history ?? []),
75
+ { id: `uh-${Date.now()}`, role: "user", content: clean, timestamp: stamp },
76
+ { id: `bh-${Date.now()}`, role: "blueprint", content: res.reply, timestamp: stamp },
77
+ ];
78
+ dataRef.current = res.data;
79
+ setDataState(res.data);
80
+ setMessages((m) => [...m, { id: `b-${Date.now()}`, role: "blueprint", content: res.reply, time: now() }]);
81
+ setDirty(true);
82
+ setBusy(false);
83
+ saveWorkspace(keyRef.current, res.data); // C4 autosave
84
+
85
+ // C3 — optional, bounded, fail-open AI refinement of the explanation only.
86
+ void refineWithAI(res.data, clean)
87
+ .then((ref) => {
88
+ if (!ref) return;
89
+ const patched: BlueprintDetailsData = {
90
+ ...dataRef.current,
91
+ overview: ref.overview ?? dataRef.current.overview,
92
+ design_brain: ref.designBrain ?? dataRef.current.design_brain,
93
+ };
94
+ dataRef.current = patched;
95
+ setDataState(patched);
96
+ saveWorkspace(keyRef.current, patched);
97
+ if (ref.reply) setMessages((m) => [...m, { id: `r-${Date.now()}`, role: "blueprint", content: `✦ ${ref.reply}`, time: now() }]);
98
+ })
99
+ .catch(() => undefined);
100
+ }, 140);
101
+ }, [busy]);
102
+
103
+ const markSaved = useCallback(() => setDirty(false), []);
104
+
105
+ return { data, messages, dirty, busy, applyInstruction, setData, markSaved };
106
+ }
web/src/lib/capabilities.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // C5 — Capability probe: is a control plane actually reachable?
2
+ //
3
+ // The Blueprint workspace is fully client-side (C0–C4). The server is an OPTIONAL sync target:
4
+ // callers gate every `/api/v1/blueprints/*` request behind `getCapabilities()`, so when no backend
5
+ // is present (static hosting, offline) the app makes zero per-interaction requests and the UX is
6
+ // identical. The probe runs at most once (cached), is timeout-bounded, and never throws.
7
+
8
+ import { apiBaseUrl } from "@/lib/api-client";
9
+
10
+ export interface Capabilities {
11
+ /** A control plane responded to /health — server sync/enhancement is available. */
12
+ server: boolean;
13
+ }
14
+
15
+ let cache: Capabilities | null = null;
16
+ let inflight: Promise<Capabilities> | null = null;
17
+
18
+ export async function getCapabilities(opts: { timeoutMs?: number } = {}): Promise<Capabilities> {
19
+ if (cache) return cache;
20
+ if (inflight) return inflight;
21
+ if (typeof window === "undefined" || typeof fetch === "undefined") return { server: false };
22
+
23
+ inflight = (async () => {
24
+ let server = false;
25
+ try {
26
+ const ctrl = new AbortController();
27
+ const t = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 1500);
28
+ const res = await fetch(`${apiBaseUrl}/health`, { signal: ctrl.signal });
29
+ clearTimeout(t);
30
+ server = res.ok;
31
+ } catch {
32
+ server = false; // unreachable / blocked / refused — stay fully local
33
+ }
34
+ cache = { server };
35
+ inflight = null;
36
+ return cache;
37
+ })();
38
+ return inflight;
39
+ }
40
+
41
+ /** Test/runtime hook to forget the cached probe. */
42
+ export function resetCapabilities(): void {
43
+ cache = null;
44
+ inflight = null;
45
+ }
web/src/styles/matrix-builder.css CHANGED
@@ -1492,3 +1492,190 @@ button,select,a{-webkit-tap-highlight-color:transparent;touch-action:manipulatio
1492
  .mb-src-regen:hover{border-color:rgba(83,243,157,.45);color:#eafff4}
1493
  .mb-src-regen svg{color:var(--grn-bright)}
1494
  @media(max-width:640px){.mb-src-idea,.mb-src-edit{max-width:none;flex:1 1 100%;min-width:0}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1492
  .mb-src-regen:hover{border-color:rgba(83,243,157,.45);color:#eafff4}
1493
  .mb-src-regen svg{color:var(--grn-bright)}
1494
  @media(max-width:640px){.mb-src-idea,.mb-src-edit{max-width:none;flex:1 1 100%;min-width:0}}
1495
+
1496
+ /* ============================================================================
1497
+ Blueprint Details — a calm, premium dark planning room (see BlueprintDetails)
1498
+ ============================================================================ */
1499
+ /* Card secondary action: keep cards clean — Details is a quiet link, Choose stays primary. */
1500
+ .cand-actions{margin-top:6px;display:flex;align-items:center;gap:10px}
1501
+ .cand-actions .cand-choose{margin-top:0;flex:1}
1502
+ .cand-details{flex:none;background:transparent;border:1px solid var(--bd-light);color:var(--tx-muted);border-radius:11px;min-height:44px;padding:0 14px;font-weight:600;cursor:pointer;transition:.15s}
1503
+ .cand-details:hover{border-color:var(--grn);color:var(--grn-deep)}
1504
+
1505
+ /* Full-bleed dark surface */
1506
+ .bd-surface{background:linear-gradient(165deg,var(--bg-dark-2),var(--bg-dark) 60%);min-height:calc(100vh - 60px);color:#dff1e6}
1507
+ .bd-page{max-width:1180px}
1508
+ .bd-crumb{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12px;color:#7fb8a0;margin-bottom:14px}
1509
+ .bd-crumb .bd-step{color:var(--grn-bright)}
1510
+ .bd-crumb .bd-sep{opacity:.4}
1511
+ .bd-crumb .bd-cur{color:#eafff4}
1512
+ .bd-head{margin-bottom:22px}
1513
+ .bd-h1{font-family:var(--serif);font-size:clamp(28px,4vw,40px);line-height:1.08;color:#fff;margin:0 0 10px;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
1514
+ .bd-rec{font-family:var(--sans);font-size:12px;font-weight:700;color:var(--bg-dark);background:var(--grn-bright);border-radius:999px;padding:5px 11px}
1515
+ .bd-lede{color:#a9cbbb;font-size:16px;max-width:70ch;margin:0 0 14px}
1516
+ .bd-pills{display:flex;flex-wrap:wrap;gap:8px}
1517
+ .bd-page .mb-tag{background:rgba(255,255,255,.05);color:#cfe8db;border:1px solid rgba(120,200,160,.18)}
1518
+ .bd-page .mb-tag.n{background:rgba(255,255,255,.04);color:#9bb8aa}
1519
+ .bd-page .mb-tag.bd-ai{background:rgba(34,200,120,.16);color:var(--grn-bright);border-color:rgba(34,200,120,.35)}
1520
+ .bd-page .mb-tag.bd-lock{display:inline-flex;align-items:center;gap:5px}
1521
+
1522
+ .bd-grid{display:grid;grid-template-columns:1.55fr 1fr;gap:20px;align-items:start}
1523
+ .bd-col{display:flex;flex-direction:column;gap:18px}
1524
+ .bd-card{background:rgba(255,255,255,.035);border:1px solid rgba(120,200,160,.16);border-radius:18px;padding:20px 22px;backdrop-filter:blur(6px)}
1525
+ .bd-card-h{display:flex;align-items:center;gap:9px;margin-bottom:14px}
1526
+ .bd-card-h svg{color:var(--grn-bright)}
1527
+ .bd-card-h h3{font-size:16px;font-weight:700;color:#fff;margin:0}
1528
+ .bd-card-sub{margin-left:auto;font-family:var(--mono);font-size:11px;color:#8aae9d}
1529
+ .bd-p{color:#bcd8c9;font-size:14.5px;margin:0 0 14px}
1530
+ .bd-ov,.bd-brain-grid{display:flex;flex-direction:column;gap:12px}
1531
+ .bd-ov>div,.bd-brain-grid>div{display:flex;flex-direction:column;gap:3px}
1532
+ .bd-ov-k{font-family:var(--mono);font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:#7fb8a0}
1533
+ .bd-ov-v{color:#dbeee3;font-size:14px}
1534
+
1535
+ /* Batches — thin, elegant ordered rows */
1536
+ .bd-batches{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
1537
+ .bd-batch{display:flex;gap:14px;padding:14px;border-radius:13px;background:rgba(0,0,0,.18);border:1px solid rgba(120,200,160,.10);border-left:2px solid var(--grn)}
1538
+ .bd-batch-n{font-family:var(--mono);font-size:13px;font-weight:700;color:var(--grn-bright);flex:none;width:26px;text-align:center;padding-top:1px}
1539
+ .bd-batch-body{flex:1;min-width:0}
1540
+ .bd-batch-top{display:flex;align-items:center;justify-content:space-between;gap:10px}
1541
+ .bd-batch-name{font-weight:700;color:#eafff4;font-size:14.5px}
1542
+ .bd-batch-status{font-family:var(--mono);font-size:10.5px;color:#86a99a;border:1px solid rgba(120,200,160,.18);border-radius:999px;padding:2px 8px;flex:none}
1543
+ .bd-batch-purpose{color:#a9cbbb;font-size:13.5px;margin:5px 0 8px}
1544
+ .bd-batch-meta{display:flex;flex-wrap:wrap;gap:6px}
1545
+ .bd-batch-tag{font-family:var(--mono);font-size:10.5px;color:#8fb3a4;background:rgba(255,255,255,.04);border:1px solid rgba(120,200,160,.12);border-radius:7px;padding:3px 7px}
1546
+
1547
+ /* Design Brain */
1548
+ .bd-brain{border-color:rgba(138,99,210,.35);background:linear-gradient(180deg,rgba(138,99,210,.10),rgba(255,255,255,.03))}
1549
+ .bd-brain-ic{font-size:16px}
1550
+
1551
+ /* Talk to blueprint */
1552
+ .bd-chat-sub{margin:-6px 0 12px}
1553
+ .bd-save{margin-left:auto;width:34px;height:34px;border-radius:9px;display:grid;place-items:center;background:rgba(34,200,120,.14);border:1px solid rgba(34,200,120,.3);color:var(--grn-bright);cursor:pointer;transition:.15s}
1554
+ .bd-save:hover{background:rgba(34,200,120,.24)}
1555
+ .bd-chat-log{display:flex;flex-direction:column;gap:10px;margin-bottom:12px;max-height:230px;overflow-y:auto}
1556
+ .bd-msg{display:flex;flex-direction:column;gap:3px;padding:10px 12px;border-radius:11px;background:rgba(0,0,0,.2)}
1557
+ .bd-msg.user{background:rgba(34,200,120,.10)}
1558
+ .bd-msg-role{font-family:var(--mono);font-size:10.5px;color:#8aae9d}
1559
+ .bd-msg-text{color:#dbeee3;font-size:14px}
1560
+ .bd-composer{display:flex;align-items:center;gap:8px;background:rgba(0,0,0,.25);border:1px solid rgba(120,200,160,.18);border-radius:13px;padding:6px 6px 6px 14px}
1561
+ .bd-composer:focus-within{border-color:var(--grn)}
1562
+ .bd-composer-input{flex:1;background:transparent;border:0;outline:0;color:#eafff4;font-size:14.5px;min-height:36px}
1563
+ .bd-composer-input::placeholder{color:#7c9a8c}
1564
+ .bd-composer-send{flex:none;width:38px;height:38px;border-radius:10px;display:grid;place-items:center;background:var(--grn);color:#fff;border:0;cursor:pointer;transition:.15s}
1565
+ .bd-composer-send:disabled{opacity:.4;cursor:default}
1566
+
1567
+ /* Architecture nodes */
1568
+ .bd-arch{display:grid;grid-template-columns:1fr 1fr;gap:10px}
1569
+ .bd-arch-node{display:flex;flex-direction:column;gap:3px;padding:12px;border-radius:12px;background:rgba(0,0,0,.18);border:1px solid rgba(120,200,160,.16)}
1570
+ .bd-arch-ic{color:var(--grn-bright)}
1571
+ .bd-arch-label{font-weight:700;color:#eafff4;font-size:13.5px}
1572
+ .bd-arch-sub{font-family:var(--mono);font-size:10.5px;color:#88ab9c}
1573
+
1574
+ /* File plan */
1575
+ .bd-files{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:9px}
1576
+ .bd-file{display:flex;flex-direction:column;gap:2px}
1577
+ .bd-file-path{font-family:var(--mono);font-size:12.5px;color:var(--grn-bright)}
1578
+ .bd-file-why{font-size:13px;color:#a9cbbb}
1579
+
1580
+ /* Matrix rules */
1581
+ .bd-rules{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:11px}
1582
+ .bd-rules li{display:flex;align-items:center;gap:9px;color:#cfe8db;font-size:13.5px}
1583
+ .bd-rules svg{color:var(--grn-bright);flex:none}
1584
+
1585
+ /* Actions */
1586
+ .bd-actions{display:flex;flex-direction:column;gap:10px;margin-top:4px}
1587
+ .bd-back{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:46px;border-radius:12px;background:transparent;border:1px solid rgba(120,200,160,.22);color:#cfe8db;font-weight:600;cursor:pointer;transition:.15s}
1588
+ .bd-back:hover{border-color:var(--grn);color:#fff}
1589
+ .bd-choose{display:inline-flex;align-items:center;justify-content:center;gap:9px;min-height:52px;border-radius:13px;background:var(--grn);color:#fff;border:0;font-weight:700;font-size:15.5px;cursor:pointer;box-shadow:0 18px 40px -18px rgba(34,200,120,.8);transition:transform .15s,background .15s}
1590
+ .bd-choose:hover{transform:translateY(-1px);background:var(--grn-bright)}
1591
+
1592
+ @media (max-width:880px){
1593
+ .bd-grid{grid-template-columns:1fr}
1594
+ .bd-arch{grid-template-columns:1fr 1fr}
1595
+ }
1596
+
1597
+ /* Settings — Matrix Designer toggle badge */
1598
+ .md-badge{font-family:var(--mono);font-size:10px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--grn-deep);background:var(--grn-tint);border:1px solid rgba(18,138,82,.2);border-radius:999px;padding:2px 7px;margin-left:6px;vertical-align:middle}
1599
+
1600
+ /* Blueprint Details — batch-06/07 polish (unsaved badge, dependency graph, busy) */
1601
+ .bd-unsaved{margin-left:auto;font-family:var(--mono);font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#ffd27f;background:rgba(255,176,0,.14);border:1px solid rgba(255,176,0,.35);border-radius:999px;padding:2px 8px}
1602
+ .bd-chat .bd-card-h .bd-unsaved + .bd-save{margin-left:8px}
1603
+ .bd-save.on{background:rgba(34,200,120,.26);border-color:var(--grn)}
1604
+ .bd-depgraph{display:flex;flex-wrap:wrap;align-items:center;gap:4px}
1605
+ .bd-dep{display:inline-flex;align-items:center;gap:4px}
1606
+ .bd-dep-arrow{color:#6f9a88}
1607
+ .bd-dep-node{font-family:var(--mono);font-size:10.5px;color:var(--grn-bright);background:rgba(34,200,120,.10);border:1px solid rgba(34,200,160,.22);border-radius:6px;padding:1px 6px}
1608
+ .bd-typing{color:#8aae9d;font-style:italic}
1609
+
1610
+ /* ============================================================================
1611
+ Blueprint Details — image-exact redesign (sticky sidebar, calm Apple/Linear feel)
1612
+ ============================================================================ */
1613
+ .bd-grid{align-items:start}
1614
+ .bd-aside{position:sticky;top:76px;align-self:start;padding-right:2px}
1615
+
1616
+ /* Overview — narrative left, scope/quality/outcome right */
1617
+ .bd-ov-split{display:grid;grid-template-columns:1.5fr 1fr;gap:30px}
1618
+ .bd-ov-split .bd-p{margin:0}
1619
+ .bd-ov{display:flex;flex-direction:column;gap:16px}
1620
+ .bd-ov-scope{display:flex;flex-direction:column;gap:4px}
1621
+ .bd-ov-row{display:flex;gap:30px}
1622
+ .bd-ov-row>div{display:flex;flex-direction:column;gap:4px}
1623
+ @media (max-width:880px){ .bd-ov-split{grid-template-columns:1fr} }
1624
+
1625
+ /* Build batches — thin rows, description inline, status pinned right */
1626
+ .bd-batch{align-items:center}
1627
+ .bd-batch-body{display:flex;flex-direction:column;gap:8px}
1628
+ .bd-batch-top{display:flex;align-items:baseline;gap:12px}
1629
+ .bd-batch-desc{color:#9bbcad;font-size:13px;font-weight:400}
1630
+ .bd-batch-status{margin-left:auto;display:inline-flex;align-items:center;gap:7px;flex:none;font-family:var(--mono);font-size:11px;color:#8fb3a4}
1631
+ .bd-status-dot{width:13px;height:13px;border-radius:50%;border:1.5px solid rgba(143,179,164,.5);position:relative}
1632
+ .bd-status-dot::after{content:"";position:absolute;left:3px;right:3px;top:5.5px;height:1.5px;background:rgba(143,179,164,.7)}
1633
+
1634
+ /* Card "View details" link */
1635
+ .bd-card-link{margin-top:14px;display:inline-flex;align-items:center;gap:6px;background:none;border:0;color:var(--grn-bright);font-size:13px;font-weight:600;cursor:pointer;padding:0}
1636
+ .bd-card-link:hover{color:#fff}
1637
+
1638
+ /* Architecture nodes — stacked title row + subtitle */
1639
+ .bd-arch-node{gap:6px}
1640
+ .bd-arch-top{display:flex;align-items:center;gap:8px}
1641
+ .bd-arch-ic{color:var(--grn-bright);display:grid;place-items:center}
1642
+
1643
+ /* Compact sidebar Design Brain */
1644
+ .bd-brain-k{font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:#7fb8a0}
1645
+ .bd-brain-intent{margin:6px 0 0;color:#dbeee3;font-size:13.5px;line-height:1.5}
1646
+ .bd-brain-note{margin:10px 0 0;color:#9bbcad;font-size:12.5px}
1647
+
1648
+ /* Chat — tabs header */
1649
+ .bd-chat-head{display:flex;align-items:center;gap:12px;margin-bottom:14px}
1650
+ .bd-chat-tabs{display:flex;gap:18px}
1651
+ .bd-tab{background:none;border:0;color:#7c9a8c;font-weight:600;font-size:14px;cursor:pointer;padding:0 0 8px;border-bottom:2px solid transparent;margin-bottom:-1px}
1652
+ .bd-tab.on{color:#fff;border-bottom-color:var(--grn)}
1653
+ .bd-chat-empty{color:#7c9a8c;font-size:13.5px;margin:4px 0 0}
1654
+
1655
+ /* Chat — messages with avatars (no bubbles, Claude/ChatGPT style) */
1656
+ .bd-chat-log{display:flex;flex-direction:column;gap:18px;margin-bottom:16px;max-height:340px;overflow-y:auto;padding-right:4px}
1657
+ .bd-msg{display:flex;gap:12px;align-items:flex-start;background:none;padding:0;border-radius:0}
1658
+ .bd-msg.user{background:none}
1659
+ .bd-msg-av{flex:none;width:28px;height:28px;border-radius:50%;display:grid;place-items:center;font-family:var(--mono);font-size:11px;font-weight:700;color:#dbeee3;background:rgba(255,255,255,.08);border:1px solid rgba(120,200,160,.2)}
1660
+ .bd-msg.blueprint .bd-msg-av{color:var(--grn-bright)}
1661
+ .bd-msg-body{display:flex;flex-direction:column;gap:4px;min-width:0}
1662
+ .bd-msg-head{display:flex;align-items:baseline;gap:9px}
1663
+ .bd-msg-role{font-weight:700;color:#eafff4;font-size:13.5px}
1664
+ .bd-msg-time{font-family:var(--mono);font-size:10.5px;color:#7c9a8c}
1665
+ .bd-msg-text{color:#c8e2d6;font-size:14px;line-height:1.5}
1666
+
1667
+ /* Activity feed */
1668
+ .bd-activity{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:12px}
1669
+ .bd-activity-row{display:flex;align-items:center;gap:10px;color:#c8e2d6;font-size:13.5px}
1670
+ .bd-activity-dot{width:6px;height:6px;border-radius:50%;background:var(--grn-bright);flex:none}
1671
+
1672
+ /* Composer — + button, ⌘↵ hint, send */
1673
+ .bd-composer{padding:8px 8px 8px 8px;gap:6px}
1674
+ .bd-composer-add{flex:none;width:38px;height:38px;border-radius:10px;display:grid;place-items:center;background:rgba(255,255,255,.05);border:1px solid rgba(120,200,160,.18);color:#cfe8db;cursor:pointer;transition:.15s}
1675
+ .bd-composer-add:hover{border-color:var(--grn);color:#fff}
1676
+ .bd-composer-input{padding-left:6px}
1677
+ .bd-kbd{flex:none;font-family:var(--mono);font-size:11px;color:#7c9a8c;background:rgba(255,255,255,.05);border:1px solid rgba(120,200,160,.16);border-radius:7px;padding:3px 8px}
1678
+
1679
+ /* cascade fixes for the redesign */
1680
+ .bd-batch-status{border:0;padding:0;background:none;border-radius:0}
1681
+ .bd-msg-role{font-family:var(--sans)}
web/src/types/ai-settings.ts CHANGED
@@ -23,10 +23,18 @@ export type OllaBridgeSettings = {
23
  deviceId: string;
24
  };
25
 
 
 
 
 
 
 
 
26
  export type MatrixAISettings = {
27
  provider: AIProvider;
28
  mode: AIMode;
29
  ollabridge: OllaBridgeSettings;
 
30
  };
31
 
32
  export const AI_SETTINGS_STORAGE_KEY = "matrix-builder:ai-settings:v1";
@@ -45,4 +53,7 @@ export const DEFAULT_AI_SETTINGS: MatrixAISettings = {
45
  pairToken: "",
46
  deviceId: "",
47
  },
 
 
 
48
  };
 
23
  deviceId: string;
24
  };
25
 
26
+ // Optional enhancement: the "design brain". When enabled AND Internal AI (OllaBridge) assist is on,
27
+ // Matrix Builder uses the AI to design the full batch plan + the coder prompts for the next stages.
28
+ // Default OFF — it never changes the deterministic Matrix contract.
29
+ export type MatrixDesignerSettings = {
30
+ enabled: boolean;
31
+ };
32
+
33
  export type MatrixAISettings = {
34
  provider: AIProvider;
35
  mode: AIMode;
36
  ollabridge: OllaBridgeSettings;
37
+ matrixDesigner: MatrixDesignerSettings;
38
  };
39
 
40
  export const AI_SETTINGS_STORAGE_KEY = "matrix-builder:ai-settings:v1";
 
53
  pairToken: "",
54
  deviceId: "",
55
  },
56
+ matrixDesigner: {
57
+ enabled: false,
58
+ },
59
  };
web/src/types/blueprint-state.ts ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // C0 — Client state model for the Blueprint Details workspace.
2
+ //
3
+ // One state object is the single source of truth for the live workspace. It is produced and
4
+ // mutated entirely in the browser by `blueprint-engine.ts` (no network on the render path);
5
+ // the server and the LLM are optional progressive enhancements layered on top.
6
+
7
+ export interface ArchitectureNode {
8
+ name: string;
9
+ description: string;
10
+ dependencies: string[];
11
+ }
12
+ export interface FilePlanItem {
13
+ path: string;
14
+ description: string;
15
+ }
16
+ export interface DesignerBatch {
17
+ id: string;
18
+ name: string;
19
+ purpose: string;
20
+ tasks: string[];
21
+ allowed_files: string[];
22
+ depends_on: string[];
23
+ acceptance_criteria: string[];
24
+ validation_checks: string[];
25
+ must_not_change: string[];
26
+ }
27
+ export interface DesignerChatMessage {
28
+ id: string;
29
+ role: "user" | "blueprint";
30
+ content: string;
31
+ timestamp: string;
32
+ }
33
+ export interface BlueprintDetailsData {
34
+ candidate_id: string;
35
+ overview: string;
36
+ architecture: ArchitectureNode[];
37
+ batches: DesignerBatch[];
38
+ file_plan: FilePlanItem[];
39
+ matrix_rules: string[];
40
+ acceptance_criteria: string[];
41
+ validation_plan: string[];
42
+ risks: string[];
43
+ assumptions: string[];
44
+ design_brain?: string | null;
45
+ chat_history: DesignerChatMessage[];
46
+ }
47
+
48
+ export interface DesignerCandidate {
49
+ id: string;
50
+ tier: string;
51
+ title: string;
52
+ summary: string;
53
+ file_count: number;
54
+ difficulty: string;
55
+ estimate: string;
56
+ stack: string[];
57
+ recommended: boolean;
58
+ }
59
+
60
+ export interface ChatMessage {
61
+ id: string;
62
+ role: "user" | "blueprint";
63
+ content: string;
64
+ time?: string;
65
+ }
66
+
67
+ /** Which sections an engine `apply()` touched — lets the UI know what changed. */
68
+ export type BlueprintSection = "overview" | "architecture" | "filePlan" | "batches" | "designBrain";
69
+
70
+ /** The single source of truth for the live workspace (held by `useBlueprintWorkspace`). */
71
+ export interface BlueprintWorkspaceState {
72
+ candidateId: string;
73
+ idea: string;
74
+ data: BlueprintDetailsData;
75
+ messages: ChatMessage[];
76
+ dirty: boolean;
77
+ busy: boolean;
78
+ }
79
+
80
+ export const RMD_RULES = [
81
+ "RMD-101: AI coders are workers, not architects.",
82
+ "RMD-103: Control files are protected.",
83
+ "RMD-111: Acceptance criteria are law.",
84
+ ];