Skip to content

Commit 9a6550e

Browse files
tbitcsoz-agent
andcommitted
fix: resolve 5 open GitHub issues (#196 #197 #198 #199 #200)
#200 chronomemory import crash: wrap import in try/except in esdb/__init__.py; stub all exports; save_cmd treats ImportError as non-fatal warning. #196 type_override ignored: add type_override field to ProjectConfig; skip type-mismatch check when type_override==type or type is unknown custom string. #199 sync empty descriptions: _DIRECT_HEADING captures optional title group; parse_requirements_md collects plain paragraph lines as description. #198 req add wrong format: add_req now emits H2 heading with title and plain paragraph description; CLI passes --title in legacy mode. #197 preflight wrong path: read docs/REQUIREMENTS.md not root/REQUIREMENTS.md; broker.parse_requirements rewritten to handle Style A (REQ-NNN: Title) format. Co-Authored-By: Oz <oz-agent@warp.dev>
1 parent 7091e88 commit 9a6550e

8 files changed

Lines changed: 225 additions & 41 deletions

File tree

src/specsmith/agent/broker.py

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,11 @@ def classify_intent(utterance: str) -> Intent:
144144
# Extended to match project-prefixed IDs e.g. REQ-NN-001, REQ-CLI-042
145145
_REQ_ID = re.compile(r"-\s*\*\*ID:\*\*\s*(REQ-(?:[A-Z][A-Z0-9_]*-)?\d+)")
146146
_REQ_DESC = re.compile(r"-\s*\*\*Description:\*\*\s*(.+)")
147+
# Style A heading: ## REQ-001 or ## REQ-CLI-001: Title
148+
_STYLE_A_HEADING = re.compile(
149+
r"^#{1,3}\s+(REQ-(?:[A-Z][A-Z0-9_]*-)?\d+)(?::\s*(.+))?\s*$",
150+
re.MULTILINE,
151+
)
147152

148153
# A small stopword list to keep keyword matches meaningful.
149154
_STOPWORDS = frozenset(
@@ -205,6 +210,19 @@ def text_blob(self) -> str:
205210
def parse_requirements(req_md_path: Path) -> list[RequirementSummary]:
206211
"""Parse REQUIREMENTS.md into ``RequirementSummary`` records.
207212
213+
Handles two heading styles:
214+
215+
Style A (standard specsmith format)::
216+
217+
## REQ-001: Title
218+
Description text as a plain paragraph.
219+
220+
Style B (numbered-heading format)::
221+
222+
## 1. Title
223+
- **ID:** REQ-001
224+
- **Description:** description text
225+
208226
Best-effort: missing files yield an empty list.
209227
"""
210228
req_md_path = req_md_path.resolve() # CodeQL py/path-injection: normalise before fs access
@@ -215,14 +233,53 @@ def parse_requirements(req_md_path: Path) -> list[RequirementSummary]:
215233
except ValueError:
216234
return []
217235
out: list[RequirementSummary] = []
236+
237+
# ── Style A: ## REQ-NNN: Title (standard specsmith format) ────────────────
238+
# Split on any Style A heading first; if the file uses this format
239+
# exclusively, the Style B pass below will be a no-op.
240+
lines = text.splitlines()
241+
current_id: str = ""
242+
current_title: str = ""
243+
current_desc: str = ""
244+
seen_ids: set[str] = set()
245+
246+
def _flush_style_a() -> None:
247+
if current_id and current_id not in seen_ids:
248+
seen_ids.add(current_id)
249+
out.append(
250+
RequirementSummary(req_id=current_id, title=current_title, description=current_desc)
251+
)
252+
253+
for line in lines:
254+
m_a = _STYLE_A_HEADING.match(line)
255+
if m_a:
256+
_flush_style_a()
257+
current_id = m_a.group(1)
258+
current_title = (m_a.group(2) or "").strip()
259+
current_desc = ""
260+
continue
261+
if current_id:
262+
stripped = line.strip()
263+
# Bullet description fields
264+
m_desc = _REQ_DESC.match(line)
265+
if m_desc:
266+
current_desc = current_desc or m_desc.group(1).strip()
267+
continue
268+
# Plain paragraph text (not a heading, not a bullet, not empty)
269+
if stripped and not stripped.startswith("#") and not stripped.startswith("-"):
270+
current_desc = current_desc or stripped
271+
_flush_style_a()
272+
273+
# ── Style B: ## N. Title with - **ID:** REQ-NNN inline ────────────────────
218274
blocks = re.split(r"^##\s+\d+\.\s+", text, flags=re.MULTILINE)[1:]
219275
for block in blocks:
220-
lines = block.splitlines()
221-
title = lines[0].strip() if lines else ""
276+
block_lines = block.splitlines()
277+
title = block_lines[0].strip() if block_lines else ""
222278
m_id = _REQ_ID.search(block)
223279
m_desc = _REQ_DESC.search(block)
224-
if not m_id:
225-
continue
280+
if not m_id or m_id.group(1) in seen_ids:
281+
continue # skip if no ID or already captured by Style A pass
282+
seen_ids.add(m_id.group(1))
226283
out.append(
227284
RequirementSummary(
228285
req_id=m_id.group(1),

src/specsmith/auditor.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,34 @@ def check_type_mismatch(root: Path) -> list[AuditResult]:
779779
raw = yaml.safe_load(f)
780780
config = ProjectConfig(**raw)
781781

782+
# Skip when type_override == type (explicit user suppression, #196).
783+
if config.type_override and config.type_override == config.type:
784+
results.append(
785+
AuditResult(
786+
name="type-mismatch",
787+
passed=True,
788+
message=(
789+
f"Project type {config.type} is explicitly overridden; "
790+
f"auto-detection skipped"
791+
),
792+
)
793+
)
794+
return results
795+
796+
# Skip for unrecognised (custom) type strings — there is no known
797+
# ProjectType to compare against, so detection is meaningless.
798+
if config.project_type_enum is None:
799+
results.append(
800+
AuditResult(
801+
name="type-mismatch",
802+
passed=True,
803+
message=(
804+
f"Project type {config.type!r} is a custom type; auto-detection skipped"
805+
),
806+
)
807+
)
808+
return results
809+
782810
# Skip auto-detection for types that must be specified explicitly.
783811
if config.type in _EXPLICIT_ONLY_TYPES:
784812
results.append(

src/specsmith/cli.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2159,6 +2159,7 @@ def req_add(
21592159
add_req(
21602160
root,
21612161
effective_id,
2162+
title=title,
21622163
component=component,
21632164
priority=priority,
21642165
description=description,
@@ -2592,6 +2593,13 @@ def save_cmd(project_dir: str, message: str, no_push: bool, force: bool, as_json
25922593
steps.append(
25932594
{"step": "esdb_backup", "ok": True, "note": "JSON fallback (no WAL to backup)"}
25942595
)
2596+
except ImportError:
2597+
# chronomemory not installed — non-fatal; commit and push still proceed.
2598+
# Install with: pipx inject specsmith
2599+
# "chronomemory @ git+https://github.com/layer1labs/chronomemory.git@v0.1.1"
2600+
from specsmith.esdb import _INSTALL_HINT # noqa: PLC0415
2601+
2602+
steps.append({"step": "esdb_backup", "ok": True, "note": f"skipped — {_INSTALL_HINT}"})
25952603
except Exception as exc: # noqa: BLE001
25962604
steps.append({"step": "esdb_backup", "ok": False, "error": str(exc)})
25972605

src/specsmith/config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,18 @@ class ProjectConfig(BaseModel):
224224
),
225225
)
226226

227+
# Explicit type override — suppresses the type-mismatch audit check.
228+
# Set this when your project uses a custom or research-specific type string
229+
# that cannot be auto-detected (e.g. 'research-mathematics'). When
230+
# type_override matches type, check_type_mismatch is suppressed.
231+
type_override: str = Field(
232+
default="",
233+
description=(
234+
"Explicit type override. When set to the same value as `type`, "
235+
"the type-mismatch audit check is suppressed regardless of what "
236+
"auto-detection infers from the project files."
237+
),
238+
)
227239
# Fallback type — used when this project type is not yet supported
228240
# by the installed specsmith version. specsmith silently falls back to
229241
# this type for scaffolding purposes while still recording the intended type.

src/specsmith/esdb/__init__.py

Lines changed: 76 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,37 +3,85 @@
33
# Re-exports the full chronomemory v0.1.1 public surface under the
44
# specsmith.esdb namespace so internal modules can use a single import
55
# path and never import chronomemory directly in more than one place.
6+
#
7+
# chronomemory is a git-URL dependency stripped from the PyPI wheel.
8+
# If it is not installed, all symbols are stubbed with a class that
9+
# raises a clear ImportError with install instructions rather than
10+
# crashing silently at module import time.
11+
#
12+
# Install the missing dep:
13+
# pipx inject specsmith "chronomemory @ git+https://github.com/layer1labs/chronomemory.git@v0.1.1"
614

7-
# Re-export query and metrics as module references so callers can do:
8-
# from specsmith.esdb import query, metrics
9-
from chronomemory import (
10-
RUST_BACKEND,
11-
# Core store
12-
ChronoRecord,
13-
ChronoStore,
14-
# Phase 2: context pack compiler
15-
ContextPack,
16-
ContextPackCompiler,
17-
ContextPackEntry,
18-
DependencyEdge,
19-
# Phase 2: dependency graph
20-
DepGraph,
21-
# Bridge (backward-compat with .specsmith/*.json)
22-
EsdbBridge,
23-
EsdbRecord,
24-
EsdbStatus,
25-
# Phase 2: epistemic rollback
26-
RollbackReport,
27-
# Phase 3: optional Rust acceleration (None / False when not compiled)
28-
RustChronoStore,
29-
RustRecord,
30-
WalEvent,
31-
invalidate,
32-
metrics, # noqa: F401 — module re-export
33-
open_store,
34-
query, # noqa: F401 — module re-export
15+
_INSTALL_HINT = (
16+
"chronomemory is not installed.\n"
17+
"Run: pipx inject specsmith "
18+
'"chronomemory @ git+https://github.com/layer1labs/chronomemory.git@v0.1.1"'
3519
)
3620

21+
try:
22+
# Re-export query and metrics as module references so callers can do:
23+
# from specsmith.esdb import query, metrics
24+
from chronomemory import (
25+
RUST_BACKEND,
26+
# Core store
27+
ChronoRecord,
28+
ChronoStore,
29+
# Phase 2: context pack compiler
30+
ContextPack,
31+
ContextPackCompiler,
32+
ContextPackEntry,
33+
DependencyEdge,
34+
# Phase 2: dependency graph
35+
DepGraph,
36+
# Bridge (backward-compat with .specsmith/*.json)
37+
EsdbBridge,
38+
EsdbRecord,
39+
EsdbStatus,
40+
# Phase 2: epistemic rollback
41+
RollbackReport,
42+
# Phase 3: optional Rust acceleration (None / False when not compiled)
43+
RustChronoStore,
44+
RustRecord,
45+
WalEvent,
46+
invalidate,
47+
metrics, # noqa: F401 — module re-export
48+
open_store,
49+
query, # noqa: F401 — module re-export
50+
)
51+
52+
CHRONO_AVAILABLE: bool = True
53+
54+
except ImportError:
55+
CHRONO_AVAILABLE = False
56+
57+
class _Stub: # type: ignore[no-redef]
58+
"""Placeholder that raises a clear error on instantiation or call."""
59+
60+
def __init__(self, *_: object, **__: object) -> None:
61+
raise ImportError(_INSTALL_HINT)
62+
63+
def __call__(self, *_: object, **__: object) -> "_Stub":
64+
raise ImportError(_INSTALL_HINT)
65+
66+
def __class_getitem__(cls, _: object) -> "type[_Stub]":
67+
return cls
68+
69+
# Stub every exported name so `from specsmith.esdb import X` doesn't fail.
70+
ChronoRecord = ChronoStore = EsdbBridge = EsdbRecord = EsdbStatus = _Stub # type: ignore[misc]
71+
ContextPack = ContextPackCompiler = ContextPackEntry = _Stub # type: ignore[misc]
72+
DepGraph = DependencyEdge = RollbackReport = _Stub # type: ignore[misc]
73+
RustChronoStore = RustRecord = WalEvent = invalidate = open_store = _Stub # type: ignore[misc]
74+
RUST_BACKEND: bool = False # type: ignore[misc]
75+
76+
class _StubModule:
77+
"""Stub module reference so 'from specsmith.esdb import query, metrics' works."""
78+
79+
def __getattr__(self, name: str) -> "_Stub":
80+
raise ImportError(_INSTALL_HINT)
81+
82+
query = _StubModule() # type: ignore[assignment]
83+
metrics = _StubModule() # type: ignore[assignment]
84+
3785
__all__ = [
3886
# Core
3987
"ChronoStore",

src/specsmith/governance_logic.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,16 @@ def run_preflight(
7373

7474
root = _safe_resolve(project_dir)
7575
intent = classify_intent(utterance)
76+
# Requirements live at docs/REQUIREMENTS.md, not at the project root.
77+
# Falling back to root/REQUIREMENTS.md would always yield an empty list
78+
# on standard projects, causing preflight to always return
79+
# needs_clarification (GitHub issue #197).
80+
_req_md = root / "docs" / "REQUIREMENTS.md"
81+
if not _req_md.exists():
82+
_req_md = root / "REQUIREMENTS.md" # legacy fallback
7683
scope = infer_scope(
7784
utterance,
78-
root / "REQUIREMENTS.md",
85+
_req_md,
7986
repo_index_path=root / ".repo-index" / "files.json",
8087
)
8188

src/specsmith/requirements.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,19 +92,33 @@ def add_req(
9292
root: Path,
9393
req_id: str,
9494
*,
95+
title: str = "",
9596
component: str = "",
9697
priority: str = "medium",
9798
description: str = "",
9899
) -> None:
99-
"""Append a new requirement to REQUIREMENTS.md."""
100+
"""Append a new requirement to REQUIREMENTS.md.
101+
102+
Emits the standard Style A format::
103+
104+
## REQ-NNN: Title
105+
Description text as a plain paragraph.
106+
107+
This matches the format already used by every other requirement in
108+
REQUIREMENTS.md and is correctly parsed by ``sync`` and ``preflight``.
109+
"""
100110
req_path = root / "docs" / "REQUIREMENTS.md"
101-
entry = f"\n### {req_id}\n"
111+
# Build heading: ## REQ-NNN or ## REQ-NNN: Title
112+
heading_title = f": {title}" if title else ""
113+
entry = f"\n## {req_id}{heading_title}\n"
114+
if description:
115+
# Plain paragraph — not a bullet list (matches parser expectations)
116+
entry += f"{description}\n"
117+
# Legacy fields kept for backward-compat but written as metadata bullets
102118
if component:
103119
entry += f"- **Component**: {component}\n"
104-
entry += f"- **Priority**: {priority}\n"
105-
entry += "- **Status**: Draft\n"
106-
if description:
107-
entry += f"- **Description**: {description}\n"
120+
if priority and priority != "medium":
121+
entry += f"- **Priority**: {priority}\n"
108122

109123
content = req_path.read_text(encoding="utf-8") if req_path.exists() else "# Requirements\n"
110124

src/specsmith/sync.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,13 @@
3333
# ---------------------------------------------------------------------------
3434

3535
# Matches either:
36-
# Style A: ## REQ-001 or ## REQ-CLI-001 — Title
36+
# Style A: ## REQ-001 or ## REQ-CLI-001: Title
37+
# The optional ": Title" suffix is captured in group(2).
3738
# Style B: ## N. Title (ID comes from inline - **ID:** REQ-NNN field)
3839
_FLEX_REQ_ID = r"REQ-(?:[A-Z][A-Z0-9_]*-)?\d+"
3940
_NUMBERED_HEADING = re.compile(r"^#{1,3}\s+\d+\.\s+(.+?)\s*$")
40-
_DIRECT_HEADING = re.compile(r"^#{1,3}\s+(" + _FLEX_REQ_ID + r")\b")
41+
# Group 1: REQ-ID, Group 2 (optional): title text after the colon.
42+
_DIRECT_HEADING = re.compile(r"^#{1,3}\s+(" + _FLEX_REQ_ID + r")(?::\s*(.+))?\s*$")
4143
_ID_FIELD = re.compile(r"^-\s+\*\*ID:\*\*\s+(" + _FLEX_REQ_ID + r")")
4244
_FIELD_LINE = re.compile(r"^-\s+\*\*(.+?):\*\*\s+(.+)")
4345

@@ -61,7 +63,11 @@ def _flush() -> None:
6163
m_direct = _DIRECT_HEADING.match(line)
6264
if m_direct:
6365
_flush()
66+
# Group 2 carries the title when the heading is '## REQ-NNN: Title'
67+
inline_title = (m_direct.group(2) or "").strip()
6468
current = {"id": m_direct.group(1)}
69+
if inline_title:
70+
current["title"] = inline_title
6571
pending_title = ""
6672
continue
6773

@@ -86,6 +92,10 @@ def _flush() -> None:
8692
val = m_field.group(2).strip()
8793
if key not in ("id",):
8894
current.setdefault(key, val)
95+
elif line.strip() and not line.startswith("#"):
96+
# Plain paragraph text after the heading — capture as description
97+
# (e.g. '## REQ-001: Title\nThe system SHALL...')
98+
current.setdefault("description", line.strip())
8999

90100
_flush()
91101
return [

0 commit comments

Comments
 (0)