hstack 0.16.0 → 0.18.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 (134) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +76 -163
  3. package/VERSION +1 -1
  4. package/dist/commands/doctor.js +18 -8
  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 +9 -2
  9. package/dist/commands/update.js.map +1 -1
  10. package/dist/lib/descriptions.js +10 -17
  11. package/dist/lib/descriptions.js.map +1 -1
  12. package/dist/lib/wire.js +166 -138
  13. package/dist/lib/wire.js.map +1 -1
  14. package/dist/manifest.js +41 -29
  15. package/dist/manifest.js.map +1 -1
  16. package/package.json +3 -4
  17. package/template/.claude/agents/adversarial-reviewer.md +44 -50
  18. package/template/.claude/agents/app-architect.md +44 -96
  19. package/template/.claude/agents/data-architect.md +46 -89
  20. package/template/.claude/agents/test-strategist.md +39 -68
  21. package/template/.claude/skills/hstack-adr-new/SKILL.md +23 -73
  22. package/template/.claude/skills/hstack-adversarial-review/SKILL.md +46 -152
  23. package/template/.claude/skills/hstack-adversarial-review/references/finding-categories.md +89 -103
  24. package/template/.claude/skills/hstack-app-architecture/SKILL.md +43 -68
  25. package/template/.claude/skills/hstack-commit/SKILL.md +27 -68
  26. package/template/.claude/skills/hstack-data-architecture/SKILL.md +34 -56
  27. package/template/.claude/skills/hstack-promote/SKILL.md +55 -0
  28. package/template/.claude/skills/hstack-story/SKILL.md +58 -0
  29. package/template/.claude/skills/hstack-test-audit/SKILL.md +64 -0
  30. package/template/.claude/skills/hstack-wrap/SKILL.md +50 -0
  31. package/template/.claude/skills/hstack-wrap/references/pr-description.md +45 -0
  32. package/template/KERNEL.md +75 -318
  33. package/template/templates/adr.md +2 -18
  34. package/template/templates/app-architecture.md +38 -50
  35. package/template/templates/code-standards.md +107 -0
  36. package/template/templates/data-architecture.md +38 -89
  37. package/template/templates/eslint-clean-code.mjs +322 -0
  38. package/template/templates/infrastructure.md +54 -148
  39. package/template/templates/roadmap.md +6 -12
  40. package/template/templates/story.md +7 -13
  41. package/template/templates/tech-debt.md +2 -37
  42. package/template/templates/tech-stack.md +2 -12
  43. package/template/.claude/agents/data-specialist.md +0 -81
  44. package/template/.claude/agents/implementer.md +0 -97
  45. package/template/.claude/agents/kernel-fit-analyst.md +0 -129
  46. package/template/.claude/agents/planner.md +0 -75
  47. package/template/.claude/agents/product-discovery.md +0 -129
  48. package/template/.claude/agents/product-manager.md +0 -76
  49. package/template/.claude/agents/researcher.md +0 -87
  50. package/template/.claude/agents/security-reviewer.md +0 -88
  51. package/template/.claude/agents/spec-author.md +0 -89
  52. package/template/.claude/agents/stack-architect.md +0 -108
  53. package/template/.claude/agents/ui-ux-briefer.md +0 -75
  54. package/template/.claude/agents/verifier.md +0 -81
  55. package/template/.claude/skills/hstack-branch/SKILL.md +0 -76
  56. package/template/.claude/skills/hstack-brownfield-init/SKILL.md +0 -131
  57. package/template/.claude/skills/hstack-change-new/SKILL.md +0 -84
  58. package/template/.claude/skills/hstack-change-plan/SKILL.md +0 -108
  59. package/template/.claude/skills/hstack-configure/SKILL.md +0 -99
  60. package/template/.claude/skills/hstack-coord/SKILL.md +0 -95
  61. package/template/.claude/skills/hstack-data-review/SKILL.md +0 -93
  62. package/template/.claude/skills/hstack-finalize/SKILL.md +0 -140
  63. package/template/.claude/skills/hstack-flag/SKILL.md +0 -99
  64. package/template/.claude/skills/hstack-greenfield-init/SKILL.md +0 -148
  65. package/template/.claude/skills/hstack-help/SKILL.md +0 -153
  66. package/template/.claude/skills/hstack-implement/SKILL.md +0 -155
  67. package/template/.claude/skills/hstack-kernel-fit-promote/SKILL.md +0 -125
  68. package/template/.claude/skills/hstack-kernel-fit-scan/SKILL.md +0 -142
  69. package/template/.claude/skills/hstack-kernel-fit-scan/references/slack-setup.md +0 -42
  70. package/template/.claude/skills/hstack-kernel-fit-triage/SKILL.md +0 -121
  71. package/template/.claude/skills/hstack-module-spec/SKILL.md +0 -78
  72. package/template/.claude/skills/hstack-product-discovery/SKILL.md +0 -75
  73. package/template/.claude/skills/hstack-research/SKILL.md +0 -115
  74. package/template/.claude/skills/hstack-scaffold/SKILL.md +0 -113
  75. package/template/.claude/skills/hstack-security-review/SKILL.md +0 -95
  76. package/template/.claude/skills/hstack-ship/SKILL.md +0 -119
  77. package/template/.claude/skills/hstack-stack-decide/SKILL.md +0 -81
  78. package/template/.claude/skills/hstack-story-draft/SKILL.md +0 -92
  79. package/template/.claude/skills/hstack-tech-debt-new/SKILL.md +0 -95
  80. package/template/.claude/skills/hstack-tech-debt-resolve/SKILL.md +0 -123
  81. package/template/.claude/skills/hstack-tech-debt-stale/SKILL.md +0 -86
  82. package/template/.claude/skills/hstack-tech-debt-wontfix/SKILL.md +0 -77
  83. package/template/.claude/skills/hstack-telemetry/SKILL.md +0 -73
  84. package/template/.claude/skills/hstack-test-plan/SKILL.md +0 -159
  85. package/template/.claude/skills/hstack-ui-brief/SKILL.md +0 -81
  86. package/template/.claude/skills/hstack-verify/SKILL.md +0 -128
  87. package/template/scripts/compute-merge-readiness.mjs +0 -780
  88. package/template/scripts/coord/coord_scan.py +0 -681
  89. package/template/scripts/run-gates.sh +0 -388
  90. package/template/scripts/telemetry/__init__.py +0 -6
  91. package/template/scripts/telemetry/insights/__init__.py +0 -0
  92. package/template/scripts/telemetry/insights/contract_drift.py +0 -137
  93. package/template/scripts/telemetry/insights/kernel_fit.py +0 -438
  94. package/template/scripts/telemetry/insights/overengineering.py +0 -115
  95. package/template/scripts/telemetry/insights/quality_outcomes.py +0 -131
  96. package/template/scripts/telemetry/insights/token_economics.py +0 -302
  97. package/template/scripts/telemetry/insights/workflow_shape.py +0 -198
  98. package/template/scripts/telemetry/parsers/__init__.py +0 -0
  99. package/template/scripts/telemetry/parsers/bodies.py +0 -87
  100. package/template/scripts/telemetry/parsers/commits.py +0 -219
  101. package/template/scripts/telemetry/parsers/frontmatter.py +0 -322
  102. package/template/scripts/telemetry/parsers/sidecars.py +0 -61
  103. package/template/scripts/telemetry/parsers/transcripts.py +0 -294
  104. package/template/scripts/telemetry/render.py +0 -460
  105. package/template/scripts/telemetry/report.py +0 -150
  106. package/template/scripts/telemetry/run_kernel_fit.py +0 -95
  107. package/template/scripts/telemetry/session_id.py +0 -139
  108. package/template/scripts/validate-spec.mjs +0 -3303
  109. package/template/templates/adversarial-review.md +0 -54
  110. package/template/templates/bootstrap.md +0 -105
  111. package/template/templates/change-spec.md +0 -82
  112. package/template/templates/ci-cd.md +0 -41
  113. package/template/templates/coord-message.md +0 -56
  114. package/template/templates/data-review.md +0 -54
  115. package/template/templates/discovery/brainstorm.md +0 -69
  116. package/template/templates/discovery/forcing-questions.md +0 -76
  117. package/template/templates/discovery/project-brief.md +0 -53
  118. package/template/templates/figma-handoff.md +0 -38
  119. package/template/templates/glossary.md +0 -20
  120. package/template/templates/hardening-checklist.md +0 -73
  121. package/template/templates/incident-runbook.md +0 -57
  122. package/template/templates/kernel-fit-finding.md +0 -63
  123. package/template/templates/kernel-fit-flag.md +0 -52
  124. package/template/templates/module-spec.md +0 -49
  125. package/template/templates/persona.md +0 -38
  126. package/template/templates/plan.md +0 -53
  127. package/template/templates/product-brief.md +0 -56
  128. package/template/templates/security-review.md +0 -63
  129. package/template/templates/telemetry-sidecar.md +0 -227
  130. package/template/templates/test-plan.md +0 -119
  131. package/template/templates/threat-model.md +0 -54
  132. package/template/templates/ui-brief.md +0 -49
  133. package/template/templates/verification.md +0 -63
  134. 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
@@ -1,61 +0,0 @@
1
- """Load the per-change telemetry sidecars written by the five emitting Skills.
2
-
3
- Sidecars live at `hstack/specs/changes/<change-id>/.telemetry/*.json` and are
4
- derivative, gitignored, and never authoritative (ADR-0001). This parser only
5
- reads them; a malformed or absent sidecar is skipped, never repaired.
6
-
7
- Schema versions (see `templates/telemetry-sidecar.md`):
8
- 1 — no phase window. Reads as *unmeasured*: `phase_usage` returns None.
9
- 2 — carries `session_id`, `phase_opened_at`, `phase_closed_at` (ADR-0009).
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import json
15
- from pathlib import Path
16
-
17
-
18
- def load_sidecars(hstack_root: Path) -> list[dict]:
19
- """Every sidecar under every change folder, sorted by change then filename.
20
-
21
- Each entry: {path, change_id, file, skill, phase_id, schema_version, data}.
22
- `skill` and `phase_id` fall back to the filename when the payload omits them
23
- (`implement-<phase-id>.json`), so a hand-truncated sidecar still groups.
24
- """
25
- out: list[dict] = []
26
- changes_dir = hstack_root / "specs" / "changes"
27
- if not changes_dir.is_dir():
28
- return out
29
- for change_dir in sorted(changes_dir.iterdir()):
30
- if not change_dir.is_dir():
31
- continue
32
- telemetry_dir = change_dir / ".telemetry"
33
- if not telemetry_dir.is_dir():
34
- continue
35
- for f in sorted(telemetry_dir.glob("*.json")):
36
- data = _read_json(f)
37
- if data is None:
38
- continue
39
- stem = f.stem
40
- skill = data.get("skill") or f"hstack-{stem.split('-')[0]}"
41
- phase_id = data.get("phase_id")
42
- if phase_id is None and stem.startswith("implement-"):
43
- phase_id = stem[len("implement-"):]
44
- out.append({
45
- "path": f,
46
- "file": f.name,
47
- "change_id": data.get("change_id") or change_dir.name,
48
- "skill": skill,
49
- "phase_id": phase_id,
50
- "schema_version": data.get("schema_version"),
51
- "data": data,
52
- })
53
- return out
54
-
55
-
56
- def _read_json(path: Path) -> dict | None:
57
- try:
58
- loaded = json.loads(path.read_text(encoding="utf-8", errors="replace"))
59
- except (OSError, json.JSONDecodeError):
60
- return None
61
- return loaded if isinstance(loaded, dict) else None