hstack 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +73 -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 +42 -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 +80 -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 +48 -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/data-architecture.md +38 -89
  36. package/template/templates/infrastructure.md +54 -148
  37. package/template/templates/roadmap.md +6 -12
  38. package/template/templates/story.md +7 -13
  39. package/template/templates/tech-debt.md +2 -37
  40. package/template/templates/tech-stack.md +2 -12
  41. package/template/.claude/agents/data-specialist.md +0 -81
  42. package/template/.claude/agents/implementer.md +0 -97
  43. package/template/.claude/agents/kernel-fit-analyst.md +0 -129
  44. package/template/.claude/agents/planner.md +0 -75
  45. package/template/.claude/agents/product-discovery.md +0 -129
  46. package/template/.claude/agents/product-manager.md +0 -76
  47. package/template/.claude/agents/researcher.md +0 -87
  48. package/template/.claude/agents/security-reviewer.md +0 -88
  49. package/template/.claude/agents/spec-author.md +0 -89
  50. package/template/.claude/agents/stack-architect.md +0 -108
  51. package/template/.claude/agents/ui-ux-briefer.md +0 -75
  52. package/template/.claude/agents/verifier.md +0 -81
  53. package/template/.claude/skills/hstack-branch/SKILL.md +0 -76
  54. package/template/.claude/skills/hstack-brownfield-init/SKILL.md +0 -131
  55. package/template/.claude/skills/hstack-change-new/SKILL.md +0 -84
  56. package/template/.claude/skills/hstack-change-plan/SKILL.md +0 -108
  57. package/template/.claude/skills/hstack-configure/SKILL.md +0 -99
  58. package/template/.claude/skills/hstack-coord/SKILL.md +0 -95
  59. package/template/.claude/skills/hstack-data-review/SKILL.md +0 -93
  60. package/template/.claude/skills/hstack-finalize/SKILL.md +0 -140
  61. package/template/.claude/skills/hstack-flag/SKILL.md +0 -99
  62. package/template/.claude/skills/hstack-greenfield-init/SKILL.md +0 -148
  63. package/template/.claude/skills/hstack-help/SKILL.md +0 -153
  64. package/template/.claude/skills/hstack-implement/SKILL.md +0 -155
  65. package/template/.claude/skills/hstack-kernel-fit-promote/SKILL.md +0 -125
  66. package/template/.claude/skills/hstack-kernel-fit-scan/SKILL.md +0 -142
  67. package/template/.claude/skills/hstack-kernel-fit-scan/references/slack-setup.md +0 -42
  68. package/template/.claude/skills/hstack-kernel-fit-triage/SKILL.md +0 -121
  69. package/template/.claude/skills/hstack-module-spec/SKILL.md +0 -78
  70. package/template/.claude/skills/hstack-product-discovery/SKILL.md +0 -75
  71. package/template/.claude/skills/hstack-research/SKILL.md +0 -115
  72. package/template/.claude/skills/hstack-scaffold/SKILL.md +0 -113
  73. package/template/.claude/skills/hstack-security-review/SKILL.md +0 -95
  74. package/template/.claude/skills/hstack-ship/SKILL.md +0 -119
  75. package/template/.claude/skills/hstack-stack-decide/SKILL.md +0 -81
  76. package/template/.claude/skills/hstack-story-draft/SKILL.md +0 -92
  77. package/template/.claude/skills/hstack-tech-debt-new/SKILL.md +0 -95
  78. package/template/.claude/skills/hstack-tech-debt-resolve/SKILL.md +0 -123
  79. package/template/.claude/skills/hstack-tech-debt-stale/SKILL.md +0 -86
  80. package/template/.claude/skills/hstack-tech-debt-wontfix/SKILL.md +0 -77
  81. package/template/.claude/skills/hstack-telemetry/SKILL.md +0 -73
  82. package/template/.claude/skills/hstack-test-plan/SKILL.md +0 -159
  83. package/template/.claude/skills/hstack-ui-brief/SKILL.md +0 -81
  84. package/template/.claude/skills/hstack-verify/SKILL.md +0 -128
  85. package/template/scripts/compute-merge-readiness.mjs +0 -780
  86. package/template/scripts/coord/coord_scan.py +0 -681
  87. package/template/scripts/run-gates.sh +0 -388
  88. package/template/scripts/telemetry/__init__.py +0 -6
  89. package/template/scripts/telemetry/insights/__init__.py +0 -0
  90. package/template/scripts/telemetry/insights/contract_drift.py +0 -137
  91. package/template/scripts/telemetry/insights/kernel_fit.py +0 -438
  92. package/template/scripts/telemetry/insights/overengineering.py +0 -115
  93. package/template/scripts/telemetry/insights/quality_outcomes.py +0 -131
  94. package/template/scripts/telemetry/insights/token_economics.py +0 -302
  95. package/template/scripts/telemetry/insights/workflow_shape.py +0 -198
  96. package/template/scripts/telemetry/parsers/__init__.py +0 -0
  97. package/template/scripts/telemetry/parsers/bodies.py +0 -87
  98. package/template/scripts/telemetry/parsers/commits.py +0 -219
  99. package/template/scripts/telemetry/parsers/frontmatter.py +0 -322
  100. package/template/scripts/telemetry/parsers/sidecars.py +0 -61
  101. package/template/scripts/telemetry/parsers/transcripts.py +0 -294
  102. package/template/scripts/telemetry/render.py +0 -460
  103. package/template/scripts/telemetry/report.py +0 -150
  104. package/template/scripts/telemetry/run_kernel_fit.py +0 -95
  105. package/template/scripts/telemetry/session_id.py +0 -139
  106. package/template/scripts/validate-spec.mjs +0 -3303
  107. package/template/templates/adversarial-review.md +0 -54
  108. package/template/templates/bootstrap.md +0 -105
  109. package/template/templates/change-spec.md +0 -82
  110. package/template/templates/ci-cd.md +0 -41
  111. package/template/templates/coord-message.md +0 -56
  112. package/template/templates/data-review.md +0 -54
  113. package/template/templates/discovery/brainstorm.md +0 -69
  114. package/template/templates/discovery/forcing-questions.md +0 -76
  115. package/template/templates/discovery/project-brief.md +0 -53
  116. package/template/templates/figma-handoff.md +0 -38
  117. package/template/templates/glossary.md +0 -20
  118. package/template/templates/hardening-checklist.md +0 -73
  119. package/template/templates/incident-runbook.md +0 -57
  120. package/template/templates/kernel-fit-finding.md +0 -63
  121. package/template/templates/kernel-fit-flag.md +0 -52
  122. package/template/templates/module-spec.md +0 -49
  123. package/template/templates/persona.md +0 -38
  124. package/template/templates/plan.md +0 -53
  125. package/template/templates/product-brief.md +0 -56
  126. package/template/templates/security-review.md +0 -63
  127. package/template/templates/telemetry-sidecar.md +0 -227
  128. package/template/templates/test-plan.md +0 -119
  129. package/template/templates/threat-model.md +0 -54
  130. package/template/templates/ui-brief.md +0 -49
  131. package/template/templates/verification.md +0 -63
  132. 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())