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.
Files changed (99) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +169 -0
  4. package/VERSION +1 -0
  5. package/dist/cli.js +55 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/commands/doctor.js +155 -0
  8. package/dist/commands/doctor.js.map +1 -0
  9. package/dist/commands/init.js +87 -0
  10. package/dist/commands/init.js.map +1 -0
  11. package/dist/commands/update.js +118 -0
  12. package/dist/commands/update.js.map +1 -0
  13. package/dist/lib/diff.js +120 -0
  14. package/dist/lib/diff.js.map +1 -0
  15. package/dist/lib/git.js +26 -0
  16. package/dist/lib/git.js.map +1 -0
  17. package/dist/lib/paths.js +35 -0
  18. package/dist/lib/paths.js.map +1 -0
  19. package/dist/lib/wire.js +314 -0
  20. package/dist/lib/wire.js.map +1 -0
  21. package/dist/manifest.js +53 -0
  22. package/dist/manifest.js.map +1 -0
  23. package/package.json +54 -0
  24. package/template/.claude/agents/adversarial-reviewer.md +123 -0
  25. package/template/.claude/agents/data-specialist.md +126 -0
  26. package/template/.claude/agents/implementer.md +154 -0
  27. package/template/.claude/agents/planner.md +110 -0
  28. package/template/.claude/agents/product-manager.md +111 -0
  29. package/template/.claude/agents/researcher.md +123 -0
  30. package/template/.claude/agents/security-reviewer.md +123 -0
  31. package/template/.claude/agents/spec-author.md +118 -0
  32. package/template/.claude/agents/test-strategist.md +129 -0
  33. package/template/.claude/agents/ui-ux-briefer.md +105 -0
  34. package/template/.claude/agents/verifier.md +109 -0
  35. package/template/.claude/skills/hstack-adr-new/SKILL.md +118 -0
  36. package/template/.claude/skills/hstack-adversarial-review/SKILL.md +187 -0
  37. package/template/.claude/skills/hstack-branch/SKILL.md +103 -0
  38. package/template/.claude/skills/hstack-change-new/SKILL.md +109 -0
  39. package/template/.claude/skills/hstack-change-plan/SKILL.md +119 -0
  40. package/template/.claude/skills/hstack-commit/SKILL.md +122 -0
  41. package/template/.claude/skills/hstack-configure/SKILL.md +126 -0
  42. package/template/.claude/skills/hstack-data-review/SKILL.md +132 -0
  43. package/template/.claude/skills/hstack-finalize/SKILL.md +159 -0
  44. package/template/.claude/skills/hstack-help/SKILL.md +174 -0
  45. package/template/.claude/skills/hstack-implement/SKILL.md +185 -0
  46. package/template/.claude/skills/hstack-init/SKILL.md +152 -0
  47. package/template/.claude/skills/hstack-module-spec/SKILL.md +105 -0
  48. package/template/.claude/skills/hstack-research/SKILL.md +145 -0
  49. package/template/.claude/skills/hstack-security-review/SKILL.md +133 -0
  50. package/template/.claude/skills/hstack-ship/SKILL.md +128 -0
  51. package/template/.claude/skills/hstack-story-draft/SKILL.md +117 -0
  52. package/template/.claude/skills/hstack-tech-debt-new/SKILL.md +122 -0
  53. package/template/.claude/skills/hstack-tech-debt-resolve/SKILL.md +158 -0
  54. package/template/.claude/skills/hstack-tech-debt-stale/SKILL.md +113 -0
  55. package/template/.claude/skills/hstack-tech-debt-wontfix/SKILL.md +104 -0
  56. package/template/.claude/skills/hstack-telemetry/SKILL.md +96 -0
  57. package/template/.claude/skills/hstack-test-plan/SKILL.md +182 -0
  58. package/template/.claude/skills/hstack-ui-brief/SKILL.md +108 -0
  59. package/template/.claude/skills/hstack-verify/SKILL.md +139 -0
  60. package/template/CLAUDE.md +390 -0
  61. package/template/scripts/telemetry/__init__.py +6 -0
  62. package/template/scripts/telemetry/insights/__init__.py +0 -0
  63. package/template/scripts/telemetry/insights/contract_drift.py +137 -0
  64. package/template/scripts/telemetry/insights/overengineering.py +115 -0
  65. package/template/scripts/telemetry/insights/quality_outcomes.py +131 -0
  66. package/template/scripts/telemetry/insights/token_economics.py +129 -0
  67. package/template/scripts/telemetry/insights/workflow_shape.py +198 -0
  68. package/template/scripts/telemetry/parsers/__init__.py +0 -0
  69. package/template/scripts/telemetry/parsers/bodies.py +87 -0
  70. package/template/scripts/telemetry/parsers/commits.py +219 -0
  71. package/template/scripts/telemetry/parsers/frontmatter.py +322 -0
  72. package/template/scripts/telemetry/parsers/transcripts.py +181 -0
  73. package/template/scripts/telemetry/render.py +311 -0
  74. package/template/scripts/telemetry/report.py +112 -0
  75. package/template/templates/adr.md +38 -0
  76. package/template/templates/adversarial-review.md +54 -0
  77. package/template/templates/change-spec.md +80 -0
  78. package/template/templates/ci-cd.md +27 -0
  79. package/template/templates/data-architecture.md +35 -0
  80. package/template/templates/data-review.md +54 -0
  81. package/template/templates/figma-handoff.md +38 -0
  82. package/template/templates/glossary.md +20 -0
  83. package/template/templates/hardening-checklist.md +73 -0
  84. package/template/templates/incident-runbook.md +57 -0
  85. package/template/templates/infrastructure.md +190 -0
  86. package/template/templates/module-spec.md +49 -0
  87. package/template/templates/mvp-scope.md +34 -0
  88. package/template/templates/persona.md +38 -0
  89. package/template/templates/plan.md +49 -0
  90. package/template/templates/security-review.md +63 -0
  91. package/template/templates/story.md +37 -0
  92. package/template/templates/tech-debt.md +61 -0
  93. package/template/templates/tech-stack.md +41 -0
  94. package/template/templates/telemetry-sidecar.md +184 -0
  95. package/template/templates/test-plan.md +119 -0
  96. package/template/templates/threat-model.md +54 -0
  97. package/template/templates/ui-brief.md +49 -0
  98. package/template/templates/verification.md +63 -0
  99. 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)