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.
Files changed (131) hide show
  1. package/CHANGELOG.md +308 -0
  2. package/README.md +90 -154
  3. package/VERSION +1 -1
  4. package/dist/commands/doctor.js +69 -9
  5. package/dist/commands/doctor.js.map +1 -1
  6. package/dist/commands/init.js +7 -3
  7. package/dist/commands/init.js.map +1 -1
  8. package/dist/commands/update.js +17 -4
  9. package/dist/commands/update.js.map +1 -1
  10. package/dist/lib/descriptions.js +160 -0
  11. package/dist/lib/descriptions.js.map +1 -0
  12. package/dist/lib/diff.js +1 -1
  13. package/dist/lib/git.js +16 -0
  14. package/dist/lib/git.js.map +1 -1
  15. package/dist/lib/wire.js +274 -142
  16. package/dist/lib/wire.js.map +1 -1
  17. package/dist/manifest.js +49 -22
  18. package/dist/manifest.js.map +1 -1
  19. package/package.json +4 -3
  20. package/template/.claude/agents/adversarial-reviewer.md +42 -98
  21. package/template/.claude/agents/app-architect.md +44 -133
  22. package/template/.claude/agents/data-architect.md +46 -127
  23. package/template/.claude/agents/test-strategist.md +39 -109
  24. package/template/.claude/skills/hstack-adr-new/SKILL.md +23 -100
  25. package/template/.claude/skills/hstack-adversarial-review/SKILL.md +47 -174
  26. package/template/.claude/skills/hstack-adversarial-review/references/finding-categories.md +134 -0
  27. package/template/.claude/skills/hstack-app-architecture/SKILL.md +43 -95
  28. package/template/.claude/skills/hstack-commit/SKILL.md +28 -97
  29. package/template/.claude/skills/hstack-data-architecture/SKILL.md +34 -82
  30. package/template/.claude/skills/hstack-promote/SKILL.md +55 -0
  31. package/template/.claude/skills/hstack-story/SKILL.md +58 -0
  32. package/template/.claude/skills/hstack-test-audit/SKILL.md +64 -0
  33. package/template/.claude/skills/hstack-wrap/SKILL.md +48 -0
  34. package/template/.claude/skills/hstack-wrap/references/pr-description.md +45 -0
  35. package/template/KERNEL.md +167 -0
  36. package/template/templates/adr.md +9 -18
  37. package/template/templates/app-architecture.md +38 -50
  38. package/template/templates/data-architecture.md +39 -93
  39. package/template/templates/infrastructure.md +54 -148
  40. package/template/templates/roadmap.md +35 -0
  41. package/template/templates/story.md +7 -13
  42. package/template/templates/tech-debt.md +2 -37
  43. package/template/templates/tech-stack.md +2 -12
  44. package/template/.claude/agents/data-specialist.md +0 -126
  45. package/template/.claude/agents/implementer.md +0 -154
  46. package/template/.claude/agents/kernel-fit-analyst.md +0 -190
  47. package/template/.claude/agents/planner.md +0 -110
  48. package/template/.claude/agents/product-discovery.md +0 -165
  49. package/template/.claude/agents/product-manager.md +0 -111
  50. package/template/.claude/agents/researcher.md +0 -123
  51. package/template/.claude/agents/security-reviewer.md +0 -123
  52. package/template/.claude/agents/spec-author.md +0 -123
  53. package/template/.claude/agents/stack-architect.md +0 -137
  54. package/template/.claude/agents/ui-ux-briefer.md +0 -105
  55. package/template/.claude/agents/verifier.md +0 -113
  56. package/template/.claude/skills/hstack-branch/SKILL.md +0 -103
  57. package/template/.claude/skills/hstack-brownfield-init/SKILL.md +0 -158
  58. package/template/.claude/skills/hstack-change-new/SKILL.md +0 -111
  59. package/template/.claude/skills/hstack-change-plan/SKILL.md +0 -119
  60. package/template/.claude/skills/hstack-configure/SKILL.md +0 -126
  61. package/template/.claude/skills/hstack-coord/SKILL.md +0 -131
  62. package/template/.claude/skills/hstack-data-review/SKILL.md +0 -132
  63. package/template/.claude/skills/hstack-finalize/SKILL.md +0 -159
  64. package/template/.claude/skills/hstack-flag/SKILL.md +0 -138
  65. package/template/.claude/skills/hstack-greenfield-init/SKILL.md +0 -175
  66. package/template/.claude/skills/hstack-help/SKILL.md +0 -179
  67. package/template/.claude/skills/hstack-implement/SKILL.md +0 -185
  68. package/template/.claude/skills/hstack-kernel-fit-promote/SKILL.md +0 -164
  69. package/template/.claude/skills/hstack-kernel-fit-scan/SKILL.md +0 -197
  70. package/template/.claude/skills/hstack-kernel-fit-triage/SKILL.md +0 -159
  71. package/template/.claude/skills/hstack-module-spec/SKILL.md +0 -105
  72. package/template/.claude/skills/hstack-product-discovery/SKILL.md +0 -101
  73. package/template/.claude/skills/hstack-research/SKILL.md +0 -145
  74. package/template/.claude/skills/hstack-scaffold/SKILL.md +0 -140
  75. package/template/.claude/skills/hstack-security-review/SKILL.md +0 -133
  76. package/template/.claude/skills/hstack-ship/SKILL.md +0 -129
  77. package/template/.claude/skills/hstack-stack-decide/SKILL.md +0 -108
  78. package/template/.claude/skills/hstack-story-draft/SKILL.md +0 -119
  79. package/template/.claude/skills/hstack-tech-debt-new/SKILL.md +0 -122
  80. package/template/.claude/skills/hstack-tech-debt-resolve/SKILL.md +0 -158
  81. package/template/.claude/skills/hstack-tech-debt-stale/SKILL.md +0 -113
  82. package/template/.claude/skills/hstack-tech-debt-wontfix/SKILL.md +0 -104
  83. package/template/.claude/skills/hstack-telemetry/SKILL.md +0 -98
  84. package/template/.claude/skills/hstack-test-plan/SKILL.md +0 -182
  85. package/template/.claude/skills/hstack-ui-brief/SKILL.md +0 -108
  86. package/template/.claude/skills/hstack-verify/SKILL.md +0 -150
  87. package/template/CLAUDE.md +0 -443
  88. package/template/scripts/coord/coord_scan.py +0 -681
  89. package/template/scripts/telemetry/__init__.py +0 -6
  90. package/template/scripts/telemetry/insights/__init__.py +0 -0
  91. package/template/scripts/telemetry/insights/contract_drift.py +0 -137
  92. package/template/scripts/telemetry/insights/kernel_fit.py +0 -438
  93. package/template/scripts/telemetry/insights/overengineering.py +0 -115
  94. package/template/scripts/telemetry/insights/quality_outcomes.py +0 -131
  95. package/template/scripts/telemetry/insights/token_economics.py +0 -129
  96. package/template/scripts/telemetry/insights/workflow_shape.py +0 -198
  97. package/template/scripts/telemetry/parsers/__init__.py +0 -0
  98. package/template/scripts/telemetry/parsers/bodies.py +0 -87
  99. package/template/scripts/telemetry/parsers/commits.py +0 -219
  100. package/template/scripts/telemetry/parsers/frontmatter.py +0 -322
  101. package/template/scripts/telemetry/parsers/transcripts.py +0 -181
  102. package/template/scripts/telemetry/render.py +0 -395
  103. package/template/scripts/telemetry/report.py +0 -138
  104. package/template/scripts/telemetry/run_kernel_fit.py +0 -91
  105. package/template/templates/adversarial-review.md +0 -54
  106. package/template/templates/bootstrap.md +0 -105
  107. package/template/templates/change-spec.md +0 -82
  108. package/template/templates/ci-cd.md +0 -27
  109. package/template/templates/coord-message.md +0 -55
  110. package/template/templates/data-review.md +0 -54
  111. package/template/templates/discovery/brainstorm.md +0 -69
  112. package/template/templates/discovery/forcing-questions.md +0 -76
  113. package/template/templates/discovery/project-brief.md +0 -53
  114. package/template/templates/figma-handoff.md +0 -38
  115. package/template/templates/glossary.md +0 -20
  116. package/template/templates/hardening-checklist.md +0 -73
  117. package/template/templates/incident-runbook.md +0 -57
  118. package/template/templates/kernel-fit-finding.md +0 -63
  119. package/template/templates/kernel-fit-flag.md +0 -52
  120. package/template/templates/module-spec.md +0 -49
  121. package/template/templates/mvp-scope.md +0 -34
  122. package/template/templates/persona.md +0 -38
  123. package/template/templates/plan.md +0 -49
  124. package/template/templates/product-brief.md +0 -56
  125. package/template/templates/security-review.md +0 -63
  126. package/template/templates/telemetry-sidecar.md +0 -184
  127. package/template/templates/test-plan.md +0 -119
  128. package/template/templates/threat-model.md +0 -54
  129. package/template/templates/ui-brief.md +0 -49
  130. package/template/templates/verification.md +0 -63
  131. package/template/templates/vision.md +0 -34
@@ -1,131 +0,0 @@
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}
@@ -1,129 +0,0 @@
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
- }
@@ -1,198 +0,0 @@
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
@@ -1,87 +0,0 @@
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)