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,115 @@
|
|
|
1
|
+
"""Overengineering insights: OE-1 artifact/diff ratio, OE-3 context-load ×
|
|
2
|
+
invocations × downstream-refs, OE-5 trivial-eligible that ran the gauntlet."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
|
|
8
|
+
from telemetry.parsers.bodies import approx_token_count
|
|
9
|
+
from telemetry.parsers.commits import diff_line_count
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def compute(commits: list[dict], changes: dict, session_rows: list[dict], repo) -> dict:
|
|
13
|
+
return {
|
|
14
|
+
"oe_1_artifact_to_diff_ratio": _oe_1(changes, commits, repo),
|
|
15
|
+
"oe_3_subagent_context_load_amortization": _oe_3(session_rows, changes),
|
|
16
|
+
"oe_5_trivial_eligible_full_gauntlet": _oe_5(changes, commits),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _oe_1(changes: dict, commits: list[dict], repo) -> dict:
|
|
21
|
+
"""OE-1: artifact tokens / code-diff lines.
|
|
22
|
+
|
|
23
|
+
Sums artifact-body token counts (approx 4 chars/token) per change, divides
|
|
24
|
+
by total diff lines on implement(<change-id>) commits for that change.
|
|
25
|
+
Ratio above ~50 is a strong overengineering signal; below ~5 the change is
|
|
26
|
+
likely under-specified for its complexity.
|
|
27
|
+
"""
|
|
28
|
+
rows = []
|
|
29
|
+
for cid, arts in sorted(changes.items()):
|
|
30
|
+
artifact_tokens = 0
|
|
31
|
+
for atype, data in arts.items():
|
|
32
|
+
artifact_tokens += approx_token_count(data["body"])
|
|
33
|
+
# Sum implement-commit diffs for this change.
|
|
34
|
+
diff_lines = 0
|
|
35
|
+
for c in commits:
|
|
36
|
+
if c["artifact_type"] != "implement" or c["artifact_id"] != cid:
|
|
37
|
+
continue
|
|
38
|
+
added, removed = diff_line_count(repo, c["sha"])
|
|
39
|
+
diff_lines += added + removed
|
|
40
|
+
ratio = (artifact_tokens / diff_lines) if diff_lines > 0 else None
|
|
41
|
+
rows.append({
|
|
42
|
+
"change": cid,
|
|
43
|
+
"artifact_tokens": artifact_tokens,
|
|
44
|
+
"diff_lines": diff_lines,
|
|
45
|
+
"tokens_per_diff_line": round(ratio, 1) if ratio is not None else None,
|
|
46
|
+
})
|
|
47
|
+
rows.sort(key=lambda r: r["tokens_per_diff_line"] or 0, reverse=True)
|
|
48
|
+
return {"rows": rows}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _oe_3(session_rows: list[dict], changes: dict) -> dict:
|
|
52
|
+
"""OE-3: per subagent, invocations × estimated context-load size.
|
|
53
|
+
|
|
54
|
+
Without sidecars to attribute downstream-reference-count, this v1 metric
|
|
55
|
+
surfaces just the subagent × invocation-count × cost dimension and lets
|
|
56
|
+
the reader eyeball which subagents are paying repeated entry-tax.
|
|
57
|
+
"""
|
|
58
|
+
appearances = defaultdict(int)
|
|
59
|
+
total_cost_in_host = defaultdict(int)
|
|
60
|
+
for s in session_rows:
|
|
61
|
+
for sa in s["subagents"]:
|
|
62
|
+
appearances[sa] += 1
|
|
63
|
+
total_cost_in_host[sa] += s["cost_score"]
|
|
64
|
+
rows = []
|
|
65
|
+
for sa, n in sorted(appearances.items(), key=lambda kv: -kv[1]):
|
|
66
|
+
rows.append({
|
|
67
|
+
"subagent": sa,
|
|
68
|
+
"invocations": n,
|
|
69
|
+
"host_cost_score_total": total_cost_in_host[sa],
|
|
70
|
+
"cost_per_invocation": total_cost_in_host[sa] // n if n else 0,
|
|
71
|
+
})
|
|
72
|
+
return {"rows": rows}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _oe_5(changes: dict, commits: list[dict]) -> dict:
|
|
76
|
+
"""OE-5: trivial-eligible changes that ran the full gauntlet.
|
|
77
|
+
|
|
78
|
+
Heuristic: change-spec at `shipped` (or any terminal state) with `trivial:
|
|
79
|
+
false`, empty `surfaces`, zero or one invariant. These are candidates that
|
|
80
|
+
might have qualified for the trivial-tag escape hatch but ran every gate.
|
|
81
|
+
"""
|
|
82
|
+
candidates = []
|
|
83
|
+
for cid, arts in sorted(changes.items()):
|
|
84
|
+
spec = arts.get("change-spec")
|
|
85
|
+
if not spec:
|
|
86
|
+
continue
|
|
87
|
+
fm = spec["fm"]
|
|
88
|
+
if fm.get("trivial") is True:
|
|
89
|
+
continue
|
|
90
|
+
status = fm.get("status")
|
|
91
|
+
if status not in ("shipped", "ready-to-ship", "archived"):
|
|
92
|
+
continue
|
|
93
|
+
surfaces = fm.get("surfaces") or []
|
|
94
|
+
if surfaces: # any declared surface disqualifies trivial-eligibility
|
|
95
|
+
continue
|
|
96
|
+
# diff size heuristic: a "trivial-eligible" change usually has <50 added lines
|
|
97
|
+
diff_total = 0
|
|
98
|
+
for c in commits:
|
|
99
|
+
if c["artifact_type"] == "implement" and c["artifact_id"] == cid:
|
|
100
|
+
# We can't get accurate line counts here without git access — use file count proxy
|
|
101
|
+
diff_total += len(c.get("files", []))
|
|
102
|
+
candidates.append({
|
|
103
|
+
"change": cid,
|
|
104
|
+
"status": status,
|
|
105
|
+
"surfaces": surfaces,
|
|
106
|
+
"files_touched_in_implement_commits": diff_total,
|
|
107
|
+
})
|
|
108
|
+
return {
|
|
109
|
+
"rows": candidates,
|
|
110
|
+
"note": (
|
|
111
|
+
"Heuristic only. A change with empty `surfaces` AND no `trivial: "
|
|
112
|
+
"true` tag is a candidate for retrospective trivial classification "
|
|
113
|
+
"— or a sign the surfaces list was under-declared."
|
|
114
|
+
),
|
|
115
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Quality-outcomes insights: QO-2 severity-resolution mix, QO-3 test-
|
|
2
|
+
immutability audit, QO-4 verifier observed-vs-promised."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def compute(commits: list[dict], changes: dict) -> dict:
|
|
10
|
+
return {
|
|
11
|
+
"qo_2_severity_resolution_mix": _qo_2(changes),
|
|
12
|
+
"qo_3_test_immutability_audit": _qo_3(commits),
|
|
13
|
+
"qo_4_observed_vs_promised": _qo_4(changes),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _qo_2(changes: dict) -> dict:
|
|
18
|
+
"""QO-2: per finding, cross-tab severity × resolution-type.
|
|
19
|
+
|
|
20
|
+
Resolution prefix is the part before the colon: commit | tech-debt |
|
|
21
|
+
justified-in-prose.
|
|
22
|
+
"""
|
|
23
|
+
matrix: dict[tuple[str, str], int] = defaultdict(int)
|
|
24
|
+
smell_cases = [] # high/critical severity resolved as justified-in-prose
|
|
25
|
+
for cid, arts in changes.items():
|
|
26
|
+
adv = arts.get("adversarial-review")
|
|
27
|
+
if not adv:
|
|
28
|
+
continue
|
|
29
|
+
findings = adv["fm"].get("findings")
|
|
30
|
+
if not isinstance(findings, list):
|
|
31
|
+
continue
|
|
32
|
+
for f in findings:
|
|
33
|
+
if not isinstance(f, dict):
|
|
34
|
+
continue
|
|
35
|
+
sev = (f.get("severity") or "unknown").lower()
|
|
36
|
+
res = (f.get("resolution") or "unknown")
|
|
37
|
+
res_type = res.split(":", 1)[0].strip().lower() if isinstance(res, str) else "unknown"
|
|
38
|
+
if res_type not in ("commit", "tech-debt", "justified-in-prose"):
|
|
39
|
+
res_type = "other"
|
|
40
|
+
matrix[(sev, res_type)] += 1
|
|
41
|
+
if sev in ("high", "critical") and res_type == "justified-in-prose":
|
|
42
|
+
smell_cases.append({
|
|
43
|
+
"change": cid,
|
|
44
|
+
"finding_id": f.get("id"),
|
|
45
|
+
"category": f.get("category"),
|
|
46
|
+
"severity": sev,
|
|
47
|
+
})
|
|
48
|
+
severities = sorted({s for s, _ in matrix.keys()})
|
|
49
|
+
res_types = ["commit", "tech-debt", "justified-in-prose", "other"]
|
|
50
|
+
rows = []
|
|
51
|
+
for sev in severities:
|
|
52
|
+
row = {"severity": sev}
|
|
53
|
+
for rt in res_types:
|
|
54
|
+
row[rt] = matrix.get((sev, rt), 0)
|
|
55
|
+
rows.append(row)
|
|
56
|
+
return {
|
|
57
|
+
"rows": rows,
|
|
58
|
+
"high_severity_in_prose_smells": smell_cases,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _qo_3(commits: list[dict]) -> dict:
|
|
63
|
+
"""QO-3: test-immutability audit.
|
|
64
|
+
|
|
65
|
+
For every commit touching a test file, check whether the body carries one
|
|
66
|
+
of the canonical authorization phrases. Unauthorized test-edit commits are
|
|
67
|
+
hard kernel violations.
|
|
68
|
+
"""
|
|
69
|
+
import re
|
|
70
|
+
test_re = re.compile(r"(\.test\.|\.spec\.|/__tests__/|/__snapshots__/|^e2e/|_test\.go$)")
|
|
71
|
+
auth_re = re.compile(
|
|
72
|
+
r"Ok to (change|delete) test\s+\S+"
|
|
73
|
+
r"|Ok to update snapshot\s+\S+"
|
|
74
|
+
r"|Ok to refresh fixture\s+\S+"
|
|
75
|
+
)
|
|
76
|
+
violations = []
|
|
77
|
+
authorized = []
|
|
78
|
+
for c in commits:
|
|
79
|
+
test_files = [f for f in c.get("files", []) if test_re.search(f)]
|
|
80
|
+
if not test_files:
|
|
81
|
+
continue
|
|
82
|
+
is_implement_or_test_plan = c.get("artifact_type") in ("implement", "test-plan")
|
|
83
|
+
# New-tests-in-an-implement-commit are permitted; we can't distinguish
|
|
84
|
+
# new-vs-modified without per-file diff inspection. As a heuristic, an
|
|
85
|
+
# implement commit touching a test file without an auth phrase is
|
|
86
|
+
# treated as a candidate, not a violation. Adversarial-review-time
|
|
87
|
+
# changes to a test file without auth ARE violations regardless.
|
|
88
|
+
has_auth = bool(auth_re.search(c.get("body", "")))
|
|
89
|
+
if has_auth:
|
|
90
|
+
authorized.append({"sha": c["sha"], "subject": c["subject"], "test_files": test_files})
|
|
91
|
+
elif is_implement_or_test_plan:
|
|
92
|
+
# candidate — could be a new test write, which is permitted
|
|
93
|
+
pass
|
|
94
|
+
else:
|
|
95
|
+
violations.append({
|
|
96
|
+
"sha": c["sha"],
|
|
97
|
+
"subject": c["subject"],
|
|
98
|
+
"test_files": test_files,
|
|
99
|
+
"artifact_type": c.get("artifact_type"),
|
|
100
|
+
})
|
|
101
|
+
return {
|
|
102
|
+
"authorized_count": len(authorized),
|
|
103
|
+
"candidate_violations": violations,
|
|
104
|
+
"note": (
|
|
105
|
+
"Implement-phase commits touching test files are not flagged here "
|
|
106
|
+
"(new tests are permitted by the test-immutability protocol). "
|
|
107
|
+
"Non-implement commits touching test files without an authorization "
|
|
108
|
+
"phrase are listed as candidate violations for manual review."
|
|
109
|
+
),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _qo_4(changes: dict) -> dict:
|
|
114
|
+
"""QO-4: verifier observed-vs-promised, summarized from
|
|
115
|
+
verification.test-plan-coverage map."""
|
|
116
|
+
counts = defaultdict(lambda: defaultdict(int))
|
|
117
|
+
per_change = []
|
|
118
|
+
for cid, arts in sorted(changes.items()):
|
|
119
|
+
v = arts.get("verification")
|
|
120
|
+
if not v:
|
|
121
|
+
continue
|
|
122
|
+
cov = v["fm"].get("test-plan-coverage")
|
|
123
|
+
if not isinstance(cov, dict):
|
|
124
|
+
continue
|
|
125
|
+
row = {"change": cid}
|
|
126
|
+
for key, value in cov.items():
|
|
127
|
+
counts[key][str(value)] += 1
|
|
128
|
+
row[key] = value
|
|
129
|
+
per_change.append(row)
|
|
130
|
+
summary = {key: dict(buckets) for key, buckets in counts.items()}
|
|
131
|
+
return {"summary": summary, "per_change": per_change}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Token-economics insights: TE-1 cost per change, TE-2 cache-hit ratio per
|
|
2
|
+
subagent, TE-3 subagent entry-tax amortization."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def compute(session_rows: list[dict], changes: dict) -> dict:
|
|
10
|
+
"""Compute the three TE metrics.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
session_rows: from transcripts.collect_session_rows
|
|
14
|
+
changes: from frontmatter.load_change_artifacts
|
|
15
|
+
|
|
16
|
+
Returns a dict ready for rendering.
|
|
17
|
+
"""
|
|
18
|
+
return {
|
|
19
|
+
"te_1_cost_per_change": _te_1(session_rows, changes),
|
|
20
|
+
"te_2_cache_hit_per_subagent": _te_2(session_rows),
|
|
21
|
+
"te_3_subagent_entry_tax": _te_3(session_rows),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _te_1(session_rows: list[dict], changes: dict) -> dict:
|
|
26
|
+
"""TE-1: cost-score per change-spec.
|
|
27
|
+
|
|
28
|
+
Best-effort attribution: a session is associated with a change when its skill
|
|
29
|
+
is one of the per-change Skills and any of its user messages mention the
|
|
30
|
+
change-id. We approximate by matching skill∈{change-new, change-plan,
|
|
31
|
+
implement, verify, adversarial-review, ship, finalize, test-plan,
|
|
32
|
+
security-review, data-review, ui-brief, tech-debt-resolve}, then bucket
|
|
33
|
+
unassigned sessions under '(unassigned)'.
|
|
34
|
+
|
|
35
|
+
With sidecars present (v2 of this script), TE-1 sharpens by joining sidecar
|
|
36
|
+
change_ids directly. v1 produces a coarse-but-honest ranking.
|
|
37
|
+
"""
|
|
38
|
+
per_change_skills = {
|
|
39
|
+
"change-new", "change-plan", "implement", "verify",
|
|
40
|
+
"adversarial-review", "ship", "finalize", "test-plan",
|
|
41
|
+
"security-review", "data-review", "ui-brief",
|
|
42
|
+
"tech-debt-resolve", "tech-debt-new", "tech-debt-wontfix",
|
|
43
|
+
"tech-debt-stale",
|
|
44
|
+
}
|
|
45
|
+
cost_total = defaultdict(int)
|
|
46
|
+
session_counts = defaultdict(int)
|
|
47
|
+
for s in session_rows:
|
|
48
|
+
if s["skill"] in per_change_skills:
|
|
49
|
+
# Heuristic: we don't have a structured change-id-per-session yet,
|
|
50
|
+
# so accumulate by skill until sidecars exist. The (skill, total)
|
|
51
|
+
# ranking is the v1 surface here.
|
|
52
|
+
cost_total[s["skill"]] += s["cost_score"]
|
|
53
|
+
session_counts[s["skill"]] += 1
|
|
54
|
+
rows = []
|
|
55
|
+
for skill, total in sorted(cost_total.items(), key=lambda kv: -kv[1]):
|
|
56
|
+
sessions = session_counts[skill]
|
|
57
|
+
rows.append({
|
|
58
|
+
"skill": skill,
|
|
59
|
+
"sessions": sessions,
|
|
60
|
+
"cost_score_total": total,
|
|
61
|
+
"cost_score_mean": total // sessions if sessions else 0,
|
|
62
|
+
})
|
|
63
|
+
return {
|
|
64
|
+
"rows": rows,
|
|
65
|
+
"note": (
|
|
66
|
+
"v1 attribution is per-Skill, not per-change. Per-change cost will "
|
|
67
|
+
"sharpen once verify.json / finalize.json sidecars carry change_id."
|
|
68
|
+
),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _te_2(session_rows: list[dict]) -> dict:
|
|
73
|
+
"""TE-2: cache-hit ratio = cache_read / (cache_read + cache_creation) per
|
|
74
|
+
subagent appearance. Without isSidechain attribution we group by the host
|
|
75
|
+
Skill and report per-Skill cache effectiveness — same shape, coarser grain.
|
|
76
|
+
"""
|
|
77
|
+
per_skill = defaultdict(lambda: {"cache_read": 0, "cache_creation": 0, "turns": 0})
|
|
78
|
+
for s in session_rows:
|
|
79
|
+
key = s["skill"] or "(non-hstack)"
|
|
80
|
+
t = s["totals"]
|
|
81
|
+
per_skill[key]["cache_read"] += t.get("cache_read_input_tokens", 0)
|
|
82
|
+
per_skill[key]["cache_creation"] += t.get("cache_creation_input_tokens", 0)
|
|
83
|
+
per_skill[key]["turns"] += t.get("turns", 0)
|
|
84
|
+
rows = []
|
|
85
|
+
for skill, agg in sorted(per_skill.items(), key=lambda kv: -(kv[1]["cache_read"] + kv[1]["cache_creation"])):
|
|
86
|
+
denom = agg["cache_read"] + agg["cache_creation"]
|
|
87
|
+
ratio = (agg["cache_read"] / denom) if denom > 0 else None
|
|
88
|
+
rows.append({
|
|
89
|
+
"skill": skill,
|
|
90
|
+
"turns": agg["turns"],
|
|
91
|
+
"cache_read": agg["cache_read"],
|
|
92
|
+
"cache_creation": agg["cache_creation"],
|
|
93
|
+
"ratio": ratio,
|
|
94
|
+
})
|
|
95
|
+
return {"rows": rows}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _te_3(session_rows: list[dict]) -> dict:
|
|
99
|
+
"""TE-3: subagent entry-tax amortization.
|
|
100
|
+
|
|
101
|
+
For each subagent type, count invocations and aggregate the host sessions'
|
|
102
|
+
cache_creation (the entry tax is paid as cache_creation on first turn of a
|
|
103
|
+
fresh subagent). Without isSidechain attribution, this is a structural
|
|
104
|
+
proxy: high-cache_creation skills that invoke many subagents are paying
|
|
105
|
+
the entry tax over and over.
|
|
106
|
+
"""
|
|
107
|
+
subagent_appearances = defaultdict(int)
|
|
108
|
+
subagent_host_cache_creation = defaultdict(int)
|
|
109
|
+
for s in session_rows:
|
|
110
|
+
for sa in s["subagents"]:
|
|
111
|
+
subagent_appearances[sa] += 1
|
|
112
|
+
subagent_host_cache_creation[sa] += s["totals"].get("cache_creation_input_tokens", 0)
|
|
113
|
+
rows = []
|
|
114
|
+
for sa, count in sorted(subagent_appearances.items(), key=lambda kv: -kv[1]):
|
|
115
|
+
rows.append({
|
|
116
|
+
"subagent": sa,
|
|
117
|
+
"appearances": count,
|
|
118
|
+
"host_cache_creation_total": subagent_host_cache_creation[sa],
|
|
119
|
+
"host_cache_creation_per_invocation": subagent_host_cache_creation[sa] // count if count else 0,
|
|
120
|
+
})
|
|
121
|
+
return {
|
|
122
|
+
"rows": rows,
|
|
123
|
+
"note": (
|
|
124
|
+
"Entry-tax attribution is approximate in v1 (isSidechain=False in "
|
|
125
|
+
"moso-app transcripts means subagent tokens land in the host "
|
|
126
|
+
"session bucket). v2 with sidecars carrying subagent start/end "
|
|
127
|
+
"timestamps will sharpen this."
|
|
128
|
+
),
|
|
129
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Workflow-shape insights: WS-1 phase duration, WS-2 gate findings density,
|
|
2
|
+
WS-4 scope-amendment rate."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def compute(commits: list[dict], changes: dict, session_rows: list[dict]) -> dict:
|
|
11
|
+
return {
|
|
12
|
+
"ws_1_phase_duration": _ws_1(commits),
|
|
13
|
+
"ws_2_gate_findings_density": _ws_2(changes),
|
|
14
|
+
"ws_4_scope_amendment_rate": _ws_4(commits, changes),
|
|
15
|
+
"ws_6_halt_reasons": _ws_6(commits, session_rows),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _ws_1(commits: list[dict]) -> dict:
|
|
20
|
+
"""WS-1: time between successive implement(<change-id>) phase commits.
|
|
21
|
+
|
|
22
|
+
The implementer auto-commits one commit per completed phase. The interval
|
|
23
|
+
between adjacent implement commits for the same change is a phase-duration
|
|
24
|
+
proxy. Note that this includes any cofounder time between phases (review,
|
|
25
|
+
figma work, etc.) — the metric is per-change-and-phase elapsed, not pure
|
|
26
|
+
LLM compute. v2 with sidecars (started_at / completed_at) sharpens this.
|
|
27
|
+
"""
|
|
28
|
+
per_change_implement = defaultdict(list)
|
|
29
|
+
for c in commits:
|
|
30
|
+
if c["artifact_type"] != "implement":
|
|
31
|
+
continue
|
|
32
|
+
if not c["artifact_id"] or not c["timestamp"]:
|
|
33
|
+
continue
|
|
34
|
+
per_change_implement[c["artifact_id"]].append((c["timestamp"], c["phase_id"]))
|
|
35
|
+
all_intervals = []
|
|
36
|
+
per_change_rows = []
|
|
37
|
+
for cid, entries in per_change_implement.items():
|
|
38
|
+
entries.sort()
|
|
39
|
+
intervals = []
|
|
40
|
+
for i in range(1, len(entries)):
|
|
41
|
+
dt = entries[i][0] - entries[i - 1][0]
|
|
42
|
+
intervals.append(dt.total_seconds() / 3600.0) # hours
|
|
43
|
+
if intervals:
|
|
44
|
+
per_change_rows.append({
|
|
45
|
+
"change": cid,
|
|
46
|
+
"phases": len(entries),
|
|
47
|
+
"min_h": round(min(intervals), 2),
|
|
48
|
+
"mean_h": round(sum(intervals) / len(intervals), 2),
|
|
49
|
+
"max_h": round(max(intervals), 2),
|
|
50
|
+
})
|
|
51
|
+
all_intervals.extend(intervals)
|
|
52
|
+
summary = None
|
|
53
|
+
if all_intervals:
|
|
54
|
+
all_intervals.sort()
|
|
55
|
+
n = len(all_intervals)
|
|
56
|
+
median = all_intervals[n // 2]
|
|
57
|
+
summary = {
|
|
58
|
+
"count": n,
|
|
59
|
+
"median_h": round(median, 2),
|
|
60
|
+
"p90_h": round(all_intervals[min(int(n * 0.9), n - 1)], 2),
|
|
61
|
+
"max_h": round(max(all_intervals), 2),
|
|
62
|
+
}
|
|
63
|
+
return {"summary": summary, "per_change": per_change_rows}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _ws_2(changes: dict) -> dict:
|
|
67
|
+
"""WS-2: findings density per gate.
|
|
68
|
+
|
|
69
|
+
For each change, count findings on adversarial-review.md and PASS/CONCERNS/
|
|
70
|
+
FAIL distribution on security-review (via frontmatter scores when present)
|
|
71
|
+
and RLS-coverage on data-review. Outputs per-gate totals + per-change rows.
|
|
72
|
+
"""
|
|
73
|
+
adv_findings = []
|
|
74
|
+
sec_concerns = 0
|
|
75
|
+
sec_total = 0
|
|
76
|
+
data_partial = 0
|
|
77
|
+
data_total = 0
|
|
78
|
+
per_change = []
|
|
79
|
+
for cid, arts in sorted(changes.items()):
|
|
80
|
+
adv = arts.get("adversarial-review")
|
|
81
|
+
n_findings = 0
|
|
82
|
+
if adv:
|
|
83
|
+
findings_arr = adv["fm"].get("findings") or []
|
|
84
|
+
if isinstance(findings_arr, list):
|
|
85
|
+
n_findings = len(findings_arr)
|
|
86
|
+
adv_findings.extend(findings_arr if isinstance(findings_arr, list) else [])
|
|
87
|
+
sec = arts.get("security-review")
|
|
88
|
+
sec_status = sec["fm"].get("status") if sec else None
|
|
89
|
+
if sec_status:
|
|
90
|
+
sec_total += 1
|
|
91
|
+
scores = sec["fm"].get("scores") or {}
|
|
92
|
+
if isinstance(scores, dict):
|
|
93
|
+
for v in scores.values():
|
|
94
|
+
if isinstance(v, str) and v.upper() in ("CONCERNS", "FAIL"):
|
|
95
|
+
sec_concerns += 1
|
|
96
|
+
dr = arts.get("data-review")
|
|
97
|
+
if dr:
|
|
98
|
+
data_total += 1
|
|
99
|
+
rls = dr["fm"].get("rls-coverage") or dr["fm"].get("RLS-coverage")
|
|
100
|
+
if isinstance(rls, dict):
|
|
101
|
+
for v in rls.values():
|
|
102
|
+
if isinstance(v, str) and v.lower() in ("partial", "missing"):
|
|
103
|
+
data_partial += 1
|
|
104
|
+
elif isinstance(rls, str) and rls.lower() in ("partial", "missing"):
|
|
105
|
+
data_partial += 1
|
|
106
|
+
per_change.append({
|
|
107
|
+
"change": cid,
|
|
108
|
+
"adv_findings": n_findings,
|
|
109
|
+
"security": sec_status or "-",
|
|
110
|
+
"data_review": (dr["fm"].get("status") if dr else "-"),
|
|
111
|
+
})
|
|
112
|
+
return {
|
|
113
|
+
"adversarial_review_total_findings": len(adv_findings),
|
|
114
|
+
"adversarial_review_changes_with_findings": sum(1 for r in per_change if r["adv_findings"] > 0),
|
|
115
|
+
"security_review_changes_scored": sec_total,
|
|
116
|
+
"security_review_concerns_or_fail_items": sec_concerns,
|
|
117
|
+
"data_review_changes_scored": data_total,
|
|
118
|
+
"data_review_partial_or_missing_items": data_partial,
|
|
119
|
+
"per_change": per_change,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _ws_4(commits: list[dict], changes: dict) -> dict:
|
|
124
|
+
"""WS-4: scope-amendment rate.
|
|
125
|
+
|
|
126
|
+
For each change-spec, count commits that touch spec.md after the change-spec
|
|
127
|
+
first reaches status: ready-for-implementation. Each such commit is a
|
|
128
|
+
candidate scope amendment (or status flip — we can't tell apart without
|
|
129
|
+
diff parsing in v1). The metric tracks the upper bound.
|
|
130
|
+
"""
|
|
131
|
+
spec_writes_after_rfi = defaultdict(int)
|
|
132
|
+
rfi_timestamps: dict[str, object] = {}
|
|
133
|
+
|
|
134
|
+
# First pass: locate when each change-spec first transitioned to
|
|
135
|
+
# ready-for-implementation, identified by a change-spec(<id>): ready-for-implementation
|
|
136
|
+
# commit (or any later status's predecessor).
|
|
137
|
+
for c in commits:
|
|
138
|
+
if c["artifact_type"] == "change-spec" and c["action"] and "ready-for-implementation" in c["action"]:
|
|
139
|
+
cid = c["artifact_id"]
|
|
140
|
+
if cid and cid not in rfi_timestamps:
|
|
141
|
+
rfi_timestamps[cid] = c["timestamp"]
|
|
142
|
+
|
|
143
|
+
# Second pass: count subsequent commits touching that change's spec.md.
|
|
144
|
+
for c in commits:
|
|
145
|
+
if not c["timestamp"]:
|
|
146
|
+
continue
|
|
147
|
+
for f in c["files"]:
|
|
148
|
+
# spec.md path: hstack/specs/changes/<change-id>/spec.md
|
|
149
|
+
if not f.endswith("/spec.md") or "/changes/" not in f:
|
|
150
|
+
continue
|
|
151
|
+
parts = f.split("/")
|
|
152
|
+
try:
|
|
153
|
+
cid = parts[parts.index("changes") + 1]
|
|
154
|
+
except (ValueError, IndexError):
|
|
155
|
+
continue
|
|
156
|
+
rfi_ts = rfi_timestamps.get(cid)
|
|
157
|
+
if rfi_ts is None or c["timestamp"] <= rfi_ts:
|
|
158
|
+
continue
|
|
159
|
+
spec_writes_after_rfi[cid] += 1
|
|
160
|
+
total = sum(spec_writes_after_rfi.values())
|
|
161
|
+
changes_with_amendment = len([v for v in spec_writes_after_rfi.values() if v > 0])
|
|
162
|
+
return {
|
|
163
|
+
"total_spec_writes_after_rfi": total,
|
|
164
|
+
"changes_with_post_rfi_writes": changes_with_amendment,
|
|
165
|
+
"rate": (changes_with_amendment / len(changes)) if changes else 0.0,
|
|
166
|
+
"per_change": [
|
|
167
|
+
{"change": cid, "writes_after_rfi": n}
|
|
168
|
+
for cid, n in sorted(spec_writes_after_rfi.items(), key=lambda kv: -kv[1])
|
|
169
|
+
],
|
|
170
|
+
"note": (
|
|
171
|
+
"Upper bound: includes status-flip commits as well as content "
|
|
172
|
+
"amendments. v2 with structured spec-revision logs disambiguates."
|
|
173
|
+
),
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _ws_6(commits: list[dict], session_rows: list[dict]) -> dict:
|
|
178
|
+
"""WS-6: halt frequency by reason.
|
|
179
|
+
|
|
180
|
+
Sources: (a) commit-body HSTACK-HALT sentinels; (b) transcript-text
|
|
181
|
+
HSTACK-HALT sentinels (when the convention is in use).
|
|
182
|
+
"""
|
|
183
|
+
by_reason = defaultdict(int)
|
|
184
|
+
for c in commits:
|
|
185
|
+
for r in c.get("halt_reasons", []):
|
|
186
|
+
by_reason[r.lower()] += 1
|
|
187
|
+
for s in session_rows:
|
|
188
|
+
for r in s.get("halt_reasons", []):
|
|
189
|
+
by_reason[r.lower()] += 1
|
|
190
|
+
rows = sorted(by_reason.items(), key=lambda kv: -kv[1])
|
|
191
|
+
return {
|
|
192
|
+
"rows": [{"reason": r, "count": n} for r, n in rows],
|
|
193
|
+
"total": sum(by_reason.values()),
|
|
194
|
+
"note": (
|
|
195
|
+
"Counts are zero until the halt-sentinel convention is in use. "
|
|
196
|
+
"See kernel § Stop conditions for the HSTACK-HALT format."
|
|
197
|
+
),
|
|
198
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Extract structured fragments from artifact bodies (Findings, tables, etc.)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
SECTION_HEADING = re.compile(r"^##+\s+(?P<heading>.+?)\s*$", re.MULTILINE)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def split_sections(body: str) -> dict[str, str]:
|
|
12
|
+
"""Split a markdown body into top-level sections keyed by heading text.
|
|
13
|
+
|
|
14
|
+
All `##`-or-deeper sections are flattened — later sections with the same
|
|
15
|
+
heading overwrite earlier ones (rare in our templates)."""
|
|
16
|
+
matches = list(SECTION_HEADING.finditer(body))
|
|
17
|
+
out: dict[str, str] = {}
|
|
18
|
+
for i, m in enumerate(matches):
|
|
19
|
+
heading = m.group("heading").strip()
|
|
20
|
+
start = m.end()
|
|
21
|
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(body)
|
|
22
|
+
out[heading] = body[start:end].strip()
|
|
23
|
+
return out
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Adversarial-review findings: per-finding subsections like
|
|
27
|
+
# ### F-01
|
|
28
|
+
# **Category.** spec-compliance
|
|
29
|
+
# **What.** ...
|
|
30
|
+
# **Severity rationale.** medium ...
|
|
31
|
+
# **Resolution.** commit:abc123
|
|
32
|
+
FINDING_HEADING = re.compile(r"^###\s+(F-\d+)\s*$", re.MULTILINE)
|
|
33
|
+
FINDING_FIELD = re.compile(r"\*\*(?P<field>[^.*]+)\.\*\*\s*(?P<value>[^\n]*)")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_findings_section(findings_text: str) -> list[dict]:
|
|
37
|
+
"""Parse the Findings section body into per-finding dicts.
|
|
38
|
+
|
|
39
|
+
Returns [{id, category, severity, status, resolution, what, why}, ...].
|
|
40
|
+
Only used as a fallback / cross-check; the frontmatter `findings:` array is
|
|
41
|
+
authoritative for category/severity/status/resolution."""
|
|
42
|
+
out = []
|
|
43
|
+
matches = list(FINDING_HEADING.finditer(findings_text))
|
|
44
|
+
for i, m in enumerate(matches):
|
|
45
|
+
fid = m.group(1)
|
|
46
|
+
start = m.end()
|
|
47
|
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(findings_text)
|
|
48
|
+
block = findings_text[start:end]
|
|
49
|
+
fields = {}
|
|
50
|
+
for fm in FINDING_FIELD.finditer(block):
|
|
51
|
+
fields[fm.group("field").strip().lower()] = fm.group("value").strip()
|
|
52
|
+
out.append({
|
|
53
|
+
"id": fid,
|
|
54
|
+
"category": fields.get("category"),
|
|
55
|
+
"severity_rationale": fields.get("severity rationale"),
|
|
56
|
+
"what": fields.get("what"),
|
|
57
|
+
"why": fields.get("why it matters"),
|
|
58
|
+
"recommendation": fields.get("recommendation"),
|
|
59
|
+
"resolution": fields.get("resolution"),
|
|
60
|
+
})
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Plan phase overview: a three-column markdown table.
|
|
65
|
+
PHASE_TABLE_LINE = re.compile(r"^\|\s*(?P<step>phase-[a-z0-9-]+)\s*\|")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_phase_overview(plan_body: str) -> list[str]:
|
|
69
|
+
"""Return list of phase ids from plan.md's Phase Overview table."""
|
|
70
|
+
out = []
|
|
71
|
+
for line in plan_body.splitlines():
|
|
72
|
+
m = PHASE_TABLE_LINE.match(line)
|
|
73
|
+
if m:
|
|
74
|
+
out.append(m.group("step"))
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def count_bullets(text: str) -> int:
|
|
79
|
+
"""Count top-level bullet points (lines starting with `-` or `*`)."""
|
|
80
|
+
return sum(1 for line in text.splitlines() if re.match(r"^[-*]\s+\S", line))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def approx_token_count(text: str) -> int:
|
|
84
|
+
"""Crude token estimator: ~4 chars per token. Adequate for ratio metrics."""
|
|
85
|
+
if not text:
|
|
86
|
+
return 0
|
|
87
|
+
return max(1, len(text) // 4)
|