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.
- package/CHANGELOG.md +53 -0
- package/README.md +76 -163
- package/VERSION +1 -1
- package/dist/commands/doctor.js +18 -8
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.js +7 -3
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/update.js +9 -2
- package/dist/commands/update.js.map +1 -1
- package/dist/lib/descriptions.js +10 -17
- package/dist/lib/descriptions.js.map +1 -1
- package/dist/lib/wire.js +166 -138
- package/dist/lib/wire.js.map +1 -1
- package/dist/manifest.js +41 -29
- package/dist/manifest.js.map +1 -1
- package/package.json +3 -4
- package/template/.claude/agents/adversarial-reviewer.md +44 -50
- package/template/.claude/agents/app-architect.md +44 -96
- package/template/.claude/agents/data-architect.md +46 -89
- package/template/.claude/agents/test-strategist.md +39 -68
- package/template/.claude/skills/hstack-adr-new/SKILL.md +23 -73
- package/template/.claude/skills/hstack-adversarial-review/SKILL.md +46 -152
- package/template/.claude/skills/hstack-adversarial-review/references/finding-categories.md +89 -103
- package/template/.claude/skills/hstack-app-architecture/SKILL.md +43 -68
- package/template/.claude/skills/hstack-commit/SKILL.md +27 -68
- package/template/.claude/skills/hstack-data-architecture/SKILL.md +34 -56
- package/template/.claude/skills/hstack-promote/SKILL.md +55 -0
- package/template/.claude/skills/hstack-story/SKILL.md +58 -0
- package/template/.claude/skills/hstack-test-audit/SKILL.md +64 -0
- package/template/.claude/skills/hstack-wrap/SKILL.md +50 -0
- package/template/.claude/skills/hstack-wrap/references/pr-description.md +45 -0
- package/template/KERNEL.md +75 -318
- package/template/templates/adr.md +2 -18
- package/template/templates/app-architecture.md +38 -50
- package/template/templates/code-standards.md +107 -0
- package/template/templates/data-architecture.md +38 -89
- package/template/templates/eslint-clean-code.mjs +322 -0
- package/template/templates/infrastructure.md +54 -148
- package/template/templates/roadmap.md +6 -12
- package/template/templates/story.md +7 -13
- package/template/templates/tech-debt.md +2 -37
- package/template/templates/tech-stack.md +2 -12
- package/template/.claude/agents/data-specialist.md +0 -81
- package/template/.claude/agents/implementer.md +0 -97
- package/template/.claude/agents/kernel-fit-analyst.md +0 -129
- package/template/.claude/agents/planner.md +0 -75
- package/template/.claude/agents/product-discovery.md +0 -129
- package/template/.claude/agents/product-manager.md +0 -76
- package/template/.claude/agents/researcher.md +0 -87
- package/template/.claude/agents/security-reviewer.md +0 -88
- package/template/.claude/agents/spec-author.md +0 -89
- package/template/.claude/agents/stack-architect.md +0 -108
- package/template/.claude/agents/ui-ux-briefer.md +0 -75
- package/template/.claude/agents/verifier.md +0 -81
- package/template/.claude/skills/hstack-branch/SKILL.md +0 -76
- package/template/.claude/skills/hstack-brownfield-init/SKILL.md +0 -131
- package/template/.claude/skills/hstack-change-new/SKILL.md +0 -84
- package/template/.claude/skills/hstack-change-plan/SKILL.md +0 -108
- package/template/.claude/skills/hstack-configure/SKILL.md +0 -99
- package/template/.claude/skills/hstack-coord/SKILL.md +0 -95
- package/template/.claude/skills/hstack-data-review/SKILL.md +0 -93
- package/template/.claude/skills/hstack-finalize/SKILL.md +0 -140
- package/template/.claude/skills/hstack-flag/SKILL.md +0 -99
- package/template/.claude/skills/hstack-greenfield-init/SKILL.md +0 -148
- package/template/.claude/skills/hstack-help/SKILL.md +0 -153
- package/template/.claude/skills/hstack-implement/SKILL.md +0 -155
- package/template/.claude/skills/hstack-kernel-fit-promote/SKILL.md +0 -125
- package/template/.claude/skills/hstack-kernel-fit-scan/SKILL.md +0 -142
- package/template/.claude/skills/hstack-kernel-fit-scan/references/slack-setup.md +0 -42
- package/template/.claude/skills/hstack-kernel-fit-triage/SKILL.md +0 -121
- package/template/.claude/skills/hstack-module-spec/SKILL.md +0 -78
- package/template/.claude/skills/hstack-product-discovery/SKILL.md +0 -75
- package/template/.claude/skills/hstack-research/SKILL.md +0 -115
- package/template/.claude/skills/hstack-scaffold/SKILL.md +0 -113
- package/template/.claude/skills/hstack-security-review/SKILL.md +0 -95
- package/template/.claude/skills/hstack-ship/SKILL.md +0 -119
- package/template/.claude/skills/hstack-stack-decide/SKILL.md +0 -81
- package/template/.claude/skills/hstack-story-draft/SKILL.md +0 -92
- package/template/.claude/skills/hstack-tech-debt-new/SKILL.md +0 -95
- package/template/.claude/skills/hstack-tech-debt-resolve/SKILL.md +0 -123
- package/template/.claude/skills/hstack-tech-debt-stale/SKILL.md +0 -86
- package/template/.claude/skills/hstack-tech-debt-wontfix/SKILL.md +0 -77
- package/template/.claude/skills/hstack-telemetry/SKILL.md +0 -73
- package/template/.claude/skills/hstack-test-plan/SKILL.md +0 -159
- package/template/.claude/skills/hstack-ui-brief/SKILL.md +0 -81
- package/template/.claude/skills/hstack-verify/SKILL.md +0 -128
- package/template/scripts/compute-merge-readiness.mjs +0 -780
- package/template/scripts/coord/coord_scan.py +0 -681
- package/template/scripts/run-gates.sh +0 -388
- package/template/scripts/telemetry/__init__.py +0 -6
- package/template/scripts/telemetry/insights/__init__.py +0 -0
- package/template/scripts/telemetry/insights/contract_drift.py +0 -137
- package/template/scripts/telemetry/insights/kernel_fit.py +0 -438
- package/template/scripts/telemetry/insights/overengineering.py +0 -115
- package/template/scripts/telemetry/insights/quality_outcomes.py +0 -131
- package/template/scripts/telemetry/insights/token_economics.py +0 -302
- package/template/scripts/telemetry/insights/workflow_shape.py +0 -198
- package/template/scripts/telemetry/parsers/__init__.py +0 -0
- package/template/scripts/telemetry/parsers/bodies.py +0 -87
- package/template/scripts/telemetry/parsers/commits.py +0 -219
- package/template/scripts/telemetry/parsers/frontmatter.py +0 -322
- package/template/scripts/telemetry/parsers/sidecars.py +0 -61
- package/template/scripts/telemetry/parsers/transcripts.py +0 -294
- package/template/scripts/telemetry/render.py +0 -460
- package/template/scripts/telemetry/report.py +0 -150
- package/template/scripts/telemetry/run_kernel_fit.py +0 -95
- package/template/scripts/telemetry/session_id.py +0 -139
- package/template/scripts/validate-spec.mjs +0 -3303
- package/template/templates/adversarial-review.md +0 -54
- package/template/templates/bootstrap.md +0 -105
- package/template/templates/change-spec.md +0 -82
- package/template/templates/ci-cd.md +0 -41
- package/template/templates/coord-message.md +0 -56
- package/template/templates/data-review.md +0 -54
- package/template/templates/discovery/brainstorm.md +0 -69
- package/template/templates/discovery/forcing-questions.md +0 -76
- package/template/templates/discovery/project-brief.md +0 -53
- package/template/templates/figma-handoff.md +0 -38
- package/template/templates/glossary.md +0 -20
- package/template/templates/hardening-checklist.md +0 -73
- package/template/templates/incident-runbook.md +0 -57
- package/template/templates/kernel-fit-finding.md +0 -63
- package/template/templates/kernel-fit-flag.md +0 -52
- package/template/templates/module-spec.md +0 -49
- package/template/templates/persona.md +0 -38
- package/template/templates/plan.md +0 -53
- package/template/templates/product-brief.md +0 -56
- package/template/templates/security-review.md +0 -63
- package/template/templates/telemetry-sidecar.md +0 -227
- package/template/templates/test-plan.md +0 -119
- package/template/templates/threat-model.md +0 -54
- package/template/templates/ui-brief.md +0 -49
- package/template/templates/verification.md +0 -63
- package/template/templates/vision.md +0 -34
|
@@ -1,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)
|