@atmem/architect 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +8 -2
  2. package/cli/runtime.mjs +3 -1
  3. package/dist/assets/{index-CQ2C1gKF.js → index-CWUupGC1.js} +20443 -20443
  4. package/dist/assets/index-DBMP6Pxk.css +1 -0
  5. package/dist/assets/{index.es-DTyqIdaO.js → index.es-H4csXVqE.js} +1 -1
  6. package/dist/assets/{visual-export-Ox0j5xj5.js → visual-export-D0f1PbRp.js} +3 -3
  7. package/dist/index.html +2 -2
  8. package/dist/licenses/memory-dependency-notices.txt +149 -2241
  9. package/dist/reference-example.zip +0 -0
  10. package/package.json +4 -2
  11. package/public/licenses/memory-dependency-notices.txt +149 -2241
  12. package/server/memory-requirements.lock +6 -6
  13. package/server/memory-requirements.txt +2 -2
  14. package/server/memory_service.py +3 -1
  15. package/server/memory_worker.py +4 -2
  16. package/server/pricing.py +55 -1
  17. package/server/request_uploads.py +65 -0
  18. package/server/terraform_routes.py +47 -1
  19. package/src/EngineeringPanel.jsx +14 -7
  20. package/src/FinalChecklist.jsx +11 -3
  21. package/src/ImplementationDelivery.jsx +10 -7
  22. package/src/JourneyViews.jsx +4 -4
  23. package/src/OpenAIVoicePanel.jsx +3 -2
  24. package/src/OutputsView.jsx +18 -9
  25. package/src/ProjectChecks.jsx +35 -21
  26. package/src/ReadyExample.jsx +16 -0
  27. package/src/StarPrompt.jsx +26 -0
  28. package/src/engineering.css +10 -0
  29. package/src/journey-workspace.css +5 -0
  30. package/src/large-request.mjs +25 -0
  31. package/src/main.jsx +64 -29
  32. package/src/onboarding.css +10 -0
  33. package/src/output-bundle.mjs +16 -4
  34. package/src/pricing.mjs +6 -4
  35. package/src/project-file.mjs +1 -1
  36. package/src/ready-example.mjs +5 -0
  37. package/src/reference-example/project.json +1440 -0
  38. package/src/reference-example/receipt.json +24 -0
  39. package/dist/assets/index-BTLHaa3J.css +0 -1
@@ -1,14 +1,14 @@
1
1
  # This file was autogenerated by uv via the following command:
2
2
  # uv pip compile --universal --python-version 3.13 server/memory-requirements.txt --generate-hashes --output-file server/memory-requirements.lock
3
- atflows==0.1.4 \
4
- --hash=sha256:2ada812488a9f22368975936fbafdcbe066a147bc88ae949213fef3724ce5261 \
5
- --hash=sha256:d277f0aba85cbc82229cefd2aeb87b81548e4cd1f0276f2c8b4c758ecec4b36b
3
+ atflows==0.1.8 \
4
+ --hash=sha256:9819783a615d8eb21ac70adc826627f707c4072d2a67f0efe2b173b06405477f \
5
+ --hash=sha256:fe07f07f2710e4603bd29e10b2a29cb3192f9b8e7426418da7604b88fe0e4cbc
6
6
  # via
7
7
  # -r server/memory-requirements.txt
8
8
  # atmem
9
- atmem==2.3.10 \
10
- --hash=sha256:e89f27ed6d9624cc4cb4e6d59286b48e53e430d2f2019737973488c3d43956cc \
11
- --hash=sha256:f2e152ea2e710759fcb145cef34a33af5e173e0ef883aa2d75a5c01692a57c36
9
+ atmem==2.3.14 \
10
+ --hash=sha256:37597be588817aa2dad921a683b39b6e6417eb5bdf3ec23256655acf13b19a03 \
11
+ --hash=sha256:e70c4baab2e41375aee6cc7d31066d2e5b9fca324d2e086649d2cabbee34708d
12
12
  # via -r server/memory-requirements.txt
13
13
  atmem-atbot==0.1.1 \
14
14
  --hash=sha256:0b47efeda0ca46c4f3bcaa6ce4adabf7b5094841d788f3cfee783d668a6a03d1 \
@@ -1,6 +1,6 @@
1
- atmem==2.3.10
1
+ atmem==2.3.14
2
2
  atmem-atbot==0.1.1
3
- atflows==0.1.4
3
+ atflows==0.1.8
4
4
  cryptography==48.0.1
5
5
  cffi==2.1.1
6
6
  pycparser==3.12
@@ -46,6 +46,7 @@ class MemoryService:
46
46
  self.worker = str(worker or setting('MEMORY_PYTHON') or ROOT / '.memory-venv' / ('Scripts/python.exe' if os.name == 'nt' else 'bin/python'))
47
47
  self.lock = threading.RLock()
48
48
  self.error = None
49
+ self.engine_version = None
49
50
  with self.db() as db:
50
51
  db.executescript('''
51
52
  CREATE TABLE IF NOT EXISTS identity (id TEXT PRIMARY KEY);
@@ -107,6 +108,7 @@ class MemoryService:
107
108
  if result.returncode or 'error' in payload:
108
109
  raise RuntimeError('AtMem rejected the operation (' + payload.get('error', 'worker failed') + ')')
109
110
  self.error = None
111
+ self.engine_version = payload.get('version')
110
112
  return payload['results']
111
113
 
112
114
  def enqueue(self, project, scope, key, text, event_id=None, source=''):
@@ -192,7 +194,7 @@ class MemoryService:
192
194
  def status(self, project):
193
195
  with self.db() as db:
194
196
  counts = dict(db.execute('SELECT state,count(*) FROM queue WHERE project=? GROUP BY state', (project,)).fetchall())
195
- return {'enabled': self.enabled(project), 'status': 'disabled' if not self.enabled(project) else 'unavailable' if self.error else 'ready', 'pending': counts.get('pending', 0), 'rejected': counts.get('rejected', 0), 'engine': 'AtMem 2.3.10 core SDK', 'policy': 'AtMem AI Architect single-user scope and opt-out policy', 'detail': self.error}
197
+ return {'enabled': self.enabled(project), 'status': 'disabled' if not self.enabled(project) else 'unavailable' if self.error else 'ready', 'pending': counts.get('pending', 0), 'rejected': counts.get('rejected', 0), 'engine': f'AtMem {self.engine_version} core SDK' if self.engine_version else 'AtMem core SDK (not checked)', 'policy': 'AtMem AI Architect single-user scope and opt-out policy', 'detail': self.error}
196
198
 
197
199
  def profile(self, project, field, value):
198
200
  if field not in {'explanation', 'experience', 'iac', 'preferences'} or not value.strip() or len(value) > 1000:
@@ -5,6 +5,8 @@ import sqlite3
5
5
  import base64
6
6
  import re
7
7
  from importlib.metadata import version
8
+ from pathlib import Path
9
+ EXPECTED_ATMEM_VERSION = re.search(r"^atmem==(\d+\.\d+\.\d+)$", (Path(__file__).parent / "memory-requirements.txt").read_text(), re.M).group(1)
8
10
  from atmem import Memory
9
11
 
10
12
 
@@ -62,8 +64,8 @@ def keyed_records(memory,subject,record_id,limit=10001):
62
64
 
63
65
 
64
66
  def run(payload):
65
- if version('atmem') != '2.3.10':
66
- raise RuntimeError('AtMem AI Architect requires the tested AtMem 2.3.10 runtime')
67
+ if version('atmem') != EXPECTED_ATMEM_VERSION:
68
+ raise RuntimeError(f'AtMem AI Architect requires the tested AtMem {EXPECTED_ATMEM_VERSION} runtime')
67
69
  memory = Memory(payload['database'], auto_vectors=False)
68
70
  receipts = sqlite3.connect(payload['database'] + '.receipts.sqlite')
69
71
  receipts.execute('CREATE TABLE IF NOT EXISTS receipts (id TEXT PRIMARY KEY, result TEXT)')
package/server/pricing.py CHANGED
@@ -61,6 +61,9 @@ def storage_quote(provider,region):
61
61
  if provider=='aws':return {**aws_s3(region),'provider':'aws','available':True}
62
62
  if provider=='azure':return dict(_azure_storage(region,int(time.time()//3600)))
63
63
  if provider=='gcp':
64
+ if region=='us-central1':
65
+ try:return dict(_google_storage_public(region,int(time.time()//3600)))
66
+ except (ValueError,OSError):pass
64
67
  import os
65
68
  key=os.getenv('GOOGLE_CLOUD_BILLING_API_KEY')
66
69
  if key and re.fullmatch(r'[a-z]+-[a-z]+\d',region):
@@ -80,7 +83,7 @@ def storage_quote(provider,region):
80
83
  price=rates[0]['unitPrice'];value=float(price.get('units',0))+float(price.get('nanos',0))/1e9
81
84
  candidates.append({'available':True,'provider':'gcp','price':value,'currency':'USD','unit':'GiB-month','region':region,'service':'Google Cloud Storage Standard / '+sku['skuId'],'source':'Google Cloud Billing Catalog','url':'https://cloud.google.com/skus/?currency=USD','date':datetime.now(timezone.utc).date().isoformat(),'coverage':'Standard storage only; operations, replication and transfer excluded'})
82
85
  if len(candidates)==1:return candidates[0]
83
- return {'available':False,'provider':'gcp','region':region,'sourceUrl':PROVIDER_PAGES[provider],'reason':'An exact regional storage SKU needs a configured Google Cloud Billing Catalog API key or a sourced quote. A generic/default-region web price will not be substituted.','nextQuestion':'Which Google Cloud region and storage class should we price?'}
86
+ return {'available':False,'provider':'gcp','region':region,'sourceUrl':PROVIDER_PAGES[provider],'reason':f'Storage pricing for {region or "the selected region"} could not be verified. Recalculate or enter a sourced rate.','nextQuestion':'Which Google Cloud region and storage class should we price?'}
84
87
  return {'available':False,'provider':'ibm','region':region,'sourceUrl':PROVIDER_PAGES[provider],'reason':'IBM pricing depends on region, storage class and plan. No verified regional quote is configured; no AWS or invented rate is substituted.','nextQuestion':'Which IBM region and storage class or existing IBM quote should we use?'}
85
88
 
86
89
 
@@ -188,3 +191,54 @@ def google_public_quote(service,region,planning_reference=False):
188
191
  if region!='us-central1' and not (planning_reference and (not region or re.search('select|unknown|tbd',region,re.I))):
189
192
  raise ValueError('This public quote supports Iowa only; the selected region is preserved.')
190
193
  return {**_google_public(service,int(time.time()//3600)),'planningReference':region!='us-central1'}
194
+
195
+
196
+ def parse_google_storage_public(html, region, url):
197
+ """Read an explicitly selected single-region public table, never a region list match."""
198
+ import hashlib
199
+ from html.parser import HTMLParser
200
+ if region != 'us-central1':
201
+ raise ValueError('The public default storage table verifies Iowa only.')
202
+ marker = 'id="data-storage"'
203
+ if marker not in html:
204
+ raise ValueError('Google data storage section is missing.')
205
+ section = html[html.index(marker):]
206
+ # Stop before dual-region rates. Region options alone do not verify selected pricing.
207
+ boundary = re.search(r'<(?:p|h[1-6])[^>]*>\s*(?:<b>)?Dual-region(?:</b>)?\s*</', section)
208
+ if not boundary:
209
+ raise ValueError('The single-region storage boundary is missing.')
210
+ section = section[:boundary.start()]
211
+ class Selected(HTMLParser):
212
+ def __init__(self):super().__init__();self.regions=[]
213
+ def handle_starttag(self,tag,attrs):
214
+ a=dict(attrs)
215
+ if a.get('role')=='option' and a.get('aria-selected')=='true':self.regions.append(a.get('data-value'))
216
+ selected=Selected();selected.feed(section)
217
+ if selected.regions != ['Iowa (us-central1)']:
218
+ raise ValueError('The selected public storage region is not Iowa.')
219
+ table = re.search(r'<table\b[^>]*>.*?</table>', section, re.S)
220
+ rows = _public_rows(table.group(0)) if table else []
221
+ if len(rows)!=2 or rows[0].count('Standard storage')!=1:
222
+ raise ValueError('The Standard storage table could not be verified.')
223
+ index=rows[0].index('Standard storage')
224
+ if index>=len(rows[1]):raise ValueError('The storage price is missing.')
225
+ cell=rows[1][index]
226
+ rate=re.fullmatch(r'\$([0-9]+(?:\.[0-9]+)?) / 1 gibibyte hour(?:, per 1 month / account)?',cell)
227
+ if not rate or not 0<float(rate.group(1))<1:raise ValueError('Unexpected Google storage rate or unit.')
228
+ hourly=float(rate.group(1))
229
+ return {'available':True,'provider':'gcp','price':hourly*730,'currency':'USD',
230
+ 'unit':'GiB-month','region':region,'service':'Cloud Storage Standard / single-region',
231
+ 'source':'Google Cloud public pricing','url':url,
232
+ 'date':datetime.now(timezone.utc).date().isoformat(),
233
+ 'sourceSha256':hashlib.sha256(section.encode()).hexdigest(),
234
+ 'hourlyPrice':hourly,'hoursPerMonth':730,
235
+ 'coverage':'Single-region Standard storage; 730-hour planning month. Operations, replication, transfer, taxes and discounts excluded.'}
236
+
237
+
238
+ @lru_cache(maxsize=32)
239
+ def _google_storage_public(region,hour):
240
+ url='https://cloud.google.com/storage/pricing'
241
+ with urllib.request.urlopen(urllib.request.Request(url,headers={'User-Agent':'AtMem Architect pricing'}),timeout=12) as response:
242
+ raw=response.read(8_000_001)
243
+ if len(raw)>8_000_000:raise ValueError('Google storage page exceeds its size bound.')
244
+ return parse_google_storage_public(raw.decode('utf-8'),region,url)
@@ -0,0 +1,65 @@
1
+ """Bounded, temporary, ordered request assembly. Uploaded content is never executed."""
2
+ import hashlib
3
+ import secrets
4
+ import tempfile
5
+ import threading
6
+ import time
7
+
8
+ MAX_BYTES = 64 * 1024 * 1024
9
+ CHUNK_BYTES = 128 * 1024
10
+
11
+
12
+ class RequestUploads:
13
+ def __init__(self):
14
+ self.rows = {}
15
+ self.lock = threading.Lock()
16
+
17
+ def _expire(self):
18
+ for key, row in list(self.rows.items()):
19
+ if time.monotonic() - row['updated'] > 1800:
20
+ self.rows.pop(key)['file'].close()
21
+
22
+ def start(self, size, digest):
23
+ with self.lock:
24
+ self._expire()
25
+ if not 0 < size <= MAX_BYTES:
26
+ raise ValueError('Request exceeds the 64 MiB upload limit.')
27
+ if len(self.rows) >= 8 or sum(r['size'] for r in self.rows.values()) + size > 2 * MAX_BYTES:
28
+ raise ValueError('Upload capacity is busy. Retry after current uploads finish.')
29
+ key = secrets.token_hex(24)
30
+ self.rows[key] = dict(file=tempfile.TemporaryFile(), size=size, digest=digest,
31
+ received=0, index=0, updated=time.monotonic())
32
+ return {'uploadId': key, 'chunkBytes': CHUNK_BYTES}
33
+
34
+ def append(self, key, index, content):
35
+ with self.lock:
36
+ self._expire()
37
+ row = self.rows.get(key)
38
+ if row is None:
39
+ raise ValueError('Upload expired. Start validation again.')
40
+ if index != row['index'] or not 0 < len(content) <= CHUNK_BYTES or row['received'] + len(content) > row['size']:
41
+ raise ValueError('Upload chunk is out of order or exceeds its declared size.')
42
+ row['file'].write(content)
43
+ row.update(received=row['received'] + len(content), index=index + 1, updated=time.monotonic())
44
+ return {'receivedBytes': row['received']}
45
+
46
+ def take(self, key):
47
+ with self.lock:
48
+ self._expire()
49
+ row = self.rows.pop(key, None)
50
+ if row is None:
51
+ raise ValueError('Upload expired. Start validation again.')
52
+ try:
53
+ row['file'].seek(0)
54
+ data = row['file'].read()
55
+ if len(data) != row['size'] or hashlib.sha256(data).hexdigest() != row['digest']:
56
+ raise ValueError('Upload is incomplete or its checksum changed. Retry validation.')
57
+ return data
58
+ finally:
59
+ row['file'].close()
60
+
61
+ def cancel(self, key):
62
+ with self.lock:
63
+ row = self.rows.pop(key, None)
64
+ if row:
65
+ row['file'].close()
@@ -11,13 +11,49 @@ from pathlib import Path
11
11
  import shutil
12
12
  import subprocess
13
13
  import tempfile
14
+ import base64
15
+ import binascii
14
16
  from fastapi import APIRouter, HTTPException
15
- from pydantic import BaseModel, ConfigDict
17
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
18
+ from .request_uploads import RequestUploads, MAX_BYTES
16
19
  from typing import Literal
17
20
 
18
21
  router = APIRouter()
19
22
  LOCK = asyncio.Lock()
20
23
  ROOT = Path(__file__).resolve().parent.parent
24
+ UPLOADS = RequestUploads()
25
+
26
+ class UploadStart(BaseModel):
27
+ model_config = ConfigDict(extra='forbid')
28
+ bytes: int = Field(gt=0, le=MAX_BYTES, strict=True)
29
+ sha256: str = Field(pattern=r'^[a-f0-9]{64}$')
30
+
31
+ class UploadId(BaseModel):
32
+ model_config = ConfigDict(extra='forbid')
33
+ uploadId: str = Field(pattern=r'^[a-f0-9]{48}$')
34
+
35
+ class UploadChunk(UploadId):
36
+ index: int = Field(ge=0, strict=True)
37
+ data: str = Field(min_length=1, max_length=174764)
38
+
39
+ @router.post('/api/terraform/uploads/start')
40
+ async def upload_start(payload: UploadStart):
41
+ try:
42
+ return UPLOADS.start(payload.bytes, payload.sha256)
43
+ except ValueError as error:
44
+ raise HTTPException(409, str(error))
45
+
46
+ @router.post('/api/terraform/uploads/chunk')
47
+ async def upload_chunk(payload: UploadChunk):
48
+ try:
49
+ return UPLOADS.append(payload.uploadId, payload.index, base64.b64decode(payload.data, validate=True))
50
+ except (ValueError, binascii.Error) as error:
51
+ raise HTTPException(422, str(error))
52
+
53
+ @router.post('/api/terraform/uploads/cancel')
54
+ async def upload_cancel(payload: UploadId):
55
+ UPLOADS.cancel(payload.uploadId)
56
+ return {'cancelled': True}
21
57
 
22
58
  class ValidationRequest(BaseModel):
23
59
  model_config = ConfigDict(extra='forbid')
@@ -116,3 +152,13 @@ async def validate_route(payload: ValidationRequest):
116
152
  raise HTTPException(504, 'Terraform validation timed out. No deployment was attempted.')
117
153
  except (ValueError, OSError):
118
154
  raise HTTPException(422, 'Could not validate the generated Terraform draft. Check the project and local Terraform installation.')
155
+
156
+ @router.post('/api/terraform/validate-upload')
157
+ async def validate_upload(payload: UploadId):
158
+ if LOCK.locked():
159
+ raise HTTPException(409, 'A Terraform validation is already running.')
160
+ try:
161
+ request = ValidationRequest.model_validate_json(UPLOADS.take(payload.uploadId))
162
+ except (ValueError, ValidationError):
163
+ raise HTTPException(422, 'The upload is incomplete, changed, or is not a valid project validation request.')
164
+ return await validate_route(request)
@@ -59,9 +59,14 @@ export function EngineeringPanel({projectId,session,connection,saveProject,onCon
59
59
  actionLock.current=true;setBusy(true);setError('');
60
60
  try{await operation();}catch(e){if(live.current)setError(e.message);}finally{actionLock.current=false;if(live.current)setBusy(false);}
61
61
  }
62
- useEffect(()=>{onBusyChange?.(busy);return()=>onBusyChange?.(false);},[busy,onBusyChange]);
62
+ useEffect(()=>{onBusyChange?.(busy||loading||['running','starting'].includes(job?.state));return()=>onBusyChange?.(false);},[busy,loading,job?.state,onBusyChange]);
63
63
  const exactSelection=connection?.provider==='codex'&&connection.model&&connection.model!=='default'&&connection.effort;
64
64
  const running=job?.state==='running',uncertain=job?.state==='starting';
65
+ const lastProgress=events.filter(event=>event.runId===job?.run&&['progress','message'].includes(event.kind)).at(-1);
66
+ const [lastReceived,setLastReceived]=useState(null),[now,setNow]=useState(Date.now());
67
+ useEffect(()=>{if(lastProgress){const time=Date.now();setLastReceived(time);setNow(time);}},[lastProgress?.sequence]);
68
+ useEffect(()=>{if(!running&&!uncertain)return;const timer=setInterval(()=>setNow(Date.now()),1000);return()=>clearInterval(timer);},[running,uncertain]);
69
+ const activity=lastProgress?.kind==='message'?'Preparing an update':({reasoning:'Thinking through the implementation',fileChange:'Updating project files',commandExecution:'Running a development command',mcpToolCall:'Using a development tool',webSearch:'Checking documentation'}[lastProgress?.text]||'Generating project files');
65
70
  const conversation=engineeringConversation(events);
66
71
  const hasDesign=hasArchitectureDesign(project),canSubmit=running?!!draft.trim():!!buildIntent(project,draft);
67
72
  const consumedBuild=useRef(null);
@@ -112,7 +117,9 @@ export function EngineeringPanel({projectId,session,connection,saveProject,onCon
112
117
  {restoreBlocked&&<button disabled={loading||busy} onClick={()=>{setError('');setRestoreAttempt(value=>value+1);}}>Retry loading earlier work</button>}
113
118
  {loading?<p role="status">Finding your project’s work…</p>:<>
114
119
  {job?<>
115
- <p className="generation-status" role="status">{(running||uncertain||busy)&&<BrainActivity size={24}/>} {running?'Working':uncertain?'Recovering acceptance of your last request':job.last_status?({completed:'Work completed',interrupted:'Work stopped',failed:'Work needs attention'}[job.last_status]||job.last_status):'Ready'} · {job.model} · {job.effort}</p>
120
+ {(running||uncertain)&&<div className="generation-progress" role="progressbar" aria-label="Project generation" aria-valuetext={activity}><span/></div>}
121
+ {(running||uncertain)&&<p className="generation-activity" role="status">{activity}{lastReceived&&<> · Last update received {Math.max(0,Math.floor((now-lastReceived)/1000))}s ago</>}</p>}
122
+ <p className="generation-status" role="status">{(running||uncertain||busy)&&<BrainActivity size={24}/>} {running?'Working':uncertain?'Recovering acceptance of your last request':finalization?.kind==='finalization_needs_attention'?'Files generated · Checks need attention':job.last_status?({completed:'Work completed',interrupted:'Work stopped',failed:'Work needs attention'}[job.last_status]||job.last_status):'Ready'} · {job.model} · {job.effort}</p>
116
123
 
117
124
  </>:<>
118
125
  <p>{exactSelection?`${connection.model} · ${connection.effort}`:'Choose a Codex model and reasoning level to start.'}</p>
@@ -133,14 +140,14 @@ export function EngineeringPanel({projectId,session,connection,saveProject,onCon
133
140
  {running&&<button disabled={busy} onClick={()=>act(async()=>{const next=await call('interrupt');if(live.current)setJob(next);})}>Stop work</button>}
134
141
  {!running&&!uncertain&&job.last_status&&<button disabled={busy} onClick={()=>act(async()=>{const captured=await call('capture');if(live.current)setArtifact(captured);})}>Refresh package snapshot</button>}
135
142
  </details>
136
- <details><summary>Engineering progress ({events.length})</summary><ol>{events.filter(event=>!['message','accepted','steering_submitted'].includes(event.kind)).map(event=><li key={event.sequence}>{event.kind}{event.text?` · ${event.text}`:''}</li>)}</ol></details>
143
+ <details><summary>Engineering progress (<span key={events.length} className={`engineering-progress-count${running||uncertain?' updated':''}`}>{events.length}</span>)</summary><ol>{events.filter(event=>!['message','accepted','steering_submitted'].includes(event.kind)).map(event=><li key={event.sequence}>{event.kind}{event.text?` · ${event.text}`:''}</li>)}</ol></details>
137
144
  </>}
138
- {artifact&&<div className="engineering-artifact"><p>Project files · {artifact.files.filter(file=>file.kind==='file').length} {artifact.files.filter(file=>file.kind==='file').length===1?'file':'files'} · {artifact.turnStatus}.</p>
139
- {artifact.runId!==job?.run&&<p>Earlier package · regenerate for current changes.</p>}
145
+ {artifact&&<div className="engineering-artifact"><p>{artifact.runId!==job?.run||running||uncertain?'Previous files':'Project files'} · {artifact.files.filter(file=>file.kind==='file').length} {artifact.files.filter(file=>file.kind==='file').length===1?'file':'files'}</p>
146
+ {artifact.runId!==job?.run&&<p>{running||uncertain?'Generating updated files…':'Generate updated files to include your changes.'}</p>}
140
147
  {onDelivery&&<button disabled={busy} onClick={onDelivery}>Review and download handoff</button>}
141
148
 
142
- <p className="muted small" role="status">{mappingStatus||'File links pending.'}</p>
143
- {mappingStatus==='File links missing from package.'&&<button disabled={busy||contextBusy} onClick={()=>submitIntent(null,'Add architecture-work-proposal.json binding the existing implementation files to the saved design blocks and requirements. Preserve the application and requirements. Run applicable local checks; retain failed or unexecuted checks explicitly.')}>Repair file links</button>}
149
+ {onWorkModelChange&&<p className="muted small" role="status">{running||uncertain?'File links update when generation finishes.':artifact.runId!==job?.run?'File links belong to the previous snapshot.':mappingStatus||'Connecting files to design blocks…'}</p>}
150
+ {!running&&!uncertain&&artifact.runId===job?.run&&mappingStatus==='File links missing from package.'&&<button disabled={busy||contextBusy} onClick={()=>submitIntent(null,'Add architecture-work-proposal.json binding the existing implementation files to the saved design blocks and requirements. Preserve the application and requirements. Run applicable local checks; retain failed or unexecuted checks explicitly.')}>Repair file links</button>}
144
151
 
145
152
 
146
153
  </div>}
@@ -1,15 +1,23 @@
1
- import React from'react';
1
+ import React,{useState,useRef} from'react';
2
+ import{BrainActivity}from'./BrainActivity.jsx';
2
3
  import{estimate}from'./estimate.mjs';
3
4
  import{cloudMapping}from'./journey.mjs';
4
5
  import{OPTION_NAMES}from'./pack.mjs';
5
6
  export function FinalChecklist({project,implementation,terraform,checks=[],onNavigate}){
7
+ const [opening,setOpening]=useState(null),[error,setError]=useState('');const navigating=useRef(false);
8
+ async function open(name,target){if(navigating.current||!onNavigate)return;navigating.current=true;setError('');setOpening(name);
9
+ try{await onNavigate(target);}catch(e){setError(e.message||'Could not open that section. Try again.');}finally{navigating.current=false;setOpening(null);}
10
+ }
6
11
  const cost=estimate(project),mapping=cloudMapping(project),requirements=!!(project.needs?.length||project.brief?.application);
7
12
  const selectedRegion=[...(project.decisions||[])].reverse().find(d=>d.subject==='cloud.region'&&d.status==='Accepted')?.decision;
8
13
  const country=[...(project.decisions||[])].reverse().find(d=>d.subject==='data.country'&&d.status==='Accepted')?.decision;
9
14
  const proposed=project.nodes.filter(n=>n.data.kind!=='note'&&n.data.origin!=='existing');
10
15
  const placements=proposed.map(n=>n.data.config?.region).filter(r=>typeof r==='string'&&r.trim()&&!/select|unknown|tbd/i.test(r));
11
16
  const region=selectedRegion||(proposed.length&&placements.length===proposed.length?[...new Set(placements)].join(', '):null);
12
- const rows=[['Requirements',requirements?'Recorded':'Not recorded',requirements],['Cloud',mapping.provider?(OPTION_NAMES[mapping.provider]||mapping.provider):'Not selected',!!mapping.provider],['Data country',country||'Not selected',!!country],['Cloud region',region||'Choose region',!!region],['Cost estimate',cost.complete?'Calculated':cost.lines.some(l=>l.cost!=null)?'Partial · charges missing':'Not calculated',cost.complete],['Implementation',implementation?'Included':'Not generated',!!implementation],['Application checks',checks.length?checks.every(c=>c.state==='finished'&&c.result?.status==='passed')?'Passed':'Needs attention':'Not run',checks.length>0&&checks.every(c=>c.state==='finished'&&c.result?.status==='passed')],['Terraform checks',terraform?.status==='passed'?'Passed':terraform?.status==='failed'?'Failed':terraform?.status==='incomplete'?'Incomplete':'Not run',terraform?.status==='passed']];
17
+ const hasEstimate=cost.expected!==null&&!cost.mixedCurrencies;
18
+ const excluded=new Set([...cost.lines.filter(line=>line.cost==null).map(line=>line.component),...cost.notCosted]).size;
19
+ const costReason=hasEstimate&&excluded?`${excluded} items excluded · View breakdown`:'';
20
+ const rows=[['Requirements',requirements?'Recorded':'Not recorded',requirements],['Cloud',mapping.provider?(OPTION_NAMES[mapping.provider]||mapping.provider):'Not selected',!!mapping.provider],['Data country',country||'Not selected',!!country],['Cloud region',region||'Choose region',!!region],['Cost estimate',hasEstimate?'Estimated':cost.mixedCurrencies?'Choose one currency':'Not estimated',hasEstimate],['Implementation',implementation?'Included':'Not generated',!!implementation],['Application checks',checks.length?checks.every(c=>c.state==='finished'&&c.result?.status==='passed')?'Passed':'Needs attention':'Not run',checks.length>0&&checks.every(c=>c.state==='finished'&&c.result?.status==='passed')],['Terraform checks',terraform?.status==='passed'?'Passed':terraform?.status==='failed'?'Failed':terraform?.status==='incomplete'?'Incomplete':'Not run',terraform?.status==='passed']];
13
21
  const targets=['requirements','location','location','location','cost','implementation','checks','checks'];
14
- return <section className="final-checklist" aria-label="Final checklist"><h3>Final checklist</h3><ul>{rows.map(([name,status,complete],index)=><li key={name}><button type="button" onClick={()=>onNavigate?.(targets[index])} aria-label={`${name}: ${status}. Open ${name}`}><span className={complete?'check-complete':'check-pending'} aria-label={complete?'Complete':'Needs attention'}>{complete?'✓':'!'}</span><span>{name}</span><strong>{status}</strong><span className="checklist-arrow" aria-hidden="true">→</span></button></li>)}</ul></section>;
22
+ return <section className="final-checklist" aria-label="Final checklist" aria-busy={!!opening}><h3>Final checklist</h3><ul>{rows.map(([name,status,complete],index)=><li key={name}><button type="button" onClick={()=>open(name,targets[index])} disabled={!!opening||!onNavigate} aria-label={`${name}: ${status}. Open ${name}`}><span className={complete?'check-complete':'check-pending'} aria-label={name==='Cost estimate'&&hasEstimate?'Estimate available':complete?'Complete':'Needs attention'}>{complete?'✓':'!'}</span><span className="checklist-label">{name}{name==='Cost estimate'&&costReason&&<small>{costReason}</small>}</span><strong>{opening===name?<>Opening… <BrainActivity/></>:status}</strong><span className="checklist-arrow" aria-hidden="true">→</span></button></li>)}</ul>{opening&&<p role="status" className="checklist-loading">Opening {opening}…</p>}{error&&<p role="alert">{error}</p>}</section>;
15
23
  }
@@ -2,12 +2,13 @@ import {unzipSync,zipSync,strToU8} from 'fflate';
2
2
  import {FinalChecklist} from './FinalChecklist.jsx';
3
3
  import {BrainActivity} from './BrainActivity.jsx';
4
4
  import {completeProjectZip} from './final-package.mjs';
5
- import React,{useEffect,useState} from 'react';
5
+ import React,{useEffect,useState,useRef} from 'react';
6
6
  import {engineeringCall,verifiedHandoffDownload} from './engineering-client.mjs';
7
7
  import {ArtifactChecks} from './ArtifactChecks.jsx';
8
8
 
9
9
  export function ImplementationDelivery({project,session,onBuild,onSave,contextBusy=false,designBundle,onDesignDownload,onDownloaded,checkReceipt,onExplore,onNavigate}){
10
10
  const [orcaResult,setOrcaResult]=useState(null);
11
+ const projectRef=useRef(project);projectRef.current=project;
11
12
  const [prepared,setPrepared]=useState(null),[preparing,setPreparing]=useState(false);
12
13
  const [loaded,setLoaded]=useState(null),[error,setError]=useState(''),[retry,setRetry]=useState(0),[busy,setBusy]=useState(false);
13
14
  const report=loaded?.project===project?loaded.report:null;
@@ -23,9 +24,8 @@ export function ImplementationDelivery({project,session,onBuild,onSave,contextBu
23
24
  let stopped=false;const controller=new AbortController();setPreparing(true);setError('');
24
25
  (async()=>{try{
25
26
  const captured=await verifiedHandoffDownload(session,{projectId:project.id,sessionId:report.session.id,artifactId:report.artifact.id,savedDesignSha256:report.savedDesignSha256},report.artifact,{signal:controller.signal});
26
- const blob=await completeProjectZip(captured,designBundle);
27
- const files=unzipSync(new Uint8Array(await blob.arrayBuffer()));
28
- if(!stopped){setPrepared({artifactId:report.artifact.id,designSha256:report.savedDesignSha256,blob});onExplore?.({project,files});}
27
+ const files={...unzipSync(new Uint8Array(await captured.arrayBuffer())),...Object.fromEntries(Object.entries(designBundle.files).map(([name,value])=>['design-documents/'+name,typeof value==='string'?strToU8(value):value]))};
28
+ if(!stopped){setPrepared({artifactId:report.artifact.id,designSha256:report.savedDesignSha256});onExplore?.({project,files});}
29
29
  }catch(e){if(!stopped&&e.name!=='AbortError')setError(e.message);}finally{if(!stopped)setPreparing(false);}})();
30
30
  return()=>{stopped=true;controller.abort();};
31
31
  },[report?.artifact?.id,report?.savedDesignSha256,designBundle,project,session.token,retry]);
@@ -34,11 +34,12 @@ export function ImplementationDelivery({project,session,onBuild,onSave,contextBu
34
34
  const zipBase64=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(reader.result.split(',')[1]);reader.onerror=()=>reject(Error('Could not prepare the project ZIP.'));reader.readAsDataURL(blob);});
35
35
  const result=await engineeringCall(session,'orca/open',{projectId:project.id,zipBase64});setOrcaResult(result);if(!result.opened)throw Error(result.message);
36
36
  }
37
+ async function downloadDesign(){if(busy||contextBusy)return;setBusy(true);setError('');try{await onDesignDownload?.(designBundle);}catch(error){setError(error.message);}finally{setBusy(false);}}
37
38
  async function openOrca(){
38
39
  if(busy||contextBusy)return;
39
40
  setOrcaResult(null);
40
41
  if(report?.artifact)return download(true);
41
- setBusy(true);setError('');try{if(!designBundle||designBundle.project!==project)throw Error('Project files are still preparing.');if(onSave)await onSave(project);await launchOrca(new Blob([zipSync(Object.fromEntries(Object.entries(designBundle.files).map(([name,value])=>[name,typeof value==='string'?strToU8(value):value])))]));}catch(error){setError(error.message);}finally{setBusy(false);}
42
+ setBusy(true);setError('');try{if(!designBundle||designBundle.project!==project)throw Error('Project files are still preparing.');if(onSave)await onSave(project);const full=await(designBundle.prepare?.()||designBundle);if(projectRef.current!==project)throw Error('Design changed while preparing files. Try again.');await launchOrca(new Blob([zipSync(Object.fromEntries(Object.entries(full.files).map(([name,value])=>[name,typeof value==='string'?strToU8(value):value])))]));}catch(error){setError(error.message);}finally{setBusy(false);}
42
43
  }
43
44
  async function download(openInOrca=false){
44
45
  if(!report?.artifact||busy||contextBusy)return;
@@ -59,7 +60,9 @@ export function ImplementationDelivery({project,session,onBuild,onSave,contextBu
59
60
  const payload={projectId:project.id,sessionId:current.session.id,artifactId:current.artifact.id};
60
61
  const captured=await verifiedHandoffDownload(session,{...payload,savedDesignSha256:current.savedDesignSha256},current.artifact);
61
62
  if(designBundle?.project!==project)throw Error('The design documents are refreshing. Try downloading again when preparation finishes.');
62
- const blob=await completeProjectZip(captured,designBundle);
63
+ const full=await(designBundle.prepare?.()||designBundle);
64
+ if(projectRef.current!==project)throw Error('Design changed while preparing files. Try again.');
65
+ const blob=await completeProjectZip(captured,full);
63
66
  if(openInOrca){await launchOrca(blob);return;}
64
67
  const url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;
65
68
  link.download=`architect-handoff-${report.artifact.id}.zip`;link.click();onDownloaded?.();setTimeout(()=>URL.revokeObjectURL(url),1000);
@@ -69,7 +72,7 @@ export function ImplementationDelivery({project,session,onBuild,onSave,contextBu
69
72
  <h3>Your project ZIP</h3>
70
73
  {!report&&!error&&<p role="status">Preparing package…</p>}
71
74
  {report&&!report.artifact&&<p>Design package · implementation not generated.</p>}
72
- {report&&!report.canPackage&&<button className="btn primary" disabled={!designBundle||designBundle.project!==project||contextBusy} onClick={()=>onDesignDownload?.(designBundle)}>Download project ZIP</button>}
75
+ {report&&!report.canPackage&&<button className="btn primary" disabled={busy||!designBundle||designBundle.project!==project||contextBusy} onClick={downloadDesign}>{busy?'Preparing ZIP…':'Download project ZIP'}</button>}
73
76
  {report?.artifact&&<>
74
77
  <p className="delivery-state">{report.relation==='matches_saved_design'?'✓ Current design':report.relation==='design_changed'?'! Design changed since generation':'! Earlier or unverified implementation'}</p>
75
78
 
@@ -92,7 +92,7 @@ export function ReviewView({ project, findings, onChange, onOpen, onAddRequireme
92
92
  );
93
93
  }
94
94
 
95
- export function EstimateView({ project, result, onChange, onEstimate, busy=false, pricing, onNext }) {
95
+ export function EstimateView({ project, result, onChange, onEstimate, busy=false, pricing, onNext, onChooseRegion }) {
96
96
  const e = result;
97
97
  return (
98
98
  <section className="doc" aria-label="Estimate">
@@ -104,7 +104,7 @@ export function EstimateView({ project, result, onChange, onEstimate, busy=false
104
104
  <div className="row">{PROJECT_SIZES.map(size=><button type="button" className={project.estimate?.projectSize===size.id?"btn primary":"btn"} key={size.id} aria-pressed={project.estimate?.projectSize===size.id} onClick={()=>{if(project.estimate?.projectSize===size.id)return;const next=setProjectSize(project,size.id);onChange(next);onEstimate?.(next);}}>{project.estimate?.projectSize===size.id&&<Check size={16} aria-hidden="true"/>}{size.label}</button>)}</div>
105
105
  <details className="cost-assumptions"><summary>Planning assumptions</summary><p className="muted small">Small: 10,000 requests / 10 GB; Medium: 100,000 / 100 GB; Large: 1 million / 1 TB; Extra Large: 10 million / 10 TB per month. Existing usage figures take priority. Model assumptions: 3,000 tokens per request and 730 endpoint hours; training usage needs separate agreement.</p></details>
106
106
  </fieldset>
107
- {project.estimate?.projectSize&&<p className="cost-selection-status" role="status">{PROJECT_SIZES.find(size=>size.id===project.estimate.projectSize)?.label} · {pricing?.busy?'Updating prices…':e.complete?'Estimate ready':e.lines.some(line=>line.cost!=null)?'Partial estimate':'Prices unavailable'}</p>}{pricing?.errors?.map((error,i)=><p className="warn-text" key={i}>{error.component&&`${error.component}: `}{error.reason} <button className="link" disabled={busy||pricing?.busy} onClick={()=>onEstimate?.(project)}>Retry pricing</button></p>)}
107
+ {project.estimate?.projectSize&&<p className="cost-selection-status" role="status">{PROJECT_SIZES.find(size=>size.id===project.estimate.projectSize)?.label} · {pricing?.busy?'Updating prices…':e.expected!==null?'Estimate ready':'Prices unavailable'}</p>}{pricing?.errors?.map((error,i)=><p className={e.expected!==null?"muted small":"warn-text"} key={i}>{error.component&&`${error.component}: `}{error.reason} <button className="link" disabled={busy||pricing?.busy} onClick={()=>error.action==='choose-region'?onChooseRegion?.():onEstimate?.(project)}>{error.action==='choose-region'?'Choose region':'Retry pricing'}</button></p>)}
108
108
 
109
109
  <div className="totals">
110
110
  <div>
@@ -119,8 +119,8 @@ export function EstimateView({ project, result, onChange, onEstimate, busy=false
119
119
  </div>
120
120
  <div>
121
121
  <small className="muted">Status</small>
122
- <span className={e.complete ? "ok-text" : "warn-text"}>
123
- {e.complete ? "All entered charges estimated" : e.mixedCurrencies ? "Currency needs review" : "Partial estimate · see exclusions"}
122
+ <span className={e.mixedCurrencies ? "warn-text" : e.expected!==null ? "" : "muted"}>
123
+ {e.mixedCurrencies ? "Choose one currency" : e.expected!==null ? "Planning estimate" : "Not estimated"}
124
124
  </span>
125
125
  </div>
126
126
  {e.budget && (
@@ -4,10 +4,11 @@ import {BrainActivity} from './BrainActivity.jsx';
4
4
  import React,{useEffect,useRef,useState} from 'react';
5
5
  import {OpenAIVoice} from './openai-voice.mjs';
6
6
 
7
- export function OpenAIVoicePanel({session,connection,transportRef,onTranscript,onStart,onConnections}){
7
+ export function OpenAIVoicePanel({hideTrigger=false,openRequest=0,session,connection,transportRef,onTranscript,onStart,onConnections}){
8
8
  const [status,setStatus]=useState(null),[phase,setPhase]=useState('off'),[error,setError]=useState(''),[key,setKey]=useState(''),[saving,setSaving]=useState(false),[automatic,setAutomatic]=useState(false),[readReplies,setReadReplies]=useState(true),[open,setOpen]=useState(false);
9
9
  const props=useRef({onTranscript,onStart,automatic});props.current={onTranscript,onStart,automatic};
10
10
  const engine=useRef(null),live=useRef(true);
11
+ useEffect(()=>{if(openRequest>0)setOpen(true);},[openRequest]);
11
12
  const active=!['off','error'].includes(phase);
12
13
  async function refresh(){
13
14
  const response=await fetch('/api/voice/openai/status');
@@ -39,7 +40,7 @@ export function OpenAIVoicePanel({session,connection,transportRef,onTranscript,o
39
40
  }
40
41
  const caption={off:'Off',connecting:'Connecting OpenAI audio…',listening:automatic?'Listening · auto-send':'Listening',speaking:'Speaking',error:'Stopped · retry'}[phase];
41
42
  return <>
42
- <div className="voice-status"><button type="button" className="icon" aria-label="Voice settings" title="Voice settings" onClick={()=>setOpen(true)}><Volume2 size={18}/></button>{active&&<button type="button" onClick={()=>engine.current?.stop({preservePending:true})}>Stop voice</button>}{active&&error.includes('playback')&&<button type="button" onClick={()=>void engine.current?.resumeAudio()}>Enable voice playback</button>}{(active||error)&&<span role="status">{error||caption}</span>}</div>
43
+ <div className="voice-status">{!hideTrigger&&<button type="button" className="icon" aria-label="Voice settings" title="Voice settings" onClick={()=>setOpen(true)}><Volume2 size={18}/></button>}{active&&<button type="button" onClick={()=>engine.current?.stop({preservePending:true})}>Stop voice</button>}{active&&error.includes('playback')&&<button type="button" onClick={()=>void engine.current?.resumeAudio()}>Enable voice playback</button>}{(active||error)&&<span role="status">{error||caption}</span>}</div>
43
44
  {open&&<div className="backdrop" onMouseDown={event=>{if(event.target===event.currentTarget)setOpen(false)}}><section className="modal" role="dialog" aria-modal="true" aria-label="OpenAI voice" onKeyDown={event=>{if(event.key==='Escape'){event.stopPropagation();setOpen(false)}}}>
44
45
  <h2>Voice</h2><button type="button" className="icon modal-close" aria-label="Close voice settings" onClick={()=>setOpen(false)}><X size={18}/></button>
45
46
  <section aria-label="OpenAI voice settings">
@@ -1,8 +1,10 @@
1
+ import {ReadyExampleChecklist} from './ReadyExample.jsx';
2
+ import {isReadyExample} from './ready-example.mjs';
1
3
  import {FileTree} from './FileTree.jsx';
2
4
  import {strFromU8} from 'fflate';
3
5
  import {CodePreview} from './CodePreview.jsx';
4
6
  import {BrainActivity} from "./BrainActivity.jsx";
5
- import React,{useEffect,useState} from 'react';
7
+ import React,{useEffect,useState,useRef,useMemo} from 'react';
6
8
  import {EmulationPanel} from './EmulationPanel.jsx';
7
9
  import {outputBundle} from './output-bundle.mjs';
8
10
  import {ImplementationDelivery} from './ImplementationDelivery.jsx';
@@ -37,24 +39,31 @@ function Preview({name,content}){
37
39
  }
38
40
  export function OutputsView({project,run,environment,session,onDownload,onBack,emulation,onDiscuss,discussionBusy,onBuild,onSave,onDownloaded,onReview,checkReceipt,onNavigate}){
39
41
  const [exploration,setExploration]=useState(null);
42
+ const projectRef=useRef(project);projectRef.current=project;
43
+ const [packing,setPacking]=useState(false);
40
44
  const [bundle,setBundle]=useState(null),[error,setError]=useState(''),[loading,setLoading]=useState(true),[retry,setRetry]=useState(0),[selected,setSelected]=useState('spec-kit/implementation-spec.md');
41
45
  useEffect(()=>{
42
46
  let active=true;setLoading(true);setError('');
43
- outputBundle(project,run,environment,checkReceipt).then(value=>{if(active){setBundle(value);setLoading(false);}}).catch(e=>{if(active){setError(e.message);setLoading(false);}});
47
+ outputBundle(project,run,environment,checkReceipt,{deferVisuals:true}).then(value=>{if(active){setBundle(value);setLoading(false);}}).catch(e=>{if(active){setError(e.message);setLoading(false);}});
44
48
  return ()=>{active=false;};
45
49
  },[project,run,environment,retry,checkReceipt]);
46
50
  const readiness=bundle?.files['spec-kit/readiness.json']?JSON.parse(bundle.files['spec-kit/readiness.json']):null;
47
- const fileValues=exploration?.project===project?exploration.files:bundle?.files||{};
48
- const selectedPath=fileValues[selected]!==undefined?selected:fileValues['START_HERE.md']?'START_HERE.md':Object.keys(fileValues).find(name=>name.endsWith('implementation-spec.md'))||Object.keys(fileValues)[0];
49
- const raw=fileValues[selectedPath],content=typeof raw==='string'?raw:raw&&/\.(?:md|txt|json|py|js|mjs|tf|sh|yaml|yml|toml|html|css)$/.test(selectedPath)?strFromU8(raw):raw;
51
+ const artifactMode=exploration?.project===project,prefix=artifactMode?'design-documents/':'';
52
+ const fileValues=useMemo(()=>artifactMode?{...exploration.files,...Object.fromEntries(Object.entries(bundle?.files||{}).map(([name,value])=>[prefix+name,value]))}:bundle?.files||{},[artifactMode,exploration,bundle,prefix]);
53
+ const treeFiles=useMemo(()=>({...Object.fromEntries((bundle?.visualPaths||[]).map(path=>[prefix+path,null])),...fileValues}),[bundle,fileValues,prefix]);
54
+ async function selectFile(name){setSelected(name);if(treeFiles[name]===null){try{const full=await bundle.prepare();if(projectRef.current===project)setBundle(full);}catch(error){if(projectRef.current===project)setError(error.message);}}}
55
+ async function downloadDesign(){if(packing)return;setPacking(true);setError('');try{const full=await bundle.prepare();if(projectRef.current!==project)return;setBundle(full);await onDownload(full);}catch(error){if(projectRef.current===project)setError(error.message);}finally{setPacking(false);}}
56
+ const selectedPath=Object.hasOwn(treeFiles,selected)?selected:fileValues['START_HERE.md']?'START_HERE.md':Object.keys(fileValues).find(name=>name.endsWith('implementation-spec.md'))||Object.keys(fileValues)[0];
57
+ const raw=treeFiles[selectedPath],content=typeof raw==='string'?raw:raw&&/\.(?:md|txt|json|py|js|mjs|tf|sh|yaml|yml|toml|html|css)$/.test(selectedPath)?strFromU8(raw):raw;
50
58
 
51
59
  return <section className="doc outputs-view" aria-label="Outputs">
52
60
  <header className="doc-head"><h2>Delivery</h2></header>
53
61
  <button className="link" onClick={onReview||onBack}>Back to Review</button>
54
- {session?.engineeringEnabled&&<ImplementationDelivery key={project.id} project={project} session={session} onBuild={onBuild} onSave={onSave} contextBusy={discussionBusy} designBundle={loading?null:bundle} onDesignDownload={onDownload} onDownloaded={onDownloaded} checkReceipt={checkReceipt} onExplore={setExploration} onNavigate={onNavigate}/>}
62
+ {isReadyExample(project)&&<ReadyExampleChecklist/>}
63
+ {session?.engineeringEnabled&&(isReadyExample(project)?<details><summary>Checks for your edited project</summary><ImplementationDelivery key={project.id} project={project} session={session} onBuild={onBuild} onSave={onSave} contextBusy={discussionBusy} designBundle={loading?null:bundle} onDesignDownload={onDownload} onDownloaded={onDownloaded} checkReceipt={checkReceipt} onExplore={setExploration} onNavigate={onNavigate}/></details>:<ImplementationDelivery key={project.id} project={project} session={session} onBuild={onBuild} onSave={onSave} contextBusy={discussionBusy} designBundle={loading?null:bundle} onDesignDownload={onDownload} onDownloaded={onDownloaded} checkReceipt={checkReceipt} onExplore={setExploration} onNavigate={onNavigate}/>)}
55
64
  <section aria-label="Project file explorer"><h3>Explore files</h3>
56
65
 
57
- {!session?.engineeringEnabled&&<><p>Design package · no implementation generated.</p><button className="btn" disabled={!bundle||loading||!!error} onClick={()=>onDownload(bundle)}>Download project ZIP</button></>}
66
+ {!session?.engineeringEnabled&&<><p>Design package · no implementation generated.</p><button className="btn" disabled={!bundle||loading||!!error||packing} onClick={downloadDesign}>{packing?'Preparing ZIP…':'Download project ZIP'}</button></>}
58
67
 
59
68
  {loading&&<p role="status"><BrainActivity/> Preparing the current outputs…</p>}
60
69
  {error&&<div role="alert"><p>{error}</p><button className="btn" onClick={()=>setRetry(n=>n+1)}>Retry preparing outputs</button></div>}
@@ -62,8 +71,8 @@ export function OutputsView({project,run,environment,session,onDownload,onBack,e
62
71
  <p className="muted small">r{bundle.manifest.designRevision} · {Object.keys(bundle.files).length} design files</p>
63
72
  {readiness&&<details><summary>Remaining work</summary><aside aria-label="Handoff readiness"><strong>Engineering handoff — decisions and implementation remain</strong><p>{readiness.counts.priorityDecisions} priority decisions · {readiness.counts.generatedFoundations} generated foundations / {readiness.counts.components} logical components</p><p>Next: {readiness.nextAction}</p><button className="link" onClick={()=>setSelected('spec-kit/readiness.md')}>Review handoff gaps</button></aside></details>}
64
73
  <div className="outputs-browser" data-snapshot-id={bundle.manifest.snapshotId}>
65
- <FileTree files={fileValues} selected={selectedPath} onSelect={setSelected}/>
66
- <div className="output-preview" aria-label="Output preview"><h3>{selectedPath}</h3>{content!==undefined&&(typeof content==='string'||/\.(?:pdf|png|svg)$/.test(selectedPath)?<Preview key={selectedPath} name={selectedPath} content={content}/>:<p>{content.byteLength} bytes</p>)}</div>
74
+ <FileTree files={treeFiles} selected={selectedPath} onSelect={selectFile}/>
75
+ <div className="output-preview" aria-label="Output preview"><h3>{selectedPath}</h3>{raw===null?<p role="status"><BrainActivity/> Preparing preview…</p>:content!==undefined&&(typeof content==='string'||/\.(?:pdf|png|svg)$/.test(selectedPath)?<Preview key={selectedPath} name={selectedPath} content={content}/>:<p>{content.byteLength} bytes</p>)}</div>
67
76
  </div>
68
77
  </>}
69
78
  </section>