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,460 +0,0 @@
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
- _render_kernel_fit(lines, metrics.get("kernel_fit", {}))
27
-
28
- _render_watch_list(lines, metrics)
29
-
30
- return "\n".join(lines) + "\n"
31
-
32
-
33
- # ---------------- helpers ----------------
34
-
35
- def _h(lines: list[str], level: int, text: str) -> None:
36
- lines.append("#" * level + " " + text)
37
- lines.append("")
38
-
39
-
40
- def _p(lines: list[str], text: str) -> None:
41
- lines.append(text)
42
- lines.append("")
43
-
44
-
45
- def _table(lines: list[str], headers: list[str], rows: list[list[Any]]) -> None:
46
- if not rows:
47
- lines.append("_(no data)_")
48
- lines.append("")
49
- return
50
- lines.append("| " + " | ".join(headers) + " |")
51
- lines.append("|" + "|".join("---" for _ in headers) + "|")
52
- for r in rows:
53
- cells = ["" if v is None else str(v) for v in r]
54
- lines.append("| " + " | ".join(cells) + " |")
55
- lines.append("")
56
-
57
-
58
- # ---------------- buckets ----------------
59
-
60
- def _render_token_economics(lines: list[str], te: dict) -> None:
61
- _h(lines, 2, "Token economics")
62
-
63
- te1 = te.get("te_1_cost_per_change", {})
64
- _h(lines, 3, "TE-1 — cost-score per Skill (session-scoped)")
65
- _p(lines, te1.get("note", ""))
66
- _table(
67
- lines,
68
- ["skill", "sessions", "cost-score total", "cost-score / session"],
69
- [[r["skill"], r["sessions"], r["cost_score_total"], r["cost_score_mean"]]
70
- for r in te1.get("rows", [])],
71
- )
72
-
73
- te2 = te.get("te_2_cache_hit_per_subagent", {})
74
- _h(lines, 3, "TE-2 — cache-hit ratio (per Skill, session-scoped)")
75
- _p(lines, "ratio = cache_read / (cache_read + cache_creation). Below 0.5 → context "
76
- "is being rebuilt rather than reused. Above 0.8 → cross-session reuse is healthy.")
77
- _p(lines, te2.get("note", ""))
78
- _table(
79
- lines,
80
- ["skill", "turns", "cache_read", "cache_creation", "ratio"],
81
- [[r["skill"], r["turns"], r["cache_read"], r["cache_creation"],
82
- (f"{r['ratio']:.2%}" if r["ratio"] is not None else "-")]
83
- for r in te2.get("rows", [])],
84
- )
85
-
86
- te3 = te.get("te_3_subagent_entry_tax", {})
87
- _h(lines, 3, "TE-3 — subagent entry-tax amortization")
88
- _p(lines, te3.get("note", ""))
89
- _table(
90
- lines,
91
- ["subagent", "appearances", "host cache_creation total", "/ invocation"],
92
- [[r["subagent"], r["appearances"], r["host_cache_creation_total"],
93
- r["host_cache_creation_per_invocation"]]
94
- for r in te3.get("rows", [])],
95
- )
96
-
97
- te4 = te.get("te_4_cost_per_phase", {})
98
- _h(lines, 3, "TE-4 — cost per phase (sidecar phase window)")
99
- _p(lines, _coverage_line(te4, "phase"))
100
- _p(lines, te4.get("note", ""))
101
- rows4 = te4.get("rows", [])
102
- measured4 = [r for r in rows4 if r["measured"]]
103
- _table(
104
- lines,
105
- ["skill", "change", "phase", "tokens", "turns", "wall-clock (h)"],
106
- [[r["skill"], r["change"], r["phase_id"] or "-",
107
- f"{r['tokens']:,}", r["turns"], r["wall_clock_h"]]
108
- for r in measured4[:40]],
109
- )
110
- unmeasured4 = [r for r in rows4 if not r["measured"]]
111
- if unmeasured4:
112
- by_reason: dict[str, int] = {}
113
- for r in unmeasured4:
114
- by_reason[r["unmeasured_reason"] or "unknown"] = by_reason.get(r["unmeasured_reason"] or "unknown", 0) + 1
115
- _p(lines, f"**{len(unmeasured4)} unmeasured phase(s)** — counted as nothing, never as zero:")
116
- _table(
117
- lines,
118
- ["reason", "phases"],
119
- [[reason, count] for reason, count in sorted(by_reason.items(), key=lambda kv: -kv[1])],
120
- )
121
-
122
- te5 = te.get("te_5_cost_per_change", {})
123
- _h(lines, 3, "TE-5 — cost per change (sum of measured phases)")
124
- _p(lines, _coverage_line(te5, "phase"))
125
- _p(lines, te5.get("note", ""))
126
- _table(
127
- lines,
128
- ["change", "tokens", "turns", "wall-clock (h)", "phases measured / emitted", "coverage"],
129
- [[r["change"],
130
- f"{r['tokens']:,}" if r["tokens"] is not None else "unmeasured",
131
- r["turns"] if r["turns"] is not None else "-",
132
- r["wall_clock_h"] if r["wall_clock_h"] is not None else "-",
133
- f"{r['phases_measured']} / {r['phases_emitted']}",
134
- f"{r['coverage_fraction']:.0%}" if r["coverage_fraction"] is not None else "-"]
135
- for r in te5.get("rows", [])[:20]],
136
- )
137
-
138
-
139
- def _coverage_line(block: dict, unit: str) -> str:
140
- emitted = block.get("phases_emitted", 0)
141
- measured = block.get("phases_measured", 0)
142
- frac = block.get("coverage_fraction")
143
- if not emitted:
144
- return (f"**Coverage: 0 sidecars in this repo.** No {unit} is measurable — "
145
- "either no change has reached a sidecar-emitting terminal state yet, "
146
- "or the sidecars pre-date ADR-0009's phase window.")
147
- return (f"**Coverage: {measured} of {emitted} emitted sidecars measurable"
148
- + (f" ({frac:.0%})" if frac is not None else "") + ".** "
149
- "Emitted sidecars come from five Skills only, so this is a subset of the "
150
- "change's real cost — read the fraction before reading the totals.")
151
-
152
-
153
- def _render_workflow_shape(lines: list[str], ws: dict) -> None:
154
- _h(lines, 2, "Workflow shape")
155
-
156
- ws1 = ws.get("ws_1_phase_duration", {})
157
- _h(lines, 3, "WS-1 — phase duration (between successive implement commits)")
158
- summary = ws1.get("summary")
159
- if summary:
160
- _table(
161
- lines,
162
- ["intervals counted", "median (h)", "p90 (h)", "max (h)"],
163
- [[summary["count"], summary["median_h"], summary["p90_h"], summary["max_h"]]],
164
- )
165
- else:
166
- _p(lines, "_(no implement commits in window)_")
167
- _table(
168
- lines,
169
- ["change", "phases", "min (h)", "mean (h)", "max (h)"],
170
- [[r["change"], r["phases"], r["min_h"], r["mean_h"], r["max_h"]]
171
- for r in ws1.get("per_change", [])[:10]],
172
- )
173
-
174
- ws2 = ws.get("ws_2_gate_findings_density", {})
175
- _h(lines, 3, "WS-2 — gate findings density")
176
- _table(
177
- lines,
178
- ["metric", "value"],
179
- [
180
- ["adversarial-review total findings (all changes)", ws2.get("adversarial_review_total_findings", 0)],
181
- ["changes with ≥1 adversarial finding", ws2.get("adversarial_review_changes_with_findings", 0)],
182
- ["security-reviews scored", ws2.get("security_review_changes_scored", 0)],
183
- ["security-review CONCERNS-or-FAIL items", ws2.get("security_review_concerns_or_fail_items", 0)],
184
- ["data-reviews scored", ws2.get("data_review_changes_scored", 0)],
185
- ["data-review partial/missing RLS items", ws2.get("data_review_partial_or_missing_items", 0)],
186
- ],
187
- )
188
-
189
- ws4 = ws.get("ws_4_scope_amendment_rate", {})
190
- _h(lines, 3, "WS-4 — scope-amendment rate (upper bound)")
191
- _p(lines, ws4.get("note", ""))
192
- _table(
193
- lines,
194
- ["metric", "value"],
195
- [
196
- ["total spec.md writes after ready-for-implementation", ws4.get("total_spec_writes_after_rfi", 0)],
197
- ["changes with ≥1 post-RFI spec write", ws4.get("changes_with_post_rfi_writes", 0)],
198
- ["rate (changes-with-amendment / total)", f"{ws4.get('rate', 0):.2%}"],
199
- ],
200
- )
201
-
202
- ws6 = ws.get("ws_6_halt_reasons", {})
203
- _h(lines, 3, "WS-6 — halt reasons (HSTACK-HALT sentinel)")
204
- _p(lines, ws6.get("note", ""))
205
- _table(
206
- lines,
207
- ["reason", "count"],
208
- [[r["reason"], r["count"]] for r in ws6.get("rows", [])],
209
- )
210
-
211
-
212
- def _render_quality_outcomes(lines: list[str], qo: dict) -> None:
213
- _h(lines, 2, "Quality outcomes")
214
-
215
- qo2 = qo.get("qo_2_severity_resolution_mix", {})
216
- _h(lines, 3, "QO-2 — severity × resolution-type")
217
- res_types = ["commit", "tech-debt", "justified-in-prose", "other"]
218
- _table(
219
- lines,
220
- ["severity"] + res_types,
221
- [[r["severity"]] + [r.get(rt, 0) for rt in res_types] for r in qo2.get("rows", [])],
222
- )
223
- smells = qo2.get("high_severity_in_prose_smells", [])
224
- if smells:
225
- _p(lines, "**High/critical findings resolved as `justified-in-prose` (smell):**")
226
- _table(
227
- lines,
228
- ["change", "finding", "category", "severity"],
229
- [[s["change"], s["finding_id"], s["category"], s["severity"]] for s in smells],
230
- )
231
- else:
232
- _p(lines, "_No high/critical findings resolved as `justified-in-prose` — healthy._")
233
-
234
- qo3 = qo.get("qo_3_test_immutability_audit", {})
235
- _h(lines, 3, "QO-3 — test-immutability audit")
236
- _p(lines, qo3.get("note", ""))
237
- _p(lines, f"Authorized test-change commits: **{qo3.get('authorized_count', 0)}**")
238
- cv = qo3.get("candidate_violations", [])
239
- if cv:
240
- _p(lines, "**Non-implement commits touching test files without canonical authorization (review manually):**")
241
- _table(
242
- lines,
243
- ["sha", "subject", "artifact_type", "test files"],
244
- [[c["sha"][:8], c["subject"][:80], c["artifact_type"] or "-",
245
- ", ".join(c["test_files"][:3]) + ("…" if len(c["test_files"]) > 3 else "")]
246
- for c in cv[:20]],
247
- )
248
- else:
249
- _p(lines, "_No candidate violations found — healthy._")
250
-
251
- qo4 = qo.get("qo_4_observed_vs_promised", {})
252
- _h(lines, 3, "QO-4 — verifier observed-vs-promised (test-plan-coverage)")
253
- summary = qo4.get("summary", {})
254
- if summary:
255
- for key, buckets in summary.items():
256
- _p(lines, f"**{key}:** " + ", ".join(f"{v}={n}" for v, n in sorted(buckets.items())))
257
- _table(
258
- lines,
259
- ["change", "edge-cases", "tenant-isolation", "performance-budgets"],
260
- [[r.get("change"), r.get("edge-cases", "-"),
261
- r.get("tenant-isolation", "-"), r.get("performance-budgets", "-")]
262
- for r in qo4.get("per_change", [])],
263
- )
264
-
265
-
266
- def _render_overengineering(lines: list[str], oe: dict) -> None:
267
- _h(lines, 2, "Overengineering detection")
268
-
269
- oe1 = oe.get("oe_1_artifact_to_diff_ratio", {})
270
- _h(lines, 3, "OE-1 — artifact tokens per diff line")
271
- _p(lines, "Ratio above ~50 tokens/line suggests heavy spec-vs-code; ratio below ~5 "
272
- "suggests an under-specified change. Honest signal, not a verdict.")
273
- _table(
274
- lines,
275
- ["change", "artifact tokens", "diff lines (implement commits)", "tokens / line"],
276
- [[r["change"], r["artifact_tokens"], r["diff_lines"], r["tokens_per_diff_line"] or "-"]
277
- for r in oe1.get("rows", [])],
278
- )
279
-
280
- oe3 = oe.get("oe_3_subagent_context_load_amortization", {})
281
- _h(lines, 3, "OE-3 — subagent invocations × host cost")
282
- _table(
283
- lines,
284
- ["subagent", "invocations", "host cost-score total", "/ invocation"],
285
- [[r["subagent"], r["invocations"], r["host_cost_score_total"], r["cost_per_invocation"]]
286
- for r in oe3.get("rows", [])],
287
- )
288
-
289
- oe5 = oe.get("oe_5_trivial_eligible_full_gauntlet", {})
290
- _h(lines, 3, "OE-5 — trivial-eligible changes that ran the full gauntlet")
291
- _p(lines, oe5.get("note", ""))
292
- _table(
293
- lines,
294
- ["change", "status", "surfaces", "files touched in implement"],
295
- [[r["change"], r["status"], r["surfaces"], r["files_touched_in_implement_commits"]]
296
- for r in oe5.get("rows", [])],
297
- )
298
-
299
-
300
- def _render_contract_drift(lines: list[str], cd: dict) -> None:
301
- _h(lines, 2, "Contract drift")
302
-
303
- ms = cd.get("module_spec_staleness", {})
304
- _h(lines, 3, "Module-spec staleness × recent activity")
305
- _table(
306
- lines,
307
- ["module", "spec status", "spec updated", "recent commits touching module", "drift flag"],
308
- [[r["module"], r["status"], r["updated"],
309
- r["recent_commits_touching_module"], "⚠️" if r["drift_flag"] else ""]
310
- for r in ms.get("rows", [])],
311
- )
312
-
313
- adr = cd.get("adr_supersession_lag", {})
314
- _h(lines, 3, "ADR supersession lag")
315
- _table(
316
- lines,
317
- ["ADR", "superseded by", "lag (days)"],
318
- [[r["adr"], r["superseded_by"], r["lag_days"]] for r in adr.get("rows", [])],
319
- )
320
-
321
- td = cd.get("tech_debt_half_life", {})
322
- _h(lines, 3, "Tech-debt half-life by exit path")
323
- summary = td.get("summary", {})
324
- _table(
325
- lines,
326
- ["exit path", "count", "median days", "max days"],
327
- [[k, v["count"], v["median_days"], v["max_days"]] for k, v in sorted(summary.items())],
328
- )
329
-
330
-
331
- def _render_kernel_fit(lines: list[str], kf: dict) -> None:
332
- _h(lines, 2, "Kernel-fit candidates")
333
- _p(lines, "Patterns suggesting the kernel itself (KERNEL.md, templates, validators, Skill "
334
- "flows) may need revision. Each fired pattern is also written as a durable finding "
335
- "by `/hstack:kernel-fit-scan` at `hstack/kernel-fit/findings/KF-NNNN-*.md`. The "
336
- "table below is a rollup; the findings are the canonical artifact. See ADR-0004.")
337
-
338
- existing = kf.get("existing_open_findings_by_pattern", {})
339
- if existing:
340
- total_open = sum(len(v) for v in existing.values())
341
- _p(lines, f"**Currently open findings:** {total_open} "
342
- + "(" + ", ".join(f"{p}: {len(ids)}" for p, ids in sorted(existing.items())) + ")")
343
-
344
- patterns = [
345
- ("kf_p1_category_a_claim_spans_production_paths", "KF-P1 — category-a-claim-spans-production-paths"),
346
- ("kf_p2_halt_reason_cluster_uncovered_by_enum", "KF-P2 — halt-reason-cluster-uncovered-by-enum"),
347
- ("kf_p3_skill_precondition_violated_and_recoverable", "KF-P3 — skill-precondition-violated-and-recoverable"),
348
- ]
349
- for key, heading in patterns:
350
- block = kf.get(key, {})
351
- _h(lines, 3, heading)
352
- _p(lines, block.get("note", ""))
353
- fired = block.get("fired", False)
354
- rc = block.get("evidence_row_count", 0)
355
- if fired:
356
- _p(lines, f"**Fired** — {rc} evidence row(s).")
357
- else:
358
- _p(lines, f"_(not fired — {rc} evidence row(s); threshold not met)_")
359
-
360
- # Per-pattern row rendering.
361
- if key == "kf_p1_category_a_claim_spans_production_paths":
362
- rows = block.get("evidence_rows", [])
363
- _table(
364
- lines,
365
- ["change", "production paths", "enables", "downstream consumers", "classification"],
366
- [[r["change"], r["production_paths_count"], r["enables_count"],
367
- ", ".join(r["downstream_consumers"][:3]) + ("…" if len(r["downstream_consumers"]) > 3 else ""),
368
- r["classification_candidate"]]
369
- for r in rows[:10]],
370
- )
371
- elif key == "kf_p2_halt_reason_cluster_uncovered_by_enum":
372
- rows = block.get("evidence_rows", [])
373
- _table(
374
- lines,
375
- ["cluster", "size", "representative context (truncated)"],
376
- [[r["cluster_id"], r["size"], r["representative_context"][:120]]
377
- for r in rows[:10]],
378
- )
379
- elif key == "kf_p3_skill_precondition_violated_and_recoverable":
380
- rows = block.get("evidence_rows", [])
381
- _table(
382
- lines,
383
- ["change", "finding", "matched keywords", "commit subject (truncated)"],
384
- [[r["change"], r["finding_id"], ", ".join(r["matched_keywords"]),
385
- (r["commit_subject"] or "-")[:80]]
386
- for r in rows[:10]],
387
- )
388
-
389
-
390
- def watch_items(metrics: dict) -> list[str]:
391
- """Compute the watch-list lines from the metrics dict.
392
-
393
- Shared between the markdown renderer and the JSON emission so both
394
- surfaces flag the same anomalies.
395
- """
396
- items: list[str] = []
397
-
398
- # TE-2: any Skill cache-hit below 0.5
399
- te2 = metrics.get("token_economics", {}).get("te_2_cache_hit_per_subagent", {})
400
- for r in te2.get("rows", []):
401
- if r.get("ratio") is not None and r["ratio"] < 0.5 and r["turns"] > 5:
402
- items.append(f"Low cache-hit on `{r['skill']}` ({r['ratio']:.0%}) — context is being rebuilt.")
403
-
404
- # TE-4: emitted sidecars the parser could not measure
405
- te4 = metrics.get("token_economics", {}).get("te_4_cost_per_phase", {})
406
- emitted = te4.get("phases_emitted", 0)
407
- measured = te4.get("phases_measured", 0)
408
- if emitted and measured < emitted:
409
- items.append(f"{emitted - measured} of {emitted} phase sidecars are unmeasured "
410
- "(no phase window, or the session transcript is gone) — TE-4/TE-5 "
411
- "totals are a subset, not a total.")
412
-
413
- # QO-2 smells
414
- smells = metrics.get("quality_outcomes", {}).get("qo_2_severity_resolution_mix", {}).get("high_severity_in_prose_smells", [])
415
- if smells:
416
- items.append(f"{len(smells)} high/critical adversarial finding(s) resolved as `justified-in-prose` — review.")
417
-
418
- # QO-3 violations
419
- cv = metrics.get("quality_outcomes", {}).get("qo_3_test_immutability_audit", {}).get("candidate_violations", [])
420
- if cv:
421
- items.append(f"{len(cv)} candidate test-immutability violations (non-implement commits touching tests without authorization).")
422
-
423
- # WS-4 amendment rate above 30%
424
- ws4 = metrics.get("workflow_shape", {}).get("ws_4_scope_amendment_rate", {})
425
- rate = ws4.get("rate", 0)
426
- if rate > 0.3:
427
- 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.")
428
-
429
- # Module drift
430
- ms = metrics.get("contract_drift", {}).get("module_spec_staleness", {}).get("rows", [])
431
- for r in ms:
432
- if r["drift_flag"]:
433
- items.append(f"Module-spec drift: `{r['module']}` is `needs-refresh` with {r['recent_commits_touching_module']} recent commits.")
434
-
435
- # Kernel-fit fired patterns
436
- kf = metrics.get("kernel_fit", {})
437
- for key, label in (
438
- ("kf_p1_category_a_claim_spans_production_paths", "KF-P1"),
439
- ("kf_p2_halt_reason_cluster_uncovered_by_enum", "KF-P2"),
440
- ("kf_p3_skill_precondition_violated_and_recoverable", "KF-P3"),
441
- ):
442
- block = kf.get(key, {})
443
- if block.get("fired"):
444
- rc = block.get("evidence_row_count", 0)
445
- items.append(f"Kernel-fit {label} fired with {rc} evidence row(s) — "
446
- f"run `/hstack:kernel-fit-scan` to synthesize findings.")
447
-
448
- return items
449
-
450
-
451
- def _render_watch_list(lines: list[str], metrics: dict) -> None:
452
- _h(lines, 2, "Watch list")
453
- items = watch_items(metrics)
454
-
455
- if not items:
456
- _p(lines, "_Nothing flagged. Either everything is healthy, or the metrics need tuning._")
457
- return
458
- for item in items:
459
- lines.append(f"- {item}")
460
- lines.append("")
@@ -1,150 +0,0 @@
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 json
28
- import os
29
- import sys
30
- from datetime import date, datetime, timedelta, timezone
31
- from pathlib import Path
32
-
33
- # Ensure the parent (`scripts/`) is importable so `telemetry.*` resolves the
34
- # same way whether run from the repo root or from elsewhere.
35
- _THIS = Path(__file__).resolve()
36
- _SCRIPTS = _THIS.parent.parent
37
- if str(_SCRIPTS) not in sys.path:
38
- sys.path.insert(0, str(_SCRIPTS))
39
-
40
- from telemetry.parsers import frontmatter, commits, transcripts, sidecars # noqa: E402
41
- from telemetry.insights import ( # noqa: E402
42
- token_economics, workflow_shape, quality_outcomes,
43
- overengineering, contract_drift, kernel_fit,
44
- )
45
- from telemetry import render # noqa: E402
46
-
47
-
48
- def main(argv: list[str] | None = None) -> int:
49
- parser = argparse.ArgumentParser(description="Generate hstack-telemetry report.")
50
- parser.add_argument("--repo", type=Path, default=Path.cwd(),
51
- help="Consuming-repo root (default: cwd).")
52
- parser.add_argument("--window", type=int, default=30,
53
- help="Limit git/transcript history to last N days (default: 30; 0 = all).")
54
- parser.add_argument("--out", type=Path, default=None,
55
- help="Output report path (default: <repo>/hstack/telemetry/reports/<today>.md).")
56
- args = parser.parse_args(argv)
57
-
58
- repo = args.repo.resolve()
59
- hstack_root = repo / "hstack"
60
- if not hstack_root.is_dir():
61
- # Permit running against the template repo itself (no hstack/ prefix).
62
- # CLAUDE.md is the pre-ADR-0010 kernel name — still accepted so an
63
- # un-migrated tree keeps reporting.
64
- if (repo / "specs").is_dir() and (
65
- (repo / "KERNEL.md").is_file() or (repo / "CLAUDE.md").is_file()
66
- ):
67
- hstack_root = repo
68
- else:
69
- print(f"error: no hstack/ directory at {repo}", file=sys.stderr)
70
- return 1
71
-
72
- window_days: int | None = args.window if args.window > 0 else None
73
- since_dt: datetime | None = (
74
- datetime.now(timezone.utc) - timedelta(days=window_days)
75
- ) if window_days else None
76
-
77
- print(f"telemetry: reading {hstack_root}", file=sys.stderr)
78
- changes = frontmatter.load_change_artifacts(hstack_root)
79
- tech_debt = frontmatter.load_tech_debt(hstack_root)
80
- adrs = frontmatter.load_adrs(hstack_root)
81
- module_specs = frontmatter.load_module_specs(hstack_root)
82
- print(f"telemetry: {len(changes)} changes, {len(tech_debt)} TDs, "
83
- f"{len(adrs)} ADRs, {len(module_specs)} module-specs", file=sys.stderr)
84
-
85
- print("telemetry: walking git history…", file=sys.stderr)
86
- git_commits = commits.parse_commits(repo, since_days=window_days)
87
- print(f"telemetry: {len(git_commits)} commits in window", file=sys.stderr)
88
-
89
- print("telemetry: walking Claude Code transcripts…", file=sys.stderr)
90
- session_rows = transcripts.collect_session_rows([repo], since=since_dt)
91
- print(f"telemetry: {len(session_rows)} sessions in window", file=sys.stderr)
92
-
93
- # Phase sidecars (ADR-0009). Gitignored and derivative: absent on a fresh
94
- # clone, which reports as zero coverage rather than as zero cost.
95
- phase_sidecars = sidecars.load_sidecars(hstack_root)
96
- print(f"telemetry: {len(phase_sidecars)} phase sidecars", file=sys.stderr)
97
-
98
- findings_dir = hstack_root / "kernel-fit" / "findings"
99
- metrics = {
100
- "token_economics": token_economics.compute(session_rows, changes, phase_sidecars),
101
- "workflow_shape": workflow_shape.compute(git_commits, changes, session_rows),
102
- "quality_outcomes": quality_outcomes.compute(git_commits, changes),
103
- "overengineering": overengineering.compute(git_commits, changes, session_rows, repo),
104
- "contract_drift": contract_drift.compute(git_commits, changes, tech_debt, adrs, module_specs),
105
- "kernel_fit": kernel_fit.compute(git_commits, changes, tech_debt, adrs, module_specs,
106
- session_rows, findings_dir),
107
- }
108
-
109
- report_md = render.render_report(metrics, repo_name=repo.name, window_days=window_days)
110
-
111
- out_path = args.out
112
- if out_path is None:
113
- out_dir = hstack_root / "telemetry" / "reports"
114
- out_dir.mkdir(parents=True, exist_ok=True)
115
- out_path = out_dir / f"{date.today().isoformat()}.md"
116
- else:
117
- out_path.parent.mkdir(parents=True, exist_ok=True)
118
- out_path.write_text(report_md, encoding="utf-8")
119
-
120
- # Structured twin of the markdown report — same metrics dict, machine-readable.
121
- # Consumed by the telemetry UI; carries the same derivative-only guarantee.
122
- json_path = out_path.with_suffix(".json")
123
- payload = {
124
- # 2 — token_economics gains te_4_cost_per_phase / te_5_cost_per_change
125
- # (ADR-0009). Additive: every schema_version-1 key keeps its shape.
126
- "schema_version": 2,
127
- "repo": repo.name,
128
- "generated": date.today().isoformat(),
129
- "window_days": window_days,
130
- "counts": {
131
- "changes": len(changes),
132
- "tech_debt": len(tech_debt),
133
- "adrs": len(adrs),
134
- "module_specs": len(module_specs),
135
- "commits": len(git_commits),
136
- "sessions": len(session_rows),
137
- "phase_sidecars": len(phase_sidecars),
138
- },
139
- "watch_list": render.watch_items(metrics),
140
- "metrics": metrics,
141
- }
142
- json_path.write_text(json.dumps(payload, indent=1, default=str), encoding="utf-8")
143
-
144
- print(f"telemetry: report written to {out_path}", file=sys.stderr)
145
- print(f"telemetry: json written to {json_path}", file=sys.stderr)
146
- return 0
147
-
148
-
149
- if __name__ == "__main__":
150
- sys.exit(main())