@julioborges/gantry 1.0.6 → 1.1.0
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.
- package/.agents/skills/gantry/SKILL.md +15 -1
- package/.agents/skills/gantry/capabilities/codex.json +4 -3
- package/.agents/skills/gantry/dashboard/static/app.js +776 -30
- package/.agents/skills/gantry/dashboard/static/history.html +180 -0
- package/.agents/skills/gantry/dashboard/static/index.html +95 -0
- package/.agents/skills/gantry/dashboard/static/style.css +708 -0
- package/.agents/skills/gantry/reference/plan-workflow.md +13 -0
- package/.agents/skills/gantry/reference/round-workflow.md +90 -2
- package/.agents/skills/gantry/scripts/common.py +3 -0
- package/.agents/skills/gantry/scripts/dashboard.py +664 -11
- package/.agents/skills/gantry/scripts/discovery.py +117 -8
- package/.agents/skills/gantry/scripts/execution.py +131 -14
- package/.agents/skills/gantry/scripts/plan.py +658 -0
- package/.agents/skills/gantry/scripts/result.py +56 -1
- package/.agents/skills/gantry/scripts/runlog.py +2 -1
- package/.agents/skills/gantry/scripts/setup.py +101 -8
- package/.agents/skills/gantry/scripts/wait_gate.py +179 -0
- package/.agents/skills/gantry-dashboard/SKILL.md +21 -2
- package/.agents/skills/gantry-plan/SKILL.md +104 -0
- package/.agents/skills/gantry-setup/SKILL.md +9 -3
- package/README.md +125 -98
- package/package.json +2 -1
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Gantry Plan: Socratic Gate planning engine and tracer-bullet vertical slicing.
|
|
3
|
+
|
|
4
|
+
Dual entry modes:
|
|
5
|
+
1. Free-text goal: Socratic Gate interview -> repo context exploration -> spec synthesis.
|
|
6
|
+
2. Existing spec: Structural validation -> tracer-bullet vertical slicing -> budget audit -> Plan Critic -> operator quiz.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
# Support direct execution and relative import
|
|
20
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
21
|
+
if str(SCRIPTS_DIR) not in sys.path:
|
|
22
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
23
|
+
|
|
24
|
+
import common
|
|
25
|
+
import spec
|
|
26
|
+
import runlog
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def generate_slug(goal: str) -> str:
|
|
30
|
+
"""Generate a clean URL/file-friendly slug from a free-text goal."""
|
|
31
|
+
cleaned = re.sub(r"[^a-zA-Z0-9\s-]", "", goal).strip().lower()
|
|
32
|
+
slug = re.sub(r"[\s_]+", "-", cleaned)
|
|
33
|
+
slug = re.sub(r"-+", "-", slug).strip("-")
|
|
34
|
+
if not slug:
|
|
35
|
+
slug = "feature"
|
|
36
|
+
stop_words = {"a", "an", "the", "for", "to", "in", "of", "and", "with", "add", "create", "implement", "build", "data"}
|
|
37
|
+
words = [w for w in slug.split("-") if w not in stop_words]
|
|
38
|
+
if not words:
|
|
39
|
+
words = slug.split("-")
|
|
40
|
+
return "-".join(words[:5])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def explore_context(repo_root: Path) -> dict:
|
|
44
|
+
"""Survey repository context to ground Socratic questions and architecture."""
|
|
45
|
+
context_doc = repo_root / "CONTEXT.md"
|
|
46
|
+
prd_doc = repo_root / "PRD.md"
|
|
47
|
+
adr_dir = repo_root / "docs" / "adr"
|
|
48
|
+
|
|
49
|
+
glossary = {}
|
|
50
|
+
if context_doc.exists():
|
|
51
|
+
text = context_doc.read_text(encoding="utf-8")
|
|
52
|
+
for match in re.finditer(r"^([A-Za-z0-9_-]+):\s*(.+)$", text, re.MULTILINE):
|
|
53
|
+
glossary[match.group(1)] = match.group(2).strip()
|
|
54
|
+
|
|
55
|
+
adrs = []
|
|
56
|
+
if adr_dir.exists():
|
|
57
|
+
adrs = sorted([f.name for f in adr_dir.glob("*.md")])
|
|
58
|
+
|
|
59
|
+
modules = []
|
|
60
|
+
for candidate in (repo_root / "scripts", repo_root / ".agents" / "skills"):
|
|
61
|
+
if candidate.exists():
|
|
62
|
+
for item in candidate.iterdir():
|
|
63
|
+
if item.is_dir() or item.suffix in {".py", ".ts", ".js"}:
|
|
64
|
+
modules.append(item.name)
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"has_context_doc": context_doc.exists(),
|
|
68
|
+
"has_prd": prd_doc.exists(),
|
|
69
|
+
"glossary": glossary,
|
|
70
|
+
"adrs": adrs,
|
|
71
|
+
"modules": modules,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SocraticGateEngine:
|
|
76
|
+
"""Socratic Gate engine for transforming free-text goals into validated specs."""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
goal: str,
|
|
81
|
+
repo_root: Path | None = None,
|
|
82
|
+
unit_id: str | None = None,
|
|
83
|
+
run_id: str | None = None,
|
|
84
|
+
state_root: Path | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
self.goal = goal.strip()
|
|
87
|
+
self.repo_root = Path(repo_root).resolve() if repo_root else common.repo_root().resolve()
|
|
88
|
+
self.slug = generate_slug(self.goal)
|
|
89
|
+
self.context = explore_context(self.repo_root)
|
|
90
|
+
self.unit_id = unit_id
|
|
91
|
+
self.run_id = run_id
|
|
92
|
+
self.state_root = Path(state_root).resolve() if state_root else None
|
|
93
|
+
|
|
94
|
+
def get_interview_phases(self) -> list[dict]:
|
|
95
|
+
"""Generate structured interview questions across four design phases."""
|
|
96
|
+
goal_title = self.goal.capitalize()
|
|
97
|
+
return [
|
|
98
|
+
{
|
|
99
|
+
"name": "Problem Statement & Context",
|
|
100
|
+
"questions": [
|
|
101
|
+
{
|
|
102
|
+
"id": "problem_core",
|
|
103
|
+
"question": f"What specific problem does '{self.goal}' solve for the operator or developer?",
|
|
104
|
+
"recommended_answer": f"Enables automated, deterministic handling of {self.goal.lower()} without manual overhead.",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"id": "problem_current_state",
|
|
108
|
+
"question": "What is the current state in the repository regarding this capability?",
|
|
109
|
+
"recommended_answer": "Currently absent or requires manual developer interaction in terminal sessions.",
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"name": "Architectural Boundaries & Seams",
|
|
115
|
+
"questions": [
|
|
116
|
+
{
|
|
117
|
+
"id": "arch_module",
|
|
118
|
+
"question": f"Where does the logic for '{self.slug}' reside in relation to existing modules?",
|
|
119
|
+
"recommended_answer": "Implemented as a dedicated standalone script or module adhering to standard library constraints.",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"id": "arch_contract",
|
|
123
|
+
"question": "What contracts (CLI flags, JSON envelopes, or APIs) define this boundary?",
|
|
124
|
+
"recommended_answer": "CLI supporting standard flags (--json, --help) and returning structured exit codes.",
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"name": "Scope & Non-Goals",
|
|
130
|
+
"questions": [
|
|
131
|
+
{
|
|
132
|
+
"id": "scope_in",
|
|
133
|
+
"question": f"What is strictly inside the scope of '{self.slug}'?",
|
|
134
|
+
"recommended_answer": f"Core execution, verification gates, and telemetry integration for {self.goal.lower()}.",
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"id": "scope_out",
|
|
138
|
+
"question": "What is explicitly out of scope for this vertical iteration?",
|
|
139
|
+
"recommended_answer": "Third-party cloud dependencies, distributed multi-tenant hosting, or unrelated refactoring.",
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
"name": "Verifiable Criteria & Scenarios",
|
|
145
|
+
"questions": [
|
|
146
|
+
{
|
|
147
|
+
"id": "criteria_verification",
|
|
148
|
+
"question": "How will delivery be objectively verified before operator acceptance?",
|
|
149
|
+
"recommended_answer": "Passing automated unit tests, quality gates, and end-to-end integration proof.",
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
"id": "criteria_scenarios",
|
|
153
|
+
"question": "What primary Gherkin scenario proves completion?",
|
|
154
|
+
"recommended_answer": f"Given repository context, When the operator runs {self.slug}, Then expected results are verified.",
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
def log_operator_pause(self, activity: str = "Socratic Interview") -> None:
|
|
161
|
+
"""Record milestone phase.started event in Run log with operatorWaiting=true."""
|
|
162
|
+
if not self.unit_id or not self.run_id:
|
|
163
|
+
return
|
|
164
|
+
event = {
|
|
165
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
166
|
+
"run": self.run_id,
|
|
167
|
+
"event": "phase.started",
|
|
168
|
+
"issue": f"{self.slug}#00",
|
|
169
|
+
"phase": "Plan",
|
|
170
|
+
"data": {
|
|
171
|
+
"operatorWaiting": True,
|
|
172
|
+
"activity": activity,
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
state_root = self.state_root or runlog.default_state_root()
|
|
176
|
+
log_path = runlog.run_log_path(state_root, self.unit_id, self.run_id)
|
|
177
|
+
runlog.append_event(log_path, event)
|
|
178
|
+
|
|
179
|
+
def log_operator_response(self) -> None:
|
|
180
|
+
"""Record operator.approved event in Run log clearing operatorWaiting."""
|
|
181
|
+
if not self.unit_id or not self.run_id:
|
|
182
|
+
return
|
|
183
|
+
event = {
|
|
184
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
185
|
+
"run": self.run_id,
|
|
186
|
+
"event": "operator.approved",
|
|
187
|
+
"issue": f"{self.slug}#00",
|
|
188
|
+
"data": {
|
|
189
|
+
"operatorWaiting": False,
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
state_root = self.state_root or runlog.default_state_root()
|
|
193
|
+
log_path = runlog.run_log_path(state_root, self.unit_id, self.run_id)
|
|
194
|
+
runlog.append_event(log_path, event)
|
|
195
|
+
|
|
196
|
+
def synthesize_spec(
|
|
197
|
+
self,
|
|
198
|
+
context: str,
|
|
199
|
+
architecture: str,
|
|
200
|
+
constraints: str,
|
|
201
|
+
criteria: list[str],
|
|
202
|
+
guardrails: list[str],
|
|
203
|
+
scenarios: list[dict],
|
|
204
|
+
out_of_scope: list[str],
|
|
205
|
+
date_str: str | None = None,
|
|
206
|
+
) -> str:
|
|
207
|
+
"""Synthesize a complete living-spec Markdown conforming to spec.py --check."""
|
|
208
|
+
today = date_str or datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
209
|
+
title = self.goal.capitalize()
|
|
210
|
+
|
|
211
|
+
criteria_lines = "\n".join(f"- [ ] {c}" for c in criteria)
|
|
212
|
+
guardrails_lines = "\n".join(f"- {g}" for g in guardrails)
|
|
213
|
+
scope_lines = "\n".join(f"- {s}" for s in out_of_scope)
|
|
214
|
+
|
|
215
|
+
scenario_blocks = []
|
|
216
|
+
for s in scenarios:
|
|
217
|
+
scenario_blocks.append(
|
|
218
|
+
f"Scenario: {s.get('title', 'Validate ' + self.slug)}\n"
|
|
219
|
+
f" Given {s.get('given', 'the repository environment')}\n"
|
|
220
|
+
f" When {s.get('when', 'the command is executed')}\n"
|
|
221
|
+
f" Then {s.get('then', 'the expected behavior is verified')}"
|
|
222
|
+
)
|
|
223
|
+
gherkin_text = "\n\n".join(scenario_blocks)
|
|
224
|
+
|
|
225
|
+
return f"""# Spec: {title}
|
|
226
|
+
|
|
227
|
+
Type: spec
|
|
228
|
+
Status: draft
|
|
229
|
+
Map: `ROADMAP.md` (spec NN)
|
|
230
|
+
Source: Socratic Gate session
|
|
231
|
+
Created: {today}
|
|
232
|
+
|
|
233
|
+
## Blueprint
|
|
234
|
+
|
|
235
|
+
### Context
|
|
236
|
+
|
|
237
|
+
{context.strip()}
|
|
238
|
+
|
|
239
|
+
### Architecture
|
|
240
|
+
|
|
241
|
+
{architecture.strip()}
|
|
242
|
+
|
|
243
|
+
### Constraints
|
|
244
|
+
|
|
245
|
+
{constraints.strip()}
|
|
246
|
+
|
|
247
|
+
## Contract
|
|
248
|
+
|
|
249
|
+
### Definition of Done
|
|
250
|
+
|
|
251
|
+
{criteria_lines}
|
|
252
|
+
|
|
253
|
+
### Regression Guardrails
|
|
254
|
+
|
|
255
|
+
{guardrails_lines}
|
|
256
|
+
|
|
257
|
+
### Scenarios
|
|
258
|
+
|
|
259
|
+
```gherkin
|
|
260
|
+
{gherkin_text}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Out of Scope
|
|
264
|
+
|
|
265
|
+
{scope_lines}
|
|
266
|
+
|
|
267
|
+
## Changelog
|
|
268
|
+
|
|
269
|
+
- {today} — Initial draft synthesized by gantry-plan Socratic Gate.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
def write_spec(self, content: str) -> Path:
|
|
273
|
+
"""Write spec to .scratch/<slug>/spec.md."""
|
|
274
|
+
spec_dir = self.repo_root / ".scratch" / self.slug
|
|
275
|
+
spec_dir.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
spec_path = spec_dir / "spec.md"
|
|
277
|
+
spec_path.write_text(content, encoding="utf-8")
|
|
278
|
+
return spec_path
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class SlicingEngine:
|
|
282
|
+
"""Tracer-bullet vertical slicing engine adhering to to-issues principles."""
|
|
283
|
+
|
|
284
|
+
def __init__(self, spec_path: Path, repo_root: Path | None = None) -> None:
|
|
285
|
+
self.spec_path = Path(spec_path).resolve()
|
|
286
|
+
self.repo_root = Path(repo_root).resolve() if repo_root else common.repo_root().resolve()
|
|
287
|
+
# Derive slug from directory or spec filename
|
|
288
|
+
if self.spec_path.parent.name and self.spec_path.parent.name != ".scratch":
|
|
289
|
+
self.slug = self.spec_path.parent.name
|
|
290
|
+
else:
|
|
291
|
+
self.slug = self.spec_path.stem
|
|
292
|
+
|
|
293
|
+
def validate_spec(self) -> None:
|
|
294
|
+
"""Ensure input spec passes structural validation."""
|
|
295
|
+
check = spec.validate(self.spec_path, self.repo_root)
|
|
296
|
+
if not check.get("valid"):
|
|
297
|
+
errors = check.get("errors", [])
|
|
298
|
+
raise ValueError(f"Spec validation failed for {self.spec_path}: {'; '.join(errors)}")
|
|
299
|
+
|
|
300
|
+
def slice(self) -> list[dict]:
|
|
301
|
+
"""Decompose spec into vertical tracer bullets with prefactoring identified first."""
|
|
302
|
+
self.validate_spec()
|
|
303
|
+
spec_text = self.spec_path.read_text(encoding="utf-8")
|
|
304
|
+
|
|
305
|
+
# Extract title
|
|
306
|
+
title_match = re.search(r"^#\s+Spec:\s*(.+)$", spec_text, re.MULTILINE)
|
|
307
|
+
feature_title = title_match.group(1).strip() if title_match else self.slug
|
|
308
|
+
|
|
309
|
+
# Extract Definition of Done criteria
|
|
310
|
+
dod_matches = re.findall(r"^\s*-\s*\[\s*\]\s*(.+)$", spec_text, re.MULTILINE)
|
|
311
|
+
criteria_list = dod_matches if dod_matches else [f"Implement core capabilities for {feature_title}"]
|
|
312
|
+
|
|
313
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
314
|
+
issues = []
|
|
315
|
+
|
|
316
|
+
# Slice 01: Always prefactoring / foundation
|
|
317
|
+
prefactor_title = f"Prefactor and foundation seams for {feature_title}"
|
|
318
|
+
issues.append({
|
|
319
|
+
"slice": f"{self.slug}#01",
|
|
320
|
+
"number": 1,
|
|
321
|
+
"title": prefactor_title,
|
|
322
|
+
"is_prefactor": True,
|
|
323
|
+
"spec_path": str(self.spec_path.relative_to(self.repo_root)),
|
|
324
|
+
"parent": self.slug,
|
|
325
|
+
"what_to_build": (
|
|
326
|
+
f"Establish necessary abstractions, module seams, and shared foundation for {feature_title}. "
|
|
327
|
+
"Ensure test harnesses and interfaces are ready before vertical implementation."
|
|
328
|
+
),
|
|
329
|
+
"files_to_read": [
|
|
330
|
+
f".scratch/{self.slug}/spec.md",
|
|
331
|
+
"CONTEXT.md",
|
|
332
|
+
],
|
|
333
|
+
"criteria": [
|
|
334
|
+
f"Foundation modules and seams for {self.slug} are defined and importable",
|
|
335
|
+
"Base unit test suite passes cleanly",
|
|
336
|
+
],
|
|
337
|
+
"blocked_by": [],
|
|
338
|
+
"created": today,
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
# Slice 02..N: Vertical tracer-bullet slices
|
|
342
|
+
# Group criteria into chunks of 2-3 per issue
|
|
343
|
+
chunk_size = 2
|
|
344
|
+
slice_num = 2
|
|
345
|
+
for i in range(0, len(criteria_list), chunk_size):
|
|
346
|
+
chunk = criteria_list[i:i + chunk_size]
|
|
347
|
+
slice_ref = f"{self.slug}#{slice_num:02d}"
|
|
348
|
+
primary_criterion = chunk[0]
|
|
349
|
+
slice_title = primary_criterion[:70].strip()
|
|
350
|
+
|
|
351
|
+
issues.append({
|
|
352
|
+
"slice": slice_ref,
|
|
353
|
+
"number": slice_num,
|
|
354
|
+
"title": slice_title,
|
|
355
|
+
"is_prefactor": False,
|
|
356
|
+
"spec_path": str(self.spec_path.relative_to(self.repo_root)),
|
|
357
|
+
"parent": self.slug,
|
|
358
|
+
"what_to_build": (
|
|
359
|
+
f"Implement vertical end-to-end tracer bullet delivering: {'; '.join(chunk)}. "
|
|
360
|
+
"Cut across API/CLI, business logic, persistence, and tests."
|
|
361
|
+
),
|
|
362
|
+
"files_to_read": [
|
|
363
|
+
f".scratch/{self.slug}/spec.md",
|
|
364
|
+
f".scratch/{self.slug}/issues/01-{self.slug}.md",
|
|
365
|
+
],
|
|
366
|
+
"criteria": chunk,
|
|
367
|
+
"blocked_by": [f"{self.slug}#01"],
|
|
368
|
+
"created": today,
|
|
369
|
+
})
|
|
370
|
+
slice_num += 1
|
|
371
|
+
|
|
372
|
+
return issues
|
|
373
|
+
|
|
374
|
+
def write_issues(self, issues: list[dict]) -> list[Path]:
|
|
375
|
+
"""Write issue files under .scratch/<slug>/issues/NN-<slug>.md."""
|
|
376
|
+
issues_dir = self.repo_root / ".scratch" / self.slug / "issues"
|
|
377
|
+
issues_dir.mkdir(parents=True, exist_ok=True)
|
|
378
|
+
written = []
|
|
379
|
+
|
|
380
|
+
for item in issues:
|
|
381
|
+
num = item["number"]
|
|
382
|
+
filename = f"{num:02d}-{self.slug}.md"
|
|
383
|
+
target = issues_dir / filename
|
|
384
|
+
|
|
385
|
+
files_read_text = "\n".join(f"- `{f}`" for f in item["files_to_read"])
|
|
386
|
+
criteria_text = "\n".join(f"- [ ] {c}" for c in item["criteria"])
|
|
387
|
+
blocked_by_text = "\n".join(f"- {b}" for b in item["blocked_by"]) if item["blocked_by"] else "- None"
|
|
388
|
+
|
|
389
|
+
content = f"""# {item['title']}
|
|
390
|
+
|
|
391
|
+
Type: issue
|
|
392
|
+
Status: draft
|
|
393
|
+
Slice: `{item['slice']}`
|
|
394
|
+
Spec: `{item['spec_path']}`
|
|
395
|
+
Created: {item['created']}
|
|
396
|
+
|
|
397
|
+
## Parent
|
|
398
|
+
|
|
399
|
+
`{item['parent']}`
|
|
400
|
+
|
|
401
|
+
## What to build
|
|
402
|
+
|
|
403
|
+
{item['what_to_build']}
|
|
404
|
+
|
|
405
|
+
### Files to read
|
|
406
|
+
|
|
407
|
+
{files_read_text}
|
|
408
|
+
|
|
409
|
+
## Acceptance criteria
|
|
410
|
+
|
|
411
|
+
{criteria_text}
|
|
412
|
+
|
|
413
|
+
## Blocked by
|
|
414
|
+
|
|
415
|
+
{blocked_by_text}
|
|
416
|
+
"""
|
|
417
|
+
target.write_text(content, encoding="utf-8")
|
|
418
|
+
written.append(target)
|
|
419
|
+
|
|
420
|
+
return written
|
|
421
|
+
|
|
422
|
+
def audit_budgets(self, issues: list[dict]) -> list[dict]:
|
|
423
|
+
"""Estimate context tokens for each slice based on files to read."""
|
|
424
|
+
results = []
|
|
425
|
+
for issue in issues:
|
|
426
|
+
total_chars = 0
|
|
427
|
+
for rel_path in issue.get("files_to_read", []):
|
|
428
|
+
file_path = self.repo_root / rel_path
|
|
429
|
+
if file_path.exists():
|
|
430
|
+
total_chars += len(file_path.read_text(encoding="utf-8", errors="ignore"))
|
|
431
|
+
else:
|
|
432
|
+
total_chars += 500 # Conservative estimate
|
|
433
|
+
tokens = total_chars // 4
|
|
434
|
+
over_budget = tokens > 30000 # Default safe context share
|
|
435
|
+
results.append({
|
|
436
|
+
"slice": issue["slice"],
|
|
437
|
+
"tokens": tokens,
|
|
438
|
+
"over_budget": over_budget,
|
|
439
|
+
})
|
|
440
|
+
return results
|
|
441
|
+
|
|
442
|
+
def audit_plan_critic(self, issues: list[dict]) -> dict:
|
|
443
|
+
"""Adversarial check of verticality, criteria observability, and DAG acyclicity."""
|
|
444
|
+
problems = []
|
|
445
|
+
graph = {}
|
|
446
|
+
|
|
447
|
+
for item in issues:
|
|
448
|
+
ref = item["slice"]
|
|
449
|
+
graph[ref] = item.get("blocked_by", [])
|
|
450
|
+
|
|
451
|
+
# Check criteria
|
|
452
|
+
criteria = item.get("criteria", [])
|
|
453
|
+
if not criteria:
|
|
454
|
+
problems.append(f"{ref} has no acceptance criteria")
|
|
455
|
+
|
|
456
|
+
# Check horizontal slicing smell
|
|
457
|
+
title = item.get("title", "").lower()
|
|
458
|
+
if "database only" in title or "styling only" in title or "frontend only" in title:
|
|
459
|
+
problems.append(f"{ref} appears to be horizontally sliced: '{item['title']}'")
|
|
460
|
+
|
|
461
|
+
# DAG cycle check (Kahn's algorithm)
|
|
462
|
+
in_degree = {n: 0 for n in graph}
|
|
463
|
+
for n in graph:
|
|
464
|
+
for dep in graph[n]:
|
|
465
|
+
if dep in in_degree:
|
|
466
|
+
in_degree[n] += 1
|
|
467
|
+
|
|
468
|
+
queue = [n for n, deg in in_degree.items() if deg == 0]
|
|
469
|
+
visited_count = 0
|
|
470
|
+
while queue:
|
|
471
|
+
node = queue.pop(0)
|
|
472
|
+
visited_count += 1
|
|
473
|
+
for other, deps in graph.items():
|
|
474
|
+
if node in deps:
|
|
475
|
+
in_degree[other] -= 1
|
|
476
|
+
if in_degree[other] == 0:
|
|
477
|
+
queue.append(other)
|
|
478
|
+
|
|
479
|
+
is_dag = (visited_count == len(graph))
|
|
480
|
+
if not is_dag:
|
|
481
|
+
problems.append("Cyclic dependency detected in issue blocker graph")
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
"acceptable": len(problems) == 0,
|
|
485
|
+
"problems": problems,
|
|
486
|
+
"is_dag": is_dag,
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
def generate_operator_quiz(self, issues: list[dict]) -> list[dict]:
|
|
490
|
+
"""Generate interactive quiz for operator reviewing issue breakdown."""
|
|
491
|
+
return [
|
|
492
|
+
{
|
|
493
|
+
"topic": "granularity",
|
|
494
|
+
"question": f"The spec is sliced into {len(issues)} issues. Does this level of granularity feel appropriate for single-round TDD deliveries?",
|
|
495
|
+
"recommended_answer": "Yes, each slice represents a demonstrable increment.",
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
"topic": "dependency_order",
|
|
499
|
+
"question": "Prefactoring is scheduled in Slice 01, with remaining slices depending on it. Does this dependency sequence align with your delivery preferences?",
|
|
500
|
+
"recommended_answer": "Yes, prefactoring first reduces risk for subsequent slices.",
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
"topic": "split_merge",
|
|
504
|
+
"question": "Are there any specific slices you would like to split further into separate tasks, or merge together before finalizing?",
|
|
505
|
+
"recommended_answer": "Breakdown looks solid as proposed.",
|
|
506
|
+
},
|
|
507
|
+
]
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def approve_plan(
|
|
511
|
+
slug: str,
|
|
512
|
+
repo_root: Path | None = None,
|
|
513
|
+
unit_id: str | None = None,
|
|
514
|
+
run_id: str | None = None,
|
|
515
|
+
state_root: Path | None = None,
|
|
516
|
+
) -> dict:
|
|
517
|
+
"""Transition approved draft issues to ready-for-agent and update roadmap waves."""
|
|
518
|
+
repo = Path(repo_root).resolve() if repo_root else common.repo_root().resolve()
|
|
519
|
+
issue_dir = repo / ".scratch" / slug / "issues"
|
|
520
|
+
if not issue_dir.exists():
|
|
521
|
+
raise FileNotFoundError(f"Issue directory not found: {issue_dir}")
|
|
522
|
+
|
|
523
|
+
updated_issues = []
|
|
524
|
+
for issue_file in sorted(issue_dir.glob("*.md")):
|
|
525
|
+
parsed = common.parse_issue(issue_file)
|
|
526
|
+
ref = parsed.ref
|
|
527
|
+
if not ref:
|
|
528
|
+
continue
|
|
529
|
+
res = subprocess.run(
|
|
530
|
+
[sys.executable, str(SCRIPTS_DIR / "roadmap.py"), "status", ref, "ready-for-agent"],
|
|
531
|
+
cwd=repo,
|
|
532
|
+
capture_output=True,
|
|
533
|
+
text=True,
|
|
534
|
+
check=False,
|
|
535
|
+
)
|
|
536
|
+
if res.returncode != 0:
|
|
537
|
+
raise RuntimeError(f"Failed to update status for {ref}: {res.stderr or res.stdout}")
|
|
538
|
+
updated_issues.append(ref)
|
|
539
|
+
|
|
540
|
+
res_waves = subprocess.run(
|
|
541
|
+
[sys.executable, str(SCRIPTS_DIR / "roadmap.py"), "waves", "--json"],
|
|
542
|
+
cwd=repo,
|
|
543
|
+
capture_output=True,
|
|
544
|
+
text=True,
|
|
545
|
+
check=False,
|
|
546
|
+
)
|
|
547
|
+
if res_waves.returncode != 0:
|
|
548
|
+
raise RuntimeError(f"Failed to recompute waves: {res_waves.stderr or res_waves.stdout}")
|
|
549
|
+
waves_info = json.loads(res_waves.stdout) if res_waves.stdout else {}
|
|
550
|
+
|
|
551
|
+
res_check = subprocess.run(
|
|
552
|
+
[sys.executable, str(SCRIPTS_DIR / "roadmap.py"), "check", "--json"],
|
|
553
|
+
cwd=repo,
|
|
554
|
+
capture_output=True,
|
|
555
|
+
text=True,
|
|
556
|
+
check=False,
|
|
557
|
+
)
|
|
558
|
+
if res_check.returncode != 0:
|
|
559
|
+
raise RuntimeError(f"Roadmap check failed after updating waves: {res_check.stderr or res_check.stdout}")
|
|
560
|
+
|
|
561
|
+
if unit_id and run_id:
|
|
562
|
+
root_state = Path(state_root).resolve() if state_root else runlog.default_state_root()
|
|
563
|
+
log_path = runlog.run_log_path(root_state, unit_id, run_id)
|
|
564
|
+
done_event = {
|
|
565
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
566
|
+
"run": run_id,
|
|
567
|
+
"event": "issue.done",
|
|
568
|
+
"issue": f"{slug}#00",
|
|
569
|
+
"phase": "Plan",
|
|
570
|
+
"data": {
|
|
571
|
+
"status": "ready-for-agent",
|
|
572
|
+
"issues": updated_issues,
|
|
573
|
+
},
|
|
574
|
+
}
|
|
575
|
+
runlog.append_event(log_path, done_event)
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
"slug": slug,
|
|
579
|
+
"updated_issues": updated_issues,
|
|
580
|
+
"waves": waves_info,
|
|
581
|
+
"valid": True,
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def main() -> int:
|
|
586
|
+
parser = argparse.ArgumentParser(description="Gantry Plan: Socratic Gate and vertical slicing.")
|
|
587
|
+
parser.add_argument("--goal", type=str, help="Free-text goal for Socratic Gate planning")
|
|
588
|
+
parser.add_argument("--spec", type=str, help="Path to spec for vertical slicing")
|
|
589
|
+
parser.add_argument("--check-spec", type=str, help="Check spec validity")
|
|
590
|
+
parser.add_argument("--approve", type=str, help="Approve plan and transition issues to ready-for-agent for a spec slug")
|
|
591
|
+
parser.add_argument("--unit-id", type=str, help="Run log unit ID")
|
|
592
|
+
parser.add_argument("--run-id", type=str, help="Run log run ID")
|
|
593
|
+
parser.add_argument("--state-root", type=str, help="Run log state root")
|
|
594
|
+
parser.add_argument("--json", action="store_true", help="Print JSON output")
|
|
595
|
+
args = parser.parse_args()
|
|
596
|
+
|
|
597
|
+
repo = common.repo_root()
|
|
598
|
+
|
|
599
|
+
if args.approve:
|
|
600
|
+
res = approve_plan(
|
|
601
|
+
args.approve,
|
|
602
|
+
repo_root=repo,
|
|
603
|
+
unit_id=args.unit_id,
|
|
604
|
+
run_id=args.run_id,
|
|
605
|
+
state_root=Path(args.state_root) if args.state_root else None,
|
|
606
|
+
)
|
|
607
|
+
if args.json:
|
|
608
|
+
print(json.dumps(res, indent=2))
|
|
609
|
+
else:
|
|
610
|
+
print(f"Plan approved for {res['slug']}: {len(res['updated_issues'])} issues set to ready-for-agent.")
|
|
611
|
+
return 0
|
|
612
|
+
|
|
613
|
+
if args.check_spec:
|
|
614
|
+
res = spec.validate(Path(args.check_spec), repo)
|
|
615
|
+
if args.json:
|
|
616
|
+
print(json.dumps(res, indent=2))
|
|
617
|
+
else:
|
|
618
|
+
print("Valid" if res.get("valid") else f"Invalid: {res.get('errors')}")
|
|
619
|
+
return 0 if res.get("valid") else 1
|
|
620
|
+
|
|
621
|
+
if args.goal:
|
|
622
|
+
engine = SocraticGateEngine(args.goal, repo_root=repo)
|
|
623
|
+
phases = engine.get_interview_phases()
|
|
624
|
+
if args.json:
|
|
625
|
+
print(json.dumps({"slug": engine.slug, "phases": phases}, indent=2))
|
|
626
|
+
else:
|
|
627
|
+
print(f"Socratic Gate Plan initialized for: {engine.slug}")
|
|
628
|
+
for p in phases:
|
|
629
|
+
print(f"\n--- {p['name']} ---")
|
|
630
|
+
for q in p["questions"]:
|
|
631
|
+
print(f"Q: {q['question']}")
|
|
632
|
+
print(f" Recommended: {q['recommended_answer']}")
|
|
633
|
+
return 0
|
|
634
|
+
|
|
635
|
+
if args.spec:
|
|
636
|
+
slicer = SlicingEngine(Path(args.spec), repo_root=repo)
|
|
637
|
+
issues = slicer.slice()
|
|
638
|
+
written = slicer.write_issues(issues)
|
|
639
|
+
critic = slicer.audit_plan_critic(issues)
|
|
640
|
+
if args.json:
|
|
641
|
+
print(json.dumps({
|
|
642
|
+
"slug": slicer.slug,
|
|
643
|
+
"issues": issues,
|
|
644
|
+
"filesWritten": [str(p) for p in written],
|
|
645
|
+
"critic": critic,
|
|
646
|
+
}, indent=2))
|
|
647
|
+
else:
|
|
648
|
+
print(f"Sliced {len(issues)} issues for {slicer.slug}:")
|
|
649
|
+
for p in written:
|
|
650
|
+
print(f" - {p.name}")
|
|
651
|
+
return 0
|
|
652
|
+
|
|
653
|
+
parser.print_help()
|
|
654
|
+
return 0
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
if __name__ == "__main__":
|
|
658
|
+
sys.exit(main())
|