hstack 0.16.0 → 0.18.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 (134) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +76 -163
  3. package/VERSION +1 -1
  4. package/dist/commands/doctor.js +18 -8
  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 +9 -2
  9. package/dist/commands/update.js.map +1 -1
  10. package/dist/lib/descriptions.js +10 -17
  11. package/dist/lib/descriptions.js.map +1 -1
  12. package/dist/lib/wire.js +166 -138
  13. package/dist/lib/wire.js.map +1 -1
  14. package/dist/manifest.js +41 -29
  15. package/dist/manifest.js.map +1 -1
  16. package/package.json +3 -4
  17. package/template/.claude/agents/adversarial-reviewer.md +44 -50
  18. package/template/.claude/agents/app-architect.md +44 -96
  19. package/template/.claude/agents/data-architect.md +46 -89
  20. package/template/.claude/agents/test-strategist.md +39 -68
  21. package/template/.claude/skills/hstack-adr-new/SKILL.md +23 -73
  22. package/template/.claude/skills/hstack-adversarial-review/SKILL.md +46 -152
  23. package/template/.claude/skills/hstack-adversarial-review/references/finding-categories.md +89 -103
  24. package/template/.claude/skills/hstack-app-architecture/SKILL.md +43 -68
  25. package/template/.claude/skills/hstack-commit/SKILL.md +27 -68
  26. package/template/.claude/skills/hstack-data-architecture/SKILL.md +34 -56
  27. package/template/.claude/skills/hstack-promote/SKILL.md +55 -0
  28. package/template/.claude/skills/hstack-story/SKILL.md +58 -0
  29. package/template/.claude/skills/hstack-test-audit/SKILL.md +64 -0
  30. package/template/.claude/skills/hstack-wrap/SKILL.md +50 -0
  31. package/template/.claude/skills/hstack-wrap/references/pr-description.md +45 -0
  32. package/template/KERNEL.md +75 -318
  33. package/template/templates/adr.md +2 -18
  34. package/template/templates/app-architecture.md +38 -50
  35. package/template/templates/code-standards.md +107 -0
  36. package/template/templates/data-architecture.md +38 -89
  37. package/template/templates/eslint-clean-code.mjs +322 -0
  38. package/template/templates/infrastructure.md +54 -148
  39. package/template/templates/roadmap.md +6 -12
  40. package/template/templates/story.md +7 -13
  41. package/template/templates/tech-debt.md +2 -37
  42. package/template/templates/tech-stack.md +2 -12
  43. package/template/.claude/agents/data-specialist.md +0 -81
  44. package/template/.claude/agents/implementer.md +0 -97
  45. package/template/.claude/agents/kernel-fit-analyst.md +0 -129
  46. package/template/.claude/agents/planner.md +0 -75
  47. package/template/.claude/agents/product-discovery.md +0 -129
  48. package/template/.claude/agents/product-manager.md +0 -76
  49. package/template/.claude/agents/researcher.md +0 -87
  50. package/template/.claude/agents/security-reviewer.md +0 -88
  51. package/template/.claude/agents/spec-author.md +0 -89
  52. package/template/.claude/agents/stack-architect.md +0 -108
  53. package/template/.claude/agents/ui-ux-briefer.md +0 -75
  54. package/template/.claude/agents/verifier.md +0 -81
  55. package/template/.claude/skills/hstack-branch/SKILL.md +0 -76
  56. package/template/.claude/skills/hstack-brownfield-init/SKILL.md +0 -131
  57. package/template/.claude/skills/hstack-change-new/SKILL.md +0 -84
  58. package/template/.claude/skills/hstack-change-plan/SKILL.md +0 -108
  59. package/template/.claude/skills/hstack-configure/SKILL.md +0 -99
  60. package/template/.claude/skills/hstack-coord/SKILL.md +0 -95
  61. package/template/.claude/skills/hstack-data-review/SKILL.md +0 -93
  62. package/template/.claude/skills/hstack-finalize/SKILL.md +0 -140
  63. package/template/.claude/skills/hstack-flag/SKILL.md +0 -99
  64. package/template/.claude/skills/hstack-greenfield-init/SKILL.md +0 -148
  65. package/template/.claude/skills/hstack-help/SKILL.md +0 -153
  66. package/template/.claude/skills/hstack-implement/SKILL.md +0 -155
  67. package/template/.claude/skills/hstack-kernel-fit-promote/SKILL.md +0 -125
  68. package/template/.claude/skills/hstack-kernel-fit-scan/SKILL.md +0 -142
  69. package/template/.claude/skills/hstack-kernel-fit-scan/references/slack-setup.md +0 -42
  70. package/template/.claude/skills/hstack-kernel-fit-triage/SKILL.md +0 -121
  71. package/template/.claude/skills/hstack-module-spec/SKILL.md +0 -78
  72. package/template/.claude/skills/hstack-product-discovery/SKILL.md +0 -75
  73. package/template/.claude/skills/hstack-research/SKILL.md +0 -115
  74. package/template/.claude/skills/hstack-scaffold/SKILL.md +0 -113
  75. package/template/.claude/skills/hstack-security-review/SKILL.md +0 -95
  76. package/template/.claude/skills/hstack-ship/SKILL.md +0 -119
  77. package/template/.claude/skills/hstack-stack-decide/SKILL.md +0 -81
  78. package/template/.claude/skills/hstack-story-draft/SKILL.md +0 -92
  79. package/template/.claude/skills/hstack-tech-debt-new/SKILL.md +0 -95
  80. package/template/.claude/skills/hstack-tech-debt-resolve/SKILL.md +0 -123
  81. package/template/.claude/skills/hstack-tech-debt-stale/SKILL.md +0 -86
  82. package/template/.claude/skills/hstack-tech-debt-wontfix/SKILL.md +0 -77
  83. package/template/.claude/skills/hstack-telemetry/SKILL.md +0 -73
  84. package/template/.claude/skills/hstack-test-plan/SKILL.md +0 -159
  85. package/template/.claude/skills/hstack-ui-brief/SKILL.md +0 -81
  86. package/template/.claude/skills/hstack-verify/SKILL.md +0 -128
  87. package/template/scripts/compute-merge-readiness.mjs +0 -780
  88. package/template/scripts/coord/coord_scan.py +0 -681
  89. package/template/scripts/run-gates.sh +0 -388
  90. package/template/scripts/telemetry/__init__.py +0 -6
  91. package/template/scripts/telemetry/insights/__init__.py +0 -0
  92. package/template/scripts/telemetry/insights/contract_drift.py +0 -137
  93. package/template/scripts/telemetry/insights/kernel_fit.py +0 -438
  94. package/template/scripts/telemetry/insights/overengineering.py +0 -115
  95. package/template/scripts/telemetry/insights/quality_outcomes.py +0 -131
  96. package/template/scripts/telemetry/insights/token_economics.py +0 -302
  97. package/template/scripts/telemetry/insights/workflow_shape.py +0 -198
  98. package/template/scripts/telemetry/parsers/__init__.py +0 -0
  99. package/template/scripts/telemetry/parsers/bodies.py +0 -87
  100. package/template/scripts/telemetry/parsers/commits.py +0 -219
  101. package/template/scripts/telemetry/parsers/frontmatter.py +0 -322
  102. package/template/scripts/telemetry/parsers/sidecars.py +0 -61
  103. package/template/scripts/telemetry/parsers/transcripts.py +0 -294
  104. package/template/scripts/telemetry/render.py +0 -460
  105. package/template/scripts/telemetry/report.py +0 -150
  106. package/template/scripts/telemetry/run_kernel_fit.py +0 -95
  107. package/template/scripts/telemetry/session_id.py +0 -139
  108. package/template/scripts/validate-spec.mjs +0 -3303
  109. package/template/templates/adversarial-review.md +0 -54
  110. package/template/templates/bootstrap.md +0 -105
  111. package/template/templates/change-spec.md +0 -82
  112. package/template/templates/ci-cd.md +0 -41
  113. package/template/templates/coord-message.md +0 -56
  114. package/template/templates/data-review.md +0 -54
  115. package/template/templates/discovery/brainstorm.md +0 -69
  116. package/template/templates/discovery/forcing-questions.md +0 -76
  117. package/template/templates/discovery/project-brief.md +0 -53
  118. package/template/templates/figma-handoff.md +0 -38
  119. package/template/templates/glossary.md +0 -20
  120. package/template/templates/hardening-checklist.md +0 -73
  121. package/template/templates/incident-runbook.md +0 -57
  122. package/template/templates/kernel-fit-finding.md +0 -63
  123. package/template/templates/kernel-fit-flag.md +0 -52
  124. package/template/templates/module-spec.md +0 -49
  125. package/template/templates/persona.md +0 -38
  126. package/template/templates/plan.md +0 -53
  127. package/template/templates/product-brief.md +0 -56
  128. package/template/templates/security-review.md +0 -63
  129. package/template/templates/telemetry-sidecar.md +0 -227
  130. package/template/templates/test-plan.md +0 -119
  131. package/template/templates/threat-model.md +0 -54
  132. package/template/templates/ui-brief.md +0 -49
  133. package/template/templates/verification.md +0 -63
  134. package/template/templates/vision.md +0 -34
@@ -1,302 +0,0 @@
1
- """Token-economics insights: TE-1 cost per Skill, TE-2 cache-hit ratio per
2
- subagent, TE-3 subagent entry-tax amortization, TE-4 cost per phase, TE-5 cost
3
- per change (ADR-0009).
4
-
5
- TE-1/TE-2/TE-3 are *session-scoped*: they attribute a whole session to the first
6
- Skill it invoked, because a Skill has a start marker and no end marker. TE-4/TE-5
7
- are *phase-scoped*: they read the sidecar's `[phase_opened_at, phase_closed_at]`
8
- window and sum only the turns inside it. Where a sidecar exists, TE-4/TE-5
9
- supersede TE-1.
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- from collections import defaultdict
15
-
16
- from telemetry.parsers.transcripts import phase_usage
17
-
18
-
19
- #: The five Skills that emit sidecars (ADR-0001 § v1 emission list). Every other
20
- #: Skill is invisible to TE-4/TE-5 — which is what the coverage fraction says.
21
- EMITTING_SKILLS = (
22
- "hstack-test-plan", "hstack-implement", "hstack-verify",
23
- "hstack-adversarial-review", "hstack-finalize",
24
- )
25
-
26
- UNATTRIBUTED = "(unattributed)"
27
-
28
-
29
- def compute(session_rows: list[dict], changes: dict, sidecars: list[dict] | None = None) -> dict:
30
- """Compute the five TE metrics.
31
-
32
- Args:
33
- session_rows: from transcripts.collect_session_rows
34
- changes: from frontmatter.load_change_artifacts
35
- sidecars: from sidecars.load_sidecars (empty/None → TE-4/TE-5 report no
36
- coverage rather than silently vanishing)
37
-
38
- Returns a dict ready for rendering.
39
- """
40
- phases = _te_4(sidecars or [])
41
- return {
42
- "te_1_cost_per_change": _te_1(session_rows, changes),
43
- "te_2_cache_hit_per_subagent": _te_2(session_rows),
44
- "te_3_subagent_entry_tax": _te_3(session_rows),
45
- "te_4_cost_per_phase": phases,
46
- "te_5_cost_per_change": _te_5(phases["rows"]),
47
- }
48
-
49
-
50
- def _te_1(session_rows: list[dict], changes: dict) -> dict:
51
- """TE-1: cost-score per change-spec.
52
-
53
- Best-effort attribution: a session is associated with a change when its skill
54
- is one of the per-change Skills and any of its user messages mention the
55
- change-id. We approximate by matching skill∈{change-new, change-plan,
56
- implement, verify, adversarial-review, ship, finalize, test-plan,
57
- security-review, data-review, ui-brief, tech-debt-resolve}, then bucket
58
- unassigned sessions under '(unassigned)'.
59
-
60
- With sidecars present (v2 of this script), TE-1 sharpens by joining sidecar
61
- change_ids directly. v1 produces a coarse-but-honest ranking.
62
- """
63
- per_change_skills = {
64
- "change-new", "change-plan", "implement", "verify",
65
- "adversarial-review", "ship", "finalize", "test-plan",
66
- "security-review", "data-review", "ui-brief",
67
- "tech-debt-resolve", "tech-debt-new", "tech-debt-wontfix",
68
- "tech-debt-stale",
69
- }
70
- cost_total = defaultdict(int)
71
- session_counts = defaultdict(int)
72
- unattributed = 0
73
- for s in session_rows:
74
- if s["skill"] is None:
75
- unattributed += 1
76
- if s["skill"] in per_change_skills:
77
- # Heuristic: we don't have a structured change-id-per-session yet,
78
- # so accumulate by skill until sidecars exist. The (skill, total)
79
- # ranking is the v1 surface here.
80
- cost_total[s["skill"]] += s["cost_score"]
81
- session_counts[s["skill"]] += 1
82
- rows = []
83
- for skill, total in sorted(cost_total.items(), key=lambda kv: -kv[1]):
84
- sessions = session_counts[skill]
85
- rows.append({
86
- "skill": skill,
87
- "sessions": sessions,
88
- "cost_score_total": total,
89
- "cost_score_mean": total // sessions if sessions else 0,
90
- })
91
- return {
92
- "rows": rows,
93
- "unattributed_sessions": unattributed,
94
- "note": (
95
- "Session-scoped, not phase-scoped: a Skill has a start marker and no "
96
- "end marker, so everything a session spends after the invocation "
97
- "lands in the first bucket — including later phases and unrelated "
98
- "work. Superseded by TE-4/TE-5 for any change that carries sidecars. "
99
- "Attribution reads structured invocation markers only (<command-name> "
100
- "tags, Skill tool_use blocks); sessions with no marker are "
101
- f"unattributed ({unattributed} of {len(session_rows)} in window) "
102
- "rather than credited to whichever Skill their prompt mentioned."
103
- ),
104
- }
105
-
106
-
107
- def _te_2(session_rows: list[dict]) -> dict:
108
- """TE-2: cache-hit ratio = cache_read / (cache_read + cache_creation) per
109
- subagent appearance. Without isSidechain attribution we group by the host
110
- Skill and report per-Skill cache effectiveness — same shape, coarser grain.
111
- """
112
- per_skill = defaultdict(lambda: {"cache_read": 0, "cache_creation": 0, "turns": 0})
113
- for s in session_rows:
114
- key = s["skill"] or UNATTRIBUTED
115
- t = s["totals"]
116
- per_skill[key]["cache_read"] += t.get("cache_read_input_tokens", 0)
117
- per_skill[key]["cache_creation"] += t.get("cache_creation_input_tokens", 0)
118
- per_skill[key]["turns"] += t.get("turns", 0)
119
- rows = []
120
- for skill, agg in sorted(per_skill.items(), key=lambda kv: -(kv[1]["cache_read"] + kv[1]["cache_creation"])):
121
- denom = agg["cache_read"] + agg["cache_creation"]
122
- ratio = (agg["cache_read"] / denom) if denom > 0 else None
123
- rows.append({
124
- "skill": skill,
125
- "turns": agg["turns"],
126
- "cache_read": agg["cache_read"],
127
- "cache_creation": agg["cache_creation"],
128
- "ratio": ratio,
129
- })
130
- return {
131
- "rows": rows,
132
- "note": (
133
- "Session-scoped, same caveat as TE-1 — the whole session's cache "
134
- f"behaviour is credited to its first Skill. `{UNATTRIBUTED}` holds "
135
- "every session with no structured hstack invocation marker, "
136
- "including plain non-hstack work. Superseded by TE-4/TE-5 wherever "
137
- "sidecars exist."
138
- ),
139
- }
140
-
141
-
142
- def _te_3(session_rows: list[dict]) -> dict:
143
- """TE-3: subagent entry-tax amortization.
144
-
145
- For each subagent type, count invocations and aggregate the host sessions'
146
- cache_creation (the entry tax is paid as cache_creation on first turn of a
147
- fresh subagent). Without isSidechain attribution, this is a structural
148
- proxy: high-cache_creation skills that invoke many subagents are paying
149
- the entry tax over and over.
150
- """
151
- subagent_appearances = defaultdict(int)
152
- subagent_host_cache_creation = defaultdict(int)
153
- for s in session_rows:
154
- for sa in s["subagents"]:
155
- subagent_appearances[sa] += 1
156
- subagent_host_cache_creation[sa] += s["totals"].get("cache_creation_input_tokens", 0)
157
- rows = []
158
- for sa, count in sorted(subagent_appearances.items(), key=lambda kv: -kv[1]):
159
- rows.append({
160
- "subagent": sa,
161
- "appearances": count,
162
- "host_cache_creation_total": subagent_host_cache_creation[sa],
163
- "host_cache_creation_per_invocation": subagent_host_cache_creation[sa] // count if count else 0,
164
- })
165
- return {
166
- "rows": rows,
167
- "note": (
168
- "Entry-tax attribution is approximate in v1 (isSidechain=False in "
169
- "moso-app transcripts means subagent tokens land in the host "
170
- "session bucket). v2 with sidecars carrying subagent start/end "
171
- "timestamps will sharpen this."
172
- ),
173
- }
174
-
175
-
176
- def _te_4(sidecars: list[dict]) -> dict:
177
- """TE-4: cost per phase — the sidecar's window, summed from the transcript.
178
-
179
- One row per sidecar. A row is *measured* when the sidecar carries a phase
180
- window (schema_version ≥ 2) whose session transcript is still on disk;
181
- otherwise `tokens` is `None` and the row is unmeasured. Never zero: a phase
182
- whose transcript was swept spent tokens we can no longer count, and printing
183
- 0 would fold it into the average as if it were free.
184
- """
185
- rows = []
186
- for sc in sidecars:
187
- usage = phase_usage(sc.get("data") or {})
188
- data = sc.get("data") or {}
189
- rows.append({
190
- "skill": sc.get("skill"),
191
- "change": sc.get("change_id"),
192
- "phase_id": sc.get("phase_id"),
193
- "sidecar": sc.get("file"),
194
- "schema_version": sc.get("schema_version"),
195
- "session_id": data.get("session_id"),
196
- "opened_at": data.get("phase_opened_at"),
197
- "closed_at": data.get("phase_closed_at"),
198
- "measured": usage is not None,
199
- "unmeasured_reason": None if usage is not None else _unmeasured_reason(sc),
200
- "tokens": usage["total_tokens"] if usage else None,
201
- "cost_score": usage["cost_score"] if usage else None,
202
- "turns": usage["turns"] if usage else None,
203
- "wall_clock_h": round(usage["wall_clock_s"] / 3600, 2) if usage else None,
204
- })
205
- rows.sort(key=lambda r: (-(r["tokens"] or 0), r["change"] or "", r["sidecar"] or ""))
206
- measured = [r for r in rows if r["measured"]]
207
- return {
208
- "rows": rows,
209
- "phases_emitted": len(rows),
210
- "phases_measured": len(measured),
211
- "coverage_fraction": (len(measured) / len(rows)) if rows else None,
212
- "note": (
213
- "Phase-scoped: tokens are summed over assistant turns whose "
214
- "timestamp falls inside the sidecar's [phase_opened_at, "
215
- "phase_closed_at] window. Only the five sidecar-emitting Skills "
216
- f"({', '.join(EMITTING_SKILLS)}) appear here at all — every other "
217
- "Skill is invisible, and subagent spend lands in its host's window "
218
- "(isSidechain=False). Unmeasured rows are phases whose window or "
219
- "transcript could not be read; they are never counted as zero. "
220
- "Sidecars are not window-filtered — every change folder on disk is "
221
- "read, unlike the session and git tables above. Read "
222
- "this table next to QO-4 (observed vs promised) and WS-2 (gate "
223
- "findings density): cost without an outcome beside it can only "
224
- "argue for spending less, never for spending well."
225
- ),
226
- }
227
-
228
-
229
- def _unmeasured_reason(sidecar: dict) -> str:
230
- data = sidecar.get("data") or {}
231
- if not data.get("phase_opened_at") or not data.get("phase_closed_at"):
232
- version = sidecar.get("schema_version")
233
- return ("pre-ADR-0009 sidecar (schema_version "
234
- f"{version if version is not None else '?'}) — no phase window")
235
- if not data.get("session_id"):
236
- return "session id unresolved at write time"
237
- return "transcript not found (retention sweep, or written on another machine)"
238
-
239
-
240
- def _te_5(phase_rows: list[dict]) -> dict:
241
- """TE-5: cost per change — the sum of that change's measured phases.
242
-
243
- The coverage fraction is not decoration. Five of the 27 Skills emit
244
- sidecars, so `tokens` is a sum over a subset by construction: the spec, the
245
- plan, the security- and data-reviews, the ship gate and the whole configure
246
- family are absent, and so is any phase whose transcript has aged out. A
247
- reader who takes this column for a change's total cost will read it low.
248
- """
249
- per_change: dict[str, dict] = {}
250
- for r in phase_rows:
251
- change = r.get("change") or "(unknown)"
252
- agg = per_change.setdefault(change, {
253
- "change": change,
254
- "phases_emitted": 0,
255
- "phases_measured": 0,
256
- "skills": set(),
257
- "tokens": 0,
258
- "cost_score": 0,
259
- "turns": 0,
260
- "wall_clock_h": 0.0,
261
- })
262
- agg["phases_emitted"] += 1
263
- if r.get("skill"):
264
- agg["skills"].add(r["skill"])
265
- if not r["measured"]:
266
- continue
267
- agg["phases_measured"] += 1
268
- agg["tokens"] += r["tokens"] or 0
269
- agg["cost_score"] += r["cost_score"] or 0
270
- agg["turns"] += r["turns"] or 0
271
- agg["wall_clock_h"] += r["wall_clock_h"] or 0.0
272
- rows = []
273
- for agg in per_change.values():
274
- measured = agg["phases_measured"]
275
- rows.append({
276
- "change": agg["change"],
277
- "phases_measured": measured,
278
- "phases_emitted": agg["phases_emitted"],
279
- "coverage_fraction": (measured / agg["phases_emitted"]) if agg["phases_emitted"] else None,
280
- "skills_measured": sorted(agg["skills"]),
281
- "tokens": agg["tokens"] if measured else None,
282
- "cost_score": agg["cost_score"] if measured else None,
283
- "turns": agg["turns"] if measured else None,
284
- "wall_clock_h": round(agg["wall_clock_h"], 2) if measured else None,
285
- })
286
- rows.sort(key=lambda r: (-(r["tokens"] or 0), r["change"]))
287
- total_emitted = sum(r["phases_emitted"] for r in rows)
288
- total_measured = sum(r["phases_measured"] for r in rows)
289
- return {
290
- "rows": rows,
291
- "phases_emitted": total_emitted,
292
- "phases_measured": total_measured,
293
- "coverage_fraction": (total_measured / total_emitted) if total_emitted else None,
294
- "note": (
295
- "A subset, not a total. Coverage fraction = measured phases / "
296
- "emitted sidecars, and sidecars are emitted by five Skills only — "
297
- "change-new, change-plan, security-review, data-review, ship and the "
298
- "configure family contribute nothing to these sums. Pair with QO-4 "
299
- "and the adversarial-review findings density before concluding that "
300
- "an expensive change was a wasteful one."
301
- ),
302
- }
@@ -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)