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.
- package/CHANGELOG.md +37 -0
- package/README.md +73 -163
- package/VERSION +1 -1
- package/dist/commands/doctor.js +18 -8
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.js +7 -3
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/update.js +9 -2
- package/dist/commands/update.js.map +1 -1
- package/dist/lib/descriptions.js +10 -17
- package/dist/lib/descriptions.js.map +1 -1
- package/dist/lib/wire.js +166 -138
- package/dist/lib/wire.js.map +1 -1
- package/dist/manifest.js +41 -29
- package/dist/manifest.js.map +1 -1
- package/package.json +3 -4
- package/template/.claude/agents/adversarial-reviewer.md +42 -50
- package/template/.claude/agents/app-architect.md +44 -96
- package/template/.claude/agents/data-architect.md +46 -89
- package/template/.claude/agents/test-strategist.md +39 -68
- package/template/.claude/skills/hstack-adr-new/SKILL.md +23 -73
- package/template/.claude/skills/hstack-adversarial-review/SKILL.md +46 -152
- package/template/.claude/skills/hstack-adversarial-review/references/finding-categories.md +80 -103
- package/template/.claude/skills/hstack-app-architecture/SKILL.md +43 -68
- package/template/.claude/skills/hstack-commit/SKILL.md +27 -68
- package/template/.claude/skills/hstack-data-architecture/SKILL.md +34 -56
- package/template/.claude/skills/hstack-promote/SKILL.md +55 -0
- package/template/.claude/skills/hstack-story/SKILL.md +58 -0
- package/template/.claude/skills/hstack-test-audit/SKILL.md +64 -0
- package/template/.claude/skills/hstack-wrap/SKILL.md +48 -0
- package/template/.claude/skills/hstack-wrap/references/pr-description.md +45 -0
- package/template/KERNEL.md +75 -318
- package/template/templates/adr.md +2 -18
- package/template/templates/app-architecture.md +38 -50
- package/template/templates/data-architecture.md +38 -89
- package/template/templates/infrastructure.md +54 -148
- package/template/templates/roadmap.md +6 -12
- package/template/templates/story.md +7 -13
- package/template/templates/tech-debt.md +2 -37
- package/template/templates/tech-stack.md +2 -12
- package/template/.claude/agents/data-specialist.md +0 -81
- package/template/.claude/agents/implementer.md +0 -97
- package/template/.claude/agents/kernel-fit-analyst.md +0 -129
- package/template/.claude/agents/planner.md +0 -75
- package/template/.claude/agents/product-discovery.md +0 -129
- package/template/.claude/agents/product-manager.md +0 -76
- package/template/.claude/agents/researcher.md +0 -87
- package/template/.claude/agents/security-reviewer.md +0 -88
- package/template/.claude/agents/spec-author.md +0 -89
- package/template/.claude/agents/stack-architect.md +0 -108
- package/template/.claude/agents/ui-ux-briefer.md +0 -75
- package/template/.claude/agents/verifier.md +0 -81
- package/template/.claude/skills/hstack-branch/SKILL.md +0 -76
- package/template/.claude/skills/hstack-brownfield-init/SKILL.md +0 -131
- package/template/.claude/skills/hstack-change-new/SKILL.md +0 -84
- package/template/.claude/skills/hstack-change-plan/SKILL.md +0 -108
- package/template/.claude/skills/hstack-configure/SKILL.md +0 -99
- package/template/.claude/skills/hstack-coord/SKILL.md +0 -95
- package/template/.claude/skills/hstack-data-review/SKILL.md +0 -93
- package/template/.claude/skills/hstack-finalize/SKILL.md +0 -140
- package/template/.claude/skills/hstack-flag/SKILL.md +0 -99
- package/template/.claude/skills/hstack-greenfield-init/SKILL.md +0 -148
- package/template/.claude/skills/hstack-help/SKILL.md +0 -153
- package/template/.claude/skills/hstack-implement/SKILL.md +0 -155
- package/template/.claude/skills/hstack-kernel-fit-promote/SKILL.md +0 -125
- package/template/.claude/skills/hstack-kernel-fit-scan/SKILL.md +0 -142
- package/template/.claude/skills/hstack-kernel-fit-scan/references/slack-setup.md +0 -42
- package/template/.claude/skills/hstack-kernel-fit-triage/SKILL.md +0 -121
- package/template/.claude/skills/hstack-module-spec/SKILL.md +0 -78
- package/template/.claude/skills/hstack-product-discovery/SKILL.md +0 -75
- package/template/.claude/skills/hstack-research/SKILL.md +0 -115
- package/template/.claude/skills/hstack-scaffold/SKILL.md +0 -113
- package/template/.claude/skills/hstack-security-review/SKILL.md +0 -95
- package/template/.claude/skills/hstack-ship/SKILL.md +0 -119
- package/template/.claude/skills/hstack-stack-decide/SKILL.md +0 -81
- package/template/.claude/skills/hstack-story-draft/SKILL.md +0 -92
- package/template/.claude/skills/hstack-tech-debt-new/SKILL.md +0 -95
- package/template/.claude/skills/hstack-tech-debt-resolve/SKILL.md +0 -123
- package/template/.claude/skills/hstack-tech-debt-stale/SKILL.md +0 -86
- package/template/.claude/skills/hstack-tech-debt-wontfix/SKILL.md +0 -77
- package/template/.claude/skills/hstack-telemetry/SKILL.md +0 -73
- package/template/.claude/skills/hstack-test-plan/SKILL.md +0 -159
- package/template/.claude/skills/hstack-ui-brief/SKILL.md +0 -81
- package/template/.claude/skills/hstack-verify/SKILL.md +0 -128
- package/template/scripts/compute-merge-readiness.mjs +0 -780
- package/template/scripts/coord/coord_scan.py +0 -681
- package/template/scripts/run-gates.sh +0 -388
- package/template/scripts/telemetry/__init__.py +0 -6
- package/template/scripts/telemetry/insights/__init__.py +0 -0
- package/template/scripts/telemetry/insights/contract_drift.py +0 -137
- package/template/scripts/telemetry/insights/kernel_fit.py +0 -438
- package/template/scripts/telemetry/insights/overengineering.py +0 -115
- package/template/scripts/telemetry/insights/quality_outcomes.py +0 -131
- package/template/scripts/telemetry/insights/token_economics.py +0 -302
- package/template/scripts/telemetry/insights/workflow_shape.py +0 -198
- package/template/scripts/telemetry/parsers/__init__.py +0 -0
- package/template/scripts/telemetry/parsers/bodies.py +0 -87
- package/template/scripts/telemetry/parsers/commits.py +0 -219
- package/template/scripts/telemetry/parsers/frontmatter.py +0 -322
- package/template/scripts/telemetry/parsers/sidecars.py +0 -61
- package/template/scripts/telemetry/parsers/transcripts.py +0 -294
- package/template/scripts/telemetry/render.py +0 -460
- package/template/scripts/telemetry/report.py +0 -150
- package/template/scripts/telemetry/run_kernel_fit.py +0 -95
- package/template/scripts/telemetry/session_id.py +0 -139
- package/template/scripts/validate-spec.mjs +0 -3303
- package/template/templates/adversarial-review.md +0 -54
- package/template/templates/bootstrap.md +0 -105
- package/template/templates/change-spec.md +0 -82
- package/template/templates/ci-cd.md +0 -41
- package/template/templates/coord-message.md +0 -56
- package/template/templates/data-review.md +0 -54
- package/template/templates/discovery/brainstorm.md +0 -69
- package/template/templates/discovery/forcing-questions.md +0 -76
- package/template/templates/discovery/project-brief.md +0 -53
- package/template/templates/figma-handoff.md +0 -38
- package/template/templates/glossary.md +0 -20
- package/template/templates/hardening-checklist.md +0 -73
- package/template/templates/incident-runbook.md +0 -57
- package/template/templates/kernel-fit-finding.md +0 -63
- package/template/templates/kernel-fit-flag.md +0 -52
- package/template/templates/module-spec.md +0 -49
- package/template/templates/persona.md +0 -38
- package/template/templates/plan.md +0 -53
- package/template/templates/product-brief.md +0 -56
- package/template/templates/security-review.md +0 -63
- package/template/templates/telemetry-sidecar.md +0 -227
- package/template/templates/test-plan.md +0 -119
- package/template/templates/threat-model.md +0 -54
- package/template/templates/ui-brief.md +0 -49
- package/template/templates/verification.md +0 -63
- 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())
|