@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.
@@ -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())