hstack 0.7.1 → 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 +308 -0
- package/README.md +90 -154
- package/VERSION +1 -1
- package/dist/commands/doctor.js +69 -9
- 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 +17 -4
- package/dist/commands/update.js.map +1 -1
- package/dist/lib/descriptions.js +160 -0
- package/dist/lib/descriptions.js.map +1 -0
- package/dist/lib/diff.js +1 -1
- package/dist/lib/git.js +16 -0
- package/dist/lib/git.js.map +1 -1
- package/dist/lib/wire.js +274 -142
- package/dist/lib/wire.js.map +1 -1
- package/dist/manifest.js +49 -22
- package/dist/manifest.js.map +1 -1
- package/package.json +4 -3
- package/template/.claude/agents/adversarial-reviewer.md +42 -98
- package/template/.claude/agents/app-architect.md +44 -133
- package/template/.claude/agents/data-architect.md +46 -127
- package/template/.claude/agents/test-strategist.md +39 -109
- package/template/.claude/skills/hstack-adr-new/SKILL.md +23 -100
- package/template/.claude/skills/hstack-adversarial-review/SKILL.md +47 -174
- package/template/.claude/skills/hstack-adversarial-review/references/finding-categories.md +134 -0
- package/template/.claude/skills/hstack-app-architecture/SKILL.md +43 -95
- package/template/.claude/skills/hstack-commit/SKILL.md +28 -97
- package/template/.claude/skills/hstack-data-architecture/SKILL.md +34 -82
- 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 +167 -0
- package/template/templates/adr.md +9 -18
- package/template/templates/app-architecture.md +38 -50
- package/template/templates/data-architecture.md +39 -93
- package/template/templates/infrastructure.md +54 -148
- package/template/templates/roadmap.md +35 -0
- 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 -126
- package/template/.claude/agents/implementer.md +0 -154
- package/template/.claude/agents/kernel-fit-analyst.md +0 -190
- package/template/.claude/agents/planner.md +0 -110
- package/template/.claude/agents/product-discovery.md +0 -165
- package/template/.claude/agents/product-manager.md +0 -111
- package/template/.claude/agents/researcher.md +0 -123
- package/template/.claude/agents/security-reviewer.md +0 -123
- package/template/.claude/agents/spec-author.md +0 -123
- package/template/.claude/agents/stack-architect.md +0 -137
- package/template/.claude/agents/ui-ux-briefer.md +0 -105
- package/template/.claude/agents/verifier.md +0 -113
- package/template/.claude/skills/hstack-branch/SKILL.md +0 -103
- package/template/.claude/skills/hstack-brownfield-init/SKILL.md +0 -158
- package/template/.claude/skills/hstack-change-new/SKILL.md +0 -111
- package/template/.claude/skills/hstack-change-plan/SKILL.md +0 -119
- package/template/.claude/skills/hstack-configure/SKILL.md +0 -126
- package/template/.claude/skills/hstack-coord/SKILL.md +0 -131
- package/template/.claude/skills/hstack-data-review/SKILL.md +0 -132
- package/template/.claude/skills/hstack-finalize/SKILL.md +0 -159
- package/template/.claude/skills/hstack-flag/SKILL.md +0 -138
- package/template/.claude/skills/hstack-greenfield-init/SKILL.md +0 -175
- package/template/.claude/skills/hstack-help/SKILL.md +0 -179
- package/template/.claude/skills/hstack-implement/SKILL.md +0 -185
- package/template/.claude/skills/hstack-kernel-fit-promote/SKILL.md +0 -164
- package/template/.claude/skills/hstack-kernel-fit-scan/SKILL.md +0 -197
- package/template/.claude/skills/hstack-kernel-fit-triage/SKILL.md +0 -159
- package/template/.claude/skills/hstack-module-spec/SKILL.md +0 -105
- package/template/.claude/skills/hstack-product-discovery/SKILL.md +0 -101
- package/template/.claude/skills/hstack-research/SKILL.md +0 -145
- package/template/.claude/skills/hstack-scaffold/SKILL.md +0 -140
- package/template/.claude/skills/hstack-security-review/SKILL.md +0 -133
- package/template/.claude/skills/hstack-ship/SKILL.md +0 -129
- package/template/.claude/skills/hstack-stack-decide/SKILL.md +0 -108
- package/template/.claude/skills/hstack-story-draft/SKILL.md +0 -119
- package/template/.claude/skills/hstack-tech-debt-new/SKILL.md +0 -122
- package/template/.claude/skills/hstack-tech-debt-resolve/SKILL.md +0 -158
- package/template/.claude/skills/hstack-tech-debt-stale/SKILL.md +0 -113
- package/template/.claude/skills/hstack-tech-debt-wontfix/SKILL.md +0 -104
- package/template/.claude/skills/hstack-telemetry/SKILL.md +0 -98
- package/template/.claude/skills/hstack-test-plan/SKILL.md +0 -182
- package/template/.claude/skills/hstack-ui-brief/SKILL.md +0 -108
- package/template/.claude/skills/hstack-verify/SKILL.md +0 -150
- package/template/CLAUDE.md +0 -443
- package/template/scripts/coord/coord_scan.py +0 -681
- 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 -129
- 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/transcripts.py +0 -181
- package/template/scripts/telemetry/render.py +0 -395
- package/template/scripts/telemetry/report.py +0 -138
- package/template/scripts/telemetry/run_kernel_fit.py +0 -91
- 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 -27
- package/template/templates/coord-message.md +0 -55
- 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/mvp-scope.md +0 -34
- package/template/templates/persona.md +0 -38
- package/template/templates/plan.md +0 -49
- package/template/templates/product-brief.md +0 -56
- package/template/templates/security-review.md +0 -63
- package/template/templates/telemetry-sidecar.md +0 -184
- 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,181 +0,0 @@
|
|
|
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
|
|
@@ -1,395 +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 (proxy for per-change cost)")
|
|
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, v1 coarse grain)")
|
|
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
|
-
_table(
|
|
78
|
-
lines,
|
|
79
|
-
["skill", "turns", "cache_read", "cache_creation", "ratio"],
|
|
80
|
-
[[r["skill"], r["turns"], r["cache_read"], r["cache_creation"],
|
|
81
|
-
(f"{r['ratio']:.2%}" if r["ratio"] is not None else "-")]
|
|
82
|
-
for r in te2.get("rows", [])],
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
te3 = te.get("te_3_subagent_entry_tax", {})
|
|
86
|
-
_h(lines, 3, "TE-3 — subagent entry-tax amortization")
|
|
87
|
-
_p(lines, te3.get("note", ""))
|
|
88
|
-
_table(
|
|
89
|
-
lines,
|
|
90
|
-
["subagent", "appearances", "host cache_creation total", "/ invocation"],
|
|
91
|
-
[[r["subagent"], r["appearances"], r["host_cache_creation_total"],
|
|
92
|
-
r["host_cache_creation_per_invocation"]]
|
|
93
|
-
for r in te3.get("rows", [])],
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def _render_workflow_shape(lines: list[str], ws: dict) -> None:
|
|
98
|
-
_h(lines, 2, "Workflow shape")
|
|
99
|
-
|
|
100
|
-
ws1 = ws.get("ws_1_phase_duration", {})
|
|
101
|
-
_h(lines, 3, "WS-1 — phase duration (between successive implement commits)")
|
|
102
|
-
summary = ws1.get("summary")
|
|
103
|
-
if summary:
|
|
104
|
-
_table(
|
|
105
|
-
lines,
|
|
106
|
-
["intervals counted", "median (h)", "p90 (h)", "max (h)"],
|
|
107
|
-
[[summary["count"], summary["median_h"], summary["p90_h"], summary["max_h"]]],
|
|
108
|
-
)
|
|
109
|
-
else:
|
|
110
|
-
_p(lines, "_(no implement commits in window)_")
|
|
111
|
-
_table(
|
|
112
|
-
lines,
|
|
113
|
-
["change", "phases", "min (h)", "mean (h)", "max (h)"],
|
|
114
|
-
[[r["change"], r["phases"], r["min_h"], r["mean_h"], r["max_h"]]
|
|
115
|
-
for r in ws1.get("per_change", [])[:10]],
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
ws2 = ws.get("ws_2_gate_findings_density", {})
|
|
119
|
-
_h(lines, 3, "WS-2 — gate findings density")
|
|
120
|
-
_table(
|
|
121
|
-
lines,
|
|
122
|
-
["metric", "value"],
|
|
123
|
-
[
|
|
124
|
-
["adversarial-review total findings (all changes)", ws2.get("adversarial_review_total_findings", 0)],
|
|
125
|
-
["changes with ≥1 adversarial finding", ws2.get("adversarial_review_changes_with_findings", 0)],
|
|
126
|
-
["security-reviews scored", ws2.get("security_review_changes_scored", 0)],
|
|
127
|
-
["security-review CONCERNS-or-FAIL items", ws2.get("security_review_concerns_or_fail_items", 0)],
|
|
128
|
-
["data-reviews scored", ws2.get("data_review_changes_scored", 0)],
|
|
129
|
-
["data-review partial/missing RLS items", ws2.get("data_review_partial_or_missing_items", 0)],
|
|
130
|
-
],
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
ws4 = ws.get("ws_4_scope_amendment_rate", {})
|
|
134
|
-
_h(lines, 3, "WS-4 — scope-amendment rate (upper bound)")
|
|
135
|
-
_p(lines, ws4.get("note", ""))
|
|
136
|
-
_table(
|
|
137
|
-
lines,
|
|
138
|
-
["metric", "value"],
|
|
139
|
-
[
|
|
140
|
-
["total spec.md writes after ready-for-implementation", ws4.get("total_spec_writes_after_rfi", 0)],
|
|
141
|
-
["changes with ≥1 post-RFI spec write", ws4.get("changes_with_post_rfi_writes", 0)],
|
|
142
|
-
["rate (changes-with-amendment / total)", f"{ws4.get('rate', 0):.2%}"],
|
|
143
|
-
],
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
ws6 = ws.get("ws_6_halt_reasons", {})
|
|
147
|
-
_h(lines, 3, "WS-6 — halt reasons (HSTACK-HALT sentinel)")
|
|
148
|
-
_p(lines, ws6.get("note", ""))
|
|
149
|
-
_table(
|
|
150
|
-
lines,
|
|
151
|
-
["reason", "count"],
|
|
152
|
-
[[r["reason"], r["count"]] for r in ws6.get("rows", [])],
|
|
153
|
-
)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def _render_quality_outcomes(lines: list[str], qo: dict) -> None:
|
|
157
|
-
_h(lines, 2, "Quality outcomes")
|
|
158
|
-
|
|
159
|
-
qo2 = qo.get("qo_2_severity_resolution_mix", {})
|
|
160
|
-
_h(lines, 3, "QO-2 — severity × resolution-type")
|
|
161
|
-
res_types = ["commit", "tech-debt", "justified-in-prose", "other"]
|
|
162
|
-
_table(
|
|
163
|
-
lines,
|
|
164
|
-
["severity"] + res_types,
|
|
165
|
-
[[r["severity"]] + [r.get(rt, 0) for rt in res_types] for r in qo2.get("rows", [])],
|
|
166
|
-
)
|
|
167
|
-
smells = qo2.get("high_severity_in_prose_smells", [])
|
|
168
|
-
if smells:
|
|
169
|
-
_p(lines, "**High/critical findings resolved as `justified-in-prose` (smell):**")
|
|
170
|
-
_table(
|
|
171
|
-
lines,
|
|
172
|
-
["change", "finding", "category", "severity"],
|
|
173
|
-
[[s["change"], s["finding_id"], s["category"], s["severity"]] for s in smells],
|
|
174
|
-
)
|
|
175
|
-
else:
|
|
176
|
-
_p(lines, "_No high/critical findings resolved as `justified-in-prose` — healthy._")
|
|
177
|
-
|
|
178
|
-
qo3 = qo.get("qo_3_test_immutability_audit", {})
|
|
179
|
-
_h(lines, 3, "QO-3 — test-immutability audit")
|
|
180
|
-
_p(lines, qo3.get("note", ""))
|
|
181
|
-
_p(lines, f"Authorized test-change commits: **{qo3.get('authorized_count', 0)}**")
|
|
182
|
-
cv = qo3.get("candidate_violations", [])
|
|
183
|
-
if cv:
|
|
184
|
-
_p(lines, "**Non-implement commits touching test files without canonical authorization (review manually):**")
|
|
185
|
-
_table(
|
|
186
|
-
lines,
|
|
187
|
-
["sha", "subject", "artifact_type", "test files"],
|
|
188
|
-
[[c["sha"][:8], c["subject"][:80], c["artifact_type"] or "-",
|
|
189
|
-
", ".join(c["test_files"][:3]) + ("…" if len(c["test_files"]) > 3 else "")]
|
|
190
|
-
for c in cv[:20]],
|
|
191
|
-
)
|
|
192
|
-
else:
|
|
193
|
-
_p(lines, "_No candidate violations found — healthy._")
|
|
194
|
-
|
|
195
|
-
qo4 = qo.get("qo_4_observed_vs_promised", {})
|
|
196
|
-
_h(lines, 3, "QO-4 — verifier observed-vs-promised (test-plan-coverage)")
|
|
197
|
-
summary = qo4.get("summary", {})
|
|
198
|
-
if summary:
|
|
199
|
-
for key, buckets in summary.items():
|
|
200
|
-
_p(lines, f"**{key}:** " + ", ".join(f"{v}={n}" for v, n in sorted(buckets.items())))
|
|
201
|
-
_table(
|
|
202
|
-
lines,
|
|
203
|
-
["change", "edge-cases", "tenant-isolation", "performance-budgets"],
|
|
204
|
-
[[r.get("change"), r.get("edge-cases", "-"),
|
|
205
|
-
r.get("tenant-isolation", "-"), r.get("performance-budgets", "-")]
|
|
206
|
-
for r in qo4.get("per_change", [])],
|
|
207
|
-
)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def _render_overengineering(lines: list[str], oe: dict) -> None:
|
|
211
|
-
_h(lines, 2, "Overengineering detection")
|
|
212
|
-
|
|
213
|
-
oe1 = oe.get("oe_1_artifact_to_diff_ratio", {})
|
|
214
|
-
_h(lines, 3, "OE-1 — artifact tokens per diff line")
|
|
215
|
-
_p(lines, "Ratio above ~50 tokens/line suggests heavy spec-vs-code; ratio below ~5 "
|
|
216
|
-
"suggests an under-specified change. Honest signal, not a verdict.")
|
|
217
|
-
_table(
|
|
218
|
-
lines,
|
|
219
|
-
["change", "artifact tokens", "diff lines (implement commits)", "tokens / line"],
|
|
220
|
-
[[r["change"], r["artifact_tokens"], r["diff_lines"], r["tokens_per_diff_line"] or "-"]
|
|
221
|
-
for r in oe1.get("rows", [])],
|
|
222
|
-
)
|
|
223
|
-
|
|
224
|
-
oe3 = oe.get("oe_3_subagent_context_load_amortization", {})
|
|
225
|
-
_h(lines, 3, "OE-3 — subagent invocations × host cost")
|
|
226
|
-
_table(
|
|
227
|
-
lines,
|
|
228
|
-
["subagent", "invocations", "host cost-score total", "/ invocation"],
|
|
229
|
-
[[r["subagent"], r["invocations"], r["host_cost_score_total"], r["cost_per_invocation"]]
|
|
230
|
-
for r in oe3.get("rows", [])],
|
|
231
|
-
)
|
|
232
|
-
|
|
233
|
-
oe5 = oe.get("oe_5_trivial_eligible_full_gauntlet", {})
|
|
234
|
-
_h(lines, 3, "OE-5 — trivial-eligible changes that ran the full gauntlet")
|
|
235
|
-
_p(lines, oe5.get("note", ""))
|
|
236
|
-
_table(
|
|
237
|
-
lines,
|
|
238
|
-
["change", "status", "surfaces", "files touched in implement"],
|
|
239
|
-
[[r["change"], r["status"], r["surfaces"], r["files_touched_in_implement_commits"]]
|
|
240
|
-
for r in oe5.get("rows", [])],
|
|
241
|
-
)
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
def _render_contract_drift(lines: list[str], cd: dict) -> None:
|
|
245
|
-
_h(lines, 2, "Contract drift")
|
|
246
|
-
|
|
247
|
-
ms = cd.get("module_spec_staleness", {})
|
|
248
|
-
_h(lines, 3, "Module-spec staleness × recent activity")
|
|
249
|
-
_table(
|
|
250
|
-
lines,
|
|
251
|
-
["module", "spec status", "spec updated", "recent commits touching module", "drift flag"],
|
|
252
|
-
[[r["module"], r["status"], r["updated"],
|
|
253
|
-
r["recent_commits_touching_module"], "⚠️" if r["drift_flag"] else ""]
|
|
254
|
-
for r in ms.get("rows", [])],
|
|
255
|
-
)
|
|
256
|
-
|
|
257
|
-
adr = cd.get("adr_supersession_lag", {})
|
|
258
|
-
_h(lines, 3, "ADR supersession lag")
|
|
259
|
-
_table(
|
|
260
|
-
lines,
|
|
261
|
-
["ADR", "superseded by", "lag (days)"],
|
|
262
|
-
[[r["adr"], r["superseded_by"], r["lag_days"]] for r in adr.get("rows", [])],
|
|
263
|
-
)
|
|
264
|
-
|
|
265
|
-
td = cd.get("tech_debt_half_life", {})
|
|
266
|
-
_h(lines, 3, "Tech-debt half-life by exit path")
|
|
267
|
-
summary = td.get("summary", {})
|
|
268
|
-
_table(
|
|
269
|
-
lines,
|
|
270
|
-
["exit path", "count", "median days", "max days"],
|
|
271
|
-
[[k, v["count"], v["median_days"], v["max_days"]] for k, v in sorted(summary.items())],
|
|
272
|
-
)
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
def _render_kernel_fit(lines: list[str], kf: dict) -> None:
|
|
276
|
-
_h(lines, 2, "Kernel-fit candidates")
|
|
277
|
-
_p(lines, "Patterns suggesting the kernel itself (CLAUDE.md, templates, validators, Skill "
|
|
278
|
-
"flows) may need revision. Each fired pattern is also written as a durable finding "
|
|
279
|
-
"by `/hstack:kernel-fit-scan` at `hstack/kernel-fit/findings/KF-NNNN-*.md`. The "
|
|
280
|
-
"table below is a rollup; the findings are the canonical artifact. See ADR-0004.")
|
|
281
|
-
|
|
282
|
-
existing = kf.get("existing_open_findings_by_pattern", {})
|
|
283
|
-
if existing:
|
|
284
|
-
total_open = sum(len(v) for v in existing.values())
|
|
285
|
-
_p(lines, f"**Currently open findings:** {total_open} "
|
|
286
|
-
+ "(" + ", ".join(f"{p}: {len(ids)}" for p, ids in sorted(existing.items())) + ")")
|
|
287
|
-
|
|
288
|
-
patterns = [
|
|
289
|
-
("kf_p1_category_a_claim_spans_production_paths", "KF-P1 — category-a-claim-spans-production-paths"),
|
|
290
|
-
("kf_p2_halt_reason_cluster_uncovered_by_enum", "KF-P2 — halt-reason-cluster-uncovered-by-enum"),
|
|
291
|
-
("kf_p3_skill_precondition_violated_and_recoverable", "KF-P3 — skill-precondition-violated-and-recoverable"),
|
|
292
|
-
]
|
|
293
|
-
for key, heading in patterns:
|
|
294
|
-
block = kf.get(key, {})
|
|
295
|
-
_h(lines, 3, heading)
|
|
296
|
-
_p(lines, block.get("note", ""))
|
|
297
|
-
fired = block.get("fired", False)
|
|
298
|
-
rc = block.get("evidence_row_count", 0)
|
|
299
|
-
if fired:
|
|
300
|
-
_p(lines, f"**Fired** — {rc} evidence row(s).")
|
|
301
|
-
else:
|
|
302
|
-
_p(lines, f"_(not fired — {rc} evidence row(s); threshold not met)_")
|
|
303
|
-
|
|
304
|
-
# Per-pattern row rendering.
|
|
305
|
-
if key == "kf_p1_category_a_claim_spans_production_paths":
|
|
306
|
-
rows = block.get("evidence_rows", [])
|
|
307
|
-
_table(
|
|
308
|
-
lines,
|
|
309
|
-
["change", "production paths", "enables", "downstream consumers", "classification"],
|
|
310
|
-
[[r["change"], r["production_paths_count"], r["enables_count"],
|
|
311
|
-
", ".join(r["downstream_consumers"][:3]) + ("…" if len(r["downstream_consumers"]) > 3 else ""),
|
|
312
|
-
r["classification_candidate"]]
|
|
313
|
-
for r in rows[:10]],
|
|
314
|
-
)
|
|
315
|
-
elif key == "kf_p2_halt_reason_cluster_uncovered_by_enum":
|
|
316
|
-
rows = block.get("evidence_rows", [])
|
|
317
|
-
_table(
|
|
318
|
-
lines,
|
|
319
|
-
["cluster", "size", "representative context (truncated)"],
|
|
320
|
-
[[r["cluster_id"], r["size"], r["representative_context"][:120]]
|
|
321
|
-
for r in rows[:10]],
|
|
322
|
-
)
|
|
323
|
-
elif key == "kf_p3_skill_precondition_violated_and_recoverable":
|
|
324
|
-
rows = block.get("evidence_rows", [])
|
|
325
|
-
_table(
|
|
326
|
-
lines,
|
|
327
|
-
["change", "finding", "matched keywords", "commit subject (truncated)"],
|
|
328
|
-
[[r["change"], r["finding_id"], ", ".join(r["matched_keywords"]),
|
|
329
|
-
(r["commit_subject"] or "-")[:80]]
|
|
330
|
-
for r in rows[:10]],
|
|
331
|
-
)
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def watch_items(metrics: dict) -> list[str]:
|
|
335
|
-
"""Compute the watch-list lines from the metrics dict.
|
|
336
|
-
|
|
337
|
-
Shared between the markdown renderer and the JSON emission so both
|
|
338
|
-
surfaces flag the same anomalies.
|
|
339
|
-
"""
|
|
340
|
-
items: list[str] = []
|
|
341
|
-
|
|
342
|
-
# TE-2: any Skill cache-hit below 0.5
|
|
343
|
-
te2 = metrics.get("token_economics", {}).get("te_2_cache_hit_per_subagent", {})
|
|
344
|
-
for r in te2.get("rows", []):
|
|
345
|
-
if r.get("ratio") is not None and r["ratio"] < 0.5 and r["turns"] > 5:
|
|
346
|
-
items.append(f"Low cache-hit on `{r['skill']}` ({r['ratio']:.0%}) — context is being rebuilt.")
|
|
347
|
-
|
|
348
|
-
# QO-2 smells
|
|
349
|
-
smells = metrics.get("quality_outcomes", {}).get("qo_2_severity_resolution_mix", {}).get("high_severity_in_prose_smells", [])
|
|
350
|
-
if smells:
|
|
351
|
-
items.append(f"{len(smells)} high/critical adversarial finding(s) resolved as `justified-in-prose` — review.")
|
|
352
|
-
|
|
353
|
-
# QO-3 violations
|
|
354
|
-
cv = metrics.get("quality_outcomes", {}).get("qo_3_test_immutability_audit", {}).get("candidate_violations", [])
|
|
355
|
-
if cv:
|
|
356
|
-
items.append(f"{len(cv)} candidate test-immutability violations (non-implement commits touching tests without authorization).")
|
|
357
|
-
|
|
358
|
-
# WS-4 amendment rate above 30%
|
|
359
|
-
ws4 = metrics.get("workflow_shape", {}).get("ws_4_scope_amendment_rate", {})
|
|
360
|
-
rate = ws4.get("rate", 0)
|
|
361
|
-
if rate > 0.3:
|
|
362
|
-
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.")
|
|
363
|
-
|
|
364
|
-
# Module drift
|
|
365
|
-
ms = metrics.get("contract_drift", {}).get("module_spec_staleness", {}).get("rows", [])
|
|
366
|
-
for r in ms:
|
|
367
|
-
if r["drift_flag"]:
|
|
368
|
-
items.append(f"Module-spec drift: `{r['module']}` is `needs-refresh` with {r['recent_commits_touching_module']} recent commits.")
|
|
369
|
-
|
|
370
|
-
# Kernel-fit fired patterns
|
|
371
|
-
kf = metrics.get("kernel_fit", {})
|
|
372
|
-
for key, label in (
|
|
373
|
-
("kf_p1_category_a_claim_spans_production_paths", "KF-P1"),
|
|
374
|
-
("kf_p2_halt_reason_cluster_uncovered_by_enum", "KF-P2"),
|
|
375
|
-
("kf_p3_skill_precondition_violated_and_recoverable", "KF-P3"),
|
|
376
|
-
):
|
|
377
|
-
block = kf.get(key, {})
|
|
378
|
-
if block.get("fired"):
|
|
379
|
-
rc = block.get("evidence_row_count", 0)
|
|
380
|
-
items.append(f"Kernel-fit {label} fired with {rc} evidence row(s) — "
|
|
381
|
-
f"run `/hstack:kernel-fit-scan` to synthesize findings.")
|
|
382
|
-
|
|
383
|
-
return items
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
def _render_watch_list(lines: list[str], metrics: dict) -> None:
|
|
387
|
-
_h(lines, 2, "Watch list")
|
|
388
|
-
items = watch_items(metrics)
|
|
389
|
-
|
|
390
|
-
if not items:
|
|
391
|
-
_p(lines, "_Nothing flagged. Either everything is healthy, or the metrics need tuning._")
|
|
392
|
-
return
|
|
393
|
-
for item in items:
|
|
394
|
-
lines.append(f"- {item}")
|
|
395
|
-
lines.append("")
|