arkaos 2.17.1 → 2.17.4

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 (77) hide show
  1. package/VERSION +1 -1
  2. package/arka/SKILL.md +9 -67
  3. package/arka/skills/comfyui/SKILL.md +50 -12
  4. package/arka/skills/conclave/SKILL.md +43 -141
  5. package/arka/skills/conclave/references/advisors.md +36 -0
  6. package/arka/skills/human-writing/SKILL.md +15 -100
  7. package/arka/skills/human-writing/references/forbidden-patterns.md +32 -0
  8. package/config/hooks/post-tool-use.sh +72 -0
  9. package/config/hooks/session-start.sh +16 -0
  10. package/config/hooks/user-prompt-submit.ps1 +2 -2
  11. package/config/hooks/user-prompt-submit.sh +102 -26
  12. package/core/agents/__pycache__/behavior_enforcer.cpython-313.pyc +0 -0
  13. package/core/agents/__pycache__/dna_registry.cpython-313.pyc +0 -0
  14. package/core/agents/adapters/__pycache__/disc_adapter.cpython-313.pyc +0 -0
  15. package/core/agents/adapters/disc_adapter.py +149 -0
  16. package/core/agents/behavior_enforcer.py +255 -0
  17. package/core/agents/dna_registry.py +235 -0
  18. package/core/forge/__init__.py +36 -0
  19. package/core/forge/__pycache__/__init__.cpython-313.pyc +0 -0
  20. package/core/forge/__pycache__/orchestrator.cpython-313.pyc +0 -0
  21. package/core/forge/__pycache__/runtime_dispatcher.cpython-313.pyc +0 -0
  22. package/core/forge/orchestrator.py +770 -0
  23. package/core/forge/runtime_dispatcher.py +465 -0
  24. package/core/governance/__pycache__/quality_api.cpython-313.pyc +0 -0
  25. package/core/governance/__pycache__/quality_router.cpython-313.pyc +0 -0
  26. package/core/governance/__pycache__/review_workflow.cpython-313.pyc +0 -0
  27. package/core/governance/quality_api.py +280 -0
  28. package/core/governance/quality_router.py +304 -0
  29. package/core/governance/review_workflow.py +386 -0
  30. package/core/memory/__pycache__/compressor.cpython-313.pyc +0 -0
  31. package/core/memory/__pycache__/rehydrator.cpython-313.pyc +0 -0
  32. package/core/memory/__pycache__/session_store.cpython-313.pyc +0 -0
  33. package/core/memory/compressor.py +269 -0
  34. package/core/memory/rehydrator.py +204 -0
  35. package/core/memory/session_store.py +256 -0
  36. package/core/runtime/__pycache__/context_compactor.cpython-313.pyc +0 -0
  37. package/core/runtime/__pycache__/subagent.cpython-313.pyc +0 -0
  38. package/core/synapse/__init__.py +10 -3
  39. package/core/synapse/__pycache__/__init__.cpython-313.pyc +0 -0
  40. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  41. package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
  42. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  43. package/core/synapse/engine.py +27 -16
  44. package/core/synapse/kb_cache.py +382 -0
  45. package/core/synapse/layers.py +253 -50
  46. package/core/sync/__pycache__/self_healing.cpython-313.pyc +0 -0
  47. package/core/workflow/__pycache__/announcer.cpython-313.pyc +0 -0
  48. package/core/workflow/__pycache__/dashboard.cpython-313.pyc +0 -0
  49. package/core/workflow/__pycache__/enforcer.cpython-313.pyc +0 -0
  50. package/core/workflow/__pycache__/rules_registry.cpython-313.pyc +0 -0
  51. package/core/workflow/__pycache__/state.cpython-313.pyc +0 -0
  52. package/core/workflow/announcer.py +246 -0
  53. package/core/workflow/dashboard.py +194 -0
  54. package/core/workflow/enforcer.py +234 -0
  55. package/core/workflow/recovery.py +196 -0
  56. package/core/workflow/rules_registry.py +484 -0
  57. package/core/workflow/session_summary.py +204 -0
  58. package/core/workflow/state.py +12 -2
  59. package/departments/dev/SKILL.md +10 -42
  60. package/departments/dev/skills/agent-design/SKILL.md +6 -26
  61. package/departments/dev/skills/ci-cd-pipeline/SKILL.md +6 -29
  62. package/departments/dev/skills/db-schema/SKILL.md +6 -24
  63. package/departments/dev/skills/dependency-audit/SKILL.md +1 -3
  64. package/departments/dev/skills/incident/SKILL.md +6 -24
  65. package/departments/dev/skills/mcp-builder/SKILL.md +4 -17
  66. package/departments/dev/skills/observability/SKILL.md +6 -29
  67. package/departments/dev/skills/performance-profiler/SKILL.md +5 -26
  68. package/departments/dev/skills/rag-architect/SKILL.md +5 -23
  69. package/departments/dev/skills/release/SKILL.md +4 -17
  70. package/departments/dev/skills/spec/SKILL.md +47 -148
  71. package/departments/landing/skills/landing-gen/SKILL.md +3 -15
  72. package/departments/marketing/skills/cold-email/SKILL.md +5 -17
  73. package/departments/marketing/skills/programmatic-seo/SKILL.md +6 -21
  74. package/departments/strategy/skills/board-advisor/SKILL.md +7 -21
  75. package/package.json +1 -1
  76. package/pyproject.toml +1 -1
  77. package/scripts/synapse-bridge.py +27 -3
@@ -0,0 +1,770 @@
1
+ """Forge Orchestrator — 10-step planning flow implementation.
2
+
3
+ Coordinates the full Forge planning flow:
4
+ 1. Context snapshot (git info)
5
+ 2. Obsidian knowledge check (patterns/plans)
6
+ 3. Complexity analysis
7
+ 4. Explorer dispatch (1-3 based on tier)
8
+ 5. Critic synthesis
9
+ 6. Constitution enforcement
10
+ 7. Render plan
11
+ 8. Handoff preparation
12
+ 9. Persistence
13
+
14
+ User interaction (Approve/Revise/Companion/Detail/Quit) is handled
15
+ by the caller (Claude Code session) via method calls on this orchestrator.
16
+ """
17
+
18
+ import hashlib
19
+ import subprocess
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime, timezone
22
+ from enum import Enum
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ from core.forge.schema import (
27
+ ForgeContext,
28
+ ForgePlan,
29
+ ForgeStatus,
30
+ ForgeTier,
31
+ ComplexityScore,
32
+ ComplexityDimensions,
33
+ ExplorerLens,
34
+ ExplorerApproach,
35
+ CriticVerdict,
36
+ PlanPhase,
37
+ ExecutionPath,
38
+ ForgeGovernance,
39
+ )
40
+ from core.forge.complexity import analyze_complexity
41
+ from core.forge.persistence import (
42
+ save_plan,
43
+ load_plan,
44
+ list_plans,
45
+ get_active_plan,
46
+ set_active_plan,
47
+ clear_active_plan,
48
+ export_to_obsidian,
49
+ extract_patterns,
50
+ load_patterns,
51
+ )
52
+ from core.forge.handoff import (
53
+ select_execution_path,
54
+ check_repo_drift,
55
+ generate_workflow_yaml,
56
+ )
57
+ from core.forge.renderer import (
58
+ render_terminal,
59
+ render_complexity,
60
+ render_critic_summary,
61
+ render_plan_overview,
62
+ render_html,
63
+ should_suggest_companion,
64
+ )
65
+ from core.forge.runtime_dispatcher import (
66
+ ForgeTaskDispatcher,
67
+ ClaudeCodeForgeDispatcher,
68
+ ExplorerDispatchRequest,
69
+ CriticDispatchRequest,
70
+ _tier_to_model,
71
+ )
72
+
73
+
74
+ CONSTITUTION_PHASES = [
75
+ "Create feature branch",
76
+ "Write specification",
77
+ "Quality Gate",
78
+ "Persist to Obsidian",
79
+ ]
80
+
81
+
82
+ @dataclass
83
+ class ForgeStep:
84
+ """Current step in the forge flow."""
85
+
86
+ step_number: int
87
+ step_name: str
88
+ description: str
89
+
90
+
91
+ class ForgeDecision(str, Enum):
92
+ APPROVE = "approve"
93
+ REVISE = "revise"
94
+ COMPANION = "companion"
95
+ DETAIL = "detail"
96
+ QUIT = "quit"
97
+
98
+
99
+ @dataclass
100
+ class ForgeStatusOutput:
101
+ """Output from a forge status check."""
102
+
103
+ plan_id: str
104
+ name: str
105
+ status: ForgeStatus
106
+ tier: ForgeTier
107
+ score: int
108
+ confidence: float
109
+ n_phases: int
110
+ departments: list[str]
111
+ created_at: str
112
+ revision_count: int
113
+
114
+
115
+ @dataclass
116
+ class ForgeHistoryEntry:
117
+ """Entry in forge history listing."""
118
+
119
+ plan_id: str
120
+ name: str
121
+ status: ForgeStatus
122
+ tier: ForgeTier
123
+ confidence: float
124
+ created_date: str
125
+
126
+
127
+ @dataclass
128
+ class ForgeCompareOutput:
129
+ """Output from comparing two plans."""
130
+
131
+ left: ForgeStatusOutput
132
+ right: ForgeStatusOutput
133
+ left_phases: list[PlanPhase]
134
+ right_phases: list[PlanPhase]
135
+
136
+
137
+ class ForgeOrchestrator:
138
+ """Main Forge orchestrator class.
139
+
140
+ Implements the 10-step planning flow. Stateful — maintains
141
+ the current plan being worked on.
142
+
143
+ Usage:
144
+ orch = ForgeOrchestrator()
145
+ plan = orch.forge("build user auth module")
146
+ print(orch.render())
147
+ # User decides: orch.approve() or orch.revise("add tests")
148
+ """
149
+
150
+ def __init__(self, dispatcher: Optional[ForgeTaskDispatcher] = None):
151
+ """Initialize orchestrator.
152
+
153
+ Args:
154
+ dispatcher: Task dispatcher for subagent dispatch.
155
+ Defaults to ClaudeCodeForgeDispatcher.
156
+ """
157
+ self._dispatcher = dispatcher or ClaudeCodeForgeDispatcher()
158
+ self._current_plan: Optional[ForgePlan] = None
159
+ self._current_step: Optional[ForgeStep] = None
160
+
161
+ # -------------------------------------------------------------------------
162
+ # Main Commands
163
+ # -------------------------------------------------------------------------
164
+
165
+ def forge(self, prompt: str) -> ForgePlan:
166
+ """Execute the full forge flow for a prompt.
167
+
168
+ Runs steps 1-7 (through rendering). User decision (approve/revise/etc)
169
+ is handled by separate method calls.
170
+
171
+ Args:
172
+ prompt: The user's request to plan
173
+
174
+ Returns:
175
+ ForgePlan ready for user decision
176
+ """
177
+ self._step1_snapshot_context(prompt)
178
+ self._step2_obsidian_check()
179
+ self._step3_complexity()
180
+ self._step4_confirm_tier()
181
+ self._step5_launch_explorers()
182
+ self._step6_critic_synthesis()
183
+ self._step7_enforce_constitution()
184
+ self._step8_build_plan()
185
+ self._step9_render()
186
+ return self._current_plan
187
+
188
+ def resume(self) -> Optional[ForgePlan]:
189
+ """Resume the active forge plan.
190
+
191
+ Returns:
192
+ Active plan if exists, None otherwise
193
+ """
194
+ active = get_active_plan()
195
+ if active is None:
196
+ return None
197
+ self._current_plan = active
198
+ return active
199
+
200
+ def status(self) -> Optional[ForgeStatusOutput]:
201
+ """Get status of the active plan.
202
+
203
+ Returns:
204
+ ForgeStatusOutput if active plan exists, None otherwise
205
+ """
206
+ if self._current_plan is None:
207
+ self._current_plan = get_active_plan()
208
+ if self._current_plan is None:
209
+ return None
210
+
211
+ plan = self._current_plan
212
+ depts = list({p.department for p in plan.plan_phases})
213
+ return ForgeStatusOutput(
214
+ plan_id=plan.id,
215
+ name=plan.name,
216
+ status=plan.status,
217
+ tier=plan.complexity.tier,
218
+ score=plan.complexity.score,
219
+ confidence=plan.critic.confidence,
220
+ n_phases=len(plan.plan_phases),
221
+ departments=depts,
222
+ created_at=plan.created_at,
223
+ revision_count=plan.version - 1,
224
+ )
225
+
226
+ def history(self, limit: int = 20) -> list[ForgeHistoryEntry]:
227
+ """List past forge plans.
228
+
229
+ Args:
230
+ limit: Maximum number to return
231
+
232
+ Returns:
233
+ List of ForgeHistoryEntry
234
+ """
235
+ plans = list_plans()
236
+ entries = []
237
+ for p in plans[:limit]:
238
+ entries.append(
239
+ ForgeHistoryEntry(
240
+ plan_id=p["id"],
241
+ name=p["name"],
242
+ status=ForgeStatus(p.get("status", "draft")),
243
+ tier=ForgeTier(p.get("complexity", {}).get("tier", "shallow")),
244
+ confidence=p.get("critic", {}).get("confidence", 0.0),
245
+ created_date=p.get("created_at", "")[:10],
246
+ )
247
+ )
248
+ return entries
249
+
250
+ def show(self, plan_id: str) -> Optional[ForgePlan]:
251
+ """Load and return a specific plan by ID.
252
+
253
+ Args:
254
+ plan_id: Plan ID to load
255
+
256
+ Returns:
257
+ ForgePlan if found, None otherwise
258
+ """
259
+ plan = load_plan(plan_id)
260
+ if plan:
261
+ self._current_plan = plan
262
+ return plan
263
+
264
+ def compare(self, id1: str, id2: str) -> Optional[ForgeCompareOutput]:
265
+ """Compare two plans side by side.
266
+
267
+ Args:
268
+ id1: Left plan ID
269
+ id2: Right plan ID
270
+
271
+ Returns:
272
+ ForgeCompareOutput if both found, None otherwise
273
+ """
274
+ left = load_plan(id1)
275
+ right = load_plan(id2)
276
+ if not left or not right:
277
+ return None
278
+
279
+ return ForgeCompareOutput(
280
+ left=self._plan_to_status(left),
281
+ right=self._plan_to_status(right),
282
+ left_phases=left.plan_phases,
283
+ right_phases=right.plan_phases,
284
+ )
285
+
286
+ def patterns(self) -> list[dict]:
287
+ """List reusable patterns from past plans.
288
+
289
+ Returns:
290
+ List of pattern dicts
291
+ """
292
+ return load_patterns()
293
+
294
+ def cancel(self) -> bool:
295
+ """Cancel the active plan.
296
+
297
+ Returns:
298
+ True if cancelled, False if no active plan
299
+ """
300
+ if self._current_plan is None:
301
+ self._current_plan = get_active_plan()
302
+ if self._current_plan is None:
303
+ return False
304
+
305
+ self._current_plan.status = ForgeStatus.CANCELLED
306
+ save_plan(self._current_plan)
307
+ clear_active_plan()
308
+ return True
309
+
310
+ # -------------------------------------------------------------------------
311
+ # User Decision Handlers
312
+ # -------------------------------------------------------------------------
313
+
314
+ def approve(self) -> ForgePlan:
315
+ """Handle user Approve decision.
316
+
317
+ Runs steps 9-10: handoff and persist.
318
+
319
+ Returns:
320
+ Approved ForgePlan
321
+ """
322
+ if self._current_plan is None:
323
+ raise RuntimeError("No active forge plan. Run forge() first.")
324
+
325
+ self._step9_handoff()
326
+ self._step10_persist()
327
+ return self._current_plan
328
+
329
+ def revise(self, revision_text: str) -> ForgePlan:
330
+ """Handle user Revise decision.
331
+
332
+ Re-runs critic only (not explorers), capped at 5 revisions.
333
+
334
+ Args:
335
+ revision_text: User's revision request
336
+
337
+ Returns:
338
+ Updated ForgePlan
339
+ """
340
+ if self._current_plan is None:
341
+ raise RuntimeError("No active forge plan. Run forge() first.")
342
+
343
+ plan = self._current_plan
344
+ if plan.version > 5:
345
+ raise RuntimeError("Maximum 5 revisions exceeded.")
346
+
347
+ self._step6_critic_synthesis(revision_request=revision_text)
348
+ self._step7_enforce_constitution()
349
+ self._step8_build_plan()
350
+ return self._current_plan
351
+
352
+ def companion(self) -> str:
353
+ """Generate HTML visual companion.
354
+
355
+ Returns:
356
+ Path to HTML file
357
+ """
358
+ if self._current_plan is None:
359
+ raise RuntimeError("No active forge plan.")
360
+
361
+ html = render_html(self._current_plan)
362
+ path = f"/tmp/forge-{self._current_plan.id}.html"
363
+ Path(path).write_text(html, encoding="utf-8")
364
+ return path
365
+
366
+ def detail(self, phase_index: int) -> Optional[str]:
367
+ """Get detail for a specific phase.
368
+
369
+ Args:
370
+ phase_index: 1-based phase number
371
+
372
+ Returns:
373
+ Detailed string for the phase, or None if not found
374
+ """
375
+ if self._current_plan is None:
376
+ return None
377
+ if phase_index < 1 or phase_index > len(self._current_plan.plan_phases):
378
+ return None
379
+
380
+ phase = self._current_plan.plan_phases[phase_index - 1]
381
+ lines = [
382
+ f"Phase {phase_index}: {phase.name}",
383
+ f"Department: {phase.department}",
384
+ f"Agents: {', '.join(phase.agents) if phase.agents else 'none'}",
385
+ "",
386
+ "Deliverables:",
387
+ ]
388
+ for d in phase.deliverables or []:
389
+ lines.append(f" - {d}")
390
+ lines.extend(["", "Acceptance Criteria:"])
391
+ for c in phase.acceptance_criteria or []:
392
+ lines.append(f" - {c}")
393
+ if phase.depends_on:
394
+ lines.extend(["", f"Depends on: {', '.join(phase.depends_on)}"])
395
+ return "\n".join(lines)
396
+
397
+ def quit(self) -> ForgePlan:
398
+ """Handle user Quit decision.
399
+
400
+ Saves as draft and clears active.
401
+
402
+ Returns:
403
+ Draft ForgePlan
404
+ """
405
+ if self._current_plan is None:
406
+ raise RuntimeError("No active forge plan.")
407
+
408
+ self._current_plan.status = ForgeStatus.DRAFT
409
+ save_plan(self._current_plan)
410
+ clear_active_plan()
411
+ return self._current_plan
412
+
413
+ # -------------------------------------------------------------------------
414
+ # Render Output
415
+ # -------------------------------------------------------------------------
416
+
417
+ def render(self) -> str:
418
+ """Render current plan for terminal display.
419
+
420
+ Returns:
421
+ Terminal-formatted string
422
+ """
423
+ if self._current_plan is None:
424
+ return "No active forge plan."
425
+ return render_terminal(self._current_plan)
426
+
427
+ def render_complexity(self) -> str:
428
+ """Render complexity analysis.
429
+
430
+ Returns:
431
+ Complexity analysis string
432
+ """
433
+ if self._current_plan is None:
434
+ return ""
435
+ return render_complexity(self._current_plan.complexity)
436
+
437
+ def _step9_render(self) -> None:
438
+ """Step 9: Render the plan for user review."""
439
+ if self._current_plan is None:
440
+ return
441
+ self._rendered_output = render_terminal(self._current_plan)
442
+
443
+ # -------------------------------------------------------------------------
444
+ # Internal Steps
445
+ # -------------------------------------------------------------------------
446
+
447
+ def _step1_snapshot_context(self, prompt: str) -> None:
448
+ """Step 1: Collect git context."""
449
+ try:
450
+ git_rev = subprocess.run(
451
+ ["git", "rev-parse", "HEAD"], capture_output=True, text=True, check=True
452
+ )
453
+ commit = git_rev.stdout.strip()
454
+ except (subprocess.CalledProcessError, FileNotFoundError):
455
+ commit = "unknown"
456
+
457
+ try:
458
+ git_branch = subprocess.run(
459
+ ["git", "branch", "--show-current"], capture_output=True, text=True, check=True
460
+ )
461
+ branch = git_branch.stdout.strip()
462
+ except (subprocess.CalledProcessError, FileNotFoundError):
463
+ branch = "unknown"
464
+
465
+ try:
466
+ git_remote = subprocess.run(
467
+ ["git", "remote", "get-url", "origin"], capture_output=True, text=True
468
+ )
469
+ repo = git_remote.stdout.strip()
470
+ except (subprocess.CalledProcessError, FileNotFoundError):
471
+ repo = Path.cwd().name
472
+
473
+ version_file = Path(__file__).parent.parent.parent / "VERSION"
474
+ version = version_file.read_text().strip() if version_file.exists() else "unknown"
475
+
476
+ self._forge_context = ForgeContext(
477
+ repo=repo,
478
+ branch=branch,
479
+ commit_at_forge=commit,
480
+ arkaos_version=version,
481
+ prompt=prompt,
482
+ )
483
+
484
+ def _step2_obsidian_check(self) -> None:
485
+ """Step 2: Check Obsidian for similar plans and patterns."""
486
+ self._similar_plans: list[str] = []
487
+ self._reused_patterns: list[str] = []
488
+
489
+ try:
490
+ patterns = load_patterns()
491
+ for p in patterns:
492
+ if any(kw in p.get("tags", []) for kw in ["dev", "forge"]):
493
+ self._reused_patterns.append(p.get("name", ""))
494
+ except Exception:
495
+ pass
496
+
497
+ def _step3_complexity(self) -> None:
498
+ """Step 3: Analyze complexity."""
499
+ prompt = self._forge_context.prompt
500
+ affected_files = self._estimate_affected_files(prompt)
501
+ departments = self._estimate_departments(prompt)
502
+
503
+ result = analyze_complexity(
504
+ prompt=prompt,
505
+ affected_files=affected_files,
506
+ departments=departments,
507
+ similar_plans=self._similar_plans,
508
+ reused_patterns=self._reused_patterns,
509
+ )
510
+ self._complexity = result
511
+
512
+ def _step4_confirm_tier(self) -> ForgeTier:
513
+ """Step 4: Confirm tier (returns current tier, caller handles user input).
514
+
515
+ Returns:
516
+ Confirmed ForgeTier
517
+ """
518
+ self._confirmed_tier = self._complexity.tier
519
+ return self._confirmed_tier
520
+
521
+ def set_tier(self, tier: ForgeTier) -> None:
522
+ """Override the confirmed tier (after user override request).
523
+
524
+ Args:
525
+ tier: New tier to use
526
+ """
527
+ self._confirmed_tier = tier
528
+
529
+ def _step5_launch_explorers(self) -> None:
530
+ """Step 5: Launch explorer subagents in parallel."""
531
+ tier = self._confirmed_tier
532
+ lens_map = {
533
+ ForgeTier.SHALLOW: [ExplorerLens.PRAGMATIC],
534
+ ForgeTier.STANDARD: [ExplorerLens.PRAGMATIC, ExplorerLens.ARCHITECTURAL],
535
+ ForgeTier.DEEP: [
536
+ ExplorerLens.PRAGMATIC,
537
+ ExplorerLens.ARCHITECTURAL,
538
+ ExplorerLens.CONTRARIAN,
539
+ ],
540
+ }
541
+ lenses = lens_map.get(tier, [ExplorerLens.PRAGMATIC])
542
+ model = _tier_to_model(tier)
543
+
544
+ self._approaches: list[ExplorerApproach] = []
545
+ requests = [
546
+ ExplorerDispatchRequest(
547
+ lens=lens,
548
+ prompt=self._forge_context.prompt,
549
+ context=self._forge_context,
550
+ similar_plans=self._similar_plans,
551
+ reused_patterns=self._reused_patterns,
552
+ model=model,
553
+ )
554
+ for lens in lenses
555
+ ]
556
+
557
+ for req in requests:
558
+ try:
559
+ approach = self._dispatcher.dispatch_explorer_and_parse(req)
560
+ self._approaches.append(approach)
561
+ except Exception as e:
562
+ pass
563
+
564
+ def _step6_critic_synthesis(self, revision_request: Optional[str] = None) -> None:
565
+ """Step 6: Launch critic subagent for synthesis.
566
+
567
+ Args:
568
+ revision_request: Optional revision text from user
569
+ """
570
+ model = _tier_to_model(self._confirmed_tier)
571
+ critic_req = CriticDispatchRequest(
572
+ original_prompt=self._forge_context.prompt,
573
+ approaches=self._approaches,
574
+ model=model,
575
+ )
576
+
577
+ try:
578
+ self._critic_verdict = self._dispatcher.dispatch_critic_and_parse(critic_req)
579
+ except Exception:
580
+ self._critic_verdict = CriticVerdict(
581
+ synthesis={"approach_1": [a.summary for a in self._approaches]},
582
+ rejected_elements=[],
583
+ risks=[],
584
+ confidence=0.5,
585
+ estimated_phases=len(self._approaches[0].phases) if self._approaches else 3,
586
+ )
587
+
588
+ if revision_request:
589
+ self._critic_verdict.synthesis["revision_request"] = [revision_request]
590
+
591
+ def _step7_enforce_constitution(self) -> None:
592
+ """Step 7: Enforce Constitution rules - add missing phases."""
593
+ self._enforced_phases: list[PlanPhase] = []
594
+ phase_names = [p.name.lower() for p in self._final_phases_from_critic()]
595
+
596
+ has_branch = any("branch" in n or "feature" in n for n in phase_names)
597
+ has_spec = any("spec" in n or "specification" in n for n in phase_names)
598
+ has_qg = any("quality" in n or "gate" in n or "qg" in n for n in phase_names)
599
+ has_obsidian = any("obsidian" in n or "persist" in n or "persist" in n for n in phase_names)
600
+
601
+ enforced = []
602
+ if not has_branch:
603
+ enforced.append(
604
+ PlanPhase(
605
+ name="Create feature branch",
606
+ department="dev",
607
+ agents=["developer"],
608
+ deliverables=["feature/forge-xyz branch created"],
609
+ acceptance_criteria=["git branch created from main"],
610
+ )
611
+ )
612
+ if not has_spec and self._confirmed_tier != ForgeTier.SHALLOW:
613
+ enforced.append(
614
+ PlanPhase(
615
+ name="Write specification",
616
+ department="dev",
617
+ agents=["architect"],
618
+ deliverables=["SPEC.md created"],
619
+ acceptance_criteria=["spec approved"],
620
+ )
621
+ )
622
+ if not has_qg:
623
+ enforced.append(
624
+ PlanPhase(
625
+ name="Quality Gate",
626
+ department="quality",
627
+ agents=["cqo"],
628
+ deliverables=["QA review completed"],
629
+ acceptance_criteria=["Marta APPROVED"],
630
+ )
631
+ )
632
+ if not has_obsidian:
633
+ enforced.append(
634
+ PlanPhase(
635
+ name="Persist to Obsidian",
636
+ department="kb",
637
+ agents=["knowledge-curator"],
638
+ deliverables=["plan archived in Obsidian"],
639
+ acceptance_criteria=["Obsidian note created"],
640
+ )
641
+ )
642
+
643
+ self._enforced_phases = enforced
644
+
645
+ def _step8_build_plan(self) -> None:
646
+ """Step 8: Build the ForgePlan object."""
647
+ plan_id = self._generate_plan_id(self._forge_context.prompt)
648
+ execution_path = select_execution_path(self._final_phases_from_critic())
649
+
650
+ self._current_plan = ForgePlan(
651
+ id=plan_id,
652
+ name=self._forge_context.prompt[:60],
653
+ created_at=datetime.now(timezone.utc).isoformat(),
654
+ forged_by="forge",
655
+ version=1,
656
+ context=self._forge_context,
657
+ complexity=self._complexity,
658
+ approaches=self._approaches,
659
+ critic=self._critic_verdict,
660
+ plan_phases=self._final_phases_from_critic(),
661
+ goal=self._forge_context.prompt,
662
+ execution_path=execution_path,
663
+ governance=ForgeGovernance(
664
+ constitution_check="passed",
665
+ quality_gate_required=True,
666
+ branch_strategy=f"feature/{plan_id}",
667
+ ),
668
+ status=ForgeStatus.REVIEWING,
669
+ )
670
+
671
+ def _step9_handoff(self) -> None:
672
+ """Step 9: Handoff - check drift, select execution path."""
673
+ drift = check_repo_drift(self._current_plan.context.commit_at_forge)
674
+ self._drift = drift
675
+
676
+ if drift["changed"]:
677
+ pass
678
+
679
+ self._current_plan.status = ForgeStatus.APPROVED
680
+ self._current_plan.approved_at = datetime.now(timezone.utc).isoformat()
681
+
682
+ exec_path = select_execution_path(self._current_plan.plan_phases)
683
+ self._current_plan.execution_path = exec_path
684
+
685
+ def _step10_persist(self) -> None:
686
+ """Step 10: Persist plan to YAML and Obsidian."""
687
+ save_plan(self._current_plan)
688
+ set_active_plan(self._current_plan.id)
689
+
690
+ try:
691
+ export_to_obsidian(self._current_plan)
692
+ except Exception:
693
+ pass
694
+
695
+ try:
696
+ patterns = extract_patterns(self._current_plan)
697
+ if patterns:
698
+ pass
699
+ except Exception:
700
+ pass
701
+
702
+ def _final_phases_from_critic(self) -> list[PlanPhase]:
703
+ """Build final phases list from critic verdict + constitution enforcement."""
704
+ phases = []
705
+ for i, p in enumerate(range(self._critic_verdict.estimated_phases or 3)):
706
+ phases.append(
707
+ PlanPhase(
708
+ name=f"Phase {i + 1}",
709
+ department="dev",
710
+ )
711
+ )
712
+
713
+ for ep in self._enforced_phases:
714
+ phases.append(ep)
715
+
716
+ return phases
717
+
718
+ def _generate_plan_id(self, prompt: str) -> str:
719
+ """Generate a forge plan ID."""
720
+ date = datetime.now(timezone.utc).strftime("%Y%m%d")
721
+ suffix = hashlib.md5(prompt.encode()).hexdigest()[:4]
722
+ return f"forge-{date}-{suffix}"
723
+
724
+ def _estimate_affected_files(self, prompt: str) -> list[str]:
725
+ """Estimate affected files from prompt keywords."""
726
+ keywords = {
727
+ "auth": ["auth/", "middleware/auth.py"],
728
+ "db": ["core/db/", "models/"],
729
+ "api": ["routes/", "controllers/"],
730
+ "test": ["tests/"],
731
+ "config": ["config/", ".env"],
732
+ }
733
+ files = []
734
+ lower = prompt.lower()
735
+ for kw, paths in keywords.items():
736
+ if kw in lower:
737
+ files.extend(paths)
738
+ return files
739
+
740
+ def _estimate_departments(self, prompt: str) -> list[str]:
741
+ """Estimate departments from prompt keywords."""
742
+ keywords = {
743
+ "dev": ["dev"],
744
+ "marketing": ["mkt", "marketing", "content"],
745
+ "brand": ["brand", "design"],
746
+ "sales": ["sales"],
747
+ "ops": ["ops", "operations"],
748
+ }
749
+ depts = []
750
+ lower = prompt.lower()
751
+ for kw, depts_list in keywords.items():
752
+ if kw in lower:
753
+ depts.extend(depts_list)
754
+ return list(set(depts)) or ["dev"]
755
+
756
+ def _plan_to_status(self, plan: ForgePlan) -> ForgeStatusOutput:
757
+ """Convert ForgePlan to ForgeStatusOutput."""
758
+ depts = list({p.department for p in plan.plan_phases})
759
+ return ForgeStatusOutput(
760
+ plan_id=plan.id,
761
+ name=plan.name,
762
+ status=plan.status,
763
+ tier=plan.complexity.tier,
764
+ score=plan.complexity.score,
765
+ confidence=plan.critic.confidence,
766
+ n_phases=len(plan.plan_phases),
767
+ departments=depts,
768
+ created_at=plan.created_at,
769
+ revision_count=plan.version - 1,
770
+ )