devflow-toolkit 0.3.0__py3-none-any.whl

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 (63) hide show
  1. devflow/__init__.py +0 -0
  2. devflow/adapters/__init__.py +47 -0
  3. devflow/adapters/agents.py +49 -0
  4. devflow/adapters/base.py +81 -0
  5. devflow/adapters/claude_code.py +120 -0
  6. devflow/adapters/codex.py +111 -0
  7. devflow/adapters/detector.py +118 -0
  8. devflow/adapters/instructions.py +263 -0
  9. devflow/adapters/kiro.py +211 -0
  10. devflow/adapters/opencode.py +237 -0
  11. devflow/adapters/registry.py +41 -0
  12. devflow/cli.py +39 -0
  13. devflow/commands/__init__.py +6 -0
  14. devflow/commands/approve.py +98 -0
  15. devflow/commands/harness.py +98 -0
  16. devflow/commands/init.py +544 -0
  17. devflow/commands/spec.py +514 -0
  18. devflow/commands/status.py +156 -0
  19. devflow/core/__init__.py +15 -0
  20. devflow/core/architecture.py +469 -0
  21. devflow/core/artifacts/__init__.py +29 -0
  22. devflow/core/artifacts/errors.py +19 -0
  23. devflow/core/artifacts/models.py +82 -0
  24. devflow/core/artifacts/store.py +130 -0
  25. devflow/core/clock.py +67 -0
  26. devflow/core/config.py +706 -0
  27. devflow/core/constitution.py +166 -0
  28. devflow/core/discovery.py +1304 -0
  29. devflow/core/ports.py +159 -0
  30. devflow/core/profile_manager.py +311 -0
  31. devflow/core/project.py +533 -0
  32. devflow/core/spec_workflow.py +932 -0
  33. devflow/core/specification.py +2443 -0
  34. devflow/core/state/__init__.py +75 -0
  35. devflow/core/state/errors.py +39 -0
  36. devflow/core/state/events.py +725 -0
  37. devflow/core/state/locator.py +116 -0
  38. devflow/core/state/lock.py +69 -0
  39. devflow/core/state/manager.py +960 -0
  40. devflow/core/state/models.py +164 -0
  41. devflow/core/state/transitions.py +114 -0
  42. devflow/models/__init__.py +6 -0
  43. devflow/models/profile.py +63 -0
  44. devflow/models/project.py +47 -0
  45. devflow/profiles/ai-rag/constitution/ai-rag-standards.md +49 -0
  46. devflow/profiles/ai-rag/profile.yaml +28 -0
  47. devflow/profiles/base/constitution/architecture.md +58 -0
  48. devflow/profiles/base/constitution/code-standards.md +85 -0
  49. devflow/profiles/base/constitution/constitution.md +85 -0
  50. devflow/profiles/base/profile.yaml +15 -0
  51. devflow/profiles/django-backend/constitution/django-standards.md +64 -0
  52. devflow/profiles/django-backend/profile.yaml +24 -0
  53. devflow/profiles/fastapi-backend/constitution/fastapi-standards.md +59 -0
  54. devflow/profiles/fastapi-backend/profile.yaml +23 -0
  55. devflow/prompts/__init__.py +200 -0
  56. devflow/prompts/discovery.md +119 -0
  57. devflow/prompts/spec.greenfield.md +149 -0
  58. devflow/prompts/spec.md +116 -0
  59. devflow_toolkit-0.3.0.dist-info/METADATA +628 -0
  60. devflow_toolkit-0.3.0.dist-info/RECORD +63 -0
  61. devflow_toolkit-0.3.0.dist-info/WHEEL +4 -0
  62. devflow_toolkit-0.3.0.dist-info/entry_points.txt +2 -0
  63. devflow_toolkit-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,2443 @@
1
+ """Analyze a change request and generate a structured specification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sys
7
+ import unicodedata
8
+ from dataclasses import dataclass
9
+ from datetime import date
10
+ from pathlib import Path
11
+ from types import TracebackType
12
+
13
+ try:
14
+ import fcntl
15
+ except ImportError: # pragma: no cover - DevFlow targets POSIX systems.
16
+ fcntl = None # type: ignore[assignment]
17
+
18
+ from devflow.core.architecture import ArchitectureManifest
19
+ from devflow.core.artifacts.store import ArtifactStore
20
+ from devflow.core.config import DevFlowConfig
21
+ from devflow.core.constitution import Constitution
22
+ from devflow.core.discovery import DiscoveryDocument
23
+ from devflow.core.project import (
24
+ canonicalize_dependency,
25
+ iter_project_files,
26
+ )
27
+ from devflow.models.profile import Profile
28
+ from devflow.models.project import (
29
+ PROJECT_MODES,
30
+ Project,
31
+ canonical_project_mode,
32
+ )
33
+
34
+ ANALYSIS_EXTENSIONS = {
35
+ ".py",
36
+ ".js",
37
+ ".jsx",
38
+ ".ts",
39
+ ".tsx",
40
+ ".vue",
41
+ ".svelte",
42
+ ".go",
43
+ ".rs",
44
+ ".php",
45
+ ".rb",
46
+ ".java",
47
+ ".kt",
48
+ ".html",
49
+ ".css",
50
+ ".scss",
51
+ ".sql",
52
+ ".md",
53
+ ".yaml",
54
+ ".yml",
55
+ ".toml",
56
+ ".json",
57
+ }
58
+
59
+ IGNORED_FILENAMES = {
60
+ "package-lock.json",
61
+ "poetry.lock",
62
+ "pdm.lock",
63
+ "yarn.lock",
64
+ "pnpm-lock.yaml",
65
+ "uv.lock",
66
+ }
67
+
68
+ MAX_FILE_BYTES = 256_000
69
+ MAX_ANALYSIS_FILES = 3000
70
+ MAX_REFERENCES = 25
71
+
72
+ STOPWORDS = {
73
+ "a",
74
+ "about",
75
+ "after",
76
+ "again",
77
+ "against",
78
+ "al",
79
+ "algo",
80
+ "algun",
81
+ "alguna",
82
+ "alguno",
83
+ "algunos",
84
+ "and",
85
+ "ante",
86
+ "antes",
87
+ "are",
88
+ "as",
89
+ "at",
90
+ "be",
91
+ "been",
92
+ "before",
93
+ "but",
94
+ "by",
95
+ "can",
96
+ "como",
97
+ "con",
98
+ "contra",
99
+ "could",
100
+ "cual",
101
+ "cuando",
102
+ "de",
103
+ "del",
104
+ "desde",
105
+ "do",
106
+ "does",
107
+ "donde",
108
+ "during",
109
+ "e",
110
+ "el",
111
+ "ella",
112
+ "ellas",
113
+ "ellos",
114
+ "en",
115
+ "entre",
116
+ "era",
117
+ "es",
118
+ "esa",
119
+ "ese",
120
+ "eso",
121
+ "esta",
122
+ "este",
123
+ "esto",
124
+ "estos",
125
+ "estoy",
126
+ "for",
127
+ "from",
128
+ "fue",
129
+ "ha",
130
+ "had",
131
+ "hace",
132
+ "hacia",
133
+ "han",
134
+ "has",
135
+ "have",
136
+ "hay",
137
+ "hasta",
138
+ "how",
139
+ "i",
140
+ "if",
141
+ "in",
142
+ "into",
143
+ "is",
144
+ "it",
145
+ "its",
146
+ "just",
147
+ "la",
148
+ "las",
149
+ "le",
150
+ "les",
151
+ "lo",
152
+ "los",
153
+ "mas",
154
+ "me",
155
+ "mi",
156
+ "mis",
157
+ "more",
158
+ "most",
159
+ "mucho",
160
+ "muy",
161
+ "my",
162
+ "nada",
163
+ "need",
164
+ "ni",
165
+ "no",
166
+ "nos",
167
+ "nosotros",
168
+ "not",
169
+ "o",
170
+ "of",
171
+ "on",
172
+ "or",
173
+ "os",
174
+ "otra",
175
+ "otro",
176
+ "our",
177
+ "para",
178
+ "pero",
179
+ "poco",
180
+ "por",
181
+ "porque",
182
+ "que",
183
+ "quien",
184
+ "se",
185
+ "sea",
186
+ "ser",
187
+ "should",
188
+ "si",
189
+ "sin",
190
+ "so",
191
+ "sobre",
192
+ "some",
193
+ "son",
194
+ "su",
195
+ "such",
196
+ "sus",
197
+ "tambien",
198
+ "tanto",
199
+ "te",
200
+ "than",
201
+ "that",
202
+ "the",
203
+ "their",
204
+ "them",
205
+ "then",
206
+ "there",
207
+ "these",
208
+ "they",
209
+ "this",
210
+ "tiene",
211
+ "to",
212
+ "todo",
213
+ "todos",
214
+ "too",
215
+ "tu",
216
+ "tus",
217
+ "un",
218
+ "una",
219
+ "uno",
220
+ "unos",
221
+ "use",
222
+ "using",
223
+ "was",
224
+ "we",
225
+ "were",
226
+ "what",
227
+ "when",
228
+ "where",
229
+ "which",
230
+ "who",
231
+ "will",
232
+ "with",
233
+ "would",
234
+ "y",
235
+ "ya",
236
+ "you",
237
+ "your",
238
+ }
239
+
240
+ TERM_EXPANSIONS = {
241
+ "autenticacion": ("authentication", "auth", "login"),
242
+ "authentication": ("auth", "login"),
243
+ "autorizacion": ("authorization", "permission"),
244
+ "authorization": ("permission",),
245
+ "base": ("database",),
246
+ "cliente": ("customer", "client"),
247
+ "configuracion": ("configuration", "config", "settings"),
248
+ "configuration": ("config", "settings"),
249
+ "contrasena": ("password",),
250
+ "cuenta": ("account",),
251
+ "datos": ("data", "database"),
252
+ "database": ("db",),
253
+ "documentacion": ("documentation", "docs"),
254
+ "documentation": ("docs", "readme"),
255
+ "factura": ("invoice",),
256
+ "inicio": ("login", "session"),
257
+ "invoice": ("billing",),
258
+ "login": ("auth", "session"),
259
+ "pago": ("payment",),
260
+ "payment": ("billing",),
261
+ "pedido": ("order",),
262
+ "permiso": ("permission",),
263
+ "permission": ("authorization",),
264
+ "producto": ("product",),
265
+ "session": ("auth",),
266
+ "sesion": ("session", "login"),
267
+ "usuario": ("user", "account"),
268
+ "usuarios": ("user", "account"),
269
+ }
270
+
271
+ CATEGORY_RULES = (
272
+ ("Tests", {"test", "tests", "spec", "specs"}),
273
+ (
274
+ "Database migrations",
275
+ {"migration", "migrations"},
276
+ ),
277
+ (
278
+ "Models and data",
279
+ {"model", "models", "entity", "entities"},
280
+ ),
281
+ (
282
+ "API and views",
283
+ {
284
+ "view",
285
+ "views",
286
+ "api",
287
+ "apis",
288
+ "router",
289
+ "routers",
290
+ "route",
291
+ "routes",
292
+ "controller",
293
+ "controllers",
294
+ "endpoint",
295
+ "endpoints",
296
+ },
297
+ ),
298
+ (
299
+ "Serializers and validation",
300
+ {"serializer", "serializers", "validator", "validators"},
301
+ ),
302
+ (
303
+ "Services and domain",
304
+ {"service", "services", "domain", "usecase", "usecases"},
305
+ ),
306
+ (
307
+ "Data access",
308
+ {"repository", "repositories", "query", "queries", "dao"},
309
+ ),
310
+ (
311
+ "Frontend",
312
+ {"component", "components", "page", "pages", "ui"},
313
+ ),
314
+ (
315
+ "Configuration",
316
+ {"config", "settings", "env"},
317
+ ),
318
+ (
319
+ "Documentation",
320
+ {"doc", "docs", "readme"},
321
+ ),
322
+ )
323
+
324
+ VAGUE_TERMS = {
325
+ "better",
326
+ "improve",
327
+ "improvement",
328
+ "mejor",
329
+ "mejora",
330
+ "mejorar",
331
+ "optimize",
332
+ "optimizacion",
333
+ "optimizar",
334
+ "optimization",
335
+ "robust",
336
+ "robusto",
337
+ "scalable",
338
+ "escalable",
339
+ "support",
340
+ "soportar",
341
+ "soporte",
342
+ }
343
+
344
+ DATA_TERMS = {
345
+ "campo",
346
+ "column",
347
+ "data",
348
+ "database",
349
+ "datos",
350
+ "db",
351
+ "field",
352
+ "migration",
353
+ "migracion",
354
+ "model",
355
+ "modelo",
356
+ "persistencia",
357
+ "schema",
358
+ "tabla",
359
+ "table",
360
+ }
361
+
362
+ API_TERMS = {
363
+ "api",
364
+ "endpoint",
365
+ "route",
366
+ "router",
367
+ "view",
368
+ "vista",
369
+ "webservice",
370
+ }
371
+
372
+ PYTHON_IMPORT = re.compile(
373
+ r"^\s*(?:from\s+([A-Za-z_][\w.]*)|import\s+([A-Za-z_][\w.]*))",
374
+ re.MULTILINE,
375
+ )
376
+ JS_IMPORT = re.compile(
377
+ r"""(?:from\s+|require\()\s*['"]([^'"]+)['"]""",
378
+ )
379
+ ANSI_ESCAPE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
380
+
381
+
382
+ def strip_ansi(value: str) -> str:
383
+ """Remove terminal color sequences from captured harness output."""
384
+ return ANSI_ESCAPE.sub("", value)
385
+
386
+
387
+ @dataclass(frozen=True)
388
+ class CodeReference:
389
+ """A project file relevant to the request."""
390
+
391
+ path: str
392
+ score: int
393
+ matches: tuple[str, ...]
394
+ lines: tuple[int, ...]
395
+ category: str
396
+
397
+
398
+ @dataclass(frozen=True)
399
+ class ImpactAnalysis:
400
+ """Static analysis of the code related to a request."""
401
+
402
+ keywords: tuple[str, ...]
403
+ references: tuple[CodeReference, ...]
404
+ categories: dict[str, tuple[CodeReference, ...]]
405
+ scanned_files: int
406
+ external_dependencies: tuple[str, ...]
407
+ has_tests: bool
408
+
409
+
410
+ @dataclass(frozen=True)
411
+ class GeneratedSpec:
412
+ """A generated specification document."""
413
+
414
+ spec_id: str
415
+ title: str
416
+ path: Path
417
+ open_questions: tuple[str, ...]
418
+ source: str = "static"
419
+
420
+
421
+ @dataclass(frozen=True)
422
+ class PreparedSpec:
423
+ """Analysis and content prepared before an agent writes the spec."""
424
+
425
+ spec_id: str
426
+ title: str
427
+ description: str
428
+ root: Path
429
+ directory: Path
430
+ path: Path
431
+ analysis: ImpactAnalysis
432
+ questions: tuple[str, ...]
433
+ fallback_content: str
434
+ context_content: str
435
+ project_mode: str = "existing"
436
+ architecture_digest: str | None = None
437
+ architecture: ArchitectureManifest | None = None
438
+ discovery: DiscoveryDocument | None = None
439
+ discovery_digest: str | None = None
440
+
441
+
442
+ def normalize_word(value: str) -> str:
443
+ """Lowercase and remove accents from a word."""
444
+ normalized = unicodedata.normalize("NFKD", value)
445
+ without_accents = "".join(
446
+ character
447
+ for character in normalized
448
+ if not unicodedata.combining(character)
449
+ )
450
+ return without_accents.lower()
451
+
452
+
453
+ def extract_keywords(description: str) -> list[str]:
454
+ """Extract meaningful request keywords."""
455
+ normalized = normalize_word(description)
456
+ words = re.findall(r"[a-z0-9_]{3,}", normalized)
457
+ keywords: list[str] = []
458
+ for word in words:
459
+ if word in STOPWORDS or word.isdigit():
460
+ continue
461
+ if word not in keywords:
462
+ keywords.append(word)
463
+ return keywords
464
+
465
+
466
+ def request_terms(keywords: list[str]) -> list[str]:
467
+ """Expand keywords with common domain synonyms."""
468
+ terms: list[str] = []
469
+ for keyword in keywords:
470
+ candidates = (keyword, *TERM_EXPANSIONS.get(keyword, ()))
471
+ for candidate in candidates:
472
+ if candidate and candidate not in terms:
473
+ terms.append(candidate)
474
+ return terms
475
+
476
+
477
+ def classifier_category(path: str) -> str:
478
+ """Return the architectural area inferred from a file path."""
479
+ parts = {
480
+ normalize_word(part)
481
+ for part in re.split(r"[\\/._-]+", path.lower())
482
+ if part
483
+ }
484
+
485
+ if path.lower().endswith((".md", ".rst")):
486
+ return "Documentation"
487
+
488
+ for category, markers in CATEGORY_RULES:
489
+ if parts & markers:
490
+ return category
491
+ return "Other"
492
+
493
+
494
+ def slugify(value: str) -> str:
495
+ """Create a filesystem-safe slug from a request."""
496
+ normalized = normalize_word(value)
497
+ slug = re.sub(r"[^a-z0-9]+", "-", normalized).strip("-")
498
+ if len(slug) > 60:
499
+ shortened = slug[:60]
500
+ boundary = shortened.rfind("-")
501
+ if boundary >= 30:
502
+ shortened = shortened[:boundary]
503
+ slug = shortened.rstrip("-")
504
+ return slug or "spec"
505
+
506
+
507
+ def request_title(description: str) -> str:
508
+ """Create a short human-readable title from the request."""
509
+ single_line = " ".join(description.split())
510
+ if len(single_line) <= 80:
511
+ return single_line.rstrip(".")
512
+ return single_line[:77].rstrip() + "..."
513
+
514
+
515
+ def split_requirements(description: str) -> list[str]:
516
+ """Split a request into independent requirement clauses."""
517
+ normalized = " ".join(description.split())
518
+ parts = re.split(
519
+ r"(?:[.;!?]|\n|,\s+|\s+(?:and|y|plus)\s+)",
520
+ normalized,
521
+ )
522
+ clauses = [
523
+ part.strip(" ,;.!?")
524
+ for part in parts
525
+ if len(part.strip(" ,;.!?")) >= 4
526
+ ]
527
+ return clauses or [normalized]
528
+
529
+
530
+ def _file_has_test_path(path: str) -> bool:
531
+ lowered = path.lower()
532
+ parts = {
533
+ normalize_word(part)
534
+ for part in re.split(r"[\\/._-]+", lowered)
535
+ if part
536
+ }
537
+ return (
538
+ "test" in parts
539
+ or "tests" in parts
540
+ or Path(lowered).name.startswith("test_")
541
+ or Path(lowered).name.endswith("_test.py")
542
+ )
543
+
544
+
545
+ class RequestAnalyzer:
546
+ """Inspect the codebase for evidence related to a request."""
547
+
548
+ def analyze(self, description: str, project: Project) -> ImpactAnalysis:
549
+ """Return the files and areas likely affected by a request."""
550
+ keywords = extract_keywords(description)
551
+ terms = request_terms(keywords)
552
+
553
+ references: list[CodeReference] = []
554
+ scanned = 0
555
+ has_tests = any(
556
+ _file_has_test_path(path) for path in project.source_files
557
+ )
558
+
559
+ for path in iter_project_files(
560
+ project.root,
561
+ extensions=ANALYSIS_EXTENSIONS,
562
+ max_files=MAX_ANALYSIS_FILES,
563
+ ):
564
+ if path.name in IGNORED_FILENAMES:
565
+ continue
566
+ try:
567
+ if path.stat().st_size > MAX_FILE_BYTES:
568
+ continue
569
+ content = path.read_text(encoding="utf-8")
570
+ except (OSError, UnicodeDecodeError):
571
+ continue
572
+
573
+ scanned += 1
574
+ relative = path.relative_to(project.root).as_posix()
575
+ lowered_path = relative.lower()
576
+ lowered_content = content.lower()
577
+ lowered_lines = lowered_content.splitlines()
578
+
579
+ score = 0
580
+ matches: list[str] = []
581
+ line_numbers: set[int] = set()
582
+
583
+ for term in terms:
584
+ path_matches = lowered_path.count(term)
585
+ content_matches = lowered_content.count(term)
586
+ if not path_matches and not content_matches:
587
+ continue
588
+
589
+ matches.append(term)
590
+ score += path_matches * 4 + min(content_matches, 10)
591
+ found = 0
592
+ for index, line in enumerate(lowered_lines, start=1):
593
+ if term in line:
594
+ line_numbers.add(index)
595
+ found += 1
596
+ if found >= 2:
597
+ break
598
+
599
+ if score <= 0:
600
+ continue
601
+
602
+ references.append(
603
+ CodeReference(
604
+ path=relative,
605
+ score=score,
606
+ matches=tuple(matches),
607
+ lines=tuple(sorted(line_numbers)[:5]),
608
+ category=classifier_category(relative),
609
+ )
610
+ )
611
+
612
+ references.sort(key=lambda item: (-item.score, item.path))
613
+ selected = tuple(references[:MAX_REFERENCES])
614
+
615
+ categories: dict[str, list[CodeReference]] = {}
616
+ for reference in selected:
617
+ categories.setdefault(reference.category, []).append(reference)
618
+
619
+ external = self._external_dependencies(selected, project)
620
+
621
+ return ImpactAnalysis(
622
+ keywords=tuple(keywords),
623
+ references=selected,
624
+ categories={
625
+ name: tuple(items) for name, items in categories.items()
626
+ },
627
+ scanned_files=scanned,
628
+ external_dependencies=tuple(external),
629
+ has_tests=has_tests,
630
+ )
631
+
632
+ def _external_dependencies(
633
+ self,
634
+ references: tuple[CodeReference, ...],
635
+ project: Project,
636
+ ) -> list[str]:
637
+ external: list[str] = []
638
+ known = project.dependencies
639
+ stdlib = set(sys.stdlib_module_names)
640
+
641
+ for reference in references:
642
+ path = project.root / reference.path
643
+ try:
644
+ content = path.read_text(encoding="utf-8")
645
+ except (OSError, UnicodeDecodeError):
646
+ continue
647
+
648
+ suffix = path.suffix
649
+ if suffix == ".py":
650
+ for first, second in PYTHON_IMPORT.findall(content):
651
+ module = (first or second).split(".", maxsplit=1)[0]
652
+ canonical = canonicalize_dependency(module)
653
+ if module in stdlib:
654
+ continue
655
+ if canonical in known and canonical not in external:
656
+ external.append(canonical)
657
+ elif suffix in {".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte"}:
658
+ for value in JS_IMPORT.findall(content):
659
+ if value.startswith("."):
660
+ continue
661
+ if value.startswith("@") and "/" in value:
662
+ package = "/".join(value.split("/")[:2])
663
+ else:
664
+ package = value.split("/", maxsplit=1)[0]
665
+ canonical = canonicalize_dependency(package)
666
+ if canonical in known and canonical not in external:
667
+ external.append(canonical)
668
+
669
+ return sorted(external)
670
+
671
+
672
+ class _SpecsLock:
673
+ """Exclusive lock used while assigning a specification directory."""
674
+
675
+ def __init__(self, path: Path) -> None:
676
+ self.path = path
677
+ self._handle: object | None = None
678
+
679
+ def __enter__(self) -> _SpecsLock:
680
+ self.path.parent.mkdir(parents=True, exist_ok=True)
681
+ if fcntl is None: # pragma: no cover
682
+ return self
683
+ self._handle = self.path.open("a+", encoding="utf-8")
684
+ fcntl.flock(self._handle.fileno(), fcntl.LOCK_EX)
685
+ return self
686
+
687
+ def __exit__(
688
+ self,
689
+ exc_type: type[BaseException] | None,
690
+ exc: BaseException | None,
691
+ traceback: TracebackType | None,
692
+ ) -> None:
693
+ handle = self._handle
694
+ if handle is None:
695
+ return
696
+ self._handle = None
697
+ try:
698
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
699
+ finally:
700
+ handle.close()
701
+
702
+
703
+ class SpecificationManager:
704
+ """Generate spec.md documents from a request and project context."""
705
+
706
+ def __init__(self) -> None:
707
+ self.analyzer = RequestAnalyzer()
708
+
709
+ def create(
710
+ self,
711
+ description: str,
712
+ config: DevFlowConfig,
713
+ project: Project,
714
+ constitution: Constitution,
715
+ profiles: list[Profile],
716
+ ) -> GeneratedSpec:
717
+ """Analyze a request and write a deterministic specification."""
718
+ prepared = self.prepare(
719
+ description=description,
720
+ config=config,
721
+ project=project,
722
+ constitution=constitution,
723
+ profiles=profiles,
724
+ )
725
+ return self.write(prepared)
726
+
727
+ def prepare(
728
+ self,
729
+ description: str,
730
+ config: DevFlowConfig,
731
+ project: Project,
732
+ constitution: Constitution,
733
+ profiles: list[Profile],
734
+ *,
735
+ project_mode: str = "existing",
736
+ architecture: ArchitectureManifest | None = None,
737
+ discovery: DiscoveryDocument | None = None,
738
+ discovery_digest: str | None = None,
739
+ directory: Path | None = None,
740
+ spec_id: str | None = None,
741
+ ) -> PreparedSpec:
742
+ """Analyze a request without writing the specification file."""
743
+ mode = canonical_project_mode(project_mode)
744
+ if mode not in PROJECT_MODES:
745
+ msg = (
746
+ f"Unknown project mode: {project_mode}. "
747
+ f"Supported values: {', '.join(PROJECT_MODES)}."
748
+ )
749
+ raise ValueError(msg)
750
+ if mode == "greenfield" and architecture is None:
751
+ msg = "Greenfield mode requires a declared architecture manifest."
752
+ raise ValueError(msg)
753
+
754
+ title = request_title(description)
755
+ if directory is None:
756
+ spec_id, spec_directory = self._next_spec_directory(
757
+ config.specifications_path,
758
+ slugify(description),
759
+ )
760
+ else:
761
+ spec_directory = directory.resolve()
762
+ try:
763
+ spec_directory.relative_to(
764
+ config.specifications_path.resolve()
765
+ )
766
+ except ValueError as error:
767
+ msg = (
768
+ "Specification directory must stay inside the specifications "
769
+ f"path: {spec_directory}"
770
+ )
771
+ raise ValueError(msg) from error
772
+ match = re.match(r"^(\d{3})-", spec_directory.name)
773
+ if spec_id is None:
774
+ if match is None:
775
+ msg = (
776
+ "Specification directory must use the '<id>-<slug>' "
777
+ f"format: {spec_directory.name}"
778
+ )
779
+ raise ValueError(msg)
780
+ spec_id = match.group(1)
781
+ if not spec_directory.name.startswith(f"{spec_id}-"):
782
+ msg = (
783
+ f"Specification id '{spec_id}' does not match directory "
784
+ f"'{spec_directory.name}'."
785
+ )
786
+ raise ValueError(msg)
787
+ spec_directory.mkdir(parents=True, exist_ok=True)
788
+
789
+ if mode == "greenfield":
790
+ analysis = ImpactAnalysis(
791
+ keywords=tuple(extract_keywords(description)),
792
+ references=(),
793
+ categories={},
794
+ scanned_files=0,
795
+ external_dependencies=(),
796
+ has_tests=False,
797
+ )
798
+ questions = self._greenfield_questions(
799
+ description,
800
+ architecture,
801
+ )
802
+ fallback = self._render_greenfield(
803
+ spec_id=spec_id,
804
+ title=title,
805
+ description=description,
806
+ config=config,
807
+ project=project,
808
+ constitution=constitution,
809
+ profiles=profiles,
810
+ questions=questions,
811
+ architecture=architecture,
812
+ discovery=discovery,
813
+ discovery_digest=discovery_digest,
814
+ directory=spec_directory,
815
+ )
816
+ context = self._render_greenfield_context(
817
+ spec_id=spec_id,
818
+ title=title,
819
+ description=description,
820
+ config=config,
821
+ project=project,
822
+ constitution=constitution,
823
+ profiles=profiles,
824
+ questions=questions,
825
+ architecture=architecture,
826
+ discovery=discovery,
827
+ discovery_digest=discovery_digest,
828
+ directory=spec_directory,
829
+ )
830
+ else:
831
+ analysis = self.analyzer.analyze(description, project)
832
+ questions = self._open_questions(
833
+ description,
834
+ analysis,
835
+ profiles,
836
+ project,
837
+ )
838
+ fallback = self._render(
839
+ spec_id=spec_id,
840
+ title=title,
841
+ description=description,
842
+ analysis=analysis,
843
+ config=config,
844
+ project=project,
845
+ constitution=constitution,
846
+ profiles=profiles,
847
+ questions=questions,
848
+ project_mode=mode,
849
+ architecture=architecture,
850
+ discovery=discovery,
851
+ discovery_digest=discovery_digest,
852
+ directory=spec_directory,
853
+ )
854
+ context = self._render_context(
855
+ spec_id=spec_id,
856
+ title=title,
857
+ description=description,
858
+ analysis=analysis,
859
+ config=config,
860
+ project=project,
861
+ constitution=constitution,
862
+ profiles=profiles,
863
+ questions=questions,
864
+ project_mode=mode,
865
+ architecture=architecture,
866
+ discovery=discovery,
867
+ discovery_digest=discovery_digest,
868
+ directory=spec_directory,
869
+ )
870
+
871
+ return PreparedSpec(
872
+ spec_id=spec_id,
873
+ title=title,
874
+ description=description,
875
+ root=config.root,
876
+ directory=spec_directory,
877
+ path=spec_directory / "spec.md",
878
+ analysis=analysis,
879
+ questions=tuple(questions),
880
+ fallback_content=fallback,
881
+ context_content=context,
882
+ project_mode=mode,
883
+ architecture_digest=(
884
+ architecture.digest if architecture is not None else None
885
+ ),
886
+ architecture=architecture,
887
+ discovery=discovery,
888
+ discovery_digest=discovery_digest,
889
+ )
890
+
891
+ def materialize_context(self, prepared: PreparedSpec) -> Path:
892
+ """Write the static analysis context used by agent harnesses."""
893
+ prepared.directory.mkdir(parents=True, exist_ok=True)
894
+ path = prepared.directory / "context.md"
895
+ ArtifactStore(prepared.root).write(path, prepared.context_content)
896
+ return path
897
+
898
+ def write(
899
+ self,
900
+ prepared: PreparedSpec,
901
+ content: str | None = None,
902
+ *,
903
+ source: str = "static",
904
+ archive: bool = False,
905
+ ) -> GeneratedSpec:
906
+ """Write a specification, falling back to deterministic content."""
907
+ candidate = content if content is not None else prepared.fallback_content
908
+ cleaned = self.clean_agent_output(candidate)
909
+ if not cleaned.strip():
910
+ cleaned = prepared.fallback_content
911
+ source = "static"
912
+
913
+ prepared.directory.mkdir(parents=True, exist_ok=True)
914
+ ArtifactStore(prepared.root).write(
915
+ prepared.path,
916
+ cleaned.rstrip() + "\n",
917
+ archive=archive,
918
+ )
919
+ questions = (
920
+ prepared.questions
921
+ if source == "static"
922
+ else self.extract_open_questions(cleaned) or prepared.questions
923
+ )
924
+ return GeneratedSpec(
925
+ spec_id=prepared.spec_id,
926
+ title=prepared.title,
927
+ path=prepared.path,
928
+ open_questions=tuple(questions),
929
+ source=source,
930
+ )
931
+
932
+ def validate(
933
+ self,
934
+ content: str,
935
+ *,
936
+ project_mode: str = "existing",
937
+ architecture: ArchitectureManifest | None = None,
938
+ discovery_digest: str | None = None,
939
+ ) -> tuple[bool, str]:
940
+ """Validate that agent output has the required spec structure."""
941
+ cleaned = self.clean_agent_output(content)
942
+ if not cleaned.strip():
943
+ return False, "the specification is empty"
944
+
945
+ if not re.search(
946
+ r"^#\s+Specification\b",
947
+ cleaned,
948
+ re.MULTILINE,
949
+ ):
950
+ return False, "the '# Specification' heading is missing"
951
+
952
+ sections = {
953
+ int(number)
954
+ for number in re.findall(
955
+ r"^##\s+(\d+)\.\s+",
956
+ cleaned,
957
+ re.MULTILINE,
958
+ )
959
+ }
960
+ missing = [
961
+ number for number in range(1, 12) if number not in sections
962
+ ]
963
+ if missing:
964
+ return (
965
+ False,
966
+ "missing numbered sections: "
967
+ + ", ".join(str(number) for number in missing),
968
+ )
969
+
970
+ if "**FR-" not in cleaned:
971
+ return False, "functional requirements with **FR-XXX:** are missing"
972
+ if "**AC-" not in cleaned:
973
+ return False, "acceptance criteria with **AC-XXX:** are missing"
974
+
975
+ mode = canonical_project_mode(project_mode)
976
+ if mode not in PROJECT_MODES:
977
+ return False, f"unknown project mode: {project_mode}"
978
+ if discovery_digest and discovery_digest not in cleaned:
979
+ return (
980
+ False,
981
+ "the Discovery digest is missing from the specification",
982
+ )
983
+ if mode == "greenfield":
984
+ return self._validate_greenfield(cleaned, architecture)
985
+ return True, ""
986
+
987
+ def _validate_greenfield(
988
+ self,
989
+ cleaned: str,
990
+ architecture: ArchitectureManifest | None,
991
+ ) -> tuple[bool, str]:
992
+ if architecture is None:
993
+ return (
994
+ False,
995
+ "the declared architecture manifest is unavailable",
996
+ )
997
+
998
+ if not re.search(
999
+ r"^##\s+3\.\s+Target Architecture\s*$",
1000
+ cleaned,
1001
+ re.MULTILINE,
1002
+ ):
1003
+ return (
1004
+ False,
1005
+ "section 3 must be titled 'Target Architecture' in Greenfield mode",
1006
+ )
1007
+ if not re.search(
1008
+ r"^##\s+4\.\s+Architecture Decisions and Risks\s*$",
1009
+ cleaned,
1010
+ re.MULTILINE,
1011
+ ):
1012
+ return (
1013
+ False,
1014
+ "section 4 must be titled 'Architecture Decisions and Risks'",
1015
+ )
1016
+ if not re.search(
1017
+ r"^##\s+Appendix A:\s+Declared Architecture Manifest\s*$",
1018
+ cleaned,
1019
+ re.MULTILINE,
1020
+ ):
1021
+ return (
1022
+ False,
1023
+ "the declared architecture manifest appendix is missing",
1024
+ )
1025
+ if "Current Project State" in cleaned:
1026
+ return (
1027
+ False,
1028
+ "Greenfield specifications must not describe a current project "
1029
+ "state",
1030
+ )
1031
+ if "Relevant existing code" in cleaned:
1032
+ return (
1033
+ False,
1034
+ "Greenfield specifications must not invent existing code "
1035
+ "references",
1036
+ )
1037
+ if architecture.digest not in cleaned:
1038
+ return (
1039
+ False,
1040
+ "the declared architecture manifest digest is missing",
1041
+ )
1042
+ if architecture.style.casefold() not in cleaned.casefold():
1043
+ return (
1044
+ False,
1045
+ "the declared architecture style is missing",
1046
+ )
1047
+ for module in architecture.modules:
1048
+ if module.name not in cleaned:
1049
+ return (
1050
+ False,
1051
+ f"declared module '{module.name}' is missing",
1052
+ )
1053
+ for key, values in architecture.stack.items():
1054
+ if key not in cleaned:
1055
+ return False, f"declared stack field '{key}' is missing"
1056
+ for value in values:
1057
+ if value not in cleaned:
1058
+ return (
1059
+ False,
1060
+ f"declared stack value '{value}' is missing",
1061
+ )
1062
+ for key, values in architecture.constraints.items():
1063
+ if key not in cleaned:
1064
+ return False, f"declared constraint '{key}' is missing"
1065
+ for value in values:
1066
+ if value not in cleaned:
1067
+ return (
1068
+ False,
1069
+ f"declared constraint value '{value}' is missing",
1070
+ )
1071
+ return True, ""
1072
+
1073
+ def clean_agent_output(self, content: str) -> str:
1074
+ """Remove common wrappers from an agent response."""
1075
+ cleaned = strip_ansi(content).lstrip("\ufeff").strip()
1076
+ wrapped = bool(
1077
+ re.match(
1078
+ r"^```(?:markdown|md)?[ \t]*\n",
1079
+ cleaned,
1080
+ flags=re.IGNORECASE,
1081
+ )
1082
+ )
1083
+ # Only remove the closing fence when the whole response was wrapped.
1084
+ # Specifications may legitimately end with a fenced manifest appendix.
1085
+ cleaned = re.sub(
1086
+ r"^```(?:markdown|md)?[ \t]*\n",
1087
+ "",
1088
+ cleaned,
1089
+ count=1,
1090
+ flags=re.IGNORECASE,
1091
+ )
1092
+ if wrapped and cleaned.endswith("```"):
1093
+ cleaned = cleaned[:-3].rstrip()
1094
+
1095
+ start = cleaned.find("# Specification")
1096
+ if start > 0:
1097
+ cleaned = cleaned[start:]
1098
+ return cleaned.strip()
1099
+
1100
+ def extract_open_questions(self, content: str) -> tuple[str, ...]:
1101
+ """Read the open questions listed by an agent specification."""
1102
+ match = re.search(
1103
+ r"^##\s+7\.\s+Open Questions\s*$(.*?)(?=^##\s+8\.|\Z)",
1104
+ content,
1105
+ re.MULTILINE | re.DOTALL,
1106
+ )
1107
+ if not match:
1108
+ return ()
1109
+
1110
+ body = match.group(1).strip()
1111
+ if not body or "no open questions" in body.lower():
1112
+ return ()
1113
+
1114
+ questions: list[str] = []
1115
+ for line in body.splitlines():
1116
+ item = re.match(r"^\s*(?:\d+[.)]|[-*])\s+(.*)", line)
1117
+ if item:
1118
+ question = item.group(1).strip()
1119
+ if question:
1120
+ questions.append(question)
1121
+ return tuple(questions)
1122
+
1123
+ def _next_spec_directory(
1124
+ self,
1125
+ specs_path: Path,
1126
+ slug: str,
1127
+ ) -> tuple[str, Path]:
1128
+ resolved = specs_path.resolve()
1129
+ resolved.mkdir(parents=True, exist_ok=True)
1130
+
1131
+ lock_path = resolved.parent / f".{resolved.name}.lock"
1132
+ with _SpecsLock(lock_path):
1133
+ highest = 0
1134
+ for child in resolved.iterdir():
1135
+ match = re.match(r"^(\d{3})-", child.name)
1136
+ if match:
1137
+ highest = max(highest, int(match.group(1)))
1138
+
1139
+ number = highest + 1
1140
+ while number <= 999:
1141
+ spec_id = f"{number:03d}"
1142
+ candidate = resolved / f"{spec_id}-{slug}"
1143
+ try:
1144
+ candidate.mkdir()
1145
+ except FileExistsError:
1146
+ number += 1
1147
+ continue
1148
+ return spec_id, candidate
1149
+
1150
+ msg = f"No specification id is available in {resolved}."
1151
+ raise OSError(msg)
1152
+
1153
+ def _open_questions(
1154
+ self,
1155
+ description: str,
1156
+ analysis: ImpactAnalysis,
1157
+ profiles: list[Profile],
1158
+ project: Project,
1159
+ ) -> list[str]:
1160
+ questions: list[str] = []
1161
+ meaningful_words = extract_keywords(description)
1162
+
1163
+ if len(meaningful_words) < 2:
1164
+ questions.append(
1165
+ "What exact behavior should change, and how will it be "
1166
+ "verified?"
1167
+ )
1168
+
1169
+ if not analysis.references:
1170
+ searched = (
1171
+ ", ".join(analysis.keywords)
1172
+ or "the request terms"
1173
+ )
1174
+ questions.append(
1175
+ "Which existing modules or entry points are involved? "
1176
+ f"No relevant files were found for: {searched}."
1177
+ )
1178
+
1179
+ vague = sorted(set(meaningful_words) & VAGUE_TERMS)
1180
+ if vague:
1181
+ questions.append(
1182
+ "What measurable outcome defines '"
1183
+ + "', '".join(vague)
1184
+ + "' for this change?"
1185
+ )
1186
+
1187
+ data_words = set(meaningful_words) & DATA_TERMS
1188
+ has_migrations = any(
1189
+ reference.category == "Database migrations"
1190
+ for reference in analysis.references
1191
+ )
1192
+ if data_words and not has_migrations and not project.databases:
1193
+ questions.append(
1194
+ "Does this change require schema or data persistence "
1195
+ "changes? If so, which database and migration strategy "
1196
+ "should be used?"
1197
+ )
1198
+
1199
+ api_words = set(meaningful_words) & API_TERMS
1200
+ has_api = "API and views" in analysis.categories
1201
+ if api_words and not has_api:
1202
+ questions.append(
1203
+ "Which API contract should be used (route, method, request "
1204
+ "and response schema, and error responses)?"
1205
+ )
1206
+
1207
+ if not analysis.has_tests:
1208
+ questions.append(
1209
+ "Where should automated tests for this change live, and "
1210
+ "which cases are mandatory?"
1211
+ )
1212
+
1213
+ detected_profiles = [
1214
+ profile for profile in profiles if profile.name != "base"
1215
+ ]
1216
+ if len(detected_profiles) > 1:
1217
+ names = ", ".join(
1218
+ profile.name for profile in detected_profiles
1219
+ )
1220
+ questions.append(
1221
+ f"Should this change apply to all detected profiles ({names})?"
1222
+ )
1223
+
1224
+ if not questions:
1225
+ questions.extend(
1226
+ [
1227
+ "What is explicitly out of scope for this change?",
1228
+ "Are there backward-compatibility requirements for "
1229
+ "existing clients, data, or integrations?",
1230
+ ]
1231
+ )
1232
+
1233
+ return questions[:6]
1234
+
1235
+ def _discovery_header_rows(
1236
+ self,
1237
+ discovery: DiscoveryDocument | None,
1238
+ digest: str | None,
1239
+ specs_path: Path,
1240
+ root: Path,
1241
+ ) -> list[str]:
1242
+ if discovery is None or not digest:
1243
+ return []
1244
+ path = specs_path / discovery.change / "discovery.yaml"
1245
+ try:
1246
+ relative = path.relative_to(root).as_posix()
1247
+ except ValueError:
1248
+ relative = path.as_posix()
1249
+ return [
1250
+ f"| Discovery | `{relative}` |",
1251
+ f"| Discovery digest | `{digest}` |",
1252
+ ]
1253
+
1254
+ def _discovery_context_lines(
1255
+ self,
1256
+ discovery: DiscoveryDocument | None,
1257
+ digest: str | None,
1258
+ specs_path: Path,
1259
+ root: Path,
1260
+ ) -> list[str]:
1261
+ if discovery is None:
1262
+ return []
1263
+ path = specs_path / discovery.change / "discovery.yaml"
1264
+ try:
1265
+ relative = path.relative_to(root).as_posix()
1266
+ except ValueError:
1267
+ relative = path.as_posix()
1268
+ lines = [
1269
+ "## Discovery",
1270
+ "",
1271
+ f"- File: `{relative}`",
1272
+ f"- Status: `{discovery.status}`",
1273
+ f"- Digest: `{digest or 'unknown'}`",
1274
+ f"- Project mode: `{discovery.project_mode}`",
1275
+ "",
1276
+ ]
1277
+ if discovery.questions:
1278
+ lines.extend(
1279
+ [
1280
+ "| Question | Category | Blocking | Status | Answer |",
1281
+ "| --- | --- | --- | --- | --- |",
1282
+ ]
1283
+ )
1284
+ for question in discovery.questions:
1285
+ answer = question.answer or "-"
1286
+ lines.append(
1287
+ f"| {question.id} | {question.category} | "
1288
+ f"{'yes' if question.blocking else 'no'} | "
1289
+ f"{question.status} | {answer} |"
1290
+ )
1291
+ else:
1292
+ lines.append("No discovery questions were recorded.")
1293
+ if discovery.assumptions:
1294
+ lines.extend(["", "### Proposed assumptions", ""])
1295
+ for assumption in discovery.assumptions:
1296
+ accepted = (
1297
+ f"accepted by {assumption.accepted_by}"
1298
+ if assumption.is_accepted
1299
+ else "pending acceptance"
1300
+ )
1301
+ lines.append(
1302
+ f"- {assumption.id}: {assumption.value} ({accepted})"
1303
+ )
1304
+ return lines
1305
+
1306
+ def _open_questions_lines(
1307
+ self,
1308
+ questions: list[str],
1309
+ discovery: DiscoveryDocument | None,
1310
+ ) -> list[str]:
1311
+ if discovery is not None:
1312
+ if not discovery.is_complete:
1313
+ lines = [
1314
+ "Discovery is not complete. The specification cannot be "
1315
+ "approved until these decisions are resolved:",
1316
+ "",
1317
+ ]
1318
+ for blocker in discovery.blockers():
1319
+ lines.append(f"- {blocker}")
1320
+ return lines
1321
+ lines = [
1322
+ "Discovery is complete. No blocking open questions remain.",
1323
+ ]
1324
+ decisions = discovery.decisions()
1325
+ if decisions:
1326
+ lines.extend(
1327
+ [
1328
+ "",
1329
+ "Decisions consolidated during Discovery:",
1330
+ "",
1331
+ ]
1332
+ )
1333
+ for decision in decisions:
1334
+ lines.append(f"- {decision}")
1335
+ if questions:
1336
+ lines.extend(
1337
+ [
1338
+ "",
1339
+ "Non-blocking questions recorded by static analysis:",
1340
+ "",
1341
+ ]
1342
+ )
1343
+ for index, question in enumerate(questions, start=1):
1344
+ lines.append(f"{index}. {question}")
1345
+ return lines
1346
+
1347
+ if questions:
1348
+ return [
1349
+ f"{index}. {question}"
1350
+ for index, question in enumerate(questions, start=1)
1351
+ ]
1352
+ return ["No open questions were identified by static analysis."]
1353
+
1354
+ def _discovery_approval_lines(
1355
+ self,
1356
+ discovery: DiscoveryDocument | None,
1357
+ ) -> list[str]:
1358
+ if discovery is None:
1359
+ return []
1360
+ return [
1361
+ "- [ ] Discovery is complete and its digest matches this "
1362
+ "specification.",
1363
+ ]
1364
+
1365
+ def _architecture_header_rows(
1366
+ self,
1367
+ architecture: ArchitectureManifest | None,
1368
+ root: Path,
1369
+ ) -> list[str]:
1370
+ if architecture is None:
1371
+ return []
1372
+ return [
1373
+ f"| Architecture manifest | `{architecture.relative_path(root)}` |",
1374
+ f"| Architecture digest | `{architecture.digest}` |",
1375
+ ]
1376
+
1377
+ def _architecture_context_lines(
1378
+ self,
1379
+ architecture: ArchitectureManifest,
1380
+ root: Path,
1381
+ ) -> list[str]:
1382
+ lines = [
1383
+ "The following architecture was declared by the user. It is "
1384
+ "evidence of intended design, not evidence of implemented code.",
1385
+ "",
1386
+ "| Field | Value |",
1387
+ "| --- | --- |",
1388
+ f"| Manifest | `{architecture.relative_path(root)}` |",
1389
+ f"| Digest | `{architecture.digest}` |",
1390
+ f"| Version | `{architecture.version}` |",
1391
+ f"| Style | {architecture.style} |",
1392
+ "",
1393
+ "### Declared summary",
1394
+ "",
1395
+ architecture.summary,
1396
+ "",
1397
+ "### Declared modules",
1398
+ "",
1399
+ "| Module | Responsibility | Depends on |",
1400
+ "| --- | --- | --- |",
1401
+ ]
1402
+ for module in architecture.modules:
1403
+ depends = ", ".join(module.depends_on) or "-"
1404
+ lines.append(
1405
+ f"| {module.name} | {module.responsibility} | {depends} |"
1406
+ )
1407
+
1408
+ lines.extend(["", "### Declared layers", ""])
1409
+ if architecture.layers:
1410
+ lines.extend(
1411
+ [
1412
+ "| Layer | May depend on |",
1413
+ "| --- | --- |",
1414
+ ]
1415
+ )
1416
+ for layer in architecture.layers:
1417
+ depends = ", ".join(layer.may_depend_on) or "-"
1418
+ lines.append(f"| {layer.name} | {depends} |")
1419
+ else:
1420
+ lines.append("No layers were declared.")
1421
+
1422
+ lines.extend(["", "### Declared stack", ""])
1423
+ lines.extend(self._declared_values_table(
1424
+ architecture.stack,
1425
+ "Area",
1426
+ "Technologies",
1427
+ ))
1428
+
1429
+ lines.extend(["", "### Declared constraints", ""])
1430
+ lines.extend(self._declared_values_table(
1431
+ architecture.constraints,
1432
+ "Constraint",
1433
+ "Values",
1434
+ ))
1435
+
1436
+ lines.extend(["", "### Quality attributes", ""])
1437
+ lines.extend(
1438
+ self._bullet_list(architecture.quality_attributes)
1439
+ )
1440
+
1441
+ lines.extend(["", "### Fitness functions", ""])
1442
+ lines.extend(
1443
+ self._bullet_list(architecture.fitness_functions)
1444
+ )
1445
+
1446
+ if architecture.warnings:
1447
+ lines.extend(["", "### Manifest warnings", ""])
1448
+ lines.extend(self._bullet_list(architecture.warnings))
1449
+ return lines
1450
+
1451
+ def _declared_values_table(
1452
+ self,
1453
+ values: dict[str, tuple[str, ...]],
1454
+ key_label: str,
1455
+ value_label: str,
1456
+ ) -> list[str]:
1457
+ if not values:
1458
+ return ["No values were declared."]
1459
+ lines = [
1460
+ f"| {key_label} | {value_label} |",
1461
+ "| --- | --- |",
1462
+ ]
1463
+ for key, items in values.items():
1464
+ lines.append(f"| {key} | {', '.join(items)} |")
1465
+ return lines
1466
+
1467
+ def _bullet_list(self, values: tuple[str, ...]) -> list[str]:
1468
+ if not values:
1469
+ return ["None declared."]
1470
+ return [f"- {value}" for value in values]
1471
+
1472
+ def _greenfield_questions(
1473
+ self,
1474
+ description: str,
1475
+ architecture: ArchitectureManifest,
1476
+ ) -> list[str]:
1477
+ questions: list[str] = []
1478
+ if len(extract_keywords(description)) < 2:
1479
+ questions.append(
1480
+ "What functional outcome should the new system deliver, and "
1481
+ "how will it be verified?"
1482
+ )
1483
+ if not architecture.layers:
1484
+ questions.append(
1485
+ "Which layers or boundaries should the architecture enforce?"
1486
+ )
1487
+ if not architecture.stack:
1488
+ questions.append(
1489
+ "Which languages, frameworks, databases and platforms should "
1490
+ "the new system use?"
1491
+ )
1492
+ if not architecture.constraints:
1493
+ questions.append(
1494
+ "Which non-negotiable constraints apply (authentication, "
1495
+ "deployment, compliance, performance)?"
1496
+ )
1497
+ if not architecture.quality_attributes:
1498
+ questions.append(
1499
+ "Which quality attributes matter most and how will they be "
1500
+ "measured?"
1501
+ )
1502
+ if not architecture.fitness_functions:
1503
+ questions.append(
1504
+ "Which automated checks should prove that the architecture "
1505
+ "remains intact?"
1506
+ )
1507
+
1508
+ if len(questions) < 2:
1509
+ questions.extend(
1510
+ [
1511
+ "What is explicitly out of scope for the first delivery?",
1512
+ "Which user roles, external integrations and data sources "
1513
+ "must the system support?",
1514
+ ]
1515
+ )
1516
+ return questions[:6]
1517
+
1518
+ def _render_greenfield_context(
1519
+ self,
1520
+ spec_id: str,
1521
+ title: str,
1522
+ description: str,
1523
+ config: DevFlowConfig,
1524
+ project: Project,
1525
+ constitution: Constitution,
1526
+ profiles: list[Profile],
1527
+ questions: list[str],
1528
+ architecture: ArchitectureManifest,
1529
+ discovery: DiscoveryDocument | None = None,
1530
+ discovery_digest: str | None = None,
1531
+ directory: Path | None = None,
1532
+ ) -> str:
1533
+ target_directory = directory or (
1534
+ config.specifications_path / f"{spec_id}-{slugify(description)}"
1535
+ )
1536
+ spec_path = target_directory / "spec.md"
1537
+ lines = [
1538
+ f"# DevFlow Greenfield Specification Context {spec_id}: {title}",
1539
+ "",
1540
+ "This file contains the declared architecture and requirements "
1541
+ "gathered by DevFlow. There is no existing implementation to "
1542
+ "analyze. Do not invent source files or claim that code exists.",
1543
+ "",
1544
+ "## Request",
1545
+ "",
1546
+ "> " + "\n> ".join(description.strip().splitlines()),
1547
+ "",
1548
+ "## Project",
1549
+ "",
1550
+ f"- Name: `{project.name}`",
1551
+ f"- Type: `{project.project_type}`",
1552
+ "- Project mode: `greenfield`",
1553
+ "",
1554
+ "## Constitution",
1555
+ "",
1556
+ "Apply these documents. Do not copy their contents into the "
1557
+ "specification.",
1558
+ "",
1559
+ ]
1560
+ lines.extend(self._constitution_table(constitution, profiles))
1561
+ lines.extend(["", "## Declared Architecture", ""])
1562
+ lines.extend(
1563
+ self._architecture_context_lines(architecture, config.root)
1564
+ )
1565
+ discovery_lines = self._discovery_context_lines(
1566
+ discovery,
1567
+ discovery_digest,
1568
+ config.specifications_path,
1569
+ config.root,
1570
+ )
1571
+ if discovery_lines:
1572
+ lines.extend(["", *discovery_lines])
1573
+ lines.extend(["", "## Initial Open Questions", ""])
1574
+ lines.extend(self._open_questions_lines(questions, discovery))
1575
+
1576
+ lines.extend(
1577
+ [
1578
+ "",
1579
+ "## Required Output",
1580
+ "",
1581
+ f"Write the final specification to "
1582
+ f"`{spec_path.relative_to(config.root).as_posix()}`.",
1583
+ "",
1584
+ "Use the eleven numbered sections defined by the Greenfield "
1585
+ "prompt. Describe the target system in future tense. Use "
1586
+ "`**FR-XXX:**` identifiers for functional requirements and "
1587
+ "`- [ ] **AC-XXX:**` checklist items for acceptance criteria.",
1588
+ "",
1589
+ "End the document with `## Appendix A: Declared Architecture "
1590
+ "Manifest` and include the manifest digest.",
1591
+ "",
1592
+ ]
1593
+ )
1594
+ return "\n".join(lines)
1595
+
1596
+ def _constitution_table(
1597
+ self,
1598
+ constitution: Constitution,
1599
+ profiles: list[Profile],
1600
+ ) -> list[str]:
1601
+ lines = [
1602
+ "| Document | Profile scope |",
1603
+ "| --- | --- |",
1604
+ ]
1605
+ for document in constitution.documents:
1606
+ scope = "project"
1607
+ for profile in profiles:
1608
+ if document.name in profile.constitution:
1609
+ scope = (
1610
+ "project" if profile.name == "base" else profile.name
1611
+ )
1612
+ break
1613
+ lines.append(
1614
+ f"| `.devflow/constitution/{document.name}` | {scope} |"
1615
+ )
1616
+ return lines
1617
+
1618
+ def _render_greenfield(
1619
+ self,
1620
+ spec_id: str,
1621
+ title: str,
1622
+ description: str,
1623
+ config: DevFlowConfig,
1624
+ project: Project,
1625
+ constitution: Constitution,
1626
+ profiles: list[Profile],
1627
+ questions: list[str],
1628
+ architecture: ArchitectureManifest,
1629
+ discovery: DiscoveryDocument | None = None,
1630
+ discovery_digest: str | None = None,
1631
+ directory: Path | None = None,
1632
+ ) -> str:
1633
+ detected_profiles = [
1634
+ profile for profile in profiles if profile.name != "base"
1635
+ ]
1636
+ profile_names = ", ".join(
1637
+ profile.name for profile in detected_profiles
1638
+ )
1639
+ today = date.today().isoformat()
1640
+ clauses = split_requirements(description)[:6]
1641
+ target_directory = directory or (
1642
+ config.specifications_path / f"{spec_id}-{slugify(description)}"
1643
+ )
1644
+ spec_path = target_directory / "spec.md"
1645
+
1646
+ lines = [
1647
+ f"# Specification {spec_id}: {title}",
1648
+ "",
1649
+ "| Field | Value |",
1650
+ "| --- | --- |",
1651
+ "| Status | Draft |",
1652
+ f"| Created | {today} |",
1653
+ f"| Project | `{project.name}` |",
1654
+ f"| Project type | `{project.project_type}` |",
1655
+ "| Project mode | `greenfield` |",
1656
+ f"| Profiles | {profile_names or 'none'} |",
1657
+ f"| Specification | `{spec_path.relative_to(config.root).as_posix()}` |",
1658
+ *self._architecture_header_rows(architecture, config.root),
1659
+ *self._discovery_header_rows(
1660
+ discovery,
1661
+ discovery_digest,
1662
+ config.specifications_path,
1663
+ config.root,
1664
+ ),
1665
+ "",
1666
+ "## 1. Request",
1667
+ "",
1668
+ "> " + "\n> ".join(description.strip().splitlines()),
1669
+ "",
1670
+ "## 2. Summary",
1671
+ "",
1672
+ "This specification defines the system to be built. It is "
1673
+ "derived from the requested behavior and the declared "
1674
+ "architecture manifest. No existing implementation is assumed "
1675
+ "or inferred. The document must be reviewed and approved before "
1676
+ "planning or implementation begins.",
1677
+ "",
1678
+ "## 3. Target Architecture",
1679
+ "",
1680
+ "### 3.1 Style and scope",
1681
+ "",
1682
+ f"- Architecture style: {architecture.style}",
1683
+ f"- Manifest version: `{architecture.version}`",
1684
+ f"- Manifest digest: `{architecture.digest}`",
1685
+ "",
1686
+ architecture.summary,
1687
+ "",
1688
+ "### 3.2 Declared modules",
1689
+ "",
1690
+ "| Module | Responsibility | Depends on |",
1691
+ "| --- | --- | --- |",
1692
+ ]
1693
+ for module in architecture.modules:
1694
+ depends = ", ".join(module.depends_on) or "-"
1695
+ lines.append(
1696
+ f"| {module.name} | {module.responsibility} | {depends} |"
1697
+ )
1698
+
1699
+ lines.extend(["", "### 3.3 Declared layers", ""])
1700
+ if architecture.layers:
1701
+ lines.extend(
1702
+ [
1703
+ "| Layer | May depend on |",
1704
+ "| --- | --- |",
1705
+ ]
1706
+ )
1707
+ for layer in architecture.layers:
1708
+ depends = ", ".join(layer.may_depend_on) or "-"
1709
+ lines.append(f"| {layer.name} | {depends} |")
1710
+ else:
1711
+ lines.append(
1712
+ "No layers were declared. The boundaries are defined by the "
1713
+ "modules above and must be confirmed before implementation."
1714
+ )
1715
+
1716
+ lines.extend(["", "### 3.4 Declared technology stack", ""])
1717
+ lines.extend(self._declared_values_table(
1718
+ architecture.stack,
1719
+ "Area",
1720
+ "Technologies",
1721
+ ))
1722
+
1723
+ lines.extend(
1724
+ [
1725
+ "",
1726
+ "## 4. Architecture Decisions and Risks",
1727
+ "",
1728
+ "### 4.1 Declared decisions",
1729
+ "",
1730
+ f"- Use {architecture.style} as the primary architectural style.",
1731
+ "- Keep each module responsible for the scope declared above.",
1732
+ "- Respect declared dependency directions between modules and "
1733
+ "layers.",
1734
+ ]
1735
+ )
1736
+ if architecture.stack:
1737
+ stack_summary = "; ".join(
1738
+ f"{key}: {', '.join(values)}"
1739
+ for key, values in architecture.stack.items()
1740
+ )
1741
+ lines.append(f"- Target stack: {stack_summary}.")
1742
+ if architecture.constraints:
1743
+ lines.append(
1744
+ "- Honor every constraint listed in section 6."
1745
+ )
1746
+
1747
+ lines.extend(["", "### 4.2 Quality attributes", ""])
1748
+ lines.extend(self._bullet_list(architecture.quality_attributes))
1749
+ lines.extend(["", "### 4.3 Fitness functions", ""])
1750
+ lines.extend(self._bullet_list(architecture.fitness_functions))
1751
+ lines.extend(
1752
+ [
1753
+ "",
1754
+ "### 4.4 Risks and reconciliation",
1755
+ "",
1756
+ "- The manifest records intended design, not implemented code.",
1757
+ "- DevFlow validates structural consistency only. It does not "
1758
+ "certify that this architecture is correct or sufficient.",
1759
+ "- Contradictions between requirements and the manifest must be "
1760
+ "resolved as open questions before implementation.",
1761
+ ]
1762
+ )
1763
+ if architecture.warnings:
1764
+ lines.append("- Manifest warnings:")
1765
+ for warning in architecture.warnings:
1766
+ lines.append(f" - {warning}")
1767
+
1768
+ lines.extend(
1769
+ [
1770
+ "",
1771
+ "## 5. Functional Requirements",
1772
+ "",
1773
+ ]
1774
+ )
1775
+ for index, clause in enumerate(clauses, start=1):
1776
+ sentence = clause[0].upper() + clause[1:] if clause else clause
1777
+ lines.append(
1778
+ f"- **FR-{index:03d}:** The new system must support the "
1779
+ f"following requested behavior: {sentence}."
1780
+ )
1781
+
1782
+ next_requirement = len(clauses) + 1
1783
+ lines.append(
1784
+ f"- **FR-{next_requirement:03d}:** The implementation must honor "
1785
+ "the declared module responsibilities and dependency directions."
1786
+ )
1787
+ next_requirement += 1
1788
+ lines.append(
1789
+ f"- **FR-{next_requirement:03d}:** The system must comply with "
1790
+ "every document listed in the Constitution Applied section."
1791
+ )
1792
+
1793
+ lines.extend(
1794
+ [
1795
+ "",
1796
+ "## 6. Constraints and Dependencies",
1797
+ "",
1798
+ "### 6.1 Declared constraints",
1799
+ "",
1800
+ ]
1801
+ )
1802
+ lines.extend(self._declared_values_table(
1803
+ architecture.constraints,
1804
+ "Constraint",
1805
+ "Values",
1806
+ ))
1807
+ lines.extend(
1808
+ [
1809
+ "",
1810
+ "### 6.2 Technology stack",
1811
+ "",
1812
+ ]
1813
+ )
1814
+ lines.extend(self._declared_values_table(
1815
+ architecture.stack,
1816
+ "Area",
1817
+ "Technologies",
1818
+ ))
1819
+ lines.extend(
1820
+ [
1821
+ "",
1822
+ "### 6.3 Quality gates",
1823
+ "",
1824
+ "The following fitness functions should become automated checks "
1825
+ "during planning and implementation:",
1826
+ "",
1827
+ ]
1828
+ )
1829
+ lines.extend(self._bullet_list(architecture.fitness_functions))
1830
+ lines.extend(
1831
+ [
1832
+ "",
1833
+ "No existing third-party dependency is assumed. New "
1834
+ "dependencies must be justified by a requirement and reviewed.",
1835
+ "",
1836
+ "## 7. Open Questions",
1837
+ "",
1838
+ ]
1839
+ )
1840
+ lines.extend(self._open_questions_lines(questions, discovery))
1841
+
1842
+ lines.extend(
1843
+ [
1844
+ "",
1845
+ "## 8. Acceptance Criteria",
1846
+ "",
1847
+ ]
1848
+ )
1849
+ criteria: list[str] = []
1850
+ for index, _clause in enumerate(clauses, start=1):
1851
+ criteria.append(
1852
+ f"The behavior described by FR-{index:03d} is demonstrable "
1853
+ "and verified."
1854
+ )
1855
+ criteria.extend(
1856
+ [
1857
+ "Declared module boundaries and dependency directions are "
1858
+ "verified by review or automated checks.",
1859
+ "The system uses only the declared stack and honors the "
1860
+ "declared constraints.",
1861
+ "Every declared fitness function has an automated check or a "
1862
+ "documented manual verification.",
1863
+ "Automated tests cover the new behavior, relevant edge cases "
1864
+ "and failure paths.",
1865
+ "The implementation complies with the referenced constitution "
1866
+ "documents.",
1867
+ "Documentation and configuration are created for setup, "
1868
+ "deployment and integrations.",
1869
+ ]
1870
+ )
1871
+ for index, criterion in enumerate(criteria, start=1):
1872
+ lines.append(
1873
+ f"- [ ] **AC-{index:03d}:** {criterion}"
1874
+ )
1875
+
1876
+ lines.extend(
1877
+ [
1878
+ "",
1879
+ "## 9. Constitution Applied",
1880
+ "",
1881
+ ]
1882
+ )
1883
+ lines.extend(self._constitution_table(constitution, profiles))
1884
+ lines.extend(
1885
+ [
1886
+ "",
1887
+ "Technology-specific rules apply only to the profiles listed "
1888
+ "above.",
1889
+ "",
1890
+ "## 10. Out of Scope",
1891
+ "",
1892
+ "- Generating application code or project scaffolding in this "
1893
+ "stage.",
1894
+ "- Planning, implementation and review workflows.",
1895
+ "- Guaranteeing that the declared architecture is correct.",
1896
+ "- Inventing existing code, files or dependencies that were not "
1897
+ "declared.",
1898
+ "",
1899
+ "## 11. Approval",
1900
+ "",
1901
+ "- [ ] The request and functional requirements are complete.",
1902
+ "- [ ] Open questions are resolved or explicitly accepted.",
1903
+ "- [ ] Acceptance criteria are agreed.",
1904
+ "- [ ] The declared architecture manifest digest is correct.",
1905
+ *self._discovery_approval_lines(discovery),
1906
+ "- [ ] The specification is approved for planning.",
1907
+ "",
1908
+ "---",
1909
+ "",
1910
+ "Generated by DevFlow. This document does not implement the "
1911
+ "system and must be reviewed by a developer.",
1912
+ "",
1913
+ ]
1914
+ )
1915
+ lines.append(architecture.render_appendix(config.root))
1916
+ return "\n".join(lines)
1917
+
1918
+ def _render_context(
1919
+ self,
1920
+ spec_id: str,
1921
+ title: str,
1922
+ description: str,
1923
+ analysis: ImpactAnalysis,
1924
+ config: DevFlowConfig,
1925
+ project: Project,
1926
+ constitution: Constitution,
1927
+ profiles: list[Profile],
1928
+ questions: list[str],
1929
+ project_mode: str = "existing",
1930
+ architecture: ArchitectureManifest | None = None,
1931
+ discovery: DiscoveryDocument | None = None,
1932
+ discovery_digest: str | None = None,
1933
+ directory: Path | None = None,
1934
+ ) -> str:
1935
+ detected_profiles = [
1936
+ profile for profile in profiles if profile.name != "base"
1937
+ ]
1938
+ profile_names = ", ".join(
1939
+ profile.name for profile in detected_profiles
1940
+ )
1941
+ target_directory = directory or (
1942
+ config.specifications_path / f"{spec_id}-{slugify(description)}"
1943
+ )
1944
+ spec_path = target_directory / "spec.md"
1945
+ lines = [
1946
+ f"# DevFlow Specification Context {spec_id}: {title}",
1947
+ "",
1948
+ "This file contains deterministic evidence gathered by DevFlow. "
1949
+ "It is not the specification. Use it to understand the project, "
1950
+ "then write the specification to the target path.",
1951
+ "",
1952
+ "## Request",
1953
+ "",
1954
+ "> " + "\n> ".join(description.strip().splitlines()),
1955
+ "",
1956
+ "## Project",
1957
+ "",
1958
+ f"- Name: `{project.name}`",
1959
+ f"- Type: `{project.project_type}`",
1960
+ f"- Project mode: `{project_mode}`",
1961
+ f"- Languages: {', '.join(project.languages) or 'not detected'}",
1962
+ f"- Frameworks: {', '.join(project.frameworks) or 'not detected'}",
1963
+ f"- Databases: {', '.join(project.databases) or 'not detected'}",
1964
+ f"- Profiles: {profile_names or 'none'}",
1965
+ f"- Source files: {len(project.source_files)}",
1966
+ f"- Manifests: {', '.join(project.manifests) or 'none'}",
1967
+ "",
1968
+ "## Constitution",
1969
+ "",
1970
+ "Apply these documents. Do not copy their contents into the "
1971
+ "specification.",
1972
+ "",
1973
+ "| Document | Profile scope |",
1974
+ "| --- | --- |",
1975
+ ]
1976
+
1977
+ for document in constitution.documents:
1978
+ scope = "project"
1979
+ for profile in profiles:
1980
+ if document.name in profile.constitution:
1981
+ scope = (
1982
+ "project" if profile.name == "base" else profile.name
1983
+ )
1984
+ break
1985
+ lines.append(
1986
+ f"| `.devflow/constitution/{document.name}` | {scope} |"
1987
+ )
1988
+
1989
+ if architecture is not None:
1990
+ lines.extend(
1991
+ [
1992
+ "",
1993
+ "## Declared Architecture",
1994
+ "",
1995
+ *self._architecture_context_lines(
1996
+ architecture,
1997
+ config.root,
1998
+ ),
1999
+ ]
2000
+ )
2001
+
2002
+ discovery_lines = self._discovery_context_lines(
2003
+ discovery,
2004
+ discovery_digest,
2005
+ config.specifications_path,
2006
+ config.root,
2007
+ )
2008
+ if discovery_lines:
2009
+ lines.extend(["", *discovery_lines])
2010
+
2011
+ lines.extend(
2012
+ [
2013
+ "",
2014
+ "## Static Analysis",
2015
+ "",
2016
+ "- Request keywords: "
2017
+ + (
2018
+ ", ".join(f"`{word}`" for word in analysis.keywords)
2019
+ or "none"
2020
+ ),
2021
+ f"- Scanned text files: {analysis.scanned_files}",
2022
+ f"- Existing tests detected: "
2023
+ f"{'yes' if analysis.has_tests else 'no'}",
2024
+ "",
2025
+ "## Relevant Existing Code",
2026
+ "",
2027
+ ]
2028
+ )
2029
+
2030
+ if analysis.references:
2031
+ lines.extend(
2032
+ [
2033
+ "The following files matched the request. They are evidence, "
2034
+ "not a complete impact map. Inspect them and related files "
2035
+ "before writing requirements.",
2036
+ "",
2037
+ "| Area | File | Matched terms | Lines |",
2038
+ "| --- | --- | --- | --- |",
2039
+ ]
2040
+ )
2041
+ for reference in analysis.references:
2042
+ positions = (
2043
+ ", ".join(str(line) for line in reference.lines) or "-"
2044
+ )
2045
+ lines.append(
2046
+ f"| {reference.category} | `{reference.path}` | "
2047
+ f"{', '.join(reference.matches)} | {positions} |"
2048
+ )
2049
+ else:
2050
+ lines.extend(
2051
+ [
2052
+ "No directly related source files were found. The feature may "
2053
+ "be new, the project may use different terminology, or the "
2054
+ "request may need more detail.",
2055
+ "",
2056
+ ]
2057
+ )
2058
+
2059
+ lines.extend(
2060
+ [
2061
+ "",
2062
+ "## Dependencies",
2063
+ "",
2064
+ (
2065
+ "Declared dependencies found in the relevant files: "
2066
+ + ", ".join(
2067
+ f"`{dependency}`"
2068
+ for dependency in analysis.external_dependencies
2069
+ )
2070
+ if analysis.external_dependencies
2071
+ else "No existing third-party dependency was identified in "
2072
+ "the matched code. Confirm whether the change requires one."
2073
+ ),
2074
+ "",
2075
+ "## Initial Open Questions",
2076
+ "",
2077
+ ]
2078
+ )
2079
+ lines.extend(self._open_questions_lines(questions, discovery))
2080
+
2081
+ lines.extend(
2082
+ [
2083
+ "",
2084
+ "## Required Output",
2085
+ "",
2086
+ f"Write the final specification to `{spec_path.relative_to(config.root).as_posix()}`.",
2087
+ "",
2088
+ "Use the eleven numbered sections defined by the DevFlow "
2089
+ "specification prompt. Use `**FR-XXX:**` identifiers for "
2090
+ "functional requirements and `- [ ] **AC-XXX:**` checklist "
2091
+ "items for acceptance criteria.",
2092
+ "",
2093
+ "The specification must remain valid if the project later "
2094
+ "switches to a different agent harness.",
2095
+ "",
2096
+ ]
2097
+ )
2098
+ return "\n".join(lines)
2099
+
2100
+ def _render(
2101
+ self,
2102
+ spec_id: str,
2103
+ title: str,
2104
+ description: str,
2105
+ analysis: ImpactAnalysis,
2106
+ config: DevFlowConfig,
2107
+ project: Project,
2108
+ constitution: Constitution,
2109
+ profiles: list[Profile],
2110
+ questions: list[str],
2111
+ project_mode: str = "existing",
2112
+ architecture: ArchitectureManifest | None = None,
2113
+ discovery: DiscoveryDocument | None = None,
2114
+ discovery_digest: str | None = None,
2115
+ directory: Path | None = None,
2116
+ ) -> str:
2117
+ detected_profiles = [
2118
+ profile for profile in profiles if profile.name != "base"
2119
+ ]
2120
+ profile_names = ", ".join(
2121
+ profile.name for profile in detected_profiles
2122
+ )
2123
+ today = date.today().isoformat()
2124
+ clauses = split_requirements(description)[:6]
2125
+ target_directory = directory or (
2126
+ config.specifications_path / f"{spec_id}-{slugify(description)}"
2127
+ )
2128
+ spec_path = target_directory / "spec.md"
2129
+
2130
+ lines = [
2131
+ f"# Specification {spec_id}: {title}",
2132
+ "",
2133
+ "| Field | Value |",
2134
+ "| --- | --- |",
2135
+ "| Status | Draft |",
2136
+ f"| Created | {today} |",
2137
+ f"| Project | `{project.name}` |",
2138
+ f"| Project type | `{project.project_type}` |",
2139
+ f"| Project mode | `{project_mode}` |",
2140
+ f"| Profiles | {profile_names or 'none'} |",
2141
+ f"| Specification | `{spec_path.relative_to(config.root).as_posix()}` |",
2142
+ *self._architecture_header_rows(architecture, config.root),
2143
+ *self._discovery_header_rows(
2144
+ discovery,
2145
+ discovery_digest,
2146
+ config.specifications_path,
2147
+ config.root,
2148
+ ),
2149
+ "",
2150
+ "## 1. Request",
2151
+ "",
2152
+ "> " + "\n> ".join(description.strip().splitlines()),
2153
+ "",
2154
+ "## 2. Summary",
2155
+ "",
2156
+ "This specification describes the requested change and its "
2157
+ "expected behavior. It was generated from static inspection "
2158
+ "of the existing project and must be reviewed before "
2159
+ "implementation.",
2160
+ "",
2161
+ "## 3. Current Project State",
2162
+ "",
2163
+ "### 3.1 Detected stack",
2164
+ "",
2165
+ "| Area | Detected values |",
2166
+ "| --- | --- |",
2167
+ "| Languages | "
2168
+ + (", ".join(project.languages) or "not detected")
2169
+ + " |",
2170
+ "| Frameworks | "
2171
+ + (", ".join(project.frameworks) or "not detected")
2172
+ + " |",
2173
+ "| Databases | "
2174
+ + (", ".join(project.databases) or "not detected")
2175
+ + " |",
2176
+ "| Profiles | " + (profile_names or "base only") + " |",
2177
+ "| Source files | " + str(len(project.source_files)) + " |",
2178
+ "",
2179
+ "### 3.2 Relevant existing code",
2180
+ "",
2181
+ ]
2182
+
2183
+ if analysis.references:
2184
+ lines.extend(
2185
+ [
2186
+ "The following files matched the request. They are "
2187
+ "evidence for impact analysis, not a guarantee that every "
2188
+ "affected file was found.",
2189
+ "",
2190
+ "| Area | File | Matched terms | Lines |",
2191
+ "| --- | --- | --- | --- |",
2192
+ ]
2193
+ )
2194
+ for reference in analysis.references:
2195
+ matched = ", ".join(reference.matches)
2196
+ positions = (
2197
+ ", ".join(str(line) for line in reference.lines)
2198
+ or "-"
2199
+ )
2200
+ lines.append(
2201
+ f"| {reference.category} | `{reference.path}` | "
2202
+ f"{matched} | {positions} |"
2203
+ )
2204
+ else:
2205
+ lines.extend(
2206
+ [
2207
+ "No directly related source files were found for the "
2208
+ "request keywords. This may mean that the feature is new, "
2209
+ "that different terminology is used in the codebase, or "
2210
+ "that the request needs more detail.",
2211
+ "",
2212
+ "Request keywords: "
2213
+ + (", ".join(f"`{word}`" for word in analysis.keywords)
2214
+ or "none"),
2215
+ ]
2216
+ )
2217
+
2218
+ lines.extend(
2219
+ [
2220
+ "",
2221
+ f"Analysis scanned {analysis.scanned_files} text files.",
2222
+ "",
2223
+ "## 4. Impact Analysis",
2224
+ "",
2225
+ ]
2226
+ )
2227
+
2228
+ if analysis.categories:
2229
+ lines.extend(
2230
+ [
2231
+ "| Area | Potentially affected files |",
2232
+ "| --- | --- |",
2233
+ ]
2234
+ )
2235
+ for category, references in analysis.categories.items():
2236
+ files = ", ".join(
2237
+ f"`{reference.path}`" for reference in references[:6]
2238
+ )
2239
+ lines.append(f"| {category} | {files} |")
2240
+ else:
2241
+ lines.append(
2242
+ "No affected areas can be confirmed from the current "
2243
+ "codebase. Resolve the open questions before planning."
2244
+ )
2245
+
2246
+ lines.extend(
2247
+ [
2248
+ "",
2249
+ "### 4.1 Impact notes",
2250
+ "",
2251
+ "- Existing behavior in the identified areas must be "
2252
+ "preserved unless this specification explicitly changes it.",
2253
+ "- Public interfaces, background processes, integrations "
2254
+ "and data flows connected to these files require review.",
2255
+ "- The implementation must remain within the detected "
2256
+ "project profiles.",
2257
+ "",
2258
+ "## 5. Functional Requirements",
2259
+ "",
2260
+ ]
2261
+ )
2262
+
2263
+ for index, clause in enumerate(clauses, start=1):
2264
+ sentence = clause[0].upper() + clause[1:] if clause else clause
2265
+ lines.append(
2266
+ f"- **FR-{index:03d}:** The system must support the "
2267
+ f"following requested behavior: {sentence}."
2268
+ )
2269
+
2270
+ next_requirement = len(clauses) + 1
2271
+ if analysis.references:
2272
+ lines.append(
2273
+ f"- **FR-{next_requirement:03d}:** Existing behavior in the "
2274
+ "affected components must remain compatible unless a "
2275
+ "requirement above explicitly changes it."
2276
+ )
2277
+ next_requirement += 1
2278
+
2279
+ lines.extend(
2280
+ [
2281
+ f"- **FR-{next_requirement:03d}:** The change must comply "
2282
+ "with every document listed in the Constitution Applied "
2283
+ "section.",
2284
+ "",
2285
+ "## 6. Constraints and Dependencies",
2286
+ "",
2287
+ "### 6.1 Architectural and project constraints",
2288
+ "",
2289
+ "- Follow the project constitution. Guidelines are "
2290
+ "referenced, not duplicated, in this specification.",
2291
+ "- Respect existing module boundaries and reuse existing "
2292
+ "components where possible.",
2293
+ "- Do not introduce dependencies without an explicit "
2294
+ "requirement and review.",
2295
+ ]
2296
+ )
2297
+
2298
+ if analysis.external_dependencies:
2299
+ dependencies = ", ".join(
2300
+ f"`{dependency}`"
2301
+ for dependency in analysis.external_dependencies
2302
+ )
2303
+ lines.extend(
2304
+ [
2305
+ "",
2306
+ "### 6.2 Existing dependencies used by affected code",
2307
+ "",
2308
+ f"The following declared dependencies appear in the "
2309
+ f"relevant files: {dependencies}.",
2310
+ ]
2311
+ )
2312
+ else:
2313
+ lines.extend(
2314
+ [
2315
+ "",
2316
+ "### 6.2 Dependencies",
2317
+ "",
2318
+ "No existing third-party dependency was identified in "
2319
+ "the matched code. Confirm whether the change requires a "
2320
+ "new dependency.",
2321
+ ]
2322
+ )
2323
+
2324
+ if analysis.has_tests:
2325
+ lines.extend(
2326
+ [
2327
+ "",
2328
+ "Test files were detected in the project. Reuse the "
2329
+ "existing test structure, fixtures and factories.",
2330
+ ]
2331
+ )
2332
+ else:
2333
+ lines.extend(
2334
+ [
2335
+ "",
2336
+ "No test files were detected. The testing location and "
2337
+ "strategy must be confirmed.",
2338
+ ]
2339
+ )
2340
+
2341
+ lines.extend(
2342
+ [
2343
+ "",
2344
+ "## 7. Open Questions",
2345
+ "",
2346
+ ]
2347
+ )
2348
+ lines.extend(self._open_questions_lines(questions, discovery))
2349
+
2350
+ lines.extend(
2351
+ [
2352
+ "",
2353
+ "## 8. Acceptance Criteria",
2354
+ "",
2355
+ ]
2356
+ )
2357
+
2358
+ criteria: list[str] = []
2359
+ for index, _clause in enumerate(clauses, start=1):
2360
+ criteria.append(
2361
+ f"The behavior described by FR-{index:03d} is "
2362
+ "demonstrable and verified."
2363
+ )
2364
+
2365
+ if analysis.references:
2366
+ criteria.append(
2367
+ "Existing related flows and tests continue to pass without "
2368
+ "unexpected regressions."
2369
+ )
2370
+
2371
+ criteria.extend(
2372
+ [
2373
+ "Automated tests cover the new behavior, relevant edge "
2374
+ "cases and failure paths.",
2375
+ "The implementation complies with the referenced "
2376
+ "constitution documents.",
2377
+ "Documentation and configuration are updated when public "
2378
+ "behavior, setup or integrations change.",
2379
+ ]
2380
+ )
2381
+
2382
+ for index, criterion in enumerate(criteria, start=1):
2383
+ lines.append(
2384
+ f"- [ ] **AC-{index:03d}:** {criterion}"
2385
+ )
2386
+
2387
+ lines.extend(
2388
+ [
2389
+ "",
2390
+ "## 9. Constitution Applied",
2391
+ "",
2392
+ "| Document | Profile scope |",
2393
+ "| --- | --- |",
2394
+ ]
2395
+ )
2396
+
2397
+ for document in constitution.documents:
2398
+ scope = "project"
2399
+ for profile in profiles:
2400
+ if document.name in profile.constitution:
2401
+ scope = (
2402
+ "project" if profile.name == "base" else profile.name
2403
+ )
2404
+ break
2405
+ lines.append(
2406
+ f"| `.devflow/constitution/{document.name}` | {scope} |"
2407
+ )
2408
+
2409
+ lines.extend(
2410
+ [
2411
+ "",
2412
+ "Technology-specific rules apply only to the profiles listed "
2413
+ "above.",
2414
+ "",
2415
+ "## 10. Out of Scope",
2416
+ "",
2417
+ "No out-of-scope items have been confirmed. Resolve the open "
2418
+ "questions before implementation.",
2419
+ "",
2420
+ "## 11. Approval",
2421
+ "",
2422
+ "- [ ] The request and functional requirements are complete.",
2423
+ "- [ ] Open questions are resolved or explicitly accepted.",
2424
+ "- [ ] Acceptance criteria are agreed.",
2425
+ *self._discovery_approval_lines(discovery),
2426
+ "- [ ] The specification is approved for planning.",
2427
+ "",
2428
+ "---",
2429
+ "",
2430
+ "Generated by DevFlow. This document does not implement the "
2431
+ "change and must be reviewed by a developer.",
2432
+ "",
2433
+ ]
2434
+ )
2435
+
2436
+ if architecture is not None:
2437
+ lines.extend(
2438
+ [
2439
+ architecture.render_appendix(config.root),
2440
+ ]
2441
+ )
2442
+
2443
+ return "\n".join(lines)