hstack 0.1.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 (99) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +169 -0
  4. package/VERSION +1 -0
  5. package/dist/cli.js +55 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/commands/doctor.js +155 -0
  8. package/dist/commands/doctor.js.map +1 -0
  9. package/dist/commands/init.js +87 -0
  10. package/dist/commands/init.js.map +1 -0
  11. package/dist/commands/update.js +118 -0
  12. package/dist/commands/update.js.map +1 -0
  13. package/dist/lib/diff.js +120 -0
  14. package/dist/lib/diff.js.map +1 -0
  15. package/dist/lib/git.js +26 -0
  16. package/dist/lib/git.js.map +1 -0
  17. package/dist/lib/paths.js +35 -0
  18. package/dist/lib/paths.js.map +1 -0
  19. package/dist/lib/wire.js +314 -0
  20. package/dist/lib/wire.js.map +1 -0
  21. package/dist/manifest.js +53 -0
  22. package/dist/manifest.js.map +1 -0
  23. package/package.json +54 -0
  24. package/template/.claude/agents/adversarial-reviewer.md +123 -0
  25. package/template/.claude/agents/data-specialist.md +126 -0
  26. package/template/.claude/agents/implementer.md +154 -0
  27. package/template/.claude/agents/planner.md +110 -0
  28. package/template/.claude/agents/product-manager.md +111 -0
  29. package/template/.claude/agents/researcher.md +123 -0
  30. package/template/.claude/agents/security-reviewer.md +123 -0
  31. package/template/.claude/agents/spec-author.md +118 -0
  32. package/template/.claude/agents/test-strategist.md +129 -0
  33. package/template/.claude/agents/ui-ux-briefer.md +105 -0
  34. package/template/.claude/agents/verifier.md +109 -0
  35. package/template/.claude/skills/hstack-adr-new/SKILL.md +118 -0
  36. package/template/.claude/skills/hstack-adversarial-review/SKILL.md +187 -0
  37. package/template/.claude/skills/hstack-branch/SKILL.md +103 -0
  38. package/template/.claude/skills/hstack-change-new/SKILL.md +109 -0
  39. package/template/.claude/skills/hstack-change-plan/SKILL.md +119 -0
  40. package/template/.claude/skills/hstack-commit/SKILL.md +122 -0
  41. package/template/.claude/skills/hstack-configure/SKILL.md +126 -0
  42. package/template/.claude/skills/hstack-data-review/SKILL.md +132 -0
  43. package/template/.claude/skills/hstack-finalize/SKILL.md +159 -0
  44. package/template/.claude/skills/hstack-help/SKILL.md +174 -0
  45. package/template/.claude/skills/hstack-implement/SKILL.md +185 -0
  46. package/template/.claude/skills/hstack-init/SKILL.md +152 -0
  47. package/template/.claude/skills/hstack-module-spec/SKILL.md +105 -0
  48. package/template/.claude/skills/hstack-research/SKILL.md +145 -0
  49. package/template/.claude/skills/hstack-security-review/SKILL.md +133 -0
  50. package/template/.claude/skills/hstack-ship/SKILL.md +128 -0
  51. package/template/.claude/skills/hstack-story-draft/SKILL.md +117 -0
  52. package/template/.claude/skills/hstack-tech-debt-new/SKILL.md +122 -0
  53. package/template/.claude/skills/hstack-tech-debt-resolve/SKILL.md +158 -0
  54. package/template/.claude/skills/hstack-tech-debt-stale/SKILL.md +113 -0
  55. package/template/.claude/skills/hstack-tech-debt-wontfix/SKILL.md +104 -0
  56. package/template/.claude/skills/hstack-telemetry/SKILL.md +96 -0
  57. package/template/.claude/skills/hstack-test-plan/SKILL.md +182 -0
  58. package/template/.claude/skills/hstack-ui-brief/SKILL.md +108 -0
  59. package/template/.claude/skills/hstack-verify/SKILL.md +139 -0
  60. package/template/CLAUDE.md +390 -0
  61. package/template/scripts/telemetry/__init__.py +6 -0
  62. package/template/scripts/telemetry/insights/__init__.py +0 -0
  63. package/template/scripts/telemetry/insights/contract_drift.py +137 -0
  64. package/template/scripts/telemetry/insights/overengineering.py +115 -0
  65. package/template/scripts/telemetry/insights/quality_outcomes.py +131 -0
  66. package/template/scripts/telemetry/insights/token_economics.py +129 -0
  67. package/template/scripts/telemetry/insights/workflow_shape.py +198 -0
  68. package/template/scripts/telemetry/parsers/__init__.py +0 -0
  69. package/template/scripts/telemetry/parsers/bodies.py +87 -0
  70. package/template/scripts/telemetry/parsers/commits.py +219 -0
  71. package/template/scripts/telemetry/parsers/frontmatter.py +322 -0
  72. package/template/scripts/telemetry/parsers/transcripts.py +181 -0
  73. package/template/scripts/telemetry/render.py +311 -0
  74. package/template/scripts/telemetry/report.py +112 -0
  75. package/template/templates/adr.md +38 -0
  76. package/template/templates/adversarial-review.md +54 -0
  77. package/template/templates/change-spec.md +80 -0
  78. package/template/templates/ci-cd.md +27 -0
  79. package/template/templates/data-architecture.md +35 -0
  80. package/template/templates/data-review.md +54 -0
  81. package/template/templates/figma-handoff.md +38 -0
  82. package/template/templates/glossary.md +20 -0
  83. package/template/templates/hardening-checklist.md +73 -0
  84. package/template/templates/incident-runbook.md +57 -0
  85. package/template/templates/infrastructure.md +190 -0
  86. package/template/templates/module-spec.md +49 -0
  87. package/template/templates/mvp-scope.md +34 -0
  88. package/template/templates/persona.md +38 -0
  89. package/template/templates/plan.md +49 -0
  90. package/template/templates/security-review.md +63 -0
  91. package/template/templates/story.md +37 -0
  92. package/template/templates/tech-debt.md +61 -0
  93. package/template/templates/tech-stack.md +41 -0
  94. package/template/templates/telemetry-sidecar.md +184 -0
  95. package/template/templates/test-plan.md +119 -0
  96. package/template/templates/threat-model.md +54 -0
  97. package/template/templates/ui-brief.md +49 -0
  98. package/template/templates/verification.md +63 -0
  99. package/template/templates/vision.md +34 -0
@@ -0,0 +1,181 @@
1
+ """Walk Claude Code transcript JSONL files for token economics and halt signals.
2
+
3
+ Generalized from scripts/audit-token-usage.py. Same cost-score weighting; same
4
+ classify-by-Skill heuristic. Adds: halt-sentinel detection, per-session
5
+ start/end timestamps, cwd grouping for Conductor worktree dedup.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from collections import defaultdict
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Iterable
16
+
17
+
18
+ PROJECTS_ROOT = Path.home() / ".claude" / "projects"
19
+
20
+ HSTACK_SLASH = re.compile(r"/hstack:([a-z][a-z0-9\-]*)")
21
+ HSTACK_CMD_TAG = re.compile(r"<command-name>/?hstack:?-?([a-z][a-z0-9\-]*)</command-name>")
22
+ SUBAGENT_TYPE = re.compile(r'"subagent_type"\s*:\s*"([a-z][a-z0-9\-]*)"')
23
+ HALT_SENTINEL = re.compile(r"HSTACK-HALT:\s*reason=([a-z-]+)", re.IGNORECASE)
24
+
25
+
26
+ def message_text(msg) -> str:
27
+ if msg is None:
28
+ return ""
29
+ if isinstance(msg, str):
30
+ return msg
31
+ content = msg.get("content") if isinstance(msg, dict) else None
32
+ if content is None:
33
+ return ""
34
+ if isinstance(content, str):
35
+ return content
36
+ if isinstance(content, list):
37
+ parts = []
38
+ for blk in content:
39
+ if isinstance(blk, dict):
40
+ if blk.get("type") == "text" and isinstance(blk.get("text"), str):
41
+ parts.append(blk["text"])
42
+ else:
43
+ parts.append(json.dumps(blk, default=str))
44
+ else:
45
+ parts.append(str(blk))
46
+ return "\n".join(parts)
47
+ return json.dumps(content, default=str)
48
+
49
+
50
+ def discover_transcript_files(repo_paths: Iterable[Path]) -> list[Path]:
51
+ """Discover transcript jsonl files for the given consuming-repo paths.
52
+
53
+ Each repo_path corresponds to a Claude Code project dir (`-Users-...` form).
54
+ We accept either real repo paths (we convert) or already-converted prefixes.
55
+ """
56
+ if not PROJECTS_ROOT.exists():
57
+ return []
58
+ prefixes = []
59
+ for p in repo_paths:
60
+ p_str = str(Path(p).resolve()).replace("/", "-")
61
+ prefixes.append(p_str)
62
+ # Also match Conductor worktrees of the repo's basename
63
+ basename = Path(p).name
64
+ prefixes.append(f"-Users-hugoganet-conductor-workspaces-{basename}-")
65
+ files = []
66
+ for d in PROJECTS_ROOT.iterdir():
67
+ if not d.is_dir():
68
+ continue
69
+ name = d.name
70
+ if not any(name.startswith(prefix) or name == prefix.rstrip("-") for prefix in prefixes):
71
+ continue
72
+ for f in d.glob("*.jsonl"):
73
+ files.append(f)
74
+ return files
75
+
76
+
77
+ def load_session(path: Path) -> list[dict]:
78
+ out = []
79
+ try:
80
+ with open(path, "r", errors="replace") as fh:
81
+ for line in fh:
82
+ line = line.strip()
83
+ if not line:
84
+ continue
85
+ try:
86
+ out.append(json.loads(line))
87
+ except json.JSONDecodeError:
88
+ continue
89
+ except OSError:
90
+ return []
91
+ return out
92
+
93
+
94
+ def classify_session(records: list[dict]) -> tuple[str | None, set[str], list[str]]:
95
+ """Return (skill_name, set of subagent types invoked, halt reasons)."""
96
+ skill = None
97
+ subagents: set[str] = set()
98
+ halts: list[str] = []
99
+ for r in records:
100
+ t = r.get("type")
101
+ if t == "user" and skill is None:
102
+ text = message_text(r.get("message"))
103
+ m = HSTACK_CMD_TAG.search(text) or HSTACK_SLASH.search(text)
104
+ if m:
105
+ skill = m.group(1)
106
+ elif t == "assistant":
107
+ text = message_text(r.get("message"))
108
+ for sa in SUBAGENT_TYPE.findall(text):
109
+ subagents.add(sa)
110
+ for h in HALT_SENTINEL.findall(text):
111
+ halts.append(h.lower())
112
+ return skill, subagents, halts
113
+
114
+
115
+ def session_bounds(records: list[dict]) -> tuple[datetime | None, datetime | None]:
116
+ first = last = None
117
+ for r in records:
118
+ ts = r.get("timestamp")
119
+ if not ts:
120
+ continue
121
+ try:
122
+ dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
123
+ except (ValueError, AttributeError):
124
+ continue
125
+ if first is None or dt < first:
126
+ first = dt
127
+ if last is None or dt > last:
128
+ last = dt
129
+ return first, last
130
+
131
+
132
+ def tally_usage(records: list[dict]) -> dict[str, int]:
133
+ totals: dict[str, int] = defaultdict(int)
134
+ for r in records:
135
+ if r.get("type") != "assistant":
136
+ continue
137
+ usage = (r.get("message") or {}).get("usage") or {}
138
+ for k in ("input_tokens", "cache_creation_input_tokens",
139
+ "cache_read_input_tokens", "output_tokens"):
140
+ totals[k] += usage.get(k, 0) or 0
141
+ totals["turns"] += 1
142
+ return dict(totals)
143
+
144
+
145
+ def cost_score(totals: dict[str, int]) -> int:
146
+ """Same weighting as scripts/audit-token-usage.py: input + 1.25*cache_creation
147
+ + 0.10*cache_read + 5*output. Approximates billable $-weight for ranking."""
148
+ return (
149
+ totals.get("input_tokens", 0)
150
+ + int(totals.get("cache_creation_input_tokens", 0) * 1.25)
151
+ + int(totals.get("cache_read_input_tokens", 0) * 0.10)
152
+ + totals.get("output_tokens", 0) * 5
153
+ )
154
+
155
+
156
+ def collect_session_rows(repo_paths: Iterable[Path], since: datetime | None = None) -> list[dict]:
157
+ """Walk every transcript for the given repos and return one row per session."""
158
+ out = []
159
+ for f in discover_transcript_files(repo_paths):
160
+ recs = load_session(f)
161
+ if not recs:
162
+ continue
163
+ skill, subagents, halts = classify_session(recs)
164
+ first, last = session_bounds(recs)
165
+ if since is not None and last is not None and last < since:
166
+ continue
167
+ totals = tally_usage(recs)
168
+ if totals.get("turns", 0) == 0:
169
+ continue
170
+ out.append({
171
+ "file": f,
172
+ "project_dir": f.parent.name,
173
+ "skill": skill,
174
+ "subagents": sorted(subagents),
175
+ "halt_reasons": halts,
176
+ "started_at": first,
177
+ "ended_at": last,
178
+ "totals": totals,
179
+ "cost_score": cost_score(totals),
180
+ })
181
+ return out
@@ -0,0 +1,311 @@
1
+ """Render the metrics dict to a markdown report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+ from typing import Any
7
+
8
+
9
+ def render_report(metrics: dict, repo_name: str, window_days: int | None) -> str:
10
+ lines: list[str] = []
11
+ lines.append(f"# hstack-telemetry — {repo_name}")
12
+ lines.append("")
13
+ lines.append(f"_Generated {date.today().isoformat()}; window: "
14
+ f"{'last ' + str(window_days) + ' days' if window_days else 'all history'}._")
15
+ lines.append("")
16
+ lines.append("Retrospective observability for the hstack workflow. All metrics are derived "
17
+ "from on-disk artifacts (frontmatter + bodies), git history, and Claude Code "
18
+ "transcripts. This report is read-only; the artifacts are the source of truth.")
19
+ lines.append("")
20
+
21
+ _render_token_economics(lines, metrics.get("token_economics", {}))
22
+ _render_workflow_shape(lines, metrics.get("workflow_shape", {}))
23
+ _render_quality_outcomes(lines, metrics.get("quality_outcomes", {}))
24
+ _render_overengineering(lines, metrics.get("overengineering", {}))
25
+ _render_contract_drift(lines, metrics.get("contract_drift", {}))
26
+
27
+ _render_watch_list(lines, metrics)
28
+
29
+ return "\n".join(lines) + "\n"
30
+
31
+
32
+ # ---------------- helpers ----------------
33
+
34
+ def _h(lines: list[str], level: int, text: str) -> None:
35
+ lines.append("#" * level + " " + text)
36
+ lines.append("")
37
+
38
+
39
+ def _p(lines: list[str], text: str) -> None:
40
+ lines.append(text)
41
+ lines.append("")
42
+
43
+
44
+ def _table(lines: list[str], headers: list[str], rows: list[list[Any]]) -> None:
45
+ if not rows:
46
+ lines.append("_(no data)_")
47
+ lines.append("")
48
+ return
49
+ lines.append("| " + " | ".join(headers) + " |")
50
+ lines.append("|" + "|".join("---" for _ in headers) + "|")
51
+ for r in rows:
52
+ cells = ["" if v is None else str(v) for v in r]
53
+ lines.append("| " + " | ".join(cells) + " |")
54
+ lines.append("")
55
+
56
+
57
+ # ---------------- buckets ----------------
58
+
59
+ def _render_token_economics(lines: list[str], te: dict) -> None:
60
+ _h(lines, 2, "Token economics")
61
+
62
+ te1 = te.get("te_1_cost_per_change", {})
63
+ _h(lines, 3, "TE-1 — cost-score per Skill (proxy for per-change cost)")
64
+ _p(lines, te1.get("note", ""))
65
+ _table(
66
+ lines,
67
+ ["skill", "sessions", "cost-score total", "cost-score / session"],
68
+ [[r["skill"], r["sessions"], r["cost_score_total"], r["cost_score_mean"]]
69
+ for r in te1.get("rows", [])],
70
+ )
71
+
72
+ te2 = te.get("te_2_cache_hit_per_subagent", {})
73
+ _h(lines, 3, "TE-2 — cache-hit ratio (per Skill, v1 coarse grain)")
74
+ _p(lines, "ratio = cache_read / (cache_read + cache_creation). Below 0.5 → context "
75
+ "is being rebuilt rather than reused. Above 0.8 → cross-session reuse is healthy.")
76
+ _table(
77
+ lines,
78
+ ["skill", "turns", "cache_read", "cache_creation", "ratio"],
79
+ [[r["skill"], r["turns"], r["cache_read"], r["cache_creation"],
80
+ (f"{r['ratio']:.2%}" if r["ratio"] is not None else "-")]
81
+ for r in te2.get("rows", [])],
82
+ )
83
+
84
+ te3 = te.get("te_3_subagent_entry_tax", {})
85
+ _h(lines, 3, "TE-3 — subagent entry-tax amortization")
86
+ _p(lines, te3.get("note", ""))
87
+ _table(
88
+ lines,
89
+ ["subagent", "appearances", "host cache_creation total", "/ invocation"],
90
+ [[r["subagent"], r["appearances"], r["host_cache_creation_total"],
91
+ r["host_cache_creation_per_invocation"]]
92
+ for r in te3.get("rows", [])],
93
+ )
94
+
95
+
96
+ def _render_workflow_shape(lines: list[str], ws: dict) -> None:
97
+ _h(lines, 2, "Workflow shape")
98
+
99
+ ws1 = ws.get("ws_1_phase_duration", {})
100
+ _h(lines, 3, "WS-1 — phase duration (between successive implement commits)")
101
+ summary = ws1.get("summary")
102
+ if summary:
103
+ _table(
104
+ lines,
105
+ ["intervals counted", "median (h)", "p90 (h)", "max (h)"],
106
+ [[summary["count"], summary["median_h"], summary["p90_h"], summary["max_h"]]],
107
+ )
108
+ else:
109
+ _p(lines, "_(no implement commits in window)_")
110
+ _table(
111
+ lines,
112
+ ["change", "phases", "min (h)", "mean (h)", "max (h)"],
113
+ [[r["change"], r["phases"], r["min_h"], r["mean_h"], r["max_h"]]
114
+ for r in ws1.get("per_change", [])[:10]],
115
+ )
116
+
117
+ ws2 = ws.get("ws_2_gate_findings_density", {})
118
+ _h(lines, 3, "WS-2 — gate findings density")
119
+ _table(
120
+ lines,
121
+ ["metric", "value"],
122
+ [
123
+ ["adversarial-review total findings (all changes)", ws2.get("adversarial_review_total_findings", 0)],
124
+ ["changes with ≥1 adversarial finding", ws2.get("adversarial_review_changes_with_findings", 0)],
125
+ ["security-reviews scored", ws2.get("security_review_changes_scored", 0)],
126
+ ["security-review CONCERNS-or-FAIL items", ws2.get("security_review_concerns_or_fail_items", 0)],
127
+ ["data-reviews scored", ws2.get("data_review_changes_scored", 0)],
128
+ ["data-review partial/missing RLS items", ws2.get("data_review_partial_or_missing_items", 0)],
129
+ ],
130
+ )
131
+
132
+ ws4 = ws.get("ws_4_scope_amendment_rate", {})
133
+ _h(lines, 3, "WS-4 — scope-amendment rate (upper bound)")
134
+ _p(lines, ws4.get("note", ""))
135
+ _table(
136
+ lines,
137
+ ["metric", "value"],
138
+ [
139
+ ["total spec.md writes after ready-for-implementation", ws4.get("total_spec_writes_after_rfi", 0)],
140
+ ["changes with ≥1 post-RFI spec write", ws4.get("changes_with_post_rfi_writes", 0)],
141
+ ["rate (changes-with-amendment / total)", f"{ws4.get('rate', 0):.2%}"],
142
+ ],
143
+ )
144
+
145
+ ws6 = ws.get("ws_6_halt_reasons", {})
146
+ _h(lines, 3, "WS-6 — halt reasons (HSTACK-HALT sentinel)")
147
+ _p(lines, ws6.get("note", ""))
148
+ _table(
149
+ lines,
150
+ ["reason", "count"],
151
+ [[r["reason"], r["count"]] for r in ws6.get("rows", [])],
152
+ )
153
+
154
+
155
+ def _render_quality_outcomes(lines: list[str], qo: dict) -> None:
156
+ _h(lines, 2, "Quality outcomes")
157
+
158
+ qo2 = qo.get("qo_2_severity_resolution_mix", {})
159
+ _h(lines, 3, "QO-2 — severity × resolution-type")
160
+ res_types = ["commit", "tech-debt", "justified-in-prose", "other"]
161
+ _table(
162
+ lines,
163
+ ["severity"] + res_types,
164
+ [[r["severity"]] + [r.get(rt, 0) for rt in res_types] for r in qo2.get("rows", [])],
165
+ )
166
+ smells = qo2.get("high_severity_in_prose_smells", [])
167
+ if smells:
168
+ _p(lines, "**High/critical findings resolved as `justified-in-prose` (smell):**")
169
+ _table(
170
+ lines,
171
+ ["change", "finding", "category", "severity"],
172
+ [[s["change"], s["finding_id"], s["category"], s["severity"]] for s in smells],
173
+ )
174
+ else:
175
+ _p(lines, "_No high/critical findings resolved as `justified-in-prose` — healthy._")
176
+
177
+ qo3 = qo.get("qo_3_test_immutability_audit", {})
178
+ _h(lines, 3, "QO-3 — test-immutability audit")
179
+ _p(lines, qo3.get("note", ""))
180
+ _p(lines, f"Authorized test-change commits: **{qo3.get('authorized_count', 0)}**")
181
+ cv = qo3.get("candidate_violations", [])
182
+ if cv:
183
+ _p(lines, "**Non-implement commits touching test files without canonical authorization (review manually):**")
184
+ _table(
185
+ lines,
186
+ ["sha", "subject", "artifact_type", "test files"],
187
+ [[c["sha"][:8], c["subject"][:80], c["artifact_type"] or "-",
188
+ ", ".join(c["test_files"][:3]) + ("…" if len(c["test_files"]) > 3 else "")]
189
+ for c in cv[:20]],
190
+ )
191
+ else:
192
+ _p(lines, "_No candidate violations found — healthy._")
193
+
194
+ qo4 = qo.get("qo_4_observed_vs_promised", {})
195
+ _h(lines, 3, "QO-4 — verifier observed-vs-promised (test-plan-coverage)")
196
+ summary = qo4.get("summary", {})
197
+ if summary:
198
+ for key, buckets in summary.items():
199
+ _p(lines, f"**{key}:** " + ", ".join(f"{v}={n}" for v, n in sorted(buckets.items())))
200
+ _table(
201
+ lines,
202
+ ["change", "edge-cases", "tenant-isolation", "performance-budgets"],
203
+ [[r.get("change"), r.get("edge-cases", "-"),
204
+ r.get("tenant-isolation", "-"), r.get("performance-budgets", "-")]
205
+ for r in qo4.get("per_change", [])],
206
+ )
207
+
208
+
209
+ def _render_overengineering(lines: list[str], oe: dict) -> None:
210
+ _h(lines, 2, "Overengineering detection")
211
+
212
+ oe1 = oe.get("oe_1_artifact_to_diff_ratio", {})
213
+ _h(lines, 3, "OE-1 — artifact tokens per diff line")
214
+ _p(lines, "Ratio above ~50 tokens/line suggests heavy spec-vs-code; ratio below ~5 "
215
+ "suggests an under-specified change. Honest signal, not a verdict.")
216
+ _table(
217
+ lines,
218
+ ["change", "artifact tokens", "diff lines (implement commits)", "tokens / line"],
219
+ [[r["change"], r["artifact_tokens"], r["diff_lines"], r["tokens_per_diff_line"] or "-"]
220
+ for r in oe1.get("rows", [])],
221
+ )
222
+
223
+ oe3 = oe.get("oe_3_subagent_context_load_amortization", {})
224
+ _h(lines, 3, "OE-3 — subagent invocations × host cost")
225
+ _table(
226
+ lines,
227
+ ["subagent", "invocations", "host cost-score total", "/ invocation"],
228
+ [[r["subagent"], r["invocations"], r["host_cost_score_total"], r["cost_per_invocation"]]
229
+ for r in oe3.get("rows", [])],
230
+ )
231
+
232
+ oe5 = oe.get("oe_5_trivial_eligible_full_gauntlet", {})
233
+ _h(lines, 3, "OE-5 — trivial-eligible changes that ran the full gauntlet")
234
+ _p(lines, oe5.get("note", ""))
235
+ _table(
236
+ lines,
237
+ ["change", "status", "surfaces", "files touched in implement"],
238
+ [[r["change"], r["status"], r["surfaces"], r["files_touched_in_implement_commits"]]
239
+ for r in oe5.get("rows", [])],
240
+ )
241
+
242
+
243
+ def _render_contract_drift(lines: list[str], cd: dict) -> None:
244
+ _h(lines, 2, "Contract drift")
245
+
246
+ ms = cd.get("module_spec_staleness", {})
247
+ _h(lines, 3, "Module-spec staleness × recent activity")
248
+ _table(
249
+ lines,
250
+ ["module", "spec status", "spec updated", "recent commits touching module", "drift flag"],
251
+ [[r["module"], r["status"], r["updated"],
252
+ r["recent_commits_touching_module"], "⚠️" if r["drift_flag"] else ""]
253
+ for r in ms.get("rows", [])],
254
+ )
255
+
256
+ adr = cd.get("adr_supersession_lag", {})
257
+ _h(lines, 3, "ADR supersession lag")
258
+ _table(
259
+ lines,
260
+ ["ADR", "superseded by", "lag (days)"],
261
+ [[r["adr"], r["superseded_by"], r["lag_days"]] for r in adr.get("rows", [])],
262
+ )
263
+
264
+ td = cd.get("tech_debt_half_life", {})
265
+ _h(lines, 3, "Tech-debt half-life by exit path")
266
+ summary = td.get("summary", {})
267
+ _table(
268
+ lines,
269
+ ["exit path", "count", "median days", "max days"],
270
+ [[k, v["count"], v["median_days"], v["max_days"]] for k, v in sorted(summary.items())],
271
+ )
272
+
273
+
274
+ def _render_watch_list(lines: list[str], metrics: dict) -> None:
275
+ _h(lines, 2, "Watch list")
276
+ items = []
277
+
278
+ # TE-2: any Skill cache-hit below 0.5
279
+ te2 = metrics.get("token_economics", {}).get("te_2_cache_hit_per_subagent", {})
280
+ for r in te2.get("rows", []):
281
+ if r.get("ratio") is not None and r["ratio"] < 0.5 and r["turns"] > 5:
282
+ items.append(f"Low cache-hit on `{r['skill']}` ({r['ratio']:.0%}) — context is being rebuilt.")
283
+
284
+ # QO-2 smells
285
+ smells = metrics.get("quality_outcomes", {}).get("qo_2_severity_resolution_mix", {}).get("high_severity_in_prose_smells", [])
286
+ if smells:
287
+ items.append(f"{len(smells)} high/critical adversarial finding(s) resolved as `justified-in-prose` — review.")
288
+
289
+ # QO-3 violations
290
+ cv = metrics.get("quality_outcomes", {}).get("qo_3_test_immutability_audit", {}).get("candidate_violations", [])
291
+ if cv:
292
+ items.append(f"{len(cv)} candidate test-immutability violations (non-implement commits touching tests without authorization).")
293
+
294
+ # WS-4 amendment rate above 30%
295
+ ws4 = metrics.get("workflow_shape", {}).get("ws_4_scope_amendment_rate", {})
296
+ rate = ws4.get("rate", 0)
297
+ if rate > 0.3:
298
+ items.append(f"Scope-amendment upper-bound rate at {rate:.0%}. If real (not just status flips), planner/test-strategist may be missing scope up front.")
299
+
300
+ # Module drift
301
+ ms = metrics.get("contract_drift", {}).get("module_spec_staleness", {}).get("rows", [])
302
+ for r in ms:
303
+ if r["drift_flag"]:
304
+ items.append(f"Module-spec drift: `{r['module']}` is `needs-refresh` with {r['recent_commits_touching_module']} recent commits.")
305
+
306
+ if not items:
307
+ _p(lines, "_Nothing flagged. Either everything is healthy, or the metrics need tuning._")
308
+ return
309
+ for item in items:
310
+ lines.append(f"- {item}")
311
+ lines.append("")
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env python3
2
+ """hstack-telemetry — generate the retrospective report.
3
+
4
+ Usage:
5
+ python scripts/telemetry/report.py [--repo <path>] [--window <days>] [--out <path>]
6
+
7
+ Defaults:
8
+ --repo : the current working directory
9
+ --window : 30 (days)
10
+ --out : <repo>/hstack/telemetry/reports/<YYYY-MM-DD>.md
11
+
12
+ The report reads:
13
+ - hstack/specs/changes/*/ (every change artifact)
14
+ - hstack/tech-debt/*.md
15
+ - hstack/adr/*.md
16
+ - hstack/specs/<module>/spec.md
17
+ - git log of the repo (auto-commit patterns)
18
+ - ~/.claude/projects/-<repo-path>-*/*.jsonl (Claude Code transcripts)
19
+
20
+ Nothing is written outside the report file. All read paths are local; no
21
+ network calls.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import os
28
+ import sys
29
+ from datetime import date, datetime, timedelta, timezone
30
+ from pathlib import Path
31
+
32
+ # Ensure the parent (`scripts/`) is importable so `telemetry.*` resolves the
33
+ # same way whether run from the repo root or from elsewhere.
34
+ _THIS = Path(__file__).resolve()
35
+ _SCRIPTS = _THIS.parent.parent
36
+ if str(_SCRIPTS) not in sys.path:
37
+ sys.path.insert(0, str(_SCRIPTS))
38
+
39
+ from telemetry.parsers import frontmatter, commits, transcripts # noqa: E402
40
+ from telemetry.insights import ( # noqa: E402
41
+ token_economics, workflow_shape, quality_outcomes,
42
+ overengineering, contract_drift,
43
+ )
44
+ from telemetry import render # noqa: E402
45
+
46
+
47
+ def main(argv: list[str] | None = None) -> int:
48
+ parser = argparse.ArgumentParser(description="Generate hstack-telemetry report.")
49
+ parser.add_argument("--repo", type=Path, default=Path.cwd(),
50
+ help="Consuming-repo root (default: cwd).")
51
+ parser.add_argument("--window", type=int, default=30,
52
+ help="Limit git/transcript history to last N days (default: 30; 0 = all).")
53
+ parser.add_argument("--out", type=Path, default=None,
54
+ help="Output report path (default: <repo>/hstack/telemetry/reports/<today>.md).")
55
+ args = parser.parse_args(argv)
56
+
57
+ repo = args.repo.resolve()
58
+ hstack_root = repo / "hstack"
59
+ if not hstack_root.is_dir():
60
+ # Permit running against the template repo itself (no hstack/ prefix).
61
+ if (repo / "specs").is_dir() and (repo / "CLAUDE.md").is_file():
62
+ hstack_root = repo
63
+ else:
64
+ print(f"error: no hstack/ directory at {repo}", file=sys.stderr)
65
+ return 1
66
+
67
+ window_days: int | None = args.window if args.window > 0 else None
68
+ since_dt: datetime | None = (
69
+ datetime.now(timezone.utc) - timedelta(days=window_days)
70
+ ) if window_days else None
71
+
72
+ print(f"telemetry: reading {hstack_root}", file=sys.stderr)
73
+ changes = frontmatter.load_change_artifacts(hstack_root)
74
+ tech_debt = frontmatter.load_tech_debt(hstack_root)
75
+ adrs = frontmatter.load_adrs(hstack_root)
76
+ module_specs = frontmatter.load_module_specs(hstack_root)
77
+ print(f"telemetry: {len(changes)} changes, {len(tech_debt)} TDs, "
78
+ f"{len(adrs)} ADRs, {len(module_specs)} module-specs", file=sys.stderr)
79
+
80
+ print("telemetry: walking git history…", file=sys.stderr)
81
+ git_commits = commits.parse_commits(repo, since_days=window_days)
82
+ print(f"telemetry: {len(git_commits)} commits in window", file=sys.stderr)
83
+
84
+ print("telemetry: walking Claude Code transcripts…", file=sys.stderr)
85
+ session_rows = transcripts.collect_session_rows([repo], since=since_dt)
86
+ print(f"telemetry: {len(session_rows)} sessions in window", file=sys.stderr)
87
+
88
+ metrics = {
89
+ "token_economics": token_economics.compute(session_rows, changes),
90
+ "workflow_shape": workflow_shape.compute(git_commits, changes, session_rows),
91
+ "quality_outcomes": quality_outcomes.compute(git_commits, changes),
92
+ "overengineering": overengineering.compute(git_commits, changes, session_rows, repo),
93
+ "contract_drift": contract_drift.compute(git_commits, changes, tech_debt, adrs, module_specs),
94
+ }
95
+
96
+ report_md = render.render_report(metrics, repo_name=repo.name, window_days=window_days)
97
+
98
+ out_path = args.out
99
+ if out_path is None:
100
+ out_dir = hstack_root / "telemetry" / "reports"
101
+ out_dir.mkdir(parents=True, exist_ok=True)
102
+ out_path = out_dir / f"{date.today().isoformat()}.md"
103
+ else:
104
+ out_path.parent.mkdir(parents=True, exist_ok=True)
105
+ out_path.write_text(report_md, encoding="utf-8")
106
+
107
+ print(f"telemetry: report written to {out_path}", file=sys.stderr)
108
+ return 0
109
+
110
+
111
+ if __name__ == "__main__":
112
+ sys.exit(main())
@@ -0,0 +1,38 @@
1
+ ---
2
+ id: ADR-<NNNN>-<slug>
3
+ type: adr
4
+ status: proposed # proposed | accepted | deprecated | superseded
5
+ owner: <git-handle>
6
+ decision-date: <YYYY-MM-DD>
7
+ supersedes: null # ADR id when this ADR replaces another
8
+ superseded-by: null # ADR id when this ADR has been replaced; reciprocal with supersedes
9
+ related-change-specs: []
10
+ related-modules: []
11
+ created: <YYYY-MM-DD>
12
+ updated: <YYYY-MM-DD>
13
+ schema-version: 1
14
+ ---
15
+
16
+ ## Title
17
+
18
+ _Short noun phrase._
19
+
20
+ ## Status
21
+
22
+ _Pointer to frontmatter `status`. Note any supersession relationship._
23
+
24
+ ## Context
25
+
26
+ _The situation that motivates the decision. The forces at play. Constraints. 2–4 paragraphs._
27
+
28
+ ## Decision
29
+
30
+ _The choice, stated as an active sentence. One paragraph._
31
+
32
+ ## Consequences
33
+
34
+ _Positive, negative, and neutral outcomes. Particularly the trade-offs accepted. Challenge prompt: name two consequences that look bad. If you can't, what alternative would have made them visible? 2–4 paragraphs._
35
+
36
+ ## Alternatives Considered
37
+
38
+ _What was on the table; why each was rejected. One paragraph per alternative._