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,932 @@
1
+ """Harness-independent specification workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from devflow.core.architecture import (
9
+ ArchitectureManifest,
10
+ ArchitectureManifestLoader,
11
+ )
12
+ from devflow.core.artifacts.store import ArtifactStore
13
+ from devflow.core.clock import Clock, SystemClock, format_timestamp
14
+ from devflow.core.config import (
15
+ EXECUTION_MODES,
16
+ ConfigError,
17
+ DevFlowConfig,
18
+ )
19
+ from devflow.core.constitution import Constitution, ConstitutionLoader
20
+ from devflow.core.discovery import (
21
+ DiscoveryDocument,
22
+ DiscoveryError,
23
+ DiscoveryManager,
24
+ DiscoveryPlanner,
25
+ )
26
+ from devflow.core.ports import (
27
+ HarnessExecutionError,
28
+ HarnessPort,
29
+ Invocation,
30
+ InvocationExecutor,
31
+ RunContext,
32
+ SetupResult,
33
+ build_setup_context,
34
+ )
35
+ from devflow.core.profile_manager import ProfileManager
36
+ from devflow.core.project import ProjectDetector
37
+ from devflow.core.specification import (
38
+ GeneratedSpec,
39
+ PreparedSpec,
40
+ SpecificationManager,
41
+ strip_ansi,
42
+ )
43
+ from devflow.core.state import WorkflowLocator
44
+ from devflow.models.project import (
45
+ PROJECT_MODES,
46
+ Project,
47
+ canonical_project_mode,
48
+ )
49
+ from devflow.models.profile import Profile
50
+ from devflow.prompts import (
51
+ build_discovery_prompt,
52
+ build_spec_prompt,
53
+ )
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class SpecWorkflowRequest:
58
+ """Everything the workflow needs to generate one specification."""
59
+
60
+ description: str
61
+ config: DevFlowConfig
62
+ execution_mode: str
63
+ adapter: HarnessPort | None = None
64
+ warnings: tuple[str, ...] = ()
65
+ project_mode: str = "existing"
66
+ continue_spec_id: str | None = None
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class SpecWorkflowResult:
71
+ """Outcome of a specification workflow run."""
72
+
73
+ spec: GeneratedSpec | None
74
+ harness_name: str | None
75
+ execution_mode: str
76
+ warnings: tuple[str, ...]
77
+ run_warnings: tuple[str, ...]
78
+ constitution_warnings: tuple[str, ...]
79
+ setup_result: SetupResult | None
80
+ constitution_names: tuple[str, ...]
81
+ project_mode: str = "existing"
82
+ architecture_digest: str | None = None
83
+ architecture: ArchitectureManifest | None = None
84
+ discovery: DiscoveryDocument | None = None
85
+ discovery_digest: str | None = None
86
+ discovery_path: Path | None = None
87
+ discovery_source: str = "deterministic"
88
+ discovery_created: bool = False
89
+ blocked: bool = False
90
+ spec_written: bool = False
91
+ spec_reused: bool = False
92
+ continuation: bool = False
93
+ directory: Path | None = None
94
+
95
+
96
+ class SpecWorkflow:
97
+ """Prepare, run and validate a specification independently of any harness."""
98
+
99
+ def __init__(
100
+ self,
101
+ executor: InvocationExecutor,
102
+ *,
103
+ manager: SpecificationManager | None = None,
104
+ profile_manager: ProfileManager | None = None,
105
+ project_detector: ProjectDetector | None = None,
106
+ clock: Clock | None = None,
107
+ discovery_planner: DiscoveryPlanner | None = None,
108
+ ) -> None:
109
+ self.executor = executor
110
+ self.manager = manager or SpecificationManager()
111
+ self.profile_manager = profile_manager or ProfileManager()
112
+ self.project_detector = project_detector or ProjectDetector()
113
+ self.constitution_loader = ConstitutionLoader(self.profile_manager)
114
+ self.clock = clock or SystemClock()
115
+ self.discovery_planner = discovery_planner or DiscoveryPlanner()
116
+
117
+ def run(self, request: SpecWorkflowRequest) -> SpecWorkflowResult:
118
+ """Run the specification workflow and return its outcome."""
119
+ description = request.description.strip()
120
+ continue_id = (request.continue_spec_id or "").strip() or None
121
+ if continue_id and description:
122
+ msg = (
123
+ "Pass either a new change description or --continue, not both. "
124
+ "Continuing reuses the discovery request of the existing change."
125
+ )
126
+ raise ConfigError(msg)
127
+ if not description and continue_id is None:
128
+ msg = "The change description cannot be empty."
129
+ raise ConfigError(msg)
130
+ if request.execution_mode not in EXECUTION_MODES:
131
+ msg = (
132
+ f"Unknown execution mode: {request.execution_mode}. "
133
+ f"Supported values: {', '.join(EXECUTION_MODES)}."
134
+ )
135
+ raise ConfigError(msg)
136
+
137
+ requested_mode = canonical_project_mode(request.project_mode)
138
+ if requested_mode not in PROJECT_MODES:
139
+ msg = (
140
+ f"Unknown project mode: {request.project_mode}. "
141
+ f"Supported values: {', '.join(PROJECT_MODES)}."
142
+ )
143
+ raise ConfigError(msg)
144
+
145
+ config = request.config
146
+ profiles = self.profile_manager.resolve(list(config.profiles))
147
+ constitution = self.constitution_loader.load(config, profiles)
148
+
149
+ project = self.project_detector.detect(config.root)
150
+ project.profiles = list(config.profiles)
151
+
152
+ warnings = self._profile_warnings(config, project)
153
+ discovery_manager = DiscoveryManager(config.root, clock=self.clock)
154
+ continuation = continue_id is not None
155
+ project_mode = requested_mode
156
+ location_directory: Path | None = None
157
+ location_spec_id: str | None = None
158
+ discovery: DiscoveryDocument | None = None
159
+
160
+ if continue_id is not None:
161
+ location = WorkflowLocator(config.specifications_path).find(
162
+ continue_id
163
+ )
164
+ location_directory = location.directory
165
+ location_spec_id = location.spec_id
166
+ discovery = discovery_manager.load(location.directory, required=True)
167
+ description = discovery.request.strip()
168
+ if not description:
169
+ msg = "The discovery record has an empty request."
170
+ raise ConfigError(msg)
171
+ project_mode = discovery.project_mode
172
+ if requested_mode != project_mode:
173
+ warnings.append(
174
+ f"the discovery record uses project mode '{project_mode}'. "
175
+ f"Ignoring the configured mode '{requested_mode}' for this "
176
+ "change."
177
+ )
178
+
179
+ architecture = ArchitectureManifestLoader().load(
180
+ config.root,
181
+ required=project_mode == "greenfield",
182
+ )
183
+ if architecture is not None:
184
+ warnings.extend(architecture.warnings)
185
+
186
+ prepared = self.manager.prepare(
187
+ description=description,
188
+ config=config,
189
+ project=project,
190
+ constitution=constitution,
191
+ profiles=profiles,
192
+ project_mode=project_mode,
193
+ architecture=architecture,
194
+ discovery=discovery,
195
+ discovery_digest=(
196
+ self._discovery_digest(discovery_manager, location_directory)
197
+ if continuation and location_directory is not None
198
+ else None
199
+ ),
200
+ directory=location_directory,
201
+ spec_id=location_spec_id,
202
+ )
203
+
204
+ discovery_created = False
205
+ discovery_source = "deterministic"
206
+ if continuation:
207
+ assert discovery is not None
208
+ assert location_directory is not None
209
+ discovery_path = discovery_manager.path(location_directory)
210
+ discovery_digest = self._discovery_digest(
211
+ discovery_manager,
212
+ location_directory,
213
+ )
214
+ else:
215
+ now = format_timestamp(self.clock.now())
216
+ discovery = self.discovery_planner.build(
217
+ request=description,
218
+ spec_id=prepared.spec_id,
219
+ change=prepared.directory.name,
220
+ project_mode=project_mode,
221
+ project=project,
222
+ profiles=tuple(profile.name for profile in profiles),
223
+ reference_paths=tuple(
224
+ reference.path for reference in prepared.analysis.references
225
+ ),
226
+ category_paths={
227
+ category: tuple(
228
+ reference.path for reference in references
229
+ )
230
+ for category, references in prepared.analysis.categories.items()
231
+ },
232
+ has_tests=prepared.analysis.has_tests,
233
+ architecture=architecture,
234
+ now=now,
235
+ )
236
+ discovery_path, discovery_digest = discovery_manager.write(
237
+ prepared.directory,
238
+ discovery,
239
+ )
240
+ discovery_created = True
241
+ discovery = discovery_manager.load(
242
+ prepared.directory,
243
+ required=True,
244
+ )
245
+ discovery_digest = discovery_manager.store.digest_file(
246
+ discovery_path
247
+ )
248
+ prepared = self.manager.prepare(
249
+ description=description,
250
+ config=config,
251
+ project=project,
252
+ constitution=constitution,
253
+ profiles=profiles,
254
+ project_mode=project_mode,
255
+ architecture=architecture,
256
+ discovery=discovery,
257
+ discovery_digest=discovery_digest,
258
+ directory=prepared.directory,
259
+ spec_id=prepared.spec_id,
260
+ )
261
+
262
+ warnings.extend(request.warnings)
263
+ if (
264
+ request.execution_mode == "interactive"
265
+ and not config.permissions.allow_spec_write
266
+ ):
267
+ warnings.append(
268
+ "Specification writes are disabled for the agent. The interactive "
269
+ "run cannot persist artifacts, so DevFlow will use deterministic "
270
+ "output unless the harness returns a valid document another way."
271
+ )
272
+
273
+ adapter = request.adapter
274
+ setup_result: SetupResult | None = None
275
+ if (
276
+ not discovery.is_complete
277
+ and self._can_run_discovery(adapter, request, config)
278
+ ):
279
+ assert adapter is not None
280
+ setup_result = self._setup_adapter(
281
+ adapter=adapter,
282
+ config=config,
283
+ project=project,
284
+ profiles=profiles,
285
+ constitution=constitution,
286
+ warnings=warnings,
287
+ )
288
+ discovery, discovery_digest, discovery_changed = (
289
+ self._run_discovery(
290
+ prepared=prepared,
291
+ discovery=discovery,
292
+ discovery_manager=discovery_manager,
293
+ adapter=adapter,
294
+ execution_mode=request.execution_mode,
295
+ config=config,
296
+ project=project,
297
+ constitution=constitution,
298
+ profiles=profiles,
299
+ warnings=warnings,
300
+ )
301
+ )
302
+ if discovery_changed:
303
+ discovery_source = "agent"
304
+ discovery = discovery_manager.load(
305
+ prepared.directory,
306
+ required=True,
307
+ )
308
+ discovery_digest = self._discovery_digest(
309
+ discovery_manager,
310
+ prepared.directory,
311
+ )
312
+ prepared = self.manager.prepare(
313
+ description=description,
314
+ config=config,
315
+ project=project,
316
+ constitution=constitution,
317
+ profiles=profiles,
318
+ project_mode=project_mode,
319
+ architecture=architecture,
320
+ discovery=discovery,
321
+ discovery_digest=discovery_digest,
322
+ directory=prepared.directory,
323
+ spec_id=prepared.spec_id,
324
+ )
325
+
326
+ self.manager.materialize_context(prepared)
327
+
328
+ if not discovery.is_complete:
329
+ return self._build_result(
330
+ spec=None,
331
+ harness_name=None,
332
+ execution_mode=request.execution_mode,
333
+ warnings=warnings,
334
+ run_warnings=[],
335
+ setup_result=setup_result,
336
+ constitution=constitution,
337
+ project_mode=project_mode,
338
+ architecture=architecture,
339
+ discovery=discovery,
340
+ discovery_digest=discovery_digest,
341
+ discovery_path=discovery_path,
342
+ discovery_source=discovery_source,
343
+ discovery_created=discovery_created,
344
+ blocked=True,
345
+ spec_written=False,
346
+ spec_reused=False,
347
+ continuation=continuation,
348
+ directory=prepared.directory,
349
+ )
350
+
351
+ reused_spec = self._reusable_spec(
352
+ prepared=prepared,
353
+ continuation=continuation,
354
+ project_mode=project_mode,
355
+ architecture=architecture,
356
+ discovery_digest=discovery_digest,
357
+ )
358
+ if reused_spec is not None:
359
+ return self._build_result(
360
+ spec=reused_spec,
361
+ harness_name=None,
362
+ execution_mode=request.execution_mode,
363
+ warnings=warnings,
364
+ run_warnings=[],
365
+ setup_result=setup_result,
366
+ constitution=constitution,
367
+ project_mode=project_mode,
368
+ architecture=architecture,
369
+ discovery=discovery,
370
+ discovery_digest=discovery_digest,
371
+ discovery_path=discovery_path,
372
+ discovery_source=discovery_source,
373
+ discovery_created=discovery_created,
374
+ blocked=False,
375
+ spec_written=False,
376
+ spec_reused=True,
377
+ continuation=continuation,
378
+ directory=prepared.directory,
379
+ )
380
+
381
+ archive_existing = prepared.path.is_file()
382
+
383
+ if adapter is None:
384
+ return self._fallback(
385
+ prepared=prepared,
386
+ warnings=warnings,
387
+ constitution=constitution,
388
+ execution_mode=request.execution_mode,
389
+ project_mode=project_mode,
390
+ architecture=architecture,
391
+ discovery=discovery,
392
+ discovery_digest=discovery_digest,
393
+ discovery_path=discovery_path,
394
+ discovery_source=discovery_source,
395
+ discovery_created=discovery_created,
396
+ continuation=continuation,
397
+ archive=archive_existing,
398
+ )
399
+
400
+ if not config.permissions.allow_project_read:
401
+ warnings.append(
402
+ "Project reads are disabled for the selected harness. DevFlow "
403
+ "cannot provide project context to the agent, so deterministic "
404
+ "DevFlow output will be used."
405
+ )
406
+ return self._fallback(
407
+ prepared=prepared,
408
+ warnings=warnings,
409
+ constitution=constitution,
410
+ execution_mode=request.execution_mode,
411
+ project_mode=project_mode,
412
+ architecture=architecture,
413
+ discovery=discovery,
414
+ discovery_digest=discovery_digest,
415
+ discovery_path=discovery_path,
416
+ discovery_source=discovery_source,
417
+ discovery_created=discovery_created,
418
+ continuation=continuation,
419
+ archive=archive_existing,
420
+ )
421
+
422
+ if not adapter.is_available():
423
+ warnings.append(
424
+ f"Agent harness '{adapter.name}' is no longer available on "
425
+ "PATH. DevFlow will use deterministic output."
426
+ )
427
+ return self._fallback(
428
+ prepared=prepared,
429
+ warnings=warnings,
430
+ constitution=constitution,
431
+ execution_mode=request.execution_mode,
432
+ project_mode=project_mode,
433
+ architecture=architecture,
434
+ discovery=discovery,
435
+ discovery_digest=discovery_digest,
436
+ discovery_path=discovery_path,
437
+ discovery_source=discovery_source,
438
+ discovery_created=discovery_created,
439
+ continuation=continuation,
440
+ archive=archive_existing,
441
+ )
442
+
443
+ if setup_result is None:
444
+ setup_result = self._setup_adapter(
445
+ adapter=adapter,
446
+ config=config,
447
+ project=project,
448
+ profiles=profiles,
449
+ constitution=constitution,
450
+ warnings=warnings,
451
+ )
452
+
453
+ context_path = self.manager.materialize_context(prepared)
454
+ prompt = build_spec_prompt(
455
+ request=description,
456
+ project_name=project.name,
457
+ context_path=self._relative(config, context_path),
458
+ spec_path=self._relative(config, prepared.path),
459
+ constitution_paths=[
460
+ self._relative(
461
+ config,
462
+ config.constitution.path / document.name,
463
+ )
464
+ for document in constitution.documents
465
+ ],
466
+ harness_name=adapter.display_name,
467
+ execution_mode=request.execution_mode,
468
+ permissions=config.permissions,
469
+ project_mode=project_mode,
470
+ architecture_path=(
471
+ architecture.relative_path(config.root)
472
+ if architecture is not None
473
+ else ""
474
+ ),
475
+ architecture_digest=(
476
+ architecture.digest if architecture is not None else ""
477
+ ),
478
+ architecture_style=(
479
+ architecture.style if architecture is not None else ""
480
+ ),
481
+ discovery_path=(
482
+ self._relative(config, discovery_path)
483
+ if discovery_path is not None
484
+ else ""
485
+ ),
486
+ discovery_digest=discovery_digest,
487
+ discovery_decisions=list(discovery.decisions()),
488
+ )
489
+
490
+ output_path = (
491
+ prepared.directory / ".devflow-agent-output.md"
492
+ if request.execution_mode == "non-interactive"
493
+ else None
494
+ )
495
+ run_context = RunContext(
496
+ root=config.root,
497
+ prompt=prompt,
498
+ spec_directory=prepared.directory,
499
+ spec_path=prepared.path,
500
+ mode=request.execution_mode,
501
+ permissions=config.permissions,
502
+ output_path=output_path,
503
+ )
504
+ warnings.extend(adapter.permission_warnings(run_context))
505
+
506
+ invocation = adapter.build_run(run_context)
507
+ spec, run_warnings = self._run_agent(
508
+ prepared=prepared,
509
+ adapter=adapter,
510
+ invocation=invocation,
511
+ execution_mode=request.execution_mode,
512
+ output_path=output_path,
513
+ project_mode=project_mode,
514
+ architecture=architecture,
515
+ discovery_digest=discovery_digest,
516
+ archive=archive_existing,
517
+ )
518
+ return self._build_result(
519
+ spec=spec,
520
+ harness_name=adapter.display_name,
521
+ execution_mode=request.execution_mode,
522
+ warnings=warnings,
523
+ run_warnings=run_warnings,
524
+ setup_result=setup_result,
525
+ constitution=constitution,
526
+ project_mode=project_mode,
527
+ architecture=architecture,
528
+ discovery=discovery,
529
+ discovery_digest=discovery_digest,
530
+ discovery_path=discovery_path,
531
+ discovery_source=discovery_source,
532
+ discovery_created=discovery_created,
533
+ blocked=False,
534
+ spec_written=True,
535
+ spec_reused=False,
536
+ continuation=continuation,
537
+ directory=prepared.directory,
538
+ )
539
+
540
+ def _can_run_discovery(
541
+ self,
542
+ adapter: HarnessPort | None,
543
+ request: SpecWorkflowRequest,
544
+ config: DevFlowConfig,
545
+ ) -> bool:
546
+ return (
547
+ adapter is not None
548
+ and request.execution_mode == "interactive"
549
+ and config.permissions.allow_project_read
550
+ and config.permissions.allow_spec_write
551
+ and adapter.is_available()
552
+ )
553
+
554
+ def _setup_adapter(
555
+ self,
556
+ *,
557
+ adapter: HarnessPort,
558
+ config: DevFlowConfig,
559
+ project: Project,
560
+ profiles: list[Profile],
561
+ constitution: Constitution,
562
+ warnings: list[str],
563
+ ) -> SetupResult:
564
+ setup_result = adapter.setup(
565
+ build_setup_context(
566
+ config=config,
567
+ project=project,
568
+ profiles=profiles,
569
+ constitution=constitution,
570
+ )
571
+ )
572
+ warnings.extend(setup_result.warnings)
573
+ return setup_result
574
+
575
+ def _run_discovery(
576
+ self,
577
+ *,
578
+ prepared: PreparedSpec,
579
+ discovery: DiscoveryDocument,
580
+ discovery_manager: DiscoveryManager,
581
+ adapter: HarnessPort,
582
+ execution_mode: str,
583
+ config: DevFlowConfig,
584
+ project: Project,
585
+ constitution: Constitution,
586
+ profiles: list[Profile],
587
+ warnings: list[str],
588
+ ) -> tuple[DiscoveryDocument, str, bool]:
589
+ context_path = self.manager.materialize_context(prepared)
590
+ discovery_path = discovery_manager.path(prepared.directory)
591
+ original_digest = self._discovery_digest(
592
+ discovery_manager,
593
+ prepared.directory,
594
+ )
595
+ spec_existed = prepared.path.exists()
596
+ prompt = build_discovery_prompt(
597
+ request=discovery.request,
598
+ project_name=project.name,
599
+ context_path=self._relative(config, context_path),
600
+ discovery_path=self._relative(config, discovery_path),
601
+ harness_name=adapter.display_name,
602
+ execution_mode=execution_mode,
603
+ permissions=config.permissions,
604
+ )
605
+ run_context = RunContext(
606
+ root=config.root,
607
+ prompt=prompt,
608
+ spec_directory=prepared.directory,
609
+ spec_path=prepared.path,
610
+ mode=execution_mode,
611
+ permissions=config.permissions,
612
+ output_path=None,
613
+ )
614
+ warnings.extend(adapter.permission_warnings(run_context))
615
+ invocation = adapter.build_run(run_context)
616
+ try:
617
+ self.executor(invocation)
618
+ except HarnessExecutionError as error:
619
+ warnings.append(
620
+ f"discovery agent run failed: {error}. Discovery remains "
621
+ "awaiting clarification."
622
+ )
623
+ return discovery, original_digest, False
624
+ if not spec_existed and prepared.path.exists():
625
+ warnings.append(
626
+ "the discovery agent wrote spec.md although Discovery was "
627
+ "incomplete. The file was archived and will not be recorded."
628
+ )
629
+ store = ArtifactStore(prepared.root)
630
+ store.archive(prepared.path)
631
+ prepared.path.unlink(missing_ok=True)
632
+ try:
633
+ updated = discovery_manager.load(
634
+ prepared.directory,
635
+ required=True,
636
+ )
637
+ except DiscoveryError as error:
638
+ warnings.append(
639
+ f"the discovery agent produced invalid discovery data: {error}. "
640
+ "Discovery remains unchanged."
641
+ )
642
+ return discovery, original_digest, False
643
+ digest = self._discovery_digest(
644
+ discovery_manager,
645
+ prepared.directory,
646
+ )
647
+ if digest == original_digest:
648
+ warnings.append(
649
+ "the discovery agent did not record any answer or assumption "
650
+ "acceptance."
651
+ )
652
+ return updated, digest, digest != original_digest
653
+
654
+ def _discovery_digest(
655
+ self,
656
+ discovery_manager: DiscoveryManager,
657
+ directory: Path | None,
658
+ ) -> str:
659
+ if directory is None:
660
+ return ""
661
+ path = discovery_manager.path(directory)
662
+ if not path.is_file():
663
+ return ""
664
+ return discovery_manager.store.digest_file(path)
665
+
666
+ def _reusable_spec(
667
+ self,
668
+ *,
669
+ prepared: PreparedSpec,
670
+ continuation: bool,
671
+ project_mode: str,
672
+ architecture: ArchitectureManifest | None,
673
+ discovery_digest: str,
674
+ ) -> GeneratedSpec | None:
675
+ if not continuation or not prepared.path.is_file():
676
+ return None
677
+ try:
678
+ content = prepared.path.read_text(encoding="utf-8")
679
+ except (OSError, UnicodeDecodeError):
680
+ return None
681
+ valid, _reason = self.manager.validate(
682
+ content,
683
+ project_mode=project_mode,
684
+ architecture=architecture,
685
+ discovery_digest=discovery_digest,
686
+ )
687
+ if not valid:
688
+ return None
689
+ questions = self.manager.extract_open_questions(content)
690
+ return GeneratedSpec(
691
+ spec_id=prepared.spec_id,
692
+ title=prepared.title,
693
+ path=prepared.path,
694
+ open_questions=tuple(questions),
695
+ source="existing",
696
+ )
697
+
698
+ def _run_agent(
699
+ self,
700
+ *,
701
+ prepared: PreparedSpec,
702
+ adapter: HarnessPort,
703
+ invocation: Invocation,
704
+ execution_mode: str,
705
+ output_path: Path | None,
706
+ project_mode: str,
707
+ architecture: ArchitectureManifest | None,
708
+ discovery_digest: str,
709
+ archive: bool,
710
+ ) -> tuple[GeneratedSpec, list[str]]:
711
+ source = f"{adapter.display_name} ({execution_mode})"
712
+ run_warnings: list[str] = []
713
+ try:
714
+ run_result = self.executor(invocation)
715
+ except HarnessExecutionError as error:
716
+ run_warnings.append(str(error))
717
+ return (
718
+ self.manager.write(prepared, archive=archive),
719
+ run_warnings,
720
+ )
721
+
722
+ try:
723
+ if execution_mode == "interactive":
724
+ if prepared.path.is_symlink():
725
+ run_warnings.append(
726
+ "the interactive agent wrote the specification as a "
727
+ "symbolic link. Using deterministic DevFlow output."
728
+ )
729
+ return (
730
+ self.manager.write(prepared, archive=archive),
731
+ run_warnings,
732
+ )
733
+ if prepared.path.is_file():
734
+ content = prepared.path.read_text(encoding="utf-8")
735
+ valid, reason = self.manager.validate(
736
+ content,
737
+ project_mode=project_mode,
738
+ architecture=architecture,
739
+ discovery_digest=discovery_digest,
740
+ )
741
+ if valid:
742
+ return (
743
+ self.manager.write(
744
+ prepared,
745
+ content,
746
+ source=source,
747
+ archive=archive,
748
+ ),
749
+ run_warnings,
750
+ )
751
+ run_warnings.append(
752
+ "agent specification was invalid: "
753
+ f"{reason}. Using deterministic DevFlow output."
754
+ )
755
+ return (
756
+ self.manager.write(prepared, archive=archive),
757
+ run_warnings,
758
+ )
759
+
760
+ run_warnings.append(
761
+ "the interactive agent did not write the specification. "
762
+ "Using deterministic DevFlow output."
763
+ )
764
+ return (
765
+ self.manager.write(prepared, archive=archive),
766
+ run_warnings,
767
+ )
768
+
769
+ if run_result.returncode != 0:
770
+ detail = strip_ansi(run_result.stderr).strip().splitlines()
771
+ reason = (
772
+ detail[-1]
773
+ if detail
774
+ else f"exit code {run_result.returncode}"
775
+ )
776
+ run_warnings.append(
777
+ f"agent run failed: {reason}. "
778
+ "Using deterministic DevFlow output."
779
+ )
780
+ return (
781
+ self.manager.write(prepared, archive=archive),
782
+ run_warnings,
783
+ )
784
+
785
+ content = run_result.file_output or run_result.stdout
786
+ valid, reason = self.manager.validate(
787
+ content,
788
+ project_mode=project_mode,
789
+ architecture=architecture,
790
+ discovery_digest=discovery_digest,
791
+ )
792
+ if valid:
793
+ return (
794
+ self.manager.write(
795
+ prepared,
796
+ content,
797
+ source=source,
798
+ archive=archive,
799
+ ),
800
+ run_warnings,
801
+ )
802
+
803
+ run_warnings.append(
804
+ f"agent output was invalid: {reason}. "
805
+ "Using deterministic DevFlow output."
806
+ )
807
+ return (
808
+ self.manager.write(prepared, archive=archive),
809
+ run_warnings,
810
+ )
811
+ finally:
812
+ if output_path is not None:
813
+ output_path.unlink(missing_ok=True)
814
+
815
+ def _profile_warnings(
816
+ self,
817
+ config: DevFlowConfig,
818
+ project: Project,
819
+ ) -> list[str]:
820
+ detected_profiles = {
821
+ match.profile.name
822
+ for match in self.profile_manager.detect(project)
823
+ }
824
+ configured_profiles = set(config.profiles)
825
+ new_profiles = sorted(detected_profiles - configured_profiles)
826
+ stale_profiles = sorted(configured_profiles - detected_profiles)
827
+
828
+ warnings: list[str] = []
829
+ for profile_name in new_profiles:
830
+ warnings.append(
831
+ f"detected profile '{profile_name}' is not configured. Current "
832
+ "analysis uses the profiles in .devflow/config.yaml."
833
+ )
834
+ for profile_name in stale_profiles:
835
+ warnings.append(
836
+ f"configured profile '{profile_name}' was not detected in the "
837
+ "current project. Review .devflow/config.yaml if this changed."
838
+ )
839
+ return warnings
840
+
841
+ def _fallback(
842
+ self,
843
+ *,
844
+ prepared: PreparedSpec,
845
+ warnings: list[str],
846
+ constitution: Constitution,
847
+ execution_mode: str,
848
+ project_mode: str,
849
+ architecture: ArchitectureManifest | None,
850
+ discovery: DiscoveryDocument,
851
+ discovery_digest: str,
852
+ discovery_path: Path,
853
+ discovery_source: str,
854
+ discovery_created: bool,
855
+ continuation: bool,
856
+ archive: bool,
857
+ ) -> SpecWorkflowResult:
858
+ self.manager.materialize_context(prepared)
859
+ return self._build_result(
860
+ spec=self.manager.write(prepared, archive=archive),
861
+ harness_name=None,
862
+ execution_mode=execution_mode,
863
+ warnings=warnings,
864
+ run_warnings=[],
865
+ setup_result=None,
866
+ constitution=constitution,
867
+ project_mode=project_mode,
868
+ architecture=architecture,
869
+ discovery=discovery,
870
+ discovery_digest=discovery_digest,
871
+ discovery_path=discovery_path,
872
+ discovery_source=discovery_source,
873
+ discovery_created=discovery_created,
874
+ blocked=False,
875
+ spec_written=True,
876
+ spec_reused=False,
877
+ continuation=continuation,
878
+ directory=prepared.directory,
879
+ )
880
+
881
+ def _build_result(
882
+ self,
883
+ *,
884
+ spec: GeneratedSpec | None,
885
+ harness_name: str | None,
886
+ execution_mode: str,
887
+ warnings: list[str],
888
+ run_warnings: list[str],
889
+ setup_result: SetupResult | None,
890
+ constitution: Constitution,
891
+ project_mode: str,
892
+ architecture: ArchitectureManifest | None,
893
+ discovery: DiscoveryDocument | None,
894
+ discovery_digest: str,
895
+ discovery_path: Path | None,
896
+ discovery_source: str,
897
+ discovery_created: bool,
898
+ blocked: bool,
899
+ spec_written: bool,
900
+ spec_reused: bool,
901
+ continuation: bool,
902
+ directory: Path,
903
+ ) -> SpecWorkflowResult:
904
+ return SpecWorkflowResult(
905
+ spec=spec,
906
+ harness_name=harness_name,
907
+ execution_mode=execution_mode,
908
+ warnings=tuple(warnings),
909
+ run_warnings=tuple(run_warnings),
910
+ constitution_warnings=constitution.warnings,
911
+ setup_result=setup_result,
912
+ constitution_names=constitution.names,
913
+ project_mode=project_mode,
914
+ architecture_digest=(
915
+ architecture.digest if architecture is not None else None
916
+ ),
917
+ architecture=architecture,
918
+ discovery=discovery,
919
+ discovery_digest=discovery_digest or None,
920
+ discovery_path=discovery_path,
921
+ discovery_source=discovery_source,
922
+ discovery_created=discovery_created,
923
+ blocked=blocked,
924
+ spec_written=spec_written,
925
+ spec_reused=spec_reused,
926
+ continuation=continuation,
927
+ directory=directory,
928
+ )
929
+
930
+ @staticmethod
931
+ def _relative(config: DevFlowConfig, path: Path) -> str:
932
+ return path.relative_to(config.root).as_posix()