devcouncil 0.1.0 → 0.1.1

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 (128) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +62 -543
  3. package/package.json +1 -1
  4. package/pyproject.toml +29 -26
  5. package/src/devcouncil/__main__.py +4 -4
  6. package/src/devcouncil/app/__init__.py +28 -28
  7. package/src/devcouncil/app/config.py +135 -108
  8. package/src/devcouncil/app/errors.py +23 -23
  9. package/src/devcouncil/app/events.py +44 -44
  10. package/src/devcouncil/app/orchestrator.py +67 -67
  11. package/src/devcouncil/app/project_status.py +29 -0
  12. package/src/devcouncil/app/run_context.py +39 -39
  13. package/src/devcouncil/app/state_machine.py +108 -108
  14. package/src/devcouncil/artifacts/__init__.py +1 -1
  15. package/src/devcouncil/artifacts/coverage.py +96 -96
  16. package/src/devcouncil/artifacts/graph.py +143 -143
  17. package/src/devcouncil/artifacts/migrations.py +20 -20
  18. package/src/devcouncil/artifacts/schemas.py +23 -23
  19. package/src/devcouncil/artifacts/serializer.py +21 -21
  20. package/src/devcouncil/artifacts/validators.py +27 -27
  21. package/src/devcouncil/cli/commands/artifacts.py +51 -48
  22. package/src/devcouncil/cli/commands/ast.py +22 -0
  23. package/src/devcouncil/cli/commands/baseline.py +35 -32
  24. package/src/devcouncil/cli/commands/config.py +76 -54
  25. package/src/devcouncil/cli/commands/dashboard.py +26 -0
  26. package/src/devcouncil/cli/commands/doctor.py +86 -42
  27. package/src/devcouncil/cli/commands/go.py +237 -0
  28. package/src/devcouncil/cli/commands/hook.py +96 -29
  29. package/src/devcouncil/cli/commands/init.py +67 -56
  30. package/src/devcouncil/cli/commands/integrate.py +320 -14
  31. package/src/devcouncil/cli/commands/lsp.py +20 -0
  32. package/src/devcouncil/cli/commands/map.py +25 -21
  33. package/src/devcouncil/cli/commands/plan.py +257 -206
  34. package/src/devcouncil/cli/commands/prompt.py +36 -33
  35. package/src/devcouncil/cli/commands/repair.py +72 -69
  36. package/src/devcouncil/cli/commands/report.py +112 -54
  37. package/src/devcouncil/cli/commands/reset_demo_state.py +31 -28
  38. package/src/devcouncil/cli/commands/rollback.py +49 -47
  39. package/src/devcouncil/cli/commands/run.py +252 -207
  40. package/src/devcouncil/cli/commands/setup.py +159 -18
  41. package/src/devcouncil/cli/commands/show.py +76 -57
  42. package/src/devcouncil/cli/commands/status.py +117 -105
  43. package/src/devcouncil/cli/commands/tasks.py +55 -41
  44. package/src/devcouncil/cli/commands/trace.py +2 -1
  45. package/src/devcouncil/cli/commands/verify.py +158 -128
  46. package/src/devcouncil/cli/commands/version.py +20 -20
  47. package/src/devcouncil/cli/commands/watch.py +574 -0
  48. package/src/devcouncil/cli/main.py +42 -24
  49. package/src/devcouncil/council/prompts/arbiter.md +19 -19
  50. package/src/devcouncil/council/prompts/critic_a.md +10 -10
  51. package/src/devcouncil/council/prompts/critic_b.md +10 -10
  52. package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
  53. package/src/devcouncil/council/prompts/planner_a.md +16 -16
  54. package/src/devcouncil/council/prompts/planner_b.md +16 -16
  55. package/src/devcouncil/council/prompts/rebuttal.md +10 -10
  56. package/src/devcouncil/council/prompts/spec_writer.md +12 -12
  57. package/src/devcouncil/domain/assumption.py +17 -17
  58. package/src/devcouncil/domain/critique.py +32 -32
  59. package/src/devcouncil/domain/evidence.py +27 -27
  60. package/src/devcouncil/domain/gap.py +26 -26
  61. package/src/devcouncil/domain/requirement.py +22 -22
  62. package/src/devcouncil/domain/task.py +26 -26
  63. package/src/devcouncil/execution/__init__.py +1 -1
  64. package/src/devcouncil/execution/context_builder.py +54 -54
  65. package/src/devcouncil/execution/executor.py +15 -15
  66. package/src/devcouncil/execution/hook_policy.py +24 -3
  67. package/src/devcouncil/execution/patch.py +28 -28
  68. package/src/devcouncil/execution/permissions.py +44 -44
  69. package/src/devcouncil/execution/prompt_builder.py +23 -23
  70. package/src/devcouncil/execution/task_runner.py +63 -63
  71. package/src/devcouncil/executors/__init__.py +1 -1
  72. package/src/devcouncil/executors/coding_cli.py +112 -0
  73. package/src/devcouncil/executors/mini_swe.py +63 -63
  74. package/src/devcouncil/executors/native/agent.py +81 -81
  75. package/src/devcouncil/executors/openhands.py +56 -56
  76. package/src/devcouncil/gating/__init__.py +1 -1
  77. package/src/devcouncil/gating/checks/clean_git.py +50 -45
  78. package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
  79. package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
  80. package/src/devcouncil/gating/checks/secret_scan_check.py +34 -34
  81. package/src/devcouncil/gating/policy.py +157 -157
  82. package/src/devcouncil/indexing/__init__.py +1 -1
  83. package/src/devcouncil/indexing/ast_matcher.py +168 -0
  84. package/src/devcouncil/indexing/graph_index.py +48 -48
  85. package/src/devcouncil/indexing/lsp.py +120 -0
  86. package/src/devcouncil/indexing/repo_mapper.py +208 -204
  87. package/src/devcouncil/integrations/github.py +35 -35
  88. package/src/devcouncil/integrations/gitnexus.py +27 -27
  89. package/src/devcouncil/integrations/graphify.py +34 -34
  90. package/src/devcouncil/integrations/mcp/server.py +549 -96
  91. package/src/devcouncil/integrations/pr_comments.py +62 -0
  92. package/src/devcouncil/live/__init__.py +2 -0
  93. package/src/devcouncil/live/cards.py +207 -0
  94. package/src/devcouncil/live/models.py +63 -0
  95. package/src/devcouncil/live/repair_prompt.py +83 -0
  96. package/src/devcouncil/live/reviewer.py +70 -0
  97. package/src/devcouncil/live/signals.py +135 -0
  98. package/src/devcouncil/live/summary.py +34 -0
  99. package/src/devcouncil/live/tasks.py +18 -0
  100. package/src/devcouncil/live/transcripts.py +138 -0
  101. package/src/devcouncil/llm/__init__.py +1 -1
  102. package/src/devcouncil/llm/cache.py +38 -38
  103. package/src/devcouncil/llm/provider.py +146 -125
  104. package/src/devcouncil/llm/router.py +111 -111
  105. package/src/devcouncil/planning/__init__.py +1 -1
  106. package/src/devcouncil/planning/arbiter_service.py +57 -57
  107. package/src/devcouncil/planning/critique_service.py +66 -66
  108. package/src/devcouncil/planning/plan_service.py +46 -46
  109. package/src/devcouncil/planning/prompt_enhancer_service.py +86 -0
  110. package/src/devcouncil/planning/repair_service.py +39 -39
  111. package/src/devcouncil/planning/spec_service.py +44 -44
  112. package/src/devcouncil/reporting/github_check.py +32 -32
  113. package/src/devcouncil/reporting/json_report.py +20 -17
  114. package/src/devcouncil/reporting/markdown_report.py +68 -46
  115. package/src/devcouncil/reporting/report_builder.py +14 -14
  116. package/src/devcouncil/storage/db.py +66 -66
  117. package/src/devcouncil/storage/models.py +83 -83
  118. package/src/devcouncil/storage/repositories.py +299 -222
  119. package/src/devcouncil/telemetry/cost.py +34 -34
  120. package/src/devcouncil/telemetry/tracker.py +49 -49
  121. package/src/devcouncil/ui/__init__.py +1 -0
  122. package/src/devcouncil/ui/dashboard.py +122 -0
  123. package/src/devcouncil/utils/__init__.py +1 -1
  124. package/src/devcouncil/utils/redaction.py +141 -141
  125. package/src/devcouncil/verification/__init__.py +1 -1
  126. package/src/devcouncil/verification/implementation_reviewer.py +55 -55
  127. package/src/devcouncil/verification/verifier.py +319 -302
  128. package/uv.lock +1 -1
@@ -1,26 +1,26 @@
1
- from devcouncil.domain.requirement import Requirement
2
- from devcouncil.domain.task import Task
3
- from devcouncil.domain.gap import Gap
4
- from typing import List
5
-
6
- class RequirementCoverageCheck:
7
- """Detects requirements that are not mapped to any tasks."""
8
-
9
- def check(self, requirements: List[Requirement], tasks: List[Task]) -> List[Gap]:
10
- task_req_ids = set()
11
- for t in tasks:
12
- task_req_ids.update(t.requirement_ids)
13
-
14
- gaps = []
15
- for req in requirements:
16
- if req.id not in task_req_ids:
17
- gaps.append(Gap(
18
- id=f"GAP-PLAN-{req.id}-UNMAPPED",
19
- severity="high",
20
- gap_type="requirement_not_planned",
21
- requirement_id=req.id,
22
- description=f"Requirement '{req.title}' is not covered by any task.",
23
- recommended_fix="Decompose this requirement into one or more implementation tasks.",
24
- blocking=True
25
- ))
26
- return gaps
1
+ from devcouncil.domain.requirement import Requirement
2
+ from devcouncil.domain.task import Task
3
+ from devcouncil.domain.gap import Gap
4
+ from typing import List
5
+
6
+ class RequirementCoverageCheck:
7
+ """Detects requirements that are not mapped to any tasks."""
8
+
9
+ def check(self, requirements: List[Requirement], tasks: List[Task]) -> List[Gap]:
10
+ task_req_ids = set()
11
+ for t in tasks:
12
+ task_req_ids.update(t.requirement_ids)
13
+
14
+ gaps = []
15
+ for req in requirements:
16
+ if req.id not in task_req_ids:
17
+ gaps.append(Gap(
18
+ id=f"GAP-PLAN-{req.id}-UNMAPPED",
19
+ severity="high",
20
+ gap_type="requirement_not_planned",
21
+ requirement_id=req.id,
22
+ description=f"Requirement '{req.title}' is not covered by any task.",
23
+ recommended_fix="Decompose this requirement into one or more implementation tasks.",
24
+ blocking=True
25
+ ))
26
+ return gaps
@@ -1,34 +1,34 @@
1
- from typing import List
2
- from devcouncil.domain.gap import Gap
3
- from devcouncil.utils.redaction import SECRET_PATTERNS, redact_string
4
-
5
- class SecretScanner:
6
- """Scans code diffs for potential secrets (API keys, tokens, etc.)."""
7
-
8
- def scan_diff(self, diff_content: str, task_id: str) -> List[Gap]:
9
- gaps = []
10
- lines = diff_content.splitlines()
11
- current_file = "unknown_file"
12
-
13
- for i, line in enumerate(lines):
14
- if line.startswith("+++ b/"):
15
- current_file = line[6:]
16
- continue
17
-
18
- # Only scan added lines in diff
19
- if not line.startswith("+") or line.startswith("+++"):
20
- continue
21
-
22
- for key_type, pattern in SECRET_PATTERNS.items():
23
- if pattern.search(line):
24
- gaps.append(Gap(
25
- id=f"GAP-{task_id}-SECRET-{key_type.upper()}-{i}",
26
- severity="critical",
27
- gap_type="security_risk",
28
- task_id=task_id,
29
- description=f"Potential {key_type} found in {current_file} (diff line {i+1}).",
30
- evidence=[redact_string(line.strip())],
31
- recommended_fix="Remove the secret and use environment variables or a secret manager.",
32
- blocking=True
33
- ))
34
- return gaps
1
+ from typing import List
2
+ from devcouncil.domain.gap import Gap
3
+ from devcouncil.utils.redaction import SECRET_PATTERNS, redact_string
4
+
5
+ class SecretScanner:
6
+ """Scans code diffs for potential secrets (API keys, tokens, etc.)."""
7
+
8
+ def scan_diff(self, diff_content: str, task_id: str) -> List[Gap]:
9
+ gaps = []
10
+ lines = diff_content.splitlines()
11
+ current_file = "unknown_file"
12
+
13
+ for i, line in enumerate(lines):
14
+ if line.startswith("+++ b/"):
15
+ current_file = line[6:]
16
+ continue
17
+
18
+ # Only scan added lines in diff
19
+ if not line.startswith("+") or line.startswith("+++"):
20
+ continue
21
+
22
+ for key_type, pattern in SECRET_PATTERNS.items():
23
+ if pattern.search(line):
24
+ gaps.append(Gap(
25
+ id=f"GAP-{task_id}-SECRET-{key_type.upper()}-{i}",
26
+ severity="critical",
27
+ gap_type="security_risk",
28
+ task_id=task_id,
29
+ description=f"Potential {key_type} found in {current_file} (diff line {i+1}).",
30
+ evidence=[redact_string(line.strip())],
31
+ recommended_fix="Remove the secret and use environment variables or a secret manager.",
32
+ blocking=True
33
+ ))
34
+ return gaps
@@ -1,161 +1,161 @@
1
- from pydantic import BaseModel
2
- from typing import Any, List, Optional
3
- from pathlib import Path
4
-
5
- from devcouncil.domain.requirement import Requirement
6
- from devcouncil.domain.task import Task
7
- from devcouncil.domain.gap import Gap
8
- from devcouncil.domain.assumption import Assumption
9
- from devcouncil.domain.critique import CritiqueFinding
10
- from devcouncil.gating.checks.requirement_coverage import RequirementCoverageCheck
11
- from devcouncil.gating.checks.planned_files_check import PlannedFilesCheck
12
- from devcouncil.gating.checks.clean_git import CleanGitCheck
13
-
14
- class GateResult(BaseModel):
15
- passed: bool
16
- gaps: List[Gap]
17
-
18
- class GatePolicy:
19
- """Central engine for executing project and task level quality gates."""
20
-
21
- def __init__(self):
22
- self.req_coverage = RequirementCoverageCheck()
23
- self.planned_files = PlannedFilesCheck()
24
- self.clean_git = CleanGitCheck()
25
-
26
- def check_plan_approval(
27
- self,
28
- requirements: List[Requirement],
29
- tasks: List[Task],
30
- assumptions: Optional[List[Assumption]] = None,
31
- findings: Optional[List[CritiqueFinding]] = None,
32
- blocking_questions: Optional[List[Any]] = None,
33
- ) -> GateResult:
34
- """Determines if the overall project plan is ready for execution."""
35
- gaps = []
36
- known_req_ids = {req.id for req in requirements}
37
- known_ac_ids = {
38
- ac.id
39
- for req in requirements
40
- for ac in req.acceptance_criteria
41
- }
42
-
43
- # 1. Check requirement coverage
44
- gaps.extend(self.req_coverage.check(requirements, tasks))
45
-
46
- # 2. Check for acceptance criteria presence
47
- for req in requirements:
48
- if not req.acceptance_criteria:
49
- gaps.append(Gap(
50
- id=f"GAP-PLAN-{req.id}-NO-AC",
51
- severity="high",
52
- gap_type="requirement_not_planned",
53
- requirement_id=req.id,
54
- description=f"Requirement {req.id} has no acceptance criteria.",
55
- recommended_fix="Define at least one measurable AC.",
56
- blocking=True
57
- ))
58
-
59
- for ac in req.acceptance_criteria:
60
- if not ac.verification_method:
61
- gaps.append(Gap(
62
- id=f"GAP-PLAN-{ac.id}-NO-VERIFY",
63
- severity="high",
64
- gap_type="acceptance_criteria_unproven",
65
- requirement_id=req.id,
66
- description=f"Acceptance criterion {ac.id} has no verification method.",
67
- recommended_fix="Define a deterministic verification method for this AC.",
68
- blocking=True,
69
- ))
70
-
71
- for task in tasks:
72
- if not task.requirement_ids:
73
- gaps.append(Gap(
74
- id=f"GAP-PLAN-{task.id}-NO-REQ",
75
- severity="high",
76
- gap_type="requirement_not_planned",
77
- task_id=task.id,
78
- description=f"Task {task.id} is not mapped to any requirement.",
79
- recommended_fix="Map each task to at least one requirement.",
80
- blocking=True,
81
- ))
82
-
83
- unknown_req_ids = [req_id for req_id in task.requirement_ids if req_id not in known_req_ids]
84
- if unknown_req_ids:
85
- gaps.append(Gap(
86
- id=f"GAP-PLAN-{task.id}-UNKNOWN-REQ",
87
- severity="high",
88
- gap_type="requirement_not_planned",
89
- task_id=task.id,
90
- description=f"Task {task.id} references unknown requirement(s): {', '.join(unknown_req_ids)}.",
91
- recommended_fix="Remove invalid requirement links or add the missing requirements.",
92
- blocking=True,
93
- ))
94
-
95
- unknown_ac_ids = [ac_id for ac_id in task.acceptance_criterion_ids if ac_id not in known_ac_ids]
96
- if unknown_ac_ids:
97
- gaps.append(Gap(
98
- id=f"GAP-PLAN-{task.id}-UNKNOWN-AC",
99
- severity="high",
100
- gap_type="acceptance_criteria_unproven",
101
- task_id=task.id,
102
- description=f"Task {task.id} references unknown acceptance criteria: {', '.join(unknown_ac_ids)}.",
103
- recommended_fix="Link tasks only to acceptance criteria declared by requirements.",
104
- blocking=True,
105
- ))
106
-
107
- for assumption in assumptions or []:
108
- if (
109
- assumption.impact == "high"
110
- and assumption.status == "open"
111
- and assumption.requires_user_confirmation
112
- ):
113
- gaps.append(Gap(
114
- id=f"GAP-PLAN-{assumption.id}-OPEN",
115
- severity="high",
116
- gap_type="assumption_violated",
117
- requirement_id=assumption.linked_requirement_ids[0] if assumption.linked_requirement_ids else None,
118
- description=f"High-impact assumption {assumption.id} is still open: {assumption.statement}",
119
- recommended_fix="Confirm, reject, or convert this assumption before approving the plan.",
120
- blocking=True,
121
- ))
122
-
123
- for finding in findings or []:
124
- if finding.severity in {"high", "critical"} and finding.status == "open":
125
- gaps.append(Gap(
126
- id=f"GAP-PLAN-{finding.id}-OPEN",
127
- severity=finding.severity,
128
- gap_type="architecture_drift",
129
- requirement_id=finding.linked_requirement_id,
130
- description=f"Open {finding.severity} critique finding remains: {finding.claim}",
131
- recommended_fix="Convert, rebut with evidence, or mark this finding resolved before approval.",
132
- blocking=True,
133
- ))
134
-
135
- for question in blocking_questions or []:
136
- question_id = getattr(question, "id", "QUESTION")
137
- question_text = getattr(question, "question", str(question))
138
- gaps.append(Gap(
139
- id=f"GAP-PLAN-{question_id}-BLOCKING",
140
- severity="high",
141
- gap_type="requirement_not_planned",
142
- description=f"Blocking question remains unanswered: {question_text}",
143
- recommended_fix="Answer or convert the blocking question before approving the plan.",
144
- blocking=True,
145
- ))
146
-
147
- return GateResult(
148
- passed=len([g for g in gaps if g.blocking]) == 0,
149
- gaps=gaps
150
- )
151
-
1
+ from pydantic import BaseModel
2
+ from typing import Any, List, Optional
3
+ from pathlib import Path
4
+
5
+ from devcouncil.domain.requirement import Requirement
6
+ from devcouncil.domain.task import Task
7
+ from devcouncil.domain.gap import Gap
8
+ from devcouncil.domain.assumption import Assumption
9
+ from devcouncil.domain.critique import CritiqueFinding
10
+ from devcouncil.gating.checks.requirement_coverage import RequirementCoverageCheck
11
+ from devcouncil.gating.checks.planned_files_check import PlannedFilesCheck
12
+ from devcouncil.gating.checks.clean_git import CleanGitCheck
13
+
14
+ class GateResult(BaseModel):
15
+ passed: bool
16
+ gaps: List[Gap]
17
+
18
+ class GatePolicy:
19
+ """Central engine for executing project and task level quality gates."""
20
+
21
+ def __init__(self):
22
+ self.req_coverage = RequirementCoverageCheck()
23
+ self.planned_files = PlannedFilesCheck()
24
+ self.clean_git = CleanGitCheck()
25
+
26
+ def check_plan_approval(
27
+ self,
28
+ requirements: List[Requirement],
29
+ tasks: List[Task],
30
+ assumptions: Optional[List[Assumption]] = None,
31
+ findings: Optional[List[CritiqueFinding]] = None,
32
+ blocking_questions: Optional[List[Any]] = None,
33
+ ) -> GateResult:
34
+ """Determines if the overall project plan is ready for execution."""
35
+ gaps = []
36
+ known_req_ids = {req.id for req in requirements}
37
+ known_ac_ids = {
38
+ ac.id
39
+ for req in requirements
40
+ for ac in req.acceptance_criteria
41
+ }
42
+
43
+ # 1. Check requirement coverage
44
+ gaps.extend(self.req_coverage.check(requirements, tasks))
45
+
46
+ # 2. Check for acceptance criteria presence
47
+ for req in requirements:
48
+ if not req.acceptance_criteria:
49
+ gaps.append(Gap(
50
+ id=f"GAP-PLAN-{req.id}-NO-AC",
51
+ severity="high",
52
+ gap_type="requirement_not_planned",
53
+ requirement_id=req.id,
54
+ description=f"Requirement {req.id} has no acceptance criteria.",
55
+ recommended_fix="Define at least one measurable AC.",
56
+ blocking=True
57
+ ))
58
+
59
+ for ac in req.acceptance_criteria:
60
+ if not ac.verification_method:
61
+ gaps.append(Gap(
62
+ id=f"GAP-PLAN-{ac.id}-NO-VERIFY",
63
+ severity="high",
64
+ gap_type="acceptance_criteria_unproven",
65
+ requirement_id=req.id,
66
+ description=f"Acceptance criterion {ac.id} has no verification method.",
67
+ recommended_fix="Define a deterministic verification method for this AC.",
68
+ blocking=True,
69
+ ))
70
+
71
+ for task in tasks:
72
+ if not task.requirement_ids:
73
+ gaps.append(Gap(
74
+ id=f"GAP-PLAN-{task.id}-NO-REQ",
75
+ severity="high",
76
+ gap_type="requirement_not_planned",
77
+ task_id=task.id,
78
+ description=f"Task {task.id} is not mapped to any requirement.",
79
+ recommended_fix="Map each task to at least one requirement.",
80
+ blocking=True,
81
+ ))
82
+
83
+ unknown_req_ids = [req_id for req_id in task.requirement_ids if req_id not in known_req_ids]
84
+ if unknown_req_ids:
85
+ gaps.append(Gap(
86
+ id=f"GAP-PLAN-{task.id}-UNKNOWN-REQ",
87
+ severity="high",
88
+ gap_type="requirement_not_planned",
89
+ task_id=task.id,
90
+ description=f"Task {task.id} references unknown requirement(s): {', '.join(unknown_req_ids)}.",
91
+ recommended_fix="Remove invalid requirement links or add the missing requirements.",
92
+ blocking=True,
93
+ ))
94
+
95
+ unknown_ac_ids = [ac_id for ac_id in task.acceptance_criterion_ids if ac_id not in known_ac_ids]
96
+ if unknown_ac_ids:
97
+ gaps.append(Gap(
98
+ id=f"GAP-PLAN-{task.id}-UNKNOWN-AC",
99
+ severity="high",
100
+ gap_type="acceptance_criteria_unproven",
101
+ task_id=task.id,
102
+ description=f"Task {task.id} references unknown acceptance criteria: {', '.join(unknown_ac_ids)}.",
103
+ recommended_fix="Link tasks only to acceptance criteria declared by requirements.",
104
+ blocking=True,
105
+ ))
106
+
107
+ for assumption in assumptions or []:
108
+ if (
109
+ assumption.impact == "high"
110
+ and assumption.status == "open"
111
+ and assumption.requires_user_confirmation
112
+ ):
113
+ gaps.append(Gap(
114
+ id=f"GAP-PLAN-{assumption.id}-OPEN",
115
+ severity="high",
116
+ gap_type="assumption_violated",
117
+ requirement_id=assumption.linked_requirement_ids[0] if assumption.linked_requirement_ids else None,
118
+ description=f"High-impact assumption {assumption.id} is still open: {assumption.statement}",
119
+ recommended_fix="Confirm, reject, or convert this assumption before approving the plan.",
120
+ blocking=True,
121
+ ))
122
+
123
+ for finding in findings or []:
124
+ if finding.severity in {"high", "critical"} and finding.status == "open":
125
+ gaps.append(Gap(
126
+ id=f"GAP-PLAN-{finding.id}-OPEN",
127
+ severity=finding.severity,
128
+ gap_type="architecture_drift",
129
+ requirement_id=finding.linked_requirement_id,
130
+ description=f"Open {finding.severity} critique finding remains: {finding.claim}",
131
+ recommended_fix="Convert, rebut with evidence, or mark this finding resolved before approval.",
132
+ blocking=True,
133
+ ))
134
+
135
+ for question in blocking_questions or []:
136
+ question_id = getattr(question, "id", "QUESTION")
137
+ question_text = getattr(question, "question", str(question))
138
+ gaps.append(Gap(
139
+ id=f"GAP-PLAN-{question_id}-BLOCKING",
140
+ severity="high",
141
+ gap_type="requirement_not_planned",
142
+ description=f"Blocking question remains unanswered: {question_text}",
143
+ recommended_fix="Answer or convert the blocking question before approving the plan.",
144
+ blocking=True,
145
+ ))
146
+
147
+ return GateResult(
148
+ passed=len([g for g in gaps if g.blocking]) == 0,
149
+ gaps=gaps
150
+ )
151
+
152
152
  def check_task_ready(self, task: Task, project_root: Path) -> GateResult:
153
153
  """Determines if a specific task can begin execution."""
154
154
  gaps = []
155
-
156
- # 1. Check Git state
157
- gaps.extend(self.clean_git.check(project_root, task.id))
158
-
155
+
156
+ # 1. Check Git state
157
+ gaps.extend(self.clean_git.check(project_root, task.id))
158
+
159
159
  # 2. Check planned files
160
160
  gaps.extend(self.planned_files.check(task))
161
161
 
@@ -186,5 +186,5 @@ class GatePolicy:
186
186
 
187
187
  return GateResult(
188
188
  passed=len([g for g in gaps if g.blocking]) == 0,
189
- gaps=gaps
190
- )
189
+ gaps=gaps
190
+ )
@@ -1 +1 @@
1
-
1
+
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import re
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class AstMatch:
11
+ path: str
12
+ language: str
13
+ kind: str
14
+ name: str
15
+ line: int
16
+ text: str
17
+ engine: str
18
+
19
+ def model_dump(self) -> dict[str, object]:
20
+ return {
21
+ "path": self.path,
22
+ "language": self.language,
23
+ "kind": self.kind,
24
+ "name": self.name,
25
+ "line": self.line,
26
+ "text": self.text,
27
+ "engine": self.engine,
28
+ }
29
+
30
+
31
+ class AstMatcher:
32
+ """Tree-sitter-style structural search with optional tree_sitter and deterministic fallbacks."""
33
+
34
+ _EXT_LANGUAGE = {
35
+ ".py": "python",
36
+ ".ts": "typescript",
37
+ ".tsx": "typescript",
38
+ ".js": "javascript",
39
+ ".jsx": "javascript",
40
+ ".go": "go",
41
+ ".rs": "rust",
42
+ }
43
+
44
+ _SYMBOL_PATTERNS: dict[str, re.Pattern[str]] = {
45
+ "typescript": re.compile(
46
+ r"^\s*(?:export\s+)?(?:(?:async\s+)?(?:function|class|interface|type)\s+|const\s+)([A-Za-z_$][\w$]*)"
47
+ ),
48
+ "javascript": re.compile(
49
+ r"^\s*(?:export\s+)?(?:(?:async\s+)?(?:function|class)\s+|const\s+)([A-Za-z_$][\w$]*)"
50
+ ),
51
+ "go": re.compile(r"^\s*func\s+(?:\([^)]+\)\s*)?([A-Za-z_]\w*)\s*\("),
52
+ "rust": re.compile(r"^\s*(?:pub\s+)?(?:async\s+)?(?:fn|struct|enum|trait)\s+([A-Za-z_]\w*)"),
53
+ }
54
+ _IGNORED_DIRS = {".git", ".devcouncil", "__pycache__", ".venv", "node_modules", "dist", "build", "target", "vendor"}
55
+
56
+ def __init__(self, project_root: Path):
57
+ self.project_root = project_root
58
+ self.tree_sitter_available = self._has_tree_sitter()
59
+
60
+ def _has_tree_sitter(self) -> bool:
61
+ try:
62
+ import tree_sitter # noqa: F401
63
+ except Exception:
64
+ return False
65
+ return True
66
+
67
+ def match(
68
+ self,
69
+ *,
70
+ query: str = "",
71
+ language: str | None = None,
72
+ kind: str | None = None,
73
+ limit: int = 100,
74
+ ) -> list[AstMatch]:
75
+ language = language.lower() if language else None
76
+ kind = kind.lower() if kind else None
77
+ limit = max(1, limit)
78
+ matches: list[AstMatch] = []
79
+ for path in self._candidate_files(language):
80
+ try:
81
+ text = path.read_text(encoding="utf-8")
82
+ except (OSError, UnicodeDecodeError):
83
+ continue
84
+ rel = path.relative_to(self.project_root).as_posix()
85
+ file_language = self._EXT_LANGUAGE.get(path.suffix.lower(), path.suffix.lower().lstrip("."))
86
+ matches.extend(self._match_file(rel, file_language, text, query=query, kind=kind))
87
+ if len(matches) >= limit:
88
+ return matches[:limit]
89
+ return matches[:limit]
90
+
91
+ def _candidate_files(self, language: str | None) -> list[Path]:
92
+ allowed_exts = {
93
+ ext for ext, ext_language in self._EXT_LANGUAGE.items()
94
+ if language is None or ext_language == language
95
+ }
96
+ files: list[Path] = []
97
+ try:
98
+ for path in self.project_root.rglob("*"):
99
+ if not path.is_file() or path.suffix.lower() not in allowed_exts:
100
+ continue
101
+ if any(part in self._IGNORED_DIRS for part in path.parts):
102
+ continue
103
+ files.append(path)
104
+ except OSError:
105
+ return files
106
+ return sorted(files)
107
+
108
+ def _match_file(self, rel: str, language: str, text: str, *, query: str, kind: str | None) -> list[AstMatch]:
109
+ if language == "python":
110
+ return self._match_python(rel, text, query=query, kind=kind)
111
+ pattern = self._SYMBOL_PATTERNS.get(language)
112
+ if not pattern:
113
+ return []
114
+ results: list[AstMatch] = []
115
+ for lineno, line in enumerate(text.splitlines(), start=1):
116
+ match = pattern.match(line)
117
+ if not match:
118
+ continue
119
+ symbol_name = match.group(1)
120
+ symbol_kind = self._line_kind(line)
121
+ if kind and kind != symbol_kind:
122
+ continue
123
+ if query and query.lower() not in symbol_name.lower() and query.lower() not in line.lower():
124
+ continue
125
+ results.append(AstMatch(rel, language, symbol_kind, symbol_name, lineno, line.strip(), self._engine()))
126
+ return results
127
+
128
+ def _match_python(self, rel: str, text: str, *, query: str, kind: str | None) -> list[AstMatch]:
129
+ try:
130
+ tree = ast.parse(text)
131
+ except SyntaxError:
132
+ return []
133
+ lines = text.splitlines()
134
+ results: list[AstMatch] = []
135
+ for node in ast.walk(tree):
136
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
137
+ symbol_kind = "function"
138
+ elif isinstance(node, ast.ClassDef):
139
+ symbol_kind = "class"
140
+ else:
141
+ continue
142
+ symbol_name = node.name
143
+ source = lines[node.lineno - 1].strip() if 0 < node.lineno <= len(lines) else symbol_name
144
+ if kind and kind != symbol_kind:
145
+ continue
146
+ if query and query.lower() not in symbol_name.lower() and query.lower() not in source.lower():
147
+ continue
148
+ results.append(AstMatch(rel, "python", symbol_kind, symbol_name, node.lineno, source, self._engine()))
149
+ return sorted(results, key=lambda item: (item.path, item.line))
150
+
151
+ def _line_kind(self, line: str) -> str:
152
+ stripped = line.strip()
153
+ if "class " in stripped:
154
+ return "class"
155
+ if stripped.startswith(("type ", "export type ")):
156
+ return "type"
157
+ if stripped.startswith(("interface ", "export interface ")):
158
+ return "interface"
159
+ if stripped.startswith(("struct ", "pub struct ")):
160
+ return "struct"
161
+ if stripped.startswith(("enum ", "pub enum ")):
162
+ return "enum"
163
+ if stripped.startswith(("trait ", "pub trait ")):
164
+ return "trait"
165
+ return "function"
166
+
167
+ def _engine(self) -> str:
168
+ return "tree-sitter-optional" if self.tree_sitter_available else "fallback-ast"