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,1304 @@
1
+ """Persistent discovery and clarification for specification requests.
2
+
3
+ Discovery is deliberately separate from specification. It records the
4
+ questions that change scope, the answers supplied by a human, and the
5
+ technical assumptions proposed by DevFlow. A specification may only be
6
+ generated when every blocking question is answered and every proposed
7
+ assumption has been explicitly accepted.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import unicodedata
14
+ from dataclasses import dataclass, replace
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import yaml
19
+
20
+ from devflow.core.architecture import ArchitectureManifest
21
+ from devflow.core.artifacts.store import ArtifactStore
22
+ from devflow.core.clock import Clock, SystemClock, format_timestamp
23
+ from devflow.models.project import PROJECT_MODES, Project
24
+
25
+ DISCOVERY_FILENAME = "discovery.yaml"
26
+ MAX_DISCOVERY_BYTES = 512_000
27
+ MAX_QUESTIONS = 100
28
+ MAX_ASSUMPTIONS = 100
29
+ MAX_TEXT_LENGTH = 4_000
30
+ MAX_OPTIONS = 10
31
+
32
+ DISCOVERY_PENDING = "pending"
33
+ DISCOVERY_AWAITING = "awaiting_clarification"
34
+ DISCOVERY_COMPLETE = "complete"
35
+ DISCOVERY_STATUSES = (
36
+ DISCOVERY_PENDING,
37
+ DISCOVERY_AWAITING,
38
+ DISCOVERY_COMPLETE,
39
+ )
40
+
41
+ QUESTION_PENDING = "pending"
42
+ QUESTION_ANSWERED = "answered"
43
+ QUESTION_STATUSES = (QUESTION_PENDING, QUESTION_ANSWERED)
44
+
45
+ QUESTION_CATEGORIES = (
46
+ "functional_scope",
47
+ "user_behavior",
48
+ "data_persistence",
49
+ "security_authentication",
50
+ "external_integration",
51
+ "architecture_boundary",
52
+ "dependencies",
53
+ "deployment",
54
+ )
55
+
56
+ ASSUMPTION_PENDING = "pending_acceptance"
57
+ ASSUMPTION_ACCEPTED = "accepted"
58
+ ASSUMPTION_STATUSES = (ASSUMPTION_PENDING, ASSUMPTION_ACCEPTED)
59
+ ASSUMPTION_SOURCES = ("agent_default", "human", "existing_convention")
60
+
61
+ _QUESTION_ID = re.compile(r"^Q\d{1,3}$")
62
+ _ASSUMPTION_ID = re.compile(r"^A\d{1,3}$")
63
+ _SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$")
64
+
65
+ _STOPWORDS = {
66
+ "a",
67
+ "al",
68
+ "algo",
69
+ "and",
70
+ "con",
71
+ "de",
72
+ "del",
73
+ "el",
74
+ "en",
75
+ "for",
76
+ "from",
77
+ "in",
78
+ "la",
79
+ "las",
80
+ "lo",
81
+ "los",
82
+ "my",
83
+ "of",
84
+ "on",
85
+ "para",
86
+ "por",
87
+ "que",
88
+ "se",
89
+ "the",
90
+ "to",
91
+ "un",
92
+ "una",
93
+ "with",
94
+ "y",
95
+ }
96
+
97
+ _VAGUE_TERMS = {
98
+ "better",
99
+ "improve",
100
+ "improvement",
101
+ "mejor",
102
+ "mejora",
103
+ "mejorar",
104
+ "optimize",
105
+ "optimizacion",
106
+ "optimizar",
107
+ "optimization",
108
+ "robust",
109
+ "robusto",
110
+ "scalable",
111
+ "escalable",
112
+ "support",
113
+ "soportar",
114
+ "soporte",
115
+ }
116
+
117
+ _CONTACT_WORDS = {"contact", "contacto", "contactenos"}
118
+ _FORM_WORDS = {"form", "formulario"}
119
+ _PAGE_WORDS = {
120
+ "page",
121
+ "pagina",
122
+ "template",
123
+ "plantilla",
124
+ "view",
125
+ "vista",
126
+ }
127
+ _SUBMISSION_DECISIONS = {
128
+ "store",
129
+ "stored",
130
+ "save",
131
+ "saved",
132
+ "persist",
133
+ "persisted",
134
+ "database",
135
+ "db",
136
+ "email",
137
+ "correo",
138
+ "smtp",
139
+ "send_mail",
140
+ "confirmation",
141
+ "confirmacion",
142
+ "flash",
143
+ "almacenar",
144
+ "guardar",
145
+ }
146
+ _SUBMISSION_PHRASES = {
147
+ "no persistence",
148
+ "sin persistencia",
149
+ "only show",
150
+ "solo mostrar",
151
+ "confirmation message",
152
+ "mensaje de confirmacion",
153
+ }
154
+ _NO_PERSISTENCE_PHRASES = {
155
+ "no persistence",
156
+ "sin persistencia",
157
+ "do not persist",
158
+ "no guardar",
159
+ "only show",
160
+ "solo mostrar",
161
+ }
162
+
163
+ _AUTH_WORDS = {
164
+ "auth",
165
+ "authentication",
166
+ "autenticacion",
167
+ "authorization",
168
+ "autorizacion",
169
+ "contrasena",
170
+ "jwt",
171
+ "login",
172
+ "oauth",
173
+ "password",
174
+ "permission",
175
+ "permiso",
176
+ "session",
177
+ "sesion",
178
+ "token",
179
+ }
180
+ _AUTH_INTEGRATION_WORDS = {
181
+ "api",
182
+ "bearer",
183
+ "cookie",
184
+ "endpoint",
185
+ "html",
186
+ "rest",
187
+ "session",
188
+ "sesion",
189
+ "view",
190
+ "vista",
191
+ }
192
+ _DATA_WORDS = {
193
+ "campo",
194
+ "column",
195
+ "columna",
196
+ "data",
197
+ "database",
198
+ "datos",
199
+ "db",
200
+ "field",
201
+ "migration",
202
+ "migracion",
203
+ "model",
204
+ "modelo",
205
+ "persistencia",
206
+ "persistence",
207
+ "schema",
208
+ "tabla",
209
+ "table",
210
+ }
211
+ _PERSISTENCE_DECISIONS = {
212
+ "archivo",
213
+ "database",
214
+ "db",
215
+ "file",
216
+ "mongodb",
217
+ "mysql",
218
+ "postgres",
219
+ "postgresql",
220
+ "redis",
221
+ "sqlite",
222
+ }
223
+ _INTEGRATION_PHRASES = {
224
+ "send email",
225
+ "enviar correo",
226
+ "sendgrid",
227
+ "mailgun",
228
+ "smtp",
229
+ "stripe",
230
+ "webhook",
231
+ "third party",
232
+ "integrate with",
233
+ "integration with",
234
+ "integrar con",
235
+ "payment provider",
236
+ "pasarela de pago",
237
+ }
238
+ _PROVIDER_WORDS = {
239
+ "aws",
240
+ "gcp",
241
+ "github",
242
+ "google",
243
+ "mailgun",
244
+ "oauth",
245
+ "sendgrid",
246
+ "slack",
247
+ "smtp",
248
+ "stripe",
249
+ "twilio",
250
+ }
251
+ _DEPLOYMENT_WORDS = {
252
+ "aws",
253
+ "azure",
254
+ "cd",
255
+ "ci",
256
+ "deploy",
257
+ "deployment",
258
+ "desplegar",
259
+ "despliegue",
260
+ "docker",
261
+ "gcp",
262
+ "heroku",
263
+ "kubernetes",
264
+ "produccion",
265
+ "production",
266
+ "vercel",
267
+ }
268
+ _DEPLOYMENT_TARGETS = {
269
+ "aws",
270
+ "azure",
271
+ "docker",
272
+ "gcp",
273
+ "heroku",
274
+ "kubernetes",
275
+ "vercel",
276
+ }
277
+ _ARCHITECTURE_WORDS = {
278
+ "architecture",
279
+ "arquitectura",
280
+ "boundaries",
281
+ "boundary",
282
+ "capa",
283
+ "layer",
284
+ "microservice",
285
+ "microservicio",
286
+ "modulo",
287
+ "module",
288
+ "refactor",
289
+ }
290
+
291
+
292
+ class DiscoveryError(Exception):
293
+ """Raised when discovery data is missing or malformed."""
294
+
295
+
296
+ class DiscoveryIncompleteError(DiscoveryError):
297
+ """Raised when a workflow requires a complete discovery."""
298
+
299
+
300
+ @dataclass(frozen=True)
301
+ class DiscoveryQuestion:
302
+ """One decision that may block specification generation."""
303
+
304
+ id: str
305
+ category: str
306
+ question: str
307
+ reason: str
308
+ blocking: bool
309
+ status: str = QUESTION_PENDING
310
+ options: tuple[str, ...] = ()
311
+ answer: str | None = None
312
+ answered_by: str | None = None
313
+ answered_at: str | None = None
314
+
315
+ @property
316
+ def is_answered(self) -> bool:
317
+ return self.status == QUESTION_ANSWERED and bool(self.answer)
318
+
319
+ def to_dict(self) -> dict[str, Any]:
320
+ return {
321
+ "id": self.id,
322
+ "category": self.category,
323
+ "question": self.question,
324
+ "reason": self.reason,
325
+ "blocking": self.blocking,
326
+ "status": self.status,
327
+ "options": list(self.options),
328
+ "answer": self.answer,
329
+ "answered_by": self.answered_by,
330
+ "answered_at": self.answered_at,
331
+ }
332
+
333
+
334
+ @dataclass(frozen=True)
335
+ class DiscoveryAssumption:
336
+ """A technical assumption that needs explicit human acceptance."""
337
+
338
+ id: str
339
+ value: str
340
+ reason: str
341
+ source: str = "agent_default"
342
+ status: str = ASSUMPTION_PENDING
343
+ question_id: str | None = None
344
+ accepted_by: str | None = None
345
+ accepted_at: str | None = None
346
+
347
+ @property
348
+ def is_accepted(self) -> bool:
349
+ return self.status == ASSUMPTION_ACCEPTED
350
+
351
+ def to_dict(self) -> dict[str, Any]:
352
+ return {
353
+ "id": self.id,
354
+ "question_id": self.question_id,
355
+ "value": self.value,
356
+ "reason": self.reason,
357
+ "source": self.source,
358
+ "status": self.status,
359
+ "accepted_by": self.accepted_by,
360
+ "accepted_at": self.accepted_at,
361
+ }
362
+
363
+
364
+ @dataclass(frozen=True)
365
+ class DiscoveryDocument:
366
+ """Persistent discovery record for one change."""
367
+
368
+ version: int
369
+ spec_id: str
370
+ change: str
371
+ request: str
372
+ project_mode: str
373
+ status: str
374
+ created_at: str
375
+ updated_at: str
376
+ questions: tuple[DiscoveryQuestion, ...] = ()
377
+ assumptions: tuple[DiscoveryAssumption, ...] = ()
378
+
379
+ @property
380
+ def pending_blocking(self) -> tuple[DiscoveryQuestion, ...]:
381
+ return tuple(
382
+ question
383
+ for question in self.questions
384
+ if question.blocking and not question.is_answered
385
+ )
386
+
387
+ @property
388
+ def pending_assumptions(
389
+ self,
390
+ ) -> tuple[DiscoveryAssumption, ...]:
391
+ return tuple(
392
+ assumption
393
+ for assumption in self.assumptions
394
+ if not assumption.is_accepted
395
+ )
396
+
397
+ @property
398
+ def derived_status(self) -> str:
399
+ if self.pending_blocking or self.pending_assumptions:
400
+ return DISCOVERY_AWAITING
401
+ if self.status == DISCOVERY_PENDING:
402
+ return DISCOVERY_PENDING
403
+ return DISCOVERY_COMPLETE
404
+
405
+ @property
406
+ def is_complete(self) -> bool:
407
+ return self.derived_status == DISCOVERY_COMPLETE
408
+
409
+ def blockers(self) -> tuple[str, ...]:
410
+ messages: list[str] = []
411
+ for question in self.pending_blocking:
412
+ messages.append(f"{question.id}: {question.question}")
413
+ for assumption in self.pending_assumptions:
414
+ messages.append(
415
+ f"{assumption.id}: proposed assumption requires acceptance: "
416
+ f"{assumption.value}"
417
+ )
418
+ if not messages and self.status == DISCOVERY_PENDING:
419
+ messages.append("Discovery has not been completed.")
420
+ return tuple(messages)
421
+
422
+ def decisions(self) -> tuple[str, ...]:
423
+ decisions: list[str] = []
424
+ for question in self.questions:
425
+ if question.is_answered:
426
+ decisions.append(
427
+ f"{question.id} ({question.category}): "
428
+ f"{question.question} -> {question.answer}"
429
+ )
430
+ for assumption in self.assumptions:
431
+ if assumption.is_accepted:
432
+ decisions.append(
433
+ f"{assumption.id} (accepted by "
434
+ f"{assumption.accepted_by}): {assumption.value}"
435
+ )
436
+ return tuple(decisions)
437
+
438
+ def to_dict(self) -> dict[str, Any]:
439
+ return {
440
+ "version": self.version,
441
+ "spec_id": self.spec_id,
442
+ "change": self.change,
443
+ "request": self.request,
444
+ "project_mode": self.project_mode,
445
+ "status": self.status,
446
+ "created_at": self.created_at,
447
+ "updated_at": self.updated_at,
448
+ "questions": [
449
+ question.to_dict() for question in self.questions
450
+ ],
451
+ "assumptions": [
452
+ assumption.to_dict() for assumption in self.assumptions
453
+ ],
454
+ }
455
+
456
+
457
+ class DiscoveryManager:
458
+ """Read, validate and write discovery records safely."""
459
+
460
+ def __init__(
461
+ self,
462
+ root: Path,
463
+ *,
464
+ clock: Clock | None = None,
465
+ ) -> None:
466
+ self.root = root.resolve()
467
+ self.store = ArtifactStore(self.root)
468
+ self.clock = clock or SystemClock()
469
+
470
+ def path(self, directory: Path) -> Path:
471
+ """Return the discovery path for a change directory."""
472
+ return directory / DISCOVERY_FILENAME
473
+
474
+ def exists(self, directory: Path) -> bool:
475
+ """Return whether a discovery file exists for a change."""
476
+ path = self.path(directory)
477
+ return path.exists() or path.is_symlink()
478
+
479
+ def load(
480
+ self,
481
+ directory: Path,
482
+ *,
483
+ required: bool = False,
484
+ ) -> DiscoveryDocument | None:
485
+ """Load and validate discovery data for a change."""
486
+ path = self.path(directory)
487
+ if path.is_symlink():
488
+ msg = (
489
+ "Discovery file must not be a symbolic link: "
490
+ f"{path}"
491
+ )
492
+ raise DiscoveryError(msg)
493
+ if not path.is_file():
494
+ if required:
495
+ msg = f"Discovery file does not exist: {path}"
496
+ raise DiscoveryError(msg)
497
+ return None
498
+ try:
499
+ size = path.stat().st_size
500
+ except OSError as error:
501
+ msg = f"Could not inspect discovery file: {path}"
502
+ raise DiscoveryError(msg) from error
503
+ if size > MAX_DISCOVERY_BYTES:
504
+ msg = (
505
+ "Discovery file is too large: "
506
+ f"{size} bytes (max {MAX_DISCOVERY_BYTES})."
507
+ )
508
+ raise DiscoveryError(msg)
509
+ try:
510
+ content = path.read_text(encoding="utf-8")
511
+ except (OSError, UnicodeDecodeError) as error:
512
+ msg = f"Could not read discovery file: {path}"
513
+ raise DiscoveryError(msg) from error
514
+ try:
515
+ data = yaml.safe_load(content)
516
+ except yaml.YAMLError as error:
517
+ msg = f"Invalid YAML in discovery file: {path}"
518
+ raise DiscoveryError(msg) from error
519
+ document = self._parse(data, directory)
520
+ if (
521
+ document.status == DISCOVERY_COMPLETE
522
+ and (
523
+ document.pending_blocking
524
+ or document.pending_assumptions
525
+ )
526
+ ):
527
+ msg = (
528
+ "Discovery is marked complete but still has pending "
529
+ "blocking questions or assumptions."
530
+ )
531
+ raise DiscoveryError(msg)
532
+ return replace(document, status=document.derived_status)
533
+
534
+ def write(
535
+ self,
536
+ directory: Path,
537
+ document: DiscoveryDocument,
538
+ ) -> tuple[Path, str]:
539
+ """Write discovery data atomically and return its digest."""
540
+ now = format_timestamp(self.clock.now())
541
+ normalized = replace(
542
+ document,
543
+ status=document.derived_status,
544
+ updated_at=now,
545
+ )
546
+ path = self.path(directory)
547
+ if path.is_symlink():
548
+ msg = (
549
+ "Discovery file must not be a symbolic link: "
550
+ f"{path}"
551
+ )
552
+ raise DiscoveryError(msg)
553
+ self.store.write(path, self._dump(normalized))
554
+ return path, self.store.digest_file(path)
555
+
556
+ def answer(
557
+ self,
558
+ document: DiscoveryDocument,
559
+ question_id: str,
560
+ answer: str,
561
+ *,
562
+ by: str = "user",
563
+ at: str | None = None,
564
+ ) -> DiscoveryDocument:
565
+ """Return discovery with one question answered explicitly."""
566
+ value = answer.strip()
567
+ if not value:
568
+ msg = "A discovery answer cannot be empty."
569
+ raise DiscoveryError(msg)
570
+ timestamp = at or format_timestamp(self.clock.now())
571
+ found = False
572
+ questions: list[DiscoveryQuestion] = []
573
+ for question in document.questions:
574
+ if question.id != question_id:
575
+ questions.append(question)
576
+ continue
577
+ found = True
578
+ questions.append(
579
+ replace(
580
+ question,
581
+ status=QUESTION_ANSWERED,
582
+ answer=value,
583
+ answered_by=by,
584
+ answered_at=timestamp,
585
+ )
586
+ )
587
+ if not found:
588
+ msg = f"Unknown discovery question: {question_id}"
589
+ raise DiscoveryError(msg)
590
+ updated = replace(
591
+ document,
592
+ questions=tuple(questions),
593
+ updated_at=timestamp,
594
+ )
595
+ return replace(updated, status=updated.derived_status)
596
+
597
+ def accept(
598
+ self,
599
+ document: DiscoveryDocument,
600
+ assumption_id: str,
601
+ *,
602
+ by: str = "user",
603
+ at: str | None = None,
604
+ ) -> DiscoveryDocument:
605
+ """Return discovery with one assumption explicitly accepted."""
606
+ timestamp = at or format_timestamp(self.clock.now())
607
+ found = False
608
+ assumptions: list[DiscoveryAssumption] = []
609
+ for assumption in document.assumptions:
610
+ if assumption.id != assumption_id:
611
+ assumptions.append(assumption)
612
+ continue
613
+ found = True
614
+ assumptions.append(
615
+ replace(
616
+ assumption,
617
+ status=ASSUMPTION_ACCEPTED,
618
+ accepted_by=by,
619
+ accepted_at=timestamp,
620
+ )
621
+ )
622
+ if not found:
623
+ msg = f"Unknown discovery assumption: {assumption_id}"
624
+ raise DiscoveryError(msg)
625
+ updated = replace(
626
+ document,
627
+ assumptions=tuple(assumptions),
628
+ updated_at=timestamp,
629
+ )
630
+ return replace(updated, status=updated.derived_status)
631
+
632
+ def _dump(self, document: DiscoveryDocument) -> str:
633
+ return yaml.safe_dump(
634
+ document.to_dict(),
635
+ sort_keys=False,
636
+ allow_unicode=True,
637
+ width=1000,
638
+ )
639
+
640
+ def _parse(
641
+ self,
642
+ data: object,
643
+ directory: Path,
644
+ ) -> DiscoveryDocument:
645
+ mapping = _mapping(data, "discovery")
646
+ version = _integer(mapping.get("version"), "version")
647
+ if version != 1:
648
+ msg = "Only discovery version 1 is supported."
649
+ raise DiscoveryError(msg)
650
+ spec_id = _text(mapping.get("spec_id"), "spec_id")
651
+ change = _text(mapping.get("change"), "change")
652
+ request = _text(mapping.get("request"), "request")
653
+ project_mode = _text(
654
+ mapping.get("project_mode"),
655
+ "project_mode",
656
+ ).lower()
657
+ if project_mode not in PROJECT_MODES:
658
+ msg = (
659
+ "project_mode must be one of: "
660
+ + ", ".join(PROJECT_MODES)
661
+ + "."
662
+ )
663
+ raise DiscoveryError(msg)
664
+ status = _text(mapping.get("status"), "status")
665
+ if status not in DISCOVERY_STATUSES:
666
+ msg = (
667
+ "status must be one of: "
668
+ + ", ".join(DISCOVERY_STATUSES)
669
+ + "."
670
+ )
671
+ raise DiscoveryError(msg)
672
+ created_at = _text(mapping.get("created_at"), "created_at")
673
+ updated_at = _text(mapping.get("updated_at"), "updated_at")
674
+
675
+ if change != directory.name:
676
+ msg = (
677
+ f"Discovery change '{change}' does not match directory "
678
+ f"'{directory.name}'."
679
+ )
680
+ raise DiscoveryError(msg)
681
+ if not change.startswith(f"{spec_id}-"):
682
+ msg = (
683
+ f"Discovery spec_id '{spec_id}' does not match change "
684
+ f"'{change}'."
685
+ )
686
+ raise DiscoveryError(msg)
687
+
688
+ questions_data = _list(mapping.get("questions"), "questions")
689
+ assumptions_data = _list(
690
+ mapping.get("assumptions"),
691
+ "assumptions",
692
+ )
693
+ if len(questions_data) > MAX_QUESTIONS:
694
+ msg = f"At most {MAX_QUESTIONS} discovery questions are allowed."
695
+ raise DiscoveryError(msg)
696
+ if len(assumptions_data) > MAX_ASSUMPTIONS:
697
+ msg = (
698
+ f"At most {MAX_ASSUMPTIONS} discovery assumptions are allowed."
699
+ )
700
+ raise DiscoveryError(msg)
701
+
702
+ questions = tuple(
703
+ self._question(item, index)
704
+ for index, item in enumerate(questions_data, start=1)
705
+ )
706
+ question_ids = {question.id for question in questions}
707
+ if len(question_ids) != len(questions):
708
+ msg = "Discovery question ids must be unique."
709
+ raise DiscoveryError(msg)
710
+
711
+ assumptions = tuple(
712
+ self._assumption(item, index, question_ids)
713
+ for index, item in enumerate(assumptions_data, start=1)
714
+ )
715
+ assumption_ids = {
716
+ assumption.id for assumption in assumptions
717
+ }
718
+ if len(assumption_ids) != len(assumptions):
719
+ msg = "Discovery assumption ids must be unique."
720
+ raise DiscoveryError(msg)
721
+
722
+ document = DiscoveryDocument(
723
+ version=version,
724
+ spec_id=spec_id,
725
+ change=change,
726
+ request=request,
727
+ project_mode=project_mode,
728
+ status=status,
729
+ created_at=created_at,
730
+ updated_at=updated_at,
731
+ questions=questions,
732
+ assumptions=assumptions,
733
+ )
734
+ if (
735
+ document.status == DISCOVERY_COMPLETE
736
+ and (
737
+ document.pending_blocking
738
+ or document.pending_assumptions
739
+ )
740
+ ):
741
+ msg = (
742
+ "Discovery is marked complete but still has pending "
743
+ "blocking questions or assumptions."
744
+ )
745
+ raise DiscoveryError(msg)
746
+ return document
747
+
748
+ def _question(
749
+ self,
750
+ data: object,
751
+ index: int,
752
+ ) -> DiscoveryQuestion:
753
+ mapping = _mapping(data, f"questions[{index}]")
754
+ identifier = _text(mapping.get("id"), "question id")
755
+ if not _QUESTION_ID.fullmatch(identifier):
756
+ msg = f"Invalid discovery question id: {identifier!r}"
757
+ raise DiscoveryError(msg)
758
+ category = _text(mapping.get("category"), "question category")
759
+ if category not in QUESTION_CATEGORIES:
760
+ msg = (
761
+ f"Invalid discovery question category: {category!r}"
762
+ )
763
+ raise DiscoveryError(msg)
764
+ question = _limited_text(
765
+ mapping.get("question"),
766
+ f"{identifier}.question",
767
+ )
768
+ reason = _limited_text(
769
+ mapping.get("reason"),
770
+ f"{identifier}.reason",
771
+ )
772
+ blocking = _boolean(mapping.get("blocking"), f"{identifier}.blocking")
773
+ status = _text(mapping.get("status"), f"{identifier}.status")
774
+ if status not in QUESTION_STATUSES:
775
+ msg = (
776
+ f"Invalid status for {identifier}: {status!r}. "
777
+ f"Expected one of: {', '.join(QUESTION_STATUSES)}."
778
+ )
779
+ raise DiscoveryError(msg)
780
+ options = tuple(
781
+ _limited_text(item, f"{identifier}.options")
782
+ for item in _list(
783
+ mapping.get("options", []),
784
+ f"{identifier}.options",
785
+ )
786
+ )
787
+ if len(options) > MAX_OPTIONS:
788
+ msg = f"{identifier} has too many options."
789
+ raise DiscoveryError(msg)
790
+ answer = _optional_text(mapping.get("answer"), f"{identifier}.answer")
791
+ answered_by = _optional_text(
792
+ mapping.get("answered_by"),
793
+ f"{identifier}.answered_by",
794
+ )
795
+ answered_at = _optional_text(
796
+ mapping.get("answered_at"),
797
+ f"{identifier}.answered_at",
798
+ )
799
+ if status == QUESTION_ANSWERED:
800
+ if not answer:
801
+ raise DiscoveryError(
802
+ f"{identifier} is answered but has no answer."
803
+ )
804
+ if not answered_by:
805
+ raise DiscoveryError(
806
+ f"{identifier} is answered but has no answered_by."
807
+ )
808
+ if not answered_at:
809
+ raise DiscoveryError(
810
+ f"{identifier} is answered but has no answered_at."
811
+ )
812
+ elif answer or answered_by or answered_at:
813
+ raise DiscoveryError(
814
+ f"{identifier} is pending but contains answer metadata."
815
+ )
816
+ return DiscoveryQuestion(
817
+ id=identifier,
818
+ category=category,
819
+ question=question,
820
+ reason=reason,
821
+ blocking=blocking,
822
+ status=status,
823
+ options=options,
824
+ answer=answer,
825
+ answered_by=answered_by,
826
+ answered_at=answered_at,
827
+ )
828
+
829
+ def _assumption(
830
+ self,
831
+ data: object,
832
+ index: int,
833
+ question_ids: set[str],
834
+ ) -> DiscoveryAssumption:
835
+ mapping = _mapping(data, f"assumptions[{index}]")
836
+ identifier = _text(mapping.get("id"), "assumption id")
837
+ if not _ASSUMPTION_ID.fullmatch(identifier):
838
+ msg = f"Invalid discovery assumption id: {identifier!r}"
839
+ raise DiscoveryError(msg)
840
+ question_id = _optional_text(
841
+ mapping.get("question_id"),
842
+ f"{identifier}.question_id",
843
+ )
844
+ if question_id is not None and question_id not in question_ids:
845
+ msg = (
846
+ f"{identifier} references unknown question "
847
+ f"{question_id!r}."
848
+ )
849
+ raise DiscoveryError(msg)
850
+ value = _limited_text(
851
+ mapping.get("value"),
852
+ f"{identifier}.value",
853
+ )
854
+ reason = _limited_text(
855
+ mapping.get("reason"),
856
+ f"{identifier}.reason",
857
+ )
858
+ source = _text(mapping.get("source"), f"{identifier}.source")
859
+ if source not in ASSUMPTION_SOURCES:
860
+ msg = (
861
+ f"Invalid source for {identifier}: {source!r}. "
862
+ f"Expected one of: {', '.join(ASSUMPTION_SOURCES)}."
863
+ )
864
+ raise DiscoveryError(msg)
865
+ status = _text(mapping.get("status"), f"{identifier}.status")
866
+ if status not in ASSUMPTION_STATUSES:
867
+ msg = (
868
+ f"Invalid status for {identifier}: {status!r}. "
869
+ f"Expected one of: {', '.join(ASSUMPTION_STATUSES)}."
870
+ )
871
+ raise DiscoveryError(msg)
872
+ accepted_by = _optional_text(
873
+ mapping.get("accepted_by"),
874
+ f"{identifier}.accepted_by",
875
+ )
876
+ accepted_at = _optional_text(
877
+ mapping.get("accepted_at"),
878
+ f"{identifier}.accepted_at",
879
+ )
880
+ if status == ASSUMPTION_ACCEPTED:
881
+ if not accepted_by or not accepted_at:
882
+ raise DiscoveryError(
883
+ f"{identifier} is accepted but has no acceptance metadata."
884
+ )
885
+ elif accepted_by or accepted_at:
886
+ raise DiscoveryError(
887
+ f"{identifier} is pending but contains acceptance metadata."
888
+ )
889
+ return DiscoveryAssumption(
890
+ id=identifier,
891
+ question_id=question_id,
892
+ value=value,
893
+ reason=reason,
894
+ source=source,
895
+ status=status,
896
+ accepted_by=accepted_by,
897
+ accepted_at=accepted_at,
898
+ )
899
+
900
+
901
+ class DiscoveryPlanner:
902
+ """Generate deterministic discovery questions from a request."""
903
+
904
+ def build(
905
+ self,
906
+ *,
907
+ request: str,
908
+ spec_id: str,
909
+ change: str,
910
+ project_mode: str,
911
+ project: Project,
912
+ profiles: tuple[str, ...],
913
+ reference_paths: tuple[str, ...],
914
+ category_paths: dict[str, tuple[str, ...]],
915
+ has_tests: bool,
916
+ architecture: ArchitectureManifest | None,
917
+ now: str,
918
+ ) -> DiscoveryDocument:
919
+ """Return a deterministic discovery record for a request."""
920
+ text = _normalize(request)
921
+ words = _words(text)
922
+ questions: list[DiscoveryQuestion] = []
923
+ assumptions: list[DiscoveryAssumption] = []
924
+
925
+ def add_question(
926
+ category: str,
927
+ question: str,
928
+ reason: str,
929
+ options: tuple[str, ...] = (),
930
+ ) -> str | None:
931
+ if any(
932
+ item.question.casefold() == question.casefold()
933
+ for item in questions
934
+ ):
935
+ return None
936
+ identifier = f"Q{len(questions) + 1}"
937
+ questions.append(
938
+ DiscoveryQuestion(
939
+ id=identifier,
940
+ category=category,
941
+ question=question,
942
+ reason=reason,
943
+ blocking=True,
944
+ options=options,
945
+ )
946
+ )
947
+ return identifier
948
+
949
+ keywords = [
950
+ word
951
+ for word in words
952
+ if len(word) >= 3 and word not in _STOPWORDS
953
+ ]
954
+ if len(keywords) < 2:
955
+ add_question(
956
+ "functional_scope",
957
+ "What exact behavior should change, and how will it be "
958
+ "verified?",
959
+ "The request is too short to define a testable scope.",
960
+ (
961
+ "Describe the user-visible behavior.",
962
+ "Describe the expected technical outcome.",
963
+ ),
964
+ )
965
+
966
+ vague = sorted(words & _VAGUE_TERMS)
967
+ if vague:
968
+ target = vague[0]
969
+ add_question(
970
+ "functional_scope",
971
+ f"What measurable outcome defines '{target}' for this change?",
972
+ "A vague goal changes the functional scope and the acceptance "
973
+ "criteria.",
974
+ (
975
+ "Define the expected observable behavior.",
976
+ "Define a measurable target.",
977
+ "Describe the current pain point.",
978
+ ),
979
+ )
980
+
981
+ contact_question: str | None = None
982
+ is_contact_page = (
983
+ bool(words & _CONTACT_WORDS)
984
+ and bool(words & (_FORM_WORDS | _PAGE_WORDS))
985
+ )
986
+ if is_contact_page and not _has_submission_decision(text, words):
987
+ contact_question = add_question(
988
+ "functional_scope",
989
+ "What should happen when the contact form is submitted?",
990
+ "Persistence, confirmation and email delivery change the data "
991
+ "model, dependencies and user-visible behavior.",
992
+ (
993
+ "Only show a confirmation message.",
994
+ "Store messages in the database.",
995
+ "Send an email notification.",
996
+ "Store messages and send an email.",
997
+ ),
998
+ )
999
+ if contact_question and not _has_contact_names(text, words):
1000
+ assumptions.append(
1001
+ DiscoveryAssumption(
1002
+ id="A1",
1003
+ question_id=contact_question,
1004
+ value=(
1005
+ "Use `contacto.html` as the template and `/contacto/` as "
1006
+ "the URL path."
1007
+ ),
1008
+ reason=(
1009
+ "Internal naming and routing can follow existing project "
1010
+ "conventions, but the choice must be visible."
1011
+ ),
1012
+ source="agent_default",
1013
+ )
1014
+ )
1015
+
1016
+ auth_question: str | None = None
1017
+ is_auth_request = bool(words & _AUTH_WORDS)
1018
+ if is_auth_request and not (words & _AUTH_INTEGRATION_WORDS):
1019
+ auth_question = add_question(
1020
+ "security_authentication",
1021
+ "How should authentication be integrated into the existing "
1022
+ "application?",
1023
+ "API tokens, browser cookies and session authentication change "
1024
+ "security boundaries, dependencies and user-visible behavior.",
1025
+ (
1026
+ "New JSON API with Bearer tokens.",
1027
+ "JWT in an HttpOnly cookie for HTML views.",
1028
+ "Both the JSON API and the HTML views.",
1029
+ "Other (describe).",
1030
+ ),
1031
+ )
1032
+ if auth_question and (words & {"jwt", "oauth", "token"}):
1033
+ if not (words & {
1034
+ "django",
1035
+ "djangorestframework",
1036
+ "framework",
1037
+ "pyjwt",
1038
+ "rest",
1039
+ "simplejwt",
1040
+ }):
1041
+ assumptions.append(
1042
+ DiscoveryAssumption(
1043
+ id=f"A{len(assumptions) + 1}",
1044
+ question_id=auth_question,
1045
+ value=(
1046
+ "Use the existing user identity source and a maintained "
1047
+ "JWT library instead of custom cryptographic code."
1048
+ ),
1049
+ reason=(
1050
+ "The library and identity source change dependencies and "
1051
+ "security behavior, so the assumption must be accepted."
1052
+ ),
1053
+ source="agent_default",
1054
+ )
1055
+ )
1056
+ if not (words & {
1057
+ "day",
1058
+ "days",
1059
+ "expiracion",
1060
+ "hour",
1061
+ "hours",
1062
+ "lifetime",
1063
+ "minute",
1064
+ "minutes",
1065
+ }):
1066
+ assumptions.append(
1067
+ DiscoveryAssumption(
1068
+ id=f"A{len(assumptions) + 1}",
1069
+ question_id=auth_question,
1070
+ value=(
1071
+ "Use short-lived access tokens with refresh tokens and "
1072
+ "define both lifetimes in project settings."
1073
+ ),
1074
+ reason=(
1075
+ "Token lifetimes are a security decision and must not be "
1076
+ "invented silently."
1077
+ ),
1078
+ source="agent_default",
1079
+ )
1080
+ )
1081
+
1082
+ if (
1083
+ words & _DATA_WORDS
1084
+ and not project.databases
1085
+ and not _has_persistence_decision(text, words)
1086
+ and "Database migrations" not in category_paths
1087
+ ):
1088
+ add_question(
1089
+ "data_persistence",
1090
+ "Where should the new data be stored and how should it be "
1091
+ "persisted?",
1092
+ "Persistence changes the data model, migrations, tests and "
1093
+ "deployment requirements.",
1094
+ (
1095
+ "Use the existing database.",
1096
+ "Create a new database or service.",
1097
+ "No persistence is required.",
1098
+ ),
1099
+ )
1100
+
1101
+ if (
1102
+ contact_question is None
1103
+ and _has_external_integration(text, words)
1104
+ and not (words & _PROVIDER_WORDS)
1105
+ ):
1106
+ add_question(
1107
+ "external_integration",
1108
+ "Which external service or integration should be used?",
1109
+ "The provider, credentials and failure behavior change the "
1110
+ "implementation and operations.",
1111
+ (
1112
+ "Describe the provider and its configuration.",
1113
+ "Reuse an existing integration.",
1114
+ "No external integration is required.",
1115
+ ),
1116
+ )
1117
+
1118
+ if (
1119
+ words & _DEPLOYMENT_WORDS
1120
+ and not (words & _DEPLOYMENT_TARGETS)
1121
+ ):
1122
+ add_question(
1123
+ "deployment",
1124
+ "Which deployment target and environment should the change "
1125
+ "support?",
1126
+ "Deployment constraints change configuration, secrets and "
1127
+ "operational requirements.",
1128
+ (
1129
+ "Use the existing deployment target.",
1130
+ "Add a new target (describe it).",
1131
+ "Local development only.",
1132
+ ),
1133
+ )
1134
+
1135
+ if (
1136
+ project_mode == "existing"
1137
+ and architecture is None
1138
+ and words & _ARCHITECTURE_WORDS
1139
+ ):
1140
+ add_question(
1141
+ "architecture_boundary",
1142
+ "Which module boundaries and dependency direction should the "
1143
+ "new behavior follow?",
1144
+ "Without a declared architecture manifest, ownership and "
1145
+ "dependency boundaries are ambiguous.",
1146
+ (
1147
+ "Follow the existing project structure.",
1148
+ "Create a new module (describe it).",
1149
+ "Other (describe).",
1150
+ ),
1151
+ )
1152
+
1153
+ if project_mode == "greenfield" and architecture is not None:
1154
+ if not architecture.stack:
1155
+ add_question(
1156
+ "dependencies",
1157
+ "Which technology stack should the new system use?",
1158
+ "The declared architecture manifest does not define the "
1159
+ "languages, frameworks or database.",
1160
+ (
1161
+ "Complete the architecture manifest.",
1162
+ "Describe the stack for this change.",
1163
+ ),
1164
+ )
1165
+
1166
+ questions = questions[:4]
1167
+ question_ids = {question.id for question in questions}
1168
+ assumptions = [
1169
+ assumption
1170
+ for assumption in assumptions[:3]
1171
+ if assumption.question_id is None
1172
+ or assumption.question_id in question_ids
1173
+ ]
1174
+ # Keep assumption ids contiguous after filtering.
1175
+ assumptions = [
1176
+ replace(assumption, id=f"A{index}")
1177
+ for index, assumption in enumerate(assumptions, start=1)
1178
+ ]
1179
+
1180
+ document = DiscoveryDocument(
1181
+ version=1,
1182
+ spec_id=spec_id,
1183
+ change=change,
1184
+ request=request.strip(),
1185
+ project_mode=project_mode,
1186
+ status=(
1187
+ DISCOVERY_COMPLETE
1188
+ if not questions and not assumptions
1189
+ else DISCOVERY_PENDING
1190
+ ),
1191
+ created_at=now,
1192
+ updated_at=now,
1193
+ questions=tuple(questions),
1194
+ assumptions=tuple(assumptions),
1195
+ )
1196
+ return replace(document, status=document.derived_status)
1197
+
1198
+
1199
+ def _normalize(value: str) -> str:
1200
+ normalized = unicodedata.normalize("NFKD", value)
1201
+ without_accents = "".join(
1202
+ character
1203
+ for character in normalized
1204
+ if not unicodedata.combining(character)
1205
+ )
1206
+ return without_accents.lower()
1207
+
1208
+
1209
+ def _words(text: str) -> set[str]:
1210
+ return set(re.findall(r"[a-z0-9_]+", text))
1211
+
1212
+
1213
+ def _has_submission_decision(text: str, words: set[str]) -> bool:
1214
+ if words & _SUBMISSION_DECISIONS:
1215
+ return True
1216
+ return _has_phrase(text, _SUBMISSION_PHRASES)
1217
+
1218
+
1219
+ def _has_contact_names(text: str, words: set[str]) -> bool:
1220
+ return (
1221
+ "contacto.html" in text
1222
+ or "/contacto/" in text
1223
+ or "contact.html" in text
1224
+ or "/contact/" in text
1225
+ )
1226
+
1227
+
1228
+ def _has_persistence_decision(text: str, words: set[str]) -> bool:
1229
+ if words & _PERSISTENCE_DECISIONS:
1230
+ return True
1231
+ return _has_phrase(text, _NO_PERSISTENCE_PHRASES)
1232
+
1233
+
1234
+ def _has_external_integration(text: str, words: set[str]) -> bool:
1235
+ return _has_phrase(text, _INTEGRATION_PHRASES) or (
1236
+ bool(words & {"webhook", "webhooks"})
1237
+ )
1238
+
1239
+
1240
+ def _has_phrase(text: str, phrases: set[str]) -> bool:
1241
+ return any(phrase in text for phrase in phrases)
1242
+
1243
+
1244
+ def _mapping(value: object, field: str) -> dict[str, Any]:
1245
+ if not isinstance(value, dict):
1246
+ msg = f"{field} must be a mapping."
1247
+ raise DiscoveryError(msg)
1248
+ return value
1249
+
1250
+
1251
+ def _list(value: object, field: str) -> list[Any]:
1252
+ if value is None:
1253
+ return []
1254
+ if not isinstance(value, list):
1255
+ msg = f"{field} must be a list."
1256
+ raise DiscoveryError(msg)
1257
+ return value
1258
+
1259
+
1260
+ def _text(value: object, field: str) -> str:
1261
+ if not isinstance(value, str) or not value.strip():
1262
+ msg = f"{field} must be a non-empty string."
1263
+ raise DiscoveryError(msg)
1264
+ return value.strip()
1265
+
1266
+
1267
+ def _optional_text(value: object, field: str) -> str | None:
1268
+ if value is None:
1269
+ return None
1270
+ if not isinstance(value, str):
1271
+ msg = f"{field} must be a string or null."
1272
+ raise DiscoveryError(msg)
1273
+ stripped = value.strip()
1274
+ return stripped or None
1275
+
1276
+
1277
+ def _limited_text(value: object, field: str) -> str:
1278
+ text = _text(value, field)
1279
+ if len(text) > MAX_TEXT_LENGTH:
1280
+ msg = (
1281
+ f"{field} is too long ({len(text)} characters, "
1282
+ f"max {MAX_TEXT_LENGTH})."
1283
+ )
1284
+ raise DiscoveryError(msg)
1285
+ return text
1286
+
1287
+
1288
+ def _boolean(value: object, field: str) -> bool:
1289
+ if not isinstance(value, bool):
1290
+ msg = f"{field} must be true or false."
1291
+ raise DiscoveryError(msg)
1292
+ return value
1293
+
1294
+
1295
+ def _integer(value: object, field: str) -> int:
1296
+ if isinstance(value, bool) or not isinstance(value, int):
1297
+ msg = f"{field} must be an integer."
1298
+ raise DiscoveryError(msg)
1299
+ return value
1300
+
1301
+
1302
+ def is_valid_digest(value: str) -> bool:
1303
+ """Return whether a value is a DevFlow SHA-256 digest."""
1304
+ return bool(_SHA256.fullmatch(value))