diff --git a/engine/src/sdlc_engine/adf_work.py b/engine/src/sdlc_engine/adf_work.py new file mode 100644 index 0000000..ea8f1c5 --- /dev/null +++ b/engine/src/sdlc_engine/adf_work.py @@ -0,0 +1,478 @@ +"""Init SDLC-SPDD work (REASONS canvas + requirement) from a local ADF document.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .jira_format import adf_to_markdown, load_adf_document +from .local_sessions import slugify +from .pointer import PointerStore +from .project import Project +from .registry import TeamRegistry + +_TYPE_PREFIX = { + "feature": "FEAT", + "feat": "FEAT", + "bug": "BUG", + "bugfix": "BUG", + "refactor": "REF", + "ref": "REF", + "spike": "SPIKE", + "doc": "DOC", + "test": "TEST", + "chore": "CHORE", +} + +_TYPE_LABEL = { + "FEAT": "Feature", + "BUG": "Bugfix", + "REF": "Refactor", + "SPIKE": "Spike", + "DOC": "Doc", + "TEST": "Test", + "CHORE": "Chore", +} + +_ISSUE_KEY_RE = re.compile(r"\b([A-Z][A-Z0-9]+-\d+)\b") +_WORK_ID_RE = re.compile( + r"^(FEAT|BUG|REF|SPIKE|DOC|TEST|CHORE)-\d{3}-[a-z0-9]+(?:-[a-z0-9]+)*$", + re.IGNORECASE, +) + + +def _utc_now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _first_heading(doc: dict[str, Any]) -> str: + for node in doc.get("content") or []: + if not isinstance(node, dict) or node.get("type") != "heading": + continue + parts: list[str] = [] + for child in node.get("content") or []: + if isinstance(child, dict) and child.get("type") == "text": + parts.append(str(child.get("text") or "")) + text = "".join(parts).strip() + if text: + return text + return "" + + +def _stem_title(path: Path) -> str: + name = path.name + if name.endswith(".adf.json"): + name = name[: -len(".adf.json")] + else: + name = path.stem + return name.replace("_", " ").replace("-", " ").strip() or "adf-work" + + +def infer_issue_key(path: Path, title: str = "") -> str: + for candidate in (path.name, path.stem, title): + m = _ISSUE_KEY_RE.search(candidate or "") + if m: + return m.group(1) + return "" + + +@dataclass +class AdfInitResult: + work_id: str + title: str + adf_path: str + canvas_path: str + requirement_path: str + feature_dir: str + source_issue: str + next_command: str + dry_run: bool = False + + +class AdfWorkService: + """Create draft REASONS canvas + requirement from a browsed ADF file.""" + + def __init__(self, project: Project) -> None: + self.project = project + self.pointer = PointerStore(project) + self.registry = TeamRegistry(project) + + def _next_number(self, prefix: str) -> int: + max_n = 0 + pattern = re.compile(rf"^{re.escape(prefix)}-(\d+)-", re.IGNORECASE) + roots = [ + self.project.root / "agent-context" / "features", + self.project.root / "spdd" / "canvas", + self.project.root / "requirements" / "milestones", + ] + for base in roots: + if not base.is_dir(): + continue + for child in base.iterdir(): + name = child.stem if child.is_file() else child.name + m = pattern.match(name) + if m: + max_n = max(max_n, int(m.group(1))) + return max_n + 1 + + def _resolve_adf(self, adf_path: str | Path) -> Path: + raw = Path(adf_path).expanduser() + if not raw.is_absolute(): + raw = (self.project.root / raw).resolve() + else: + raw = raw.resolve() + if not raw.is_file(): + raise FileNotFoundError(f"ADF file not found: {raw}") + return raw + + def _load_markdown(self, path: Path) -> tuple[dict[str, Any], str]: + data = json.loads(path.read_text(encoding="utf-8")) + doc = load_adf_document(data) + return doc, adf_to_markdown(doc).strip() + + def allocate_work_id(self, *, work_type: str, title: str, work_id: str = "") -> str: + if work_id: + wid = work_id.strip() + if not _WORK_ID_RE.match(wid): + raise ValueError( + f"Invalid work_id '{wid}' — expected PREFIX-NNN-slug " + "(e.g. FEAT-013-adf-init-reasons-canvas)" + ) + return wid + prefix = _TYPE_PREFIX.get(work_type.lower(), "FEAT") + slug = slugify(title) or "adf-work" + return f"{prefix}-{self._next_number(prefix):03d}-{slug}" + + def _claim_blocker(self, work_id: str) -> str | None: + """Return an error message if another owner already holds an active claim.""" + existing = next((r for r in self.registry.rows() if r.work_id == work_id), None) + if not existing or existing.status != "active" or not existing.owner: + return None + me = self.registry._owner() + if existing.owner != me: + return ( + f"claim refused: {work_id} is active under {existing.owner}. " + "Use --no-claim or coordinate / force-claim separately." + ) + return None + + def init_from_adf( + self, + adf_path: str | Path, + *, + work_type: str = "feature", + title: str = "", + work_id: str = "", + claim: bool = True, + dry_run: bool = False, + ) -> AdfInitResult: + path = self._resolve_adf(adf_path) + doc, body_md = self._load_markdown(path) + resolved_title = (title or _first_heading(doc) or _stem_title(path)).strip() + wid = self.allocate_work_id(work_type=work_type, title=resolved_title, work_id=work_id) + prefix = wid.split("-", 1)[0].upper() + work_type_label = _TYPE_LABEL.get(prefix, "Feature") + source_issue = infer_issue_key(path, resolved_title) + + try: + rel_adf = str(path.relative_to(self.project.root)) + except ValueError: + rel_adf = str(path) + + canvas_rel = f"spdd/canvas/{wid}.md" + req_rel = f"requirements/milestones/{wid}.md" + feature_rel = f"agent-context/features/{wid}" + next_cmd = f"/sdlc-spdd-analysis @{req_rel}" + + if dry_run: + return AdfInitResult( + work_id=wid, + title=resolved_title, + adf_path=rel_adf, + canvas_path=canvas_rel, + requirement_path=req_rel, + feature_dir=feature_rel, + source_issue=source_issue, + next_command=next_cmd, + dry_run=True, + ) + + existing_canvas = self.project.root / canvas_rel + if existing_canvas.is_file(): + raise FileExistsError(f"Canvas already exists: {canvas_rel}") + + if claim and not dry_run: + blocked = self._claim_blocker(wid) + if blocked: + raise PermissionError(blocked) + + self.project.ensure_runtime_dirs() + now = _utc_now() + body_block = body_md or "(empty ADF body)" + + canvas = "\n".join( + [ + f"# REASONS Canvas: {wid} - {resolved_title}", + "", + "## Metadata", + "", + f"- Work ID: {wid}", + f"- Work Type: {work_type_label}", + "- Status: Draft", + "- Readiness: Needs Analysis", + f"- Created: {now}", + f"- Updated: {now}", + "- Owner:", + "- Target Project:", + "- Stack:", + "- Source System: ADF", + f"- Source Issue: {source_issue}", + "- Source URL:", + "- Docs URL:", + "- Roadmap: ROADMAP.md", + "- Milestone:", + f"- Source ADF: {rel_adf}", + "- Related PR:", + "", + "## R - Requirements", + "", + "### User Goal", + "", + resolved_title, + "", + "### Business / Product Goal", + "", + f"Seeded from ADF document `{rel_adf}`. Refine during analysis.", + "", + "### Acceptance Criteria", + "", + "- [ ] Derive acceptance criteria from the ADF source during analysis.", + "", + "### Non-Goals", + "", + "- TBD", + "", + "### Assumptions", + "", + f"- Created from local ADF: `{rel_adf}`", + "", + "### Open Questions", + "", + "- What acceptance criteria and scope boundaries apply?", + "", + "### Source ADF (markdown)", + "", + body_block, + "", + "## E - Entities", + "", + "### Domain Entities", + "", + "- TBD", + "", + "### Application Components", + "", + "- TBD", + "", + "### External Systems", + "", + "- TBD", + "", + "### Data / Persistence", + "", + "- TBD", + "", + "### Files Likely Affected", + "", + "- TBD", + "", + "## A - Approach", + "", + "### Proposed Approach", + "", + "TBD during `/sdlc-spdd-plan` and `/sdlc-spdd-architect`.", + "", + "### Alternatives Considered", + "", + "- TBD", + "", + "### Trade-Offs", + "", + "- TBD", + "", + "### Risks", + "", + "- TBD", + "", + "### Failure Modes", + "", + "- TBD", + "", + "## S - Structure", + "", + "### Files To Add", + "", + "- TBD", + "", + "### Files To Modify", + "", + "- TBD", + "", + "### Package / Module Structure", + "", + "TBD", + "", + "### Test Structure", + "", + "TBD", + "", + "### Documentation Structure", + "", + "TBD", + "", + "## O - Operations", + "", + "### T01 - Clarify and plan", + "", + "- Status: Not Started", + "- Description: Convert the ADF-sourced draft into a complete REASONS Canvas via analysis/plan/architect.", + f"- Files: {canvas_rel}", + "- Tests: Not applicable", + "- Validation: Canvas review", + "", + "## N - Norms", + "", + "### General", + "", + "- Follow existing project conventions.", + "- Keep implementation aligned with this canvas.", + "- Do not invent requirements that were not requested.", + "- Update the canvas before behavior changes.", + "", + "### Testing", + "", + "- Add or update tests for behavior changes.", + "- Document tests that could not be run.", + "", + "## S - Safeguards", + "", + "- Do not code until the canvas is Ready For Coding.", + "- Do not implement behavior changes until this canvas is updated with `/sdlc-spdd-prompt-update`.", + "- Do not let implementation drift from this canvas without running `/sdlc-spdd-sync`.", + "", + "## Review Checklist", + "", + "- [ ] Requirements satisfied", + "- [ ] Entities updated correctly", + "- [ ] Approach followed or synced", + "- [ ] Structure followed or synced", + "- [ ] Operations completed", + "- [ ] Norms followed", + "- [ ] Safeguards respected", + "- [ ] Tests added or updated", + "- [ ] No unrelated refactors", + "- [ ] Documentation updated if needed", + "", + "## Sync Notes", + "", + f"Created from ADF `{rel_adf}`. Use sync notes to track drift between the source ADF, canvas, and implementation.", + "", + "## Final Status", + "", + "- Status:", + "- Completed Date:", + "- PR:", + "- Follow-Up Tasks:", + "", + ] + ) + + req = "\n".join( + [ + "---", + f'work_id: "{wid}"', + f'jira_key: "{source_issue}"', + 'jira_epic: ""', + 'jira_type: "Story"', + 'jira_status: "To Do"', + 'jira_assignee: ""', + 'jira_due_date: ""', + 'jira_sprint: ""', + 'milestone: ""', + "blocks: []", + "depends_on: []", + "related: []", + "---", + "", + f"# Requirement: {wid}", + "", + "## Summary", + "", + resolved_title, + "", + "## Source", + "", + f"- ADF: `{rel_adf}`", + f"- Source Issue: {source_issue or '(none inferred)'}", + "- Derived from local ADF document", + "", + "## Description", + "", + body_block, + "", + "## Acceptance Criteria", + "", + "- [ ] Refine during `/sdlc-spdd-analysis`", + "", + "## Next", + "", + next_cmd, + "", + ] + ) + + canvas_path = self.project.root / canvas_rel + req_path = self.project.root / req_rel + feature_dir = self.project.root / feature_rel + feature_dir.mkdir(parents=True, exist_ok=True) + (feature_dir / "tasks").mkdir(exist_ok=True) + canvas_path.parent.mkdir(parents=True, exist_ok=True) + req_path.parent.mkdir(parents=True, exist_ok=True) + + canvas_path.write_text(canvas, encoding="utf-8") + (feature_dir / "reasons-canvas.md").write_text(canvas, encoding="utf-8") + req_path.write_text(req, encoding="utf-8") + (feature_dir / "requirement.md").write_text(req, encoding="utf-8") + (feature_dir / "progress-log.md").write_text( + "\n".join( + [ + f"# Progress Log: {wid}", + "", + f"- {now} — Draft canvas created from ADF `{rel_adf}`", + f"- Next: {next_cmd}", + "", + ] + ), + encoding="utf-8", + ) + + if claim: + self.registry.claim(wid, note=f"init-from-adf:{rel_adf}", phase="analysis") + else: + self.pointer.set(wid) + + return AdfInitResult( + work_id=wid, + title=resolved_title, + adf_path=rel_adf, + canvas_path=canvas_rel, + requirement_path=req_rel, + feature_dir=feature_rel, + source_issue=source_issue, + next_command=next_cmd, + dry_run=False, + ) diff --git a/engine/src/sdlc_engine/cli.py b/engine/src/sdlc_engine/cli.py index dcf6a97..4559ec9 100644 --- a/engine/src/sdlc_engine/cli.py +++ b/engine/src/sdlc_engine/cli.py @@ -13,6 +13,7 @@ from .commit_message import CommitMessageError, CommitMessageService from .db import LocalIndex, format_rows from .issues import IssueSyncService +from .adf_work import AdfWorkService from .local_sessions import LocalSessionService from .pointer import PointerError, PointerStore from .project import Project @@ -467,6 +468,35 @@ def cmd_local(args: argparse.Namespace) -> int: return 2 +def cmd_work(args: argparse.Namespace) -> int: + """Work helpers that are engine-backed (init from ADF, etc.).""" + action = args.work_cmd + if action == "init-from-adf": + svc = AdfWorkService(_project(args)) + try: + result = svc.init_from_adf( + args.path, + work_type=args.type, + title=args.title or "", + work_id=args.work_id or "", + claim=not args.no_claim, + dry_run=args.dry_run, + ) + except (OSError, ValueError, PermissionError, FileExistsError) as exc: + print(str(exc), file=sys.stderr) + return 1 + prefix = "[dry-run] would create" if result.dry_run else "Created" + print(f"{prefix} {result.work_id} from {result.adf_path}") + print(f" title: {result.title}") + print(f" canvas: {result.canvas_path}") + print(f" requirement: {result.requirement_path}") + if result.source_issue: + print(f" source issue: {result.source_issue}") + print(f" next: {result.next_command}") + return 0 + return 2 + + def cmd_viewer(args: argparse.Namespace) -> int: """Launch the ADF WYSIWYG viewer (binds localhost by default).""" try: @@ -693,6 +723,23 @@ def build_parser() -> argparse.ArgumentParser: lp.add_argument("--dry-run", action="store_true") lp.set_defaults(func=cmd_local) + work = sub.add_parser( + "work", + help="Create / bootstrap Work IDs (engine-backed helpers)", + ) + work_sub = work.add_subparsers(dest="work_cmd", required=True) + wia = work_sub.add_parser( + "init-from-adf", + help="Create draft REASONS canvas + requirement from a local ADF file", + ) + wia.add_argument("--path", required=True, help="Path to .adf.json / ADF JSON file") + wia.add_argument("--type", default="feature", help="feature|spike|bug|refactor|chore|...") + wia.add_argument("--title", help="Override title (default: first ADF heading or filename)") + wia.add_argument("--work-id", help="Explicit Work ID (default: auto FEAT-NNN-slug)") + wia.add_argument("--no-claim", action="store_true", help="Create artifacts without claiming") + wia.add_argument("--dry-run", action="store_true") + wia.set_defaults(func=cmd_work) + cm = sub.add_parser( "commit-message", help="Collect staged/unstaged/ahead-of-base diff for drafting a commit message (does not commit)", diff --git a/engine/src/sdlc_engine/installer/app.py b/engine/src/sdlc_engine/installer/app.py index 7209a6c..70fbb7a 100644 --- a/engine/src/sdlc_engine/installer/app.py +++ b/engine/src/sdlc_engine/installer/app.py @@ -6,8 +6,10 @@ from pathlib import Path from typing import Any +from sdlc_engine.adf_work import AdfWorkService from sdlc_engine.db import LocalIndex from sdlc_engine.project import Project +from sdlc_engine.viewer.store import AdfStore, AdfStoreError from .detect import detect_target from .guide import checklist, ingest_command, load_config, probe_guide, save_config @@ -544,6 +546,73 @@ def api_adf_restart() -> Any: out["error"] = result["error"] return jsonify(out), (200 if result.get("ok") else 400) + @app.post("/api/adf/browse") + def api_adf_browse() -> Any: + body = request.get_json(silent=True) or {} + target = _target_from_body(body) + path = str(body.get("path") or "").strip() + store = AdfStore(target) + store.ensure_dir() + try: + listing = store.browse(path or str(store.adf_dir)) + except AdfStoreError as exc: + return jsonify({"ok": False, "error": str(exc), "target": str(target)}), 400 + return jsonify( + { + "ok": True, + "target": str(target), + "home": str(target), + "adf_dir": str(store.adf_dir), + **listing, + } + ) + + @app.post("/api/adf/init-work") + def api_adf_init_work() -> Any: + body = request.get_json(silent=True) or {} + target = _target_from_body(body) + adf_path = str(body.get("path") or "").strip() + if not adf_path: + return jsonify({"ok": False, "error": "path required"}), 400 + work_type = str(body.get("type") or "feature").strip() or "feature" + title = str(body.get("title") or "").strip() + work_id = str(body.get("work_id") or "").strip() + claim = body.get("claim", True) is not False + dry_run = bool(body.get("dry_run")) + svc = AdfWorkService(Project.resolve(target)) + try: + result = svc.init_from_adf( + adf_path, + work_type=work_type, + title=title, + work_id=work_id, + claim=claim, + dry_run=dry_run, + ) + except (OSError, ValueError, PermissionError, FileExistsError) as exc: + return jsonify({"ok": False, "error": str(exc), "target": str(target)}), 400 + return jsonify( + { + "ok": True, + "target": str(target), + "work_id": result.work_id, + "title": result.title, + "adf_path": result.adf_path, + "canvas_path": result.canvas_path, + "requirement_path": result.requirement_path, + "feature_dir": result.feature_dir, + "source_issue": result.source_issue, + "next_command": result.next_command, + "dry_run": result.dry_run, + "cli": ( + f"./scripts/sdlc.sh work init-from-adf --path {adf_path}" + + (f" --title {title!r}" if title else "") + + (f" --work-id {work_id}" if work_id else "") + + (f" --type {work_type}" if work_type != "feature" else "") + ), + } + ) + return app diff --git a/engine/src/sdlc_engine/installer/pages.py b/engine/src/sdlc_engine/installer/pages.py index c2ef7a3..de95967 100644 --- a/engine/src/sdlc_engine/installer/pages.py +++ b/engine/src/sdlc_engine/installer/pages.py @@ -143,6 +143,21 @@ .status-line { margin-bottom: 0.55rem; font-family: var(--mono); font-size: 0.78rem; color: var(--muted); } .status-line.ok { color: var(--ok); } .status-line.bad { color: var(--bad); } + .browser-list { + border: 1px solid var(--line); background: #fff; max-height: 16rem; overflow: auto; + margin: 0.5rem 0 0.75rem; + } + .browser-row { + display: flex; gap: 0.55rem; width: 100%; text-align: left; + border: 0; border-bottom: 1px solid var(--line); background: transparent; + padding: 0.45rem 0.65rem; cursor: pointer; font: 500 0.88rem var(--mono); color: var(--ink); + } + .browser-row:hover { background: #eef6f1; } + .browser-row.selected { background: color-mix(in srgb, var(--accent) 14%, white); } + .browser-row.invalid { color: var(--muted); cursor: default; } + .browser-row .kind { + flex: 0 0 2.4rem; font-size: 0.72rem; font-weight: 700; color: var(--muted); + } footer { margin-top: 1.25rem; color: var(--muted); font-size: 0.85rem; } footer code { font-family: var(--mono); font-size: 0.8rem; } @keyframes rise { @@ -388,7 +403,7 @@

ADF Viewer

-

Editing and Jira sync live in the ADF Viewer. This tab only manages the process and opens the URL (default port 5050).

+

Start/stop the viewer for editing and Jira sync. Below: browse a local ADF and init a draft REASONS canvas.

@@ -412,6 +427,45 @@
No action yet.
+ +
+

Init SPDD work from ADF

+

Browse to an .adf.json, then create a draft REASONS canvas + requirement and hand off to analysis.

+
+ + + + +
+
+
No ADF selected.
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
Ready.
+
No init yet.
+