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.
Files changed (131) hide show
  1. package/CHANGELOG.md +308 -0
  2. package/README.md +90 -154
  3. package/VERSION +1 -1
  4. package/dist/commands/doctor.js +69 -9
  5. package/dist/commands/doctor.js.map +1 -1
  6. package/dist/commands/init.js +7 -3
  7. package/dist/commands/init.js.map +1 -1
  8. package/dist/commands/update.js +17 -4
  9. package/dist/commands/update.js.map +1 -1
  10. package/dist/lib/descriptions.js +160 -0
  11. package/dist/lib/descriptions.js.map +1 -0
  12. package/dist/lib/diff.js +1 -1
  13. package/dist/lib/git.js +16 -0
  14. package/dist/lib/git.js.map +1 -1
  15. package/dist/lib/wire.js +274 -142
  16. package/dist/lib/wire.js.map +1 -1
  17. package/dist/manifest.js +49 -22
  18. package/dist/manifest.js.map +1 -1
  19. package/package.json +4 -3
  20. package/template/.claude/agents/adversarial-reviewer.md +42 -98
  21. package/template/.claude/agents/app-architect.md +44 -133
  22. package/template/.claude/agents/data-architect.md +46 -127
  23. package/template/.claude/agents/test-strategist.md +39 -109
  24. package/template/.claude/skills/hstack-adr-new/SKILL.md +23 -100
  25. package/template/.claude/skills/hstack-adversarial-review/SKILL.md +47 -174
  26. package/template/.claude/skills/hstack-adversarial-review/references/finding-categories.md +134 -0
  27. package/template/.claude/skills/hstack-app-architecture/SKILL.md +43 -95
  28. package/template/.claude/skills/hstack-commit/SKILL.md +28 -97
  29. package/template/.claude/skills/hstack-data-architecture/SKILL.md +34 -82
  30. package/template/.claude/skills/hstack-promote/SKILL.md +55 -0
  31. package/template/.claude/skills/hstack-story/SKILL.md +58 -0
  32. package/template/.claude/skills/hstack-test-audit/SKILL.md +64 -0
  33. package/template/.claude/skills/hstack-wrap/SKILL.md +48 -0
  34. package/template/.claude/skills/hstack-wrap/references/pr-description.md +45 -0
  35. package/template/KERNEL.md +167 -0
  36. package/template/templates/adr.md +9 -18
  37. package/template/templates/app-architecture.md +38 -50
  38. package/template/templates/data-architecture.md +39 -93
  39. package/template/templates/infrastructure.md +54 -148
  40. package/template/templates/roadmap.md +35 -0
  41. package/template/templates/story.md +7 -13
  42. package/template/templates/tech-debt.md +2 -37
  43. package/template/templates/tech-stack.md +2 -12
  44. package/template/.claude/agents/data-specialist.md +0 -126
  45. package/template/.claude/agents/implementer.md +0 -154
  46. package/template/.claude/agents/kernel-fit-analyst.md +0 -190
  47. package/template/.claude/agents/planner.md +0 -110
  48. package/template/.claude/agents/product-discovery.md +0 -165
  49. package/template/.claude/agents/product-manager.md +0 -111
  50. package/template/.claude/agents/researcher.md +0 -123
  51. package/template/.claude/agents/security-reviewer.md +0 -123
  52. package/template/.claude/agents/spec-author.md +0 -123
  53. package/template/.claude/agents/stack-architect.md +0 -137
  54. package/template/.claude/agents/ui-ux-briefer.md +0 -105
  55. package/template/.claude/agents/verifier.md +0 -113
  56. package/template/.claude/skills/hstack-branch/SKILL.md +0 -103
  57. package/template/.claude/skills/hstack-brownfield-init/SKILL.md +0 -158
  58. package/template/.claude/skills/hstack-change-new/SKILL.md +0 -111
  59. package/template/.claude/skills/hstack-change-plan/SKILL.md +0 -119
  60. package/template/.claude/skills/hstack-configure/SKILL.md +0 -126
  61. package/template/.claude/skills/hstack-coord/SKILL.md +0 -131
  62. package/template/.claude/skills/hstack-data-review/SKILL.md +0 -132
  63. package/template/.claude/skills/hstack-finalize/SKILL.md +0 -159
  64. package/template/.claude/skills/hstack-flag/SKILL.md +0 -138
  65. package/template/.claude/skills/hstack-greenfield-init/SKILL.md +0 -175
  66. package/template/.claude/skills/hstack-help/SKILL.md +0 -179
  67. package/template/.claude/skills/hstack-implement/SKILL.md +0 -185
  68. package/template/.claude/skills/hstack-kernel-fit-promote/SKILL.md +0 -164
  69. package/template/.claude/skills/hstack-kernel-fit-scan/SKILL.md +0 -197
  70. package/template/.claude/skills/hstack-kernel-fit-triage/SKILL.md +0 -159
  71. package/template/.claude/skills/hstack-module-spec/SKILL.md +0 -105
  72. package/template/.claude/skills/hstack-product-discovery/SKILL.md +0 -101
  73. package/template/.claude/skills/hstack-research/SKILL.md +0 -145
  74. package/template/.claude/skills/hstack-scaffold/SKILL.md +0 -140
  75. package/template/.claude/skills/hstack-security-review/SKILL.md +0 -133
  76. package/template/.claude/skills/hstack-ship/SKILL.md +0 -129
  77. package/template/.claude/skills/hstack-stack-decide/SKILL.md +0 -108
  78. package/template/.claude/skills/hstack-story-draft/SKILL.md +0 -119
  79. package/template/.claude/skills/hstack-tech-debt-new/SKILL.md +0 -122
  80. package/template/.claude/skills/hstack-tech-debt-resolve/SKILL.md +0 -158
  81. package/template/.claude/skills/hstack-tech-debt-stale/SKILL.md +0 -113
  82. package/template/.claude/skills/hstack-tech-debt-wontfix/SKILL.md +0 -104
  83. package/template/.claude/skills/hstack-telemetry/SKILL.md +0 -98
  84. package/template/.claude/skills/hstack-test-plan/SKILL.md +0 -182
  85. package/template/.claude/skills/hstack-ui-brief/SKILL.md +0 -108
  86. package/template/.claude/skills/hstack-verify/SKILL.md +0 -150
  87. package/template/CLAUDE.md +0 -443
  88. package/template/scripts/coord/coord_scan.py +0 -681
  89. package/template/scripts/telemetry/__init__.py +0 -6
  90. package/template/scripts/telemetry/insights/__init__.py +0 -0
  91. package/template/scripts/telemetry/insights/contract_drift.py +0 -137
  92. package/template/scripts/telemetry/insights/kernel_fit.py +0 -438
  93. package/template/scripts/telemetry/insights/overengineering.py +0 -115
  94. package/template/scripts/telemetry/insights/quality_outcomes.py +0 -131
  95. package/template/scripts/telemetry/insights/token_economics.py +0 -129
  96. package/template/scripts/telemetry/insights/workflow_shape.py +0 -198
  97. package/template/scripts/telemetry/parsers/__init__.py +0 -0
  98. package/template/scripts/telemetry/parsers/bodies.py +0 -87
  99. package/template/scripts/telemetry/parsers/commits.py +0 -219
  100. package/template/scripts/telemetry/parsers/frontmatter.py +0 -322
  101. package/template/scripts/telemetry/parsers/transcripts.py +0 -181
  102. package/template/scripts/telemetry/render.py +0 -395
  103. package/template/scripts/telemetry/report.py +0 -138
  104. package/template/scripts/telemetry/run_kernel_fit.py +0 -91
  105. package/template/templates/adversarial-review.md +0 -54
  106. package/template/templates/bootstrap.md +0 -105
  107. package/template/templates/change-spec.md +0 -82
  108. package/template/templates/ci-cd.md +0 -27
  109. package/template/templates/coord-message.md +0 -55
  110. package/template/templates/data-review.md +0 -54
  111. package/template/templates/discovery/brainstorm.md +0 -69
  112. package/template/templates/discovery/forcing-questions.md +0 -76
  113. package/template/templates/discovery/project-brief.md +0 -53
  114. package/template/templates/figma-handoff.md +0 -38
  115. package/template/templates/glossary.md +0 -20
  116. package/template/templates/hardening-checklist.md +0 -73
  117. package/template/templates/incident-runbook.md +0 -57
  118. package/template/templates/kernel-fit-finding.md +0 -63
  119. package/template/templates/kernel-fit-flag.md +0 -52
  120. package/template/templates/module-spec.md +0 -49
  121. package/template/templates/mvp-scope.md +0 -34
  122. package/template/templates/persona.md +0 -38
  123. package/template/templates/plan.md +0 -49
  124. package/template/templates/product-brief.md +0 -56
  125. package/template/templates/security-review.md +0 -63
  126. package/template/templates/telemetry-sidecar.md +0 -184
  127. package/template/templates/test-plan.md +0 -119
  128. package/template/templates/threat-model.md +0 -54
  129. package/template/templates/ui-brief.md +0 -49
  130. package/template/templates/verification.md +0 -63
  131. 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