hstack 0.7.1 → 0.17.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/CHANGELOG.md +308 -0
- package/README.md +90 -154
- package/VERSION +1 -1
- package/dist/commands/doctor.js +69 -9
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.js +7 -3
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/update.js +17 -4
- package/dist/commands/update.js.map +1 -1
- package/dist/lib/descriptions.js +160 -0
- package/dist/lib/descriptions.js.map +1 -0
- package/dist/lib/diff.js +1 -1
- package/dist/lib/git.js +16 -0
- package/dist/lib/git.js.map +1 -1
- package/dist/lib/wire.js +274 -142
- package/dist/lib/wire.js.map +1 -1
- package/dist/manifest.js +49 -22
- package/dist/manifest.js.map +1 -1
- package/package.json +4 -3
- package/template/.claude/agents/adversarial-reviewer.md +42 -98
- package/template/.claude/agents/app-architect.md +44 -133
- package/template/.claude/agents/data-architect.md +46 -127
- package/template/.claude/agents/test-strategist.md +39 -109
- package/template/.claude/skills/hstack-adr-new/SKILL.md +23 -100
- package/template/.claude/skills/hstack-adversarial-review/SKILL.md +47 -174
- package/template/.claude/skills/hstack-adversarial-review/references/finding-categories.md +134 -0
- package/template/.claude/skills/hstack-app-architecture/SKILL.md +43 -95
- package/template/.claude/skills/hstack-commit/SKILL.md +28 -97
- package/template/.claude/skills/hstack-data-architecture/SKILL.md +34 -82
- package/template/.claude/skills/hstack-promote/SKILL.md +55 -0
- package/template/.claude/skills/hstack-story/SKILL.md +58 -0
- package/template/.claude/skills/hstack-test-audit/SKILL.md +64 -0
- package/template/.claude/skills/hstack-wrap/SKILL.md +48 -0
- package/template/.claude/skills/hstack-wrap/references/pr-description.md +45 -0
- package/template/KERNEL.md +167 -0
- package/template/templates/adr.md +9 -18
- package/template/templates/app-architecture.md +38 -50
- package/template/templates/data-architecture.md +39 -93
- package/template/templates/infrastructure.md +54 -148
- package/template/templates/roadmap.md +35 -0
- package/template/templates/story.md +7 -13
- package/template/templates/tech-debt.md +2 -37
- package/template/templates/tech-stack.md +2 -12
- package/template/.claude/agents/data-specialist.md +0 -126
- package/template/.claude/agents/implementer.md +0 -154
- package/template/.claude/agents/kernel-fit-analyst.md +0 -190
- package/template/.claude/agents/planner.md +0 -110
- package/template/.claude/agents/product-discovery.md +0 -165
- package/template/.claude/agents/product-manager.md +0 -111
- package/template/.claude/agents/researcher.md +0 -123
- package/template/.claude/agents/security-reviewer.md +0 -123
- package/template/.claude/agents/spec-author.md +0 -123
- package/template/.claude/agents/stack-architect.md +0 -137
- package/template/.claude/agents/ui-ux-briefer.md +0 -105
- package/template/.claude/agents/verifier.md +0 -113
- package/template/.claude/skills/hstack-branch/SKILL.md +0 -103
- package/template/.claude/skills/hstack-brownfield-init/SKILL.md +0 -158
- package/template/.claude/skills/hstack-change-new/SKILL.md +0 -111
- package/template/.claude/skills/hstack-change-plan/SKILL.md +0 -119
- package/template/.claude/skills/hstack-configure/SKILL.md +0 -126
- package/template/.claude/skills/hstack-coord/SKILL.md +0 -131
- package/template/.claude/skills/hstack-data-review/SKILL.md +0 -132
- package/template/.claude/skills/hstack-finalize/SKILL.md +0 -159
- package/template/.claude/skills/hstack-flag/SKILL.md +0 -138
- package/template/.claude/skills/hstack-greenfield-init/SKILL.md +0 -175
- package/template/.claude/skills/hstack-help/SKILL.md +0 -179
- package/template/.claude/skills/hstack-implement/SKILL.md +0 -185
- package/template/.claude/skills/hstack-kernel-fit-promote/SKILL.md +0 -164
- package/template/.claude/skills/hstack-kernel-fit-scan/SKILL.md +0 -197
- package/template/.claude/skills/hstack-kernel-fit-triage/SKILL.md +0 -159
- package/template/.claude/skills/hstack-module-spec/SKILL.md +0 -105
- package/template/.claude/skills/hstack-product-discovery/SKILL.md +0 -101
- package/template/.claude/skills/hstack-research/SKILL.md +0 -145
- package/template/.claude/skills/hstack-scaffold/SKILL.md +0 -140
- package/template/.claude/skills/hstack-security-review/SKILL.md +0 -133
- package/template/.claude/skills/hstack-ship/SKILL.md +0 -129
- package/template/.claude/skills/hstack-stack-decide/SKILL.md +0 -108
- package/template/.claude/skills/hstack-story-draft/SKILL.md +0 -119
- package/template/.claude/skills/hstack-tech-debt-new/SKILL.md +0 -122
- package/template/.claude/skills/hstack-tech-debt-resolve/SKILL.md +0 -158
- package/template/.claude/skills/hstack-tech-debt-stale/SKILL.md +0 -113
- package/template/.claude/skills/hstack-tech-debt-wontfix/SKILL.md +0 -104
- package/template/.claude/skills/hstack-telemetry/SKILL.md +0 -98
- package/template/.claude/skills/hstack-test-plan/SKILL.md +0 -182
- package/template/.claude/skills/hstack-ui-brief/SKILL.md +0 -108
- package/template/.claude/skills/hstack-verify/SKILL.md +0 -150
- package/template/CLAUDE.md +0 -443
- package/template/scripts/coord/coord_scan.py +0 -681
- package/template/scripts/telemetry/__init__.py +0 -6
- package/template/scripts/telemetry/insights/__init__.py +0 -0
- package/template/scripts/telemetry/insights/contract_drift.py +0 -137
- package/template/scripts/telemetry/insights/kernel_fit.py +0 -438
- package/template/scripts/telemetry/insights/overengineering.py +0 -115
- package/template/scripts/telemetry/insights/quality_outcomes.py +0 -131
- package/template/scripts/telemetry/insights/token_economics.py +0 -129
- package/template/scripts/telemetry/insights/workflow_shape.py +0 -198
- package/template/scripts/telemetry/parsers/__init__.py +0 -0
- package/template/scripts/telemetry/parsers/bodies.py +0 -87
- package/template/scripts/telemetry/parsers/commits.py +0 -219
- package/template/scripts/telemetry/parsers/frontmatter.py +0 -322
- package/template/scripts/telemetry/parsers/transcripts.py +0 -181
- package/template/scripts/telemetry/render.py +0 -395
- package/template/scripts/telemetry/report.py +0 -138
- package/template/scripts/telemetry/run_kernel_fit.py +0 -91
- package/template/templates/adversarial-review.md +0 -54
- package/template/templates/bootstrap.md +0 -105
- package/template/templates/change-spec.md +0 -82
- package/template/templates/ci-cd.md +0 -27
- package/template/templates/coord-message.md +0 -55
- package/template/templates/data-review.md +0 -54
- package/template/templates/discovery/brainstorm.md +0 -69
- package/template/templates/discovery/forcing-questions.md +0 -76
- package/template/templates/discovery/project-brief.md +0 -53
- package/template/templates/figma-handoff.md +0 -38
- package/template/templates/glossary.md +0 -20
- package/template/templates/hardening-checklist.md +0 -73
- package/template/templates/incident-runbook.md +0 -57
- package/template/templates/kernel-fit-finding.md +0 -63
- package/template/templates/kernel-fit-flag.md +0 -52
- package/template/templates/module-spec.md +0 -49
- package/template/templates/mvp-scope.md +0 -34
- package/template/templates/persona.md +0 -38
- package/template/templates/plan.md +0 -49
- package/template/templates/product-brief.md +0 -56
- package/template/templates/security-review.md +0 -63
- package/template/templates/telemetry-sidecar.md +0 -184
- package/template/templates/test-plan.md +0 -119
- package/template/templates/threat-model.md +0 -54
- package/template/templates/ui-brief.md +0 -49
- package/template/templates/verification.md +0 -63
- package/template/templates/vision.md +0 -34
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
"""Parse git log of the consuming repo for hstack-style auto-commits."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import re
|
|
6
|
-
import subprocess
|
|
7
|
-
from datetime import datetime, timezone
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
# Auto-commit subject regex confirmed against moso-app's real git log.
|
|
12
|
-
# Examples (from exploration):
|
|
13
|
-
# change-spec(2026-05-knowledge-resolve-icon-map-duplication-cross-module): shipped
|
|
14
|
-
# tech-debt(TD-0102): resolved (resolved-by: 2026-05-knowledge-resolve-icon-map-duplication-cross-module)
|
|
15
|
-
# implement(2026-05-knowledge-kg-ui-bucket-and-entity-rendering) phase-7-fixture-cast-cleanup + plan completed
|
|
16
|
-
# adversarial-review(2026-05-knowledge-kg-ui-bucket-and-entity-rendering): findings-resolved + change-spec ready-to-ship
|
|
17
|
-
# verification(2026-05-knowledge-kg-ui-bucket-and-entity-rendering): passed
|
|
18
|
-
ARTIFACT_TYPES = (
|
|
19
|
-
"change-spec", "tech-debt", "implement", "adversarial-review",
|
|
20
|
-
"verification", "test-plan", "plan", "security-review",
|
|
21
|
-
"ui-brief", "figma-handoff", "story", "data-review",
|
|
22
|
-
"module-spec", "adr",
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
COMMIT_SUBJECT = re.compile(
|
|
26
|
-
r"^(?P<atype>" + "|".join(ARTIFACT_TYPES) + r")"
|
|
27
|
-
r"\((?P<id>[^)]+)\)"
|
|
28
|
-
r"(?P<rest>.*)$"
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
# Implement commits use `(<id>) <phase-id>` instead of `(<id>): <status>`.
|
|
32
|
-
IMPLEMENT_PHASE = re.compile(r"^\s*(?P<phase>phase-[a-z0-9-]+)")
|
|
33
|
-
|
|
34
|
-
# Halt sentinel — emitted into commit body or kernel-defined convention line.
|
|
35
|
-
HALT_SENTINEL = re.compile(r"HSTACK-HALT:\s*reason=(?P<reason>[a-z-]+)", re.IGNORECASE)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def run_git(repo: Path, *args: str) -> str:
|
|
39
|
-
try:
|
|
40
|
-
result = subprocess.run(
|
|
41
|
-
["git", "-C", str(repo), *args],
|
|
42
|
-
capture_output=True, text=True, check=False,
|
|
43
|
-
)
|
|
44
|
-
except FileNotFoundError:
|
|
45
|
-
return ""
|
|
46
|
-
return result.stdout
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def parse_commits(repo: Path, since_days: int | None = None) -> list[dict]:
|
|
50
|
-
"""Return a list of commit dicts: subject parsed, files-touched, timestamp.
|
|
51
|
-
|
|
52
|
-
Implementation: two queries to git. The first pulls metadata with a record
|
|
53
|
-
separator so multi-line bodies survive. The second pulls per-commit file
|
|
54
|
-
lists keyed by SHA. Merging the two avoids the `--pretty=format` +
|
|
55
|
-
`--name-only` interaction that mangled multi-line bodies in v1.
|
|
56
|
-
|
|
57
|
-
`--no-walk=unsorted` is intentionally not used; we keep author-date sort.
|
|
58
|
-
Conductor checkpoint commits are filtered post-parse by subject prefix.
|
|
59
|
-
"""
|
|
60
|
-
# --- pass 1: metadata ---
|
|
61
|
-
sep = "\x1f"
|
|
62
|
-
rec_sep = "\x1e"
|
|
63
|
-
args = [
|
|
64
|
-
"log", "--all",
|
|
65
|
-
f"--pretty=format:%H{sep}%cI{sep}%an{sep}%s{sep}%b{rec_sep}",
|
|
66
|
-
]
|
|
67
|
-
if since_days is not None:
|
|
68
|
-
args.append(f"--since={since_days}.days.ago")
|
|
69
|
-
raw = run_git(repo, *args)
|
|
70
|
-
metadata: dict[str, dict] = {}
|
|
71
|
-
for record in raw.split(rec_sep):
|
|
72
|
-
record = record.strip("\n")
|
|
73
|
-
if not record:
|
|
74
|
-
continue
|
|
75
|
-
parts = record.split(sep, 4)
|
|
76
|
-
if len(parts) < 5:
|
|
77
|
-
continue
|
|
78
|
-
sha, iso_ts, author, subject, body = parts
|
|
79
|
-
# Skip Conductor auto-checkpoint commits; they spam the log and carry
|
|
80
|
-
# no hstack signal.
|
|
81
|
-
if subject.startswith("checkpoint:"):
|
|
82
|
-
continue
|
|
83
|
-
metadata[sha] = {
|
|
84
|
-
"sha": sha,
|
|
85
|
-
"timestamp": _parse_iso(iso_ts),
|
|
86
|
-
"author": author,
|
|
87
|
-
"subject": subject,
|
|
88
|
-
"body": body,
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if not metadata:
|
|
92
|
-
return []
|
|
93
|
-
|
|
94
|
-
# --- pass 2: file lists for the SHAs we kept ---
|
|
95
|
-
# Use a separate query keyed by SHA to avoid the `--name-only` interaction.
|
|
96
|
-
files_by_sha = _files_by_sha(repo, list(metadata.keys()))
|
|
97
|
-
|
|
98
|
-
out: list[dict] = []
|
|
99
|
-
for sha, md in metadata.items():
|
|
100
|
-
subject = md["subject"]
|
|
101
|
-
body = md["body"]
|
|
102
|
-
m = COMMIT_SUBJECT.match(subject)
|
|
103
|
-
atype = m.group("atype") if m else None
|
|
104
|
-
artifact_id = m.group("id") if m else None
|
|
105
|
-
rest = m.group("rest") if m else ""
|
|
106
|
-
phase_id = None
|
|
107
|
-
action = None
|
|
108
|
-
if atype == "implement":
|
|
109
|
-
pm = IMPLEMENT_PHASE.search(rest)
|
|
110
|
-
if pm:
|
|
111
|
-
phase_id = pm.group("phase")
|
|
112
|
-
else:
|
|
113
|
-
action = rest.lstrip(":").strip() or None
|
|
114
|
-
halts = HALT_SENTINEL.findall(body)
|
|
115
|
-
out.append({
|
|
116
|
-
**md,
|
|
117
|
-
"artifact_type": atype,
|
|
118
|
-
"artifact_id": artifact_id,
|
|
119
|
-
"phase_id": phase_id,
|
|
120
|
-
"action": action,
|
|
121
|
-
"files": files_by_sha.get(sha, []),
|
|
122
|
-
"halt_reasons": halts,
|
|
123
|
-
})
|
|
124
|
-
return out
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def _files_by_sha(repo: Path, shas: list[str]) -> dict[str, list[str]]:
|
|
128
|
-
"""Run `git log --name-only --pretty=format:%H` filtered to the SHAs we care
|
|
129
|
-
about; chunk the SHA list to avoid command-line length limits."""
|
|
130
|
-
out: dict[str, list[str]] = {}
|
|
131
|
-
if not shas:
|
|
132
|
-
return out
|
|
133
|
-
chunk_size = 200
|
|
134
|
-
for start in range(0, len(shas), chunk_size):
|
|
135
|
-
chunk = shas[start:start + chunk_size]
|
|
136
|
-
args = ["log", "--no-walk", "--name-only", "--pretty=format:\x1eCOMMIT\x1f%H", *chunk]
|
|
137
|
-
raw = run_git(repo, *args)
|
|
138
|
-
if not raw:
|
|
139
|
-
continue
|
|
140
|
-
current_sha: str | None = None
|
|
141
|
-
files: list[str] = []
|
|
142
|
-
for line in raw.split("\n"):
|
|
143
|
-
if line.startswith("\x1eCOMMIT\x1f"):
|
|
144
|
-
if current_sha:
|
|
145
|
-
out[current_sha] = files
|
|
146
|
-
current_sha = line.split("\x1f", 1)[1].strip()
|
|
147
|
-
files = []
|
|
148
|
-
elif line.strip() and current_sha:
|
|
149
|
-
files.append(line.strip())
|
|
150
|
-
if current_sha:
|
|
151
|
-
out[current_sha] = files
|
|
152
|
-
return out
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def _parse_iso(s: str) -> datetime | None:
|
|
156
|
-
try:
|
|
157
|
-
return datetime.fromisoformat(s)
|
|
158
|
-
except ValueError:
|
|
159
|
-
try:
|
|
160
|
-
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
161
|
-
except ValueError:
|
|
162
|
-
return None
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def diff_line_count(repo: Path, sha: str) -> tuple[int, int]:
|
|
166
|
-
"""Return (added, removed) line counts for a commit."""
|
|
167
|
-
raw = run_git(repo, "show", "--stat", "--format=", sha)
|
|
168
|
-
added = removed = 0
|
|
169
|
-
for line in raw.splitlines():
|
|
170
|
-
if not line.strip():
|
|
171
|
-
continue
|
|
172
|
-
# final summary line: " N files changed, M insertions(+), K deletions(-)"
|
|
173
|
-
m = re.search(r"(\d+) insertion", line)
|
|
174
|
-
if m:
|
|
175
|
-
added = int(m.group(1))
|
|
176
|
-
m = re.search(r"(\d+) deletion", line)
|
|
177
|
-
if m:
|
|
178
|
-
removed = int(m.group(1))
|
|
179
|
-
return added, removed
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def commits_by_change(commits: list[dict]) -> dict[str, list[dict]]:
|
|
183
|
-
"""Group commits by change-id (artifact_id when artifact_type relates to a change)."""
|
|
184
|
-
out: dict[str, list[dict]] = {}
|
|
185
|
-
for c in commits:
|
|
186
|
-
aid = c.get("artifact_id")
|
|
187
|
-
atype = c.get("artifact_type")
|
|
188
|
-
if not aid or atype is None:
|
|
189
|
-
continue
|
|
190
|
-
if atype == "tech-debt":
|
|
191
|
-
# Tech-debt commits also reference a resolving change-spec in the body.
|
|
192
|
-
# Surface the TD itself; resolving-change correlation is downstream.
|
|
193
|
-
continue
|
|
194
|
-
out.setdefault(aid, []).append(c)
|
|
195
|
-
return out
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
def commits_touching_test_files(commits: list[dict]) -> list[dict]:
|
|
199
|
-
"""Filter commits that touch test files."""
|
|
200
|
-
test_re = re.compile(r"(\.test\.|\.spec\.|/__tests__/|/__snapshots__/|^e2e/|_test\.go$)")
|
|
201
|
-
out = []
|
|
202
|
-
for c in commits:
|
|
203
|
-
if any(test_re.search(f) for f in c.get("files", [])):
|
|
204
|
-
out.append(c)
|
|
205
|
-
return out
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
def commits_with_test_immutability_authorization(commits: list[dict]) -> list[dict]:
|
|
209
|
-
"""Commits whose body carries one of the canonical authorization phrases."""
|
|
210
|
-
auth_re = re.compile(
|
|
211
|
-
r"Ok to (change|delete) test\s+\S+"
|
|
212
|
-
r"|Ok to update snapshot\s+\S+"
|
|
213
|
-
r"|Ok to refresh fixture\s+\S+"
|
|
214
|
-
)
|
|
215
|
-
out = []
|
|
216
|
-
for c in commits:
|
|
217
|
-
if auth_re.search(c.get("body", "")):
|
|
218
|
-
out.append(c)
|
|
219
|
-
return out
|
|
@@ -1,322 +0,0 @@
|
|
|
1
|
-
"""Walk hstack artifacts and parse their YAML frontmatter."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import os
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Iterator
|
|
8
|
-
|
|
9
|
-
try:
|
|
10
|
-
import yaml
|
|
11
|
-
except ImportError:
|
|
12
|
-
yaml = None
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
FRONTMATTER_DELIM = "---"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def _split_frontmatter(text: str) -> tuple[dict, str]:
|
|
19
|
-
"""Return (frontmatter_dict, body_text). Empty dict if no frontmatter."""
|
|
20
|
-
if not text.startswith(FRONTMATTER_DELIM):
|
|
21
|
-
return {}, text
|
|
22
|
-
parts = text.split(FRONTMATTER_DELIM, 2)
|
|
23
|
-
if len(parts) < 3:
|
|
24
|
-
return {}, text
|
|
25
|
-
raw = parts[1]
|
|
26
|
-
body = parts[2].lstrip("\n")
|
|
27
|
-
if yaml is None:
|
|
28
|
-
return _parse_simple_yaml(raw), body
|
|
29
|
-
try:
|
|
30
|
-
loaded = yaml.safe_load(raw) or {}
|
|
31
|
-
if not isinstance(loaded, dict):
|
|
32
|
-
return {}, body
|
|
33
|
-
return loaded, body
|
|
34
|
-
except yaml.YAMLError:
|
|
35
|
-
return _parse_simple_yaml(raw), body
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _coerce_scalar(value: str):
|
|
39
|
-
value = value.strip()
|
|
40
|
-
if not value:
|
|
41
|
-
return None
|
|
42
|
-
if value.startswith('"') and value.endswith('"'):
|
|
43
|
-
return value[1:-1]
|
|
44
|
-
if value.startswith("'") and value.endswith("'"):
|
|
45
|
-
return value[1:-1]
|
|
46
|
-
if value.startswith("[") and value.endswith("]"):
|
|
47
|
-
inner = value[1:-1].strip()
|
|
48
|
-
if not inner:
|
|
49
|
-
return []
|
|
50
|
-
return [_coerce_scalar(v) for v in _split_inline_list(inner)]
|
|
51
|
-
if value.startswith("{") and value.endswith("}"):
|
|
52
|
-
# inline dict — rare in hstack frontmatter; treat as string fallback
|
|
53
|
-
return value
|
|
54
|
-
lower = value.lower()
|
|
55
|
-
if lower in ("true", "false"):
|
|
56
|
-
return lower == "true"
|
|
57
|
-
if lower in ("null", "~"):
|
|
58
|
-
return None
|
|
59
|
-
try:
|
|
60
|
-
return int(value)
|
|
61
|
-
except ValueError:
|
|
62
|
-
pass
|
|
63
|
-
try:
|
|
64
|
-
return float(value)
|
|
65
|
-
except ValueError:
|
|
66
|
-
pass
|
|
67
|
-
return value
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _split_inline_list(inner: str) -> list[str]:
|
|
71
|
-
"""Split a comma-separated inline list, respecting nested brackets."""
|
|
72
|
-
out = []
|
|
73
|
-
depth = 0
|
|
74
|
-
current = []
|
|
75
|
-
for ch in inner:
|
|
76
|
-
if ch in "[{":
|
|
77
|
-
depth += 1
|
|
78
|
-
current.append(ch)
|
|
79
|
-
elif ch in "]}":
|
|
80
|
-
depth -= 1
|
|
81
|
-
current.append(ch)
|
|
82
|
-
elif ch == "," and depth == 0:
|
|
83
|
-
out.append("".join(current).strip())
|
|
84
|
-
current = []
|
|
85
|
-
else:
|
|
86
|
-
current.append(ch)
|
|
87
|
-
if current:
|
|
88
|
-
out.append("".join(current).strip())
|
|
89
|
-
return out
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def _indent_of(line: str) -> int:
|
|
93
|
-
n = 0
|
|
94
|
-
for ch in line:
|
|
95
|
-
if ch == " ":
|
|
96
|
-
n += 1
|
|
97
|
-
else:
|
|
98
|
-
break
|
|
99
|
-
return n
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def _parse_simple_yaml(raw: str) -> dict:
|
|
103
|
-
"""Indent-aware fallback parser for the YAML subset hstack frontmatter uses:
|
|
104
|
-
top-level scalars, top-level inline arrays, nested dicts (one level),
|
|
105
|
-
and arrays of dicts (one level). Sufficient for every artifact template.
|
|
106
|
-
"""
|
|
107
|
-
lines = [ln for ln in raw.splitlines() if ln.strip() and not ln.lstrip().startswith("#")]
|
|
108
|
-
out: dict = {}
|
|
109
|
-
i = 0
|
|
110
|
-
while i < len(lines):
|
|
111
|
-
line = lines[i]
|
|
112
|
-
if _indent_of(line) != 0 or ":" not in line:
|
|
113
|
-
i += 1
|
|
114
|
-
continue
|
|
115
|
-
key, _, value = line.partition(":")
|
|
116
|
-
key = key.strip()
|
|
117
|
-
value_inline = value.strip()
|
|
118
|
-
if value_inline:
|
|
119
|
-
out[key] = _coerce_scalar(value_inline)
|
|
120
|
-
i += 1
|
|
121
|
-
continue
|
|
122
|
-
# Multi-line value; look at next-line indent and shape
|
|
123
|
-
if i + 1 >= len(lines):
|
|
124
|
-
out[key] = None
|
|
125
|
-
break
|
|
126
|
-
nxt = lines[i + 1]
|
|
127
|
-
nxt_indent = _indent_of(nxt)
|
|
128
|
-
if nxt_indent == 0:
|
|
129
|
-
out[key] = None
|
|
130
|
-
i += 1
|
|
131
|
-
continue
|
|
132
|
-
if nxt.lstrip().startswith("- "):
|
|
133
|
-
# Array — items may be inline scalars or block dicts.
|
|
134
|
-
items, consumed = _parse_block_sequence(lines, i + 1, nxt_indent)
|
|
135
|
-
out[key] = items
|
|
136
|
-
i = i + 1 + consumed
|
|
137
|
-
else:
|
|
138
|
-
# Block mapping (nested dict)
|
|
139
|
-
sub, consumed = _parse_block_mapping(lines, i + 1, nxt_indent)
|
|
140
|
-
out[key] = sub
|
|
141
|
-
i = i + 1 + consumed
|
|
142
|
-
return out
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def _parse_block_mapping(lines: list[str], start: int, base_indent: int) -> tuple[dict, int]:
|
|
146
|
-
out: dict = {}
|
|
147
|
-
i = start
|
|
148
|
-
consumed = 0
|
|
149
|
-
while i < len(lines):
|
|
150
|
-
line = lines[i]
|
|
151
|
-
ind = _indent_of(line)
|
|
152
|
-
if ind < base_indent:
|
|
153
|
-
break
|
|
154
|
-
if ind > base_indent:
|
|
155
|
-
i += 1
|
|
156
|
-
consumed += 1
|
|
157
|
-
continue
|
|
158
|
-
stripped = line.strip()
|
|
159
|
-
if ":" not in stripped:
|
|
160
|
-
i += 1
|
|
161
|
-
consumed += 1
|
|
162
|
-
continue
|
|
163
|
-
key, _, value = stripped.partition(":")
|
|
164
|
-
key = key.strip()
|
|
165
|
-
value_inline = value.strip()
|
|
166
|
-
if value_inline:
|
|
167
|
-
out[key] = _coerce_scalar(value_inline)
|
|
168
|
-
i += 1
|
|
169
|
-
consumed += 1
|
|
170
|
-
continue
|
|
171
|
-
# Possible nested structure — limited support: keep as None for now
|
|
172
|
-
if i + 1 < len(lines) and _indent_of(lines[i + 1]) > base_indent:
|
|
173
|
-
sub, sub_consumed = _parse_block_mapping(lines, i + 1, _indent_of(lines[i + 1]))
|
|
174
|
-
out[key] = sub
|
|
175
|
-
i = i + 1 + sub_consumed
|
|
176
|
-
consumed += 1 + sub_consumed
|
|
177
|
-
else:
|
|
178
|
-
out[key] = None
|
|
179
|
-
i += 1
|
|
180
|
-
consumed += 1
|
|
181
|
-
return out, consumed
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def _parse_block_sequence(lines: list[str], start: int, base_indent: int) -> tuple[list, int]:
|
|
185
|
-
items: list = []
|
|
186
|
-
i = start
|
|
187
|
-
consumed = 0
|
|
188
|
-
while i < len(lines):
|
|
189
|
-
line = lines[i]
|
|
190
|
-
ind = _indent_of(line)
|
|
191
|
-
if ind < base_indent:
|
|
192
|
-
break
|
|
193
|
-
stripped = line.strip()
|
|
194
|
-
if not stripped.startswith("- "):
|
|
195
|
-
# Continuation of previous item that we already consumed
|
|
196
|
-
i += 1
|
|
197
|
-
consumed += 1
|
|
198
|
-
continue
|
|
199
|
-
rest = stripped[2:]
|
|
200
|
-
if ":" in rest:
|
|
201
|
-
# First key of an inline dict item; merge any indented continuation
|
|
202
|
-
key, _, value = rest.partition(":")
|
|
203
|
-
item: dict = {key.strip(): _coerce_scalar(value)}
|
|
204
|
-
i += 1
|
|
205
|
-
consumed += 1
|
|
206
|
-
# Continuation lines at indent > base_indent (e.g. base_indent + 4)
|
|
207
|
-
while i < len(lines):
|
|
208
|
-
cont = lines[i]
|
|
209
|
-
cont_ind = _indent_of(cont)
|
|
210
|
-
if cont_ind <= base_indent:
|
|
211
|
-
break
|
|
212
|
-
cont_stripped = cont.strip()
|
|
213
|
-
if cont_stripped.startswith("- "):
|
|
214
|
-
break
|
|
215
|
-
if ":" in cont_stripped:
|
|
216
|
-
k, _, v = cont_stripped.partition(":")
|
|
217
|
-
item[k.strip()] = _coerce_scalar(v)
|
|
218
|
-
i += 1
|
|
219
|
-
consumed += 1
|
|
220
|
-
items.append(item)
|
|
221
|
-
else:
|
|
222
|
-
items.append(_coerce_scalar(rest))
|
|
223
|
-
i += 1
|
|
224
|
-
consumed += 1
|
|
225
|
-
return items, consumed
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
def _parse_simple_yaml_old(raw: str) -> dict:
|
|
229
|
-
"""Deprecated — retained for the smoke-test fallback path. Not exported."""
|
|
230
|
-
out: dict = {}
|
|
231
|
-
for line in raw.splitlines():
|
|
232
|
-
line = line.split("#", 1)[0].rstrip()
|
|
233
|
-
if not line or ":" not in line or line.startswith((" ", "\t")):
|
|
234
|
-
continue
|
|
235
|
-
key, _, value = line.partition(":")
|
|
236
|
-
out[key.strip()] = _coerce_scalar(value)
|
|
237
|
-
return out
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
def read_artifact(path: Path) -> tuple[dict, str] | None:
|
|
241
|
-
"""Read one artifact file. Returns (frontmatter, body) or None on error."""
|
|
242
|
-
try:
|
|
243
|
-
text = path.read_text(encoding="utf-8", errors="replace")
|
|
244
|
-
except OSError:
|
|
245
|
-
return None
|
|
246
|
-
return _split_frontmatter(text)
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
def walk_artifacts(hstack_root: Path, pattern: str) -> Iterator[tuple[Path, dict, str]]:
|
|
250
|
-
"""Yield (path, frontmatter, body) for every file under hstack_root matching pattern.
|
|
251
|
-
|
|
252
|
-
pattern is a glob relative to hstack_root, e.g. 'specs/changes/*/spec.md'.
|
|
253
|
-
"""
|
|
254
|
-
for p in sorted(hstack_root.glob(pattern)):
|
|
255
|
-
if not p.is_file():
|
|
256
|
-
continue
|
|
257
|
-
parsed = read_artifact(p)
|
|
258
|
-
if parsed is None:
|
|
259
|
-
continue
|
|
260
|
-
fm, body = parsed
|
|
261
|
-
yield p, fm, body
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
def load_change_artifacts(hstack_root: Path) -> dict[str, dict]:
|
|
265
|
-
"""For every change folder under hstack/specs/changes/, load every artifact's
|
|
266
|
-
frontmatter. Returns {change_id: {artifact_type: {path, fm, body}}}."""
|
|
267
|
-
out: dict[str, dict] = {}
|
|
268
|
-
changes_dir = hstack_root / "specs" / "changes"
|
|
269
|
-
if not changes_dir.is_dir():
|
|
270
|
-
return out
|
|
271
|
-
for change_dir in sorted(changes_dir.iterdir()):
|
|
272
|
-
if not change_dir.is_dir():
|
|
273
|
-
continue
|
|
274
|
-
change_id = change_dir.name
|
|
275
|
-
bucket: dict[str, dict] = {}
|
|
276
|
-
for f in sorted(change_dir.iterdir()):
|
|
277
|
-
if not f.is_file() or not f.name.endswith(".md"):
|
|
278
|
-
continue
|
|
279
|
-
parsed = read_artifact(f)
|
|
280
|
-
if parsed is None:
|
|
281
|
-
continue
|
|
282
|
-
fm, body = parsed
|
|
283
|
-
atype = fm.get("type") or f.stem
|
|
284
|
-
bucket[atype] = {"path": f, "fm": fm, "body": body}
|
|
285
|
-
if bucket:
|
|
286
|
-
out[change_id] = bucket
|
|
287
|
-
return out
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def load_tech_debt(hstack_root: Path) -> list[dict]:
|
|
291
|
-
"""Load every tech-debt artifact's frontmatter + body."""
|
|
292
|
-
out = []
|
|
293
|
-
for path, fm, body in walk_artifacts(hstack_root, "tech-debt/*.md"):
|
|
294
|
-
out.append({"path": path, "fm": fm, "body": body})
|
|
295
|
-
return out
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
def load_adrs(hstack_root: Path) -> list[dict]:
|
|
299
|
-
out = []
|
|
300
|
-
for path, fm, body in walk_artifacts(hstack_root, "adr/ADR-*.md"):
|
|
301
|
-
out.append({"path": path, "fm": fm, "body": body})
|
|
302
|
-
return out
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
def load_module_specs(hstack_root: Path) -> list[dict]:
|
|
306
|
-
"""Module specs live at hstack/specs/<module>/spec.md (excluding changes/)."""
|
|
307
|
-
out = []
|
|
308
|
-
specs_dir = hstack_root / "specs"
|
|
309
|
-
if not specs_dir.is_dir():
|
|
310
|
-
return out
|
|
311
|
-
for module_dir in sorted(specs_dir.iterdir()):
|
|
312
|
-
if not module_dir.is_dir() or module_dir.name == "changes":
|
|
313
|
-
continue
|
|
314
|
-
spec = module_dir / "spec.md"
|
|
315
|
-
if not spec.is_file():
|
|
316
|
-
continue
|
|
317
|
-
parsed = read_artifact(spec)
|
|
318
|
-
if parsed is None:
|
|
319
|
-
continue
|
|
320
|
-
fm, body = parsed
|
|
321
|
-
out.append({"path": spec, "fm": fm, "body": body, "module": module_dir.name})
|
|
322
|
-
return out
|