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