hstack 0.1.0 → 0.3.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 +35 -9
- package/VERSION +1 -1
- package/dist/manifest.js +1 -0
- package/dist/manifest.js.map +1 -1
- package/package.json +1 -1
- package/template/.claude/agents/kernel-fit-analyst.md +137 -0
- package/template/.claude/agents/spec-author.md +7 -2
- package/template/.claude/skills/hstack-adr-new/SKILL.md +6 -1
- package/template/.claude/skills/hstack-change-new/SKILL.md +7 -5
- package/template/.claude/skills/hstack-help/SKILL.md +6 -1
- package/template/.claude/skills/hstack-implement/SKILL.md +2 -2
- package/template/.claude/skills/hstack-kernel-fit-promote/SKILL.md +164 -0
- package/template/.claude/skills/hstack-kernel-fit-scan/SKILL.md +180 -0
- package/template/.claude/skills/hstack-kernel-fit-triage/SKILL.md +159 -0
- package/template/.claude/skills/hstack-ship/SKILL.md +6 -5
- package/template/.claude/skills/hstack-story-draft/SKILL.md +7 -5
- package/template/.claude/skills/hstack-telemetry/SKILL.md +3 -2
- package/template/.claude/skills/hstack-ui-brief/SKILL.md +1 -1
- package/template/CLAUDE.md +26 -0
- package/template/scripts/telemetry/insights/kernel_fit.py +438 -0
- package/template/scripts/telemetry/render.py +73 -0
- package/template/scripts/telemetry/report.py +4 -1
- package/template/scripts/telemetry/run_kernel_fit.py +91 -0
- package/template/templates/adr.md +2 -1
- package/template/templates/change-spec.md +3 -1
- package/template/templates/kernel-fit-finding.md +62 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""Kernel-fit insights: patterns suggesting the kernel itself needs revision.
|
|
2
|
+
|
|
3
|
+
This module is the detection layer of the kernel-fit closed-loop system. It
|
|
4
|
+
pattern-matches across shipped artifacts and emits evidence rows; an LLM
|
|
5
|
+
subagent (`kernel-fit-analyst`) then synthesizes findings from these rows.
|
|
6
|
+
|
|
7
|
+
See ADR-0004 for the full design rationale and `template/CLAUDE.md` § How
|
|
8
|
+
hstack improves itself for the loop contract.
|
|
9
|
+
|
|
10
|
+
Three starter patterns:
|
|
11
|
+
|
|
12
|
+
- KF-P1 — `category-a-claim-spans-production-paths`: changes flagged
|
|
13
|
+
`internal-tooling: true` (Category A — engineering-only) whose `in-scope`
|
|
14
|
+
touches production-code paths AND whose `enables` array is empty.
|
|
15
|
+
Under the post-PR-#5 schema (`enables` ↔ `enabled-by`, SP-13/SP-14),
|
|
16
|
+
this is the engineer mis-classifying what should be Category B
|
|
17
|
+
(foundational prerequisite) as Category A. The in-scope-overlap
|
|
18
|
+
heuristic surfaces candidate downstream consumers as evidence.
|
|
19
|
+
- KF-P2 — `halt-reason-cluster-uncovered-by-enum`: halt sentinels with
|
|
20
|
+
`reason=other` whose surrounding prose clusters above the Jaccard
|
|
21
|
+
threshold, suggesting the enum is missing a case.
|
|
22
|
+
- KF-P3 — `skill-precondition-violated-and-recoverable`: adversarial-review
|
|
23
|
+
spec-compliance findings whose resolution commit messages reveal a missed
|
|
24
|
+
upstream gate (the ADR-0002 pattern).
|
|
25
|
+
|
|
26
|
+
Detection is pure read — no writes. Output is a dict consumed by the
|
|
27
|
+
analyst subagent via the scan Skill orchestration.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
from collections import defaultdict
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from telemetry.parsers import frontmatter as fm_parser
|
|
37
|
+
from telemetry.parsers.bodies import parse_findings_section, split_sections
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# File-path prefixes that count as "internal-only" for KF-P1 classification.
|
|
41
|
+
# A change whose in-scope is entirely under these prefixes is genuine
|
|
42
|
+
# Category A (true internal tooling). Anything outside is candidate Category B
|
|
43
|
+
# (foundational prerequisite — production code with deferred user value).
|
|
44
|
+
INTERNAL_ONLY_PREFIXES = (
|
|
45
|
+
"hstack/",
|
|
46
|
+
"scripts/",
|
|
47
|
+
".github/",
|
|
48
|
+
"template/",
|
|
49
|
+
".claude/",
|
|
50
|
+
"docs/",
|
|
51
|
+
"ci/",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Kernel-rule keywords scanned in resolution commits for KF-P3. A
|
|
55
|
+
# `spec-compliance` adversarial finding whose resolving commit mentions any
|
|
56
|
+
# of these is a candidate "Skill precondition should have halted earlier"
|
|
57
|
+
# signal (the ADR-0002 missing-gate pattern).
|
|
58
|
+
KERNEL_GATE_KEYWORDS = re.compile(
|
|
59
|
+
r"\b(precondition|missed\s+gate|should\s+have\s+halted|upstream|"
|
|
60
|
+
r"ready-for-implementation|ready-for-review|ready-to-ship|status\s+gate)\b",
|
|
61
|
+
re.IGNORECASE,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Jaccard threshold for KF-P2 cluster membership. Tunable; documented in the
|
|
65
|
+
# plan as a starting value. Lower → more clustering (more cluster merges,
|
|
66
|
+
# fewer clusters). Higher → tighter clusters (fewer merges, more clusters).
|
|
67
|
+
JACCARD_THRESHOLD = 0.6
|
|
68
|
+
|
|
69
|
+
# Minimum cluster size for KF-P2 to fire. Smaller than this is noise.
|
|
70
|
+
MIN_CLUSTER_SIZE = 3
|
|
71
|
+
|
|
72
|
+
# Minimum candidate-row count for KF-P1 to fire. A single Category-B mislabel
|
|
73
|
+
# is noise; recurrence is signal.
|
|
74
|
+
KF_P1_MIN_ROWS = 2
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def compute(commits: list[dict], changes: dict, tech_debt: list[dict],
|
|
78
|
+
adrs: list[dict], module_specs: list[dict],
|
|
79
|
+
session_rows: list[dict], findings_dir: Path | None) -> dict:
|
|
80
|
+
"""Run all kernel-fit detection patterns.
|
|
81
|
+
|
|
82
|
+
`findings_dir` may be `None` or non-existent on first run — the dedup
|
|
83
|
+
cross-reference returns an empty index in that case, and the analyst
|
|
84
|
+
treats every fired pattern as net-new.
|
|
85
|
+
"""
|
|
86
|
+
existing = _load_existing_findings(findings_dir)
|
|
87
|
+
return {
|
|
88
|
+
"existing_open_findings_by_pattern": existing,
|
|
89
|
+
"kf_p1_category_a_claim_spans_production_paths": _kf_p1(changes, commits),
|
|
90
|
+
"kf_p2_halt_reason_cluster_uncovered_by_enum": _kf_p2(commits, session_rows),
|
|
91
|
+
"kf_p3_skill_precondition_violated_and_recoverable": _kf_p3(changes, commits),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------- existing-findings index ----------------
|
|
96
|
+
|
|
97
|
+
def _load_existing_findings(findings_dir: Path | None) -> dict:
|
|
98
|
+
"""Read all KF-NNNN-*.md files in findings_dir and build an index of open
|
|
99
|
+
findings keyed by pattern. Used by the analyst for dedup / supersession
|
|
100
|
+
decisions. Tolerant of missing directory."""
|
|
101
|
+
out: dict[str, list[str]] = defaultdict(list)
|
|
102
|
+
if findings_dir is None or not findings_dir.is_dir():
|
|
103
|
+
return dict(out)
|
|
104
|
+
for path in sorted(findings_dir.glob("KF-*.md")):
|
|
105
|
+
parsed = fm_parser.read_artifact(path)
|
|
106
|
+
if parsed is None:
|
|
107
|
+
continue
|
|
108
|
+
fm, _body = parsed
|
|
109
|
+
status = fm.get("status")
|
|
110
|
+
pattern = fm.get("pattern")
|
|
111
|
+
kid = fm.get("id") or path.stem
|
|
112
|
+
if not pattern:
|
|
113
|
+
continue
|
|
114
|
+
# Only "open" and "acknowledged" findings count for dedup; promoted /
|
|
115
|
+
# dismissed / superseded / archived are terminal and do not suppress
|
|
116
|
+
# re-detection.
|
|
117
|
+
if status in ("open", "acknowledged"):
|
|
118
|
+
out[pattern].append(kid)
|
|
119
|
+
return dict(out)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------- KF-P1 ----------------
|
|
123
|
+
|
|
124
|
+
def _classify_inscope_paths(in_scope: list) -> tuple[list[str], list[str]]:
|
|
125
|
+
"""Partition an in-scope list into (internal_only_paths, production_paths)."""
|
|
126
|
+
internal_only: list[str] = []
|
|
127
|
+
production: list[str] = []
|
|
128
|
+
for entry in in_scope or []:
|
|
129
|
+
if not isinstance(entry, str):
|
|
130
|
+
continue
|
|
131
|
+
path = entry.strip()
|
|
132
|
+
if not path:
|
|
133
|
+
continue
|
|
134
|
+
# Normalize leading "./" and any glob suffixes for prefix checking.
|
|
135
|
+
normalized = path[2:] if path.startswith("./") else path
|
|
136
|
+
if any(normalized.startswith(p) for p in INTERNAL_ONLY_PREFIXES):
|
|
137
|
+
internal_only.append(path)
|
|
138
|
+
else:
|
|
139
|
+
production.append(path)
|
|
140
|
+
return internal_only, production
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _forward_consumers(this_change_id: str, this_in_scope: list[str],
|
|
144
|
+
changes: dict, commits: list[dict]) -> list[str]:
|
|
145
|
+
"""Return change-ids of later changes whose in-scope OR whose commit
|
|
146
|
+
file-lists overlap with this change's in-scope production paths."""
|
|
147
|
+
if not this_in_scope:
|
|
148
|
+
return []
|
|
149
|
+
# Build set of production-path-prefixes for cheap overlap checks. We treat
|
|
150
|
+
# each in-scope entry as a prefix; this is forgiving (catches edits inside
|
|
151
|
+
# subdirs) and matches what `internal-tooling: true` plumbing changes
|
|
152
|
+
# typically introduce (a dir of new types or a new module).
|
|
153
|
+
prefixes = {(p[2:] if p.startswith("./") else p).rstrip("/*") for p in this_in_scope}
|
|
154
|
+
|
|
155
|
+
consumers: set[str] = set()
|
|
156
|
+
|
|
157
|
+
# (1) Other change-specs whose in-scope overlaps.
|
|
158
|
+
for other_id, arts in changes.items():
|
|
159
|
+
if other_id == this_change_id:
|
|
160
|
+
continue
|
|
161
|
+
spec = arts.get("change-spec") or arts.get("spec")
|
|
162
|
+
if not spec:
|
|
163
|
+
continue
|
|
164
|
+
other_in_scope = spec["fm"].get("in-scope") or []
|
|
165
|
+
for entry in other_in_scope:
|
|
166
|
+
if not isinstance(entry, str):
|
|
167
|
+
continue
|
|
168
|
+
normalized = entry[2:] if entry.startswith("./") else entry
|
|
169
|
+
if any(normalized.startswith(p) for p in prefixes):
|
|
170
|
+
consumers.add(other_id)
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
# (2) Commits whose file-list touches our production paths AND whose
|
|
174
|
+
# artifact_id is a different change-spec (avoids self-attribution).
|
|
175
|
+
for c in commits:
|
|
176
|
+
cid = c.get("artifact_id")
|
|
177
|
+
if not cid or cid == this_change_id:
|
|
178
|
+
continue
|
|
179
|
+
for f in c.get("files", []):
|
|
180
|
+
normalized = f[2:] if f.startswith("./") else f
|
|
181
|
+
if any(normalized.startswith(p) for p in prefixes):
|
|
182
|
+
consumers.add(cid)
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
return sorted(consumers)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _kf_p1(changes: dict, commits: list[dict]) -> dict:
|
|
189
|
+
"""KF-P1 — Category A (`internal-tooling: true`) claims whose `in-scope`
|
|
190
|
+
spans production-code paths AND whose `enables` array is empty. Under
|
|
191
|
+
the post-PR-#5 schema, this is the engineer mis-classifying what should
|
|
192
|
+
be Category B (foundational prerequisite) as Category A. SP-13 makes
|
|
193
|
+
A and B mutually exclusive at the validator level; KF-P1 catches the
|
|
194
|
+
case the validator cannot — claiming A when the in-scope reveals B.
|
|
195
|
+
Fires on >= KF_P1_MIN_ROWS candidate rows.
|
|
196
|
+
"""
|
|
197
|
+
rows: list[dict] = []
|
|
198
|
+
for cid, arts in changes.items():
|
|
199
|
+
spec = arts.get("change-spec") or arts.get("spec")
|
|
200
|
+
if not spec:
|
|
201
|
+
continue
|
|
202
|
+
fm = spec["fm"]
|
|
203
|
+
if fm.get("status") != "shipped":
|
|
204
|
+
continue
|
|
205
|
+
if not fm.get("internal-tooling"):
|
|
206
|
+
continue
|
|
207
|
+
in_scope = fm.get("in-scope") or []
|
|
208
|
+
enables = fm.get("enables") or []
|
|
209
|
+
internal_paths, production_paths = _classify_inscope_paths(in_scope)
|
|
210
|
+
# Classification (post-PR-#5 schema; SP-13 enforces mutual exclusivity):
|
|
211
|
+
# - no production paths → "true-category-a" (correctly classified)
|
|
212
|
+
# - has production paths AND enables empty → "category-b-misclassified" (bug)
|
|
213
|
+
# - has production paths AND enables non-empty → impossible under SP-13;
|
|
214
|
+
# if observed the validator failed and the analyst surfaces it separately
|
|
215
|
+
if not production_paths:
|
|
216
|
+
classification = "true-category-a"
|
|
217
|
+
elif not enables:
|
|
218
|
+
classification = "category-b-misclassified"
|
|
219
|
+
else:
|
|
220
|
+
# SP-13 violation should not reach here in a validated repo; flag
|
|
221
|
+
# explicitly so the analyst can route to a validator-bug finding.
|
|
222
|
+
classification = "sp-13-violation"
|
|
223
|
+
consumers: list[str] = []
|
|
224
|
+
if classification == "category-b-misclassified":
|
|
225
|
+
consumers = _forward_consumers(cid, production_paths or in_scope, changes, commits)
|
|
226
|
+
rows.append({
|
|
227
|
+
"change": cid,
|
|
228
|
+
"internal_only_paths_count": len(internal_paths),
|
|
229
|
+
"production_paths_count": len(production_paths),
|
|
230
|
+
"enables_count": len(enables),
|
|
231
|
+
"downstream_consumers": consumers,
|
|
232
|
+
"classification_candidate": classification,
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
candidate_rows = [r for r in rows
|
|
236
|
+
if r["classification_candidate"] == "category-b-misclassified"]
|
|
237
|
+
fired = len(candidate_rows) >= KF_P1_MIN_ROWS
|
|
238
|
+
return {
|
|
239
|
+
"pattern_id": "KF-P1",
|
|
240
|
+
"pattern_name": "category-a-claim-spans-production-paths",
|
|
241
|
+
"fired": fired,
|
|
242
|
+
"evidence_row_count": len(candidate_rows),
|
|
243
|
+
"min_rows_for_firing": KF_P1_MIN_ROWS,
|
|
244
|
+
"all_rows": rows,
|
|
245
|
+
"evidence_rows": candidate_rows,
|
|
246
|
+
"note": ("Changes flagged `internal-tooling: true` (Category A) whose in-scope "
|
|
247
|
+
"spans production-code paths AND whose `enables` array is empty. Under "
|
|
248
|
+
"the post-PR-#5 schema (Category A vs Category B with `enables`/`enabled-by`), "
|
|
249
|
+
"this is the engineer mis-classifying what should be Category B as Category A. "
|
|
250
|
+
"SP-13 catches the both-set case; KF-P1 catches the claim-A-while-looking-like-B case."),
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ---------------- KF-P2 ----------------
|
|
255
|
+
|
|
256
|
+
_TOKEN_RE = re.compile(r"[a-zA-Z][a-zA-Z0-9_-]{2,}")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _tokens(text: str) -> set[str]:
|
|
260
|
+
"""Lowercase token set from text; drops tokens <=3 chars and pure numbers."""
|
|
261
|
+
if not text:
|
|
262
|
+
return set()
|
|
263
|
+
return {t.lower() for t in _TOKEN_RE.findall(text)}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _jaccard(a: set[str], b: set[str]) -> float:
|
|
267
|
+
if not a or not b:
|
|
268
|
+
return 0.0
|
|
269
|
+
inter = len(a & b)
|
|
270
|
+
union = len(a | b)
|
|
271
|
+
return inter / union if union else 0.0
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _kf_p2(commits: list[dict], session_rows: list[dict]) -> dict:
|
|
275
|
+
"""KF-P2 — cluster halt sentinels with reason=other. Cluster size >=
|
|
276
|
+
MIN_CLUSTER_SIZE is evidence the enum is missing a case.
|
|
277
|
+
|
|
278
|
+
Sources: commit bodies (parser already extracted halt_reasons), and
|
|
279
|
+
session-row halt_reasons. For commits, we use the commit body as the
|
|
280
|
+
surrounding-prose context; for session rows we use the row's halt-context
|
|
281
|
+
if available, falling back to a label-only token set.
|
|
282
|
+
"""
|
|
283
|
+
docs: list[dict] = []
|
|
284
|
+
for c in commits:
|
|
285
|
+
reasons = c.get("halt_reasons") or []
|
|
286
|
+
if not any(r.lower() == "other" for r in reasons):
|
|
287
|
+
continue
|
|
288
|
+
# Use commit body as the prose context — it is what the kernel
|
|
289
|
+
# contract says accompanies the sentinel.
|
|
290
|
+
context = c.get("body", "") or c.get("subject", "")
|
|
291
|
+
docs.append({
|
|
292
|
+
"source": "commit",
|
|
293
|
+
"ref": c.get("sha", "")[:8],
|
|
294
|
+
"context": context,
|
|
295
|
+
"tokens": _tokens(context),
|
|
296
|
+
})
|
|
297
|
+
for s in session_rows:
|
|
298
|
+
reasons = s.get("halt_reasons") or []
|
|
299
|
+
for r in reasons:
|
|
300
|
+
if not isinstance(r, str):
|
|
301
|
+
continue
|
|
302
|
+
if r.lower() != "other":
|
|
303
|
+
continue
|
|
304
|
+
docs.append({
|
|
305
|
+
"source": "session",
|
|
306
|
+
"ref": s.get("file", "") or s.get("skill", "") or "",
|
|
307
|
+
# Session parser does not capture surrounding prose in v1;
|
|
308
|
+
# use the session label as a degraded token source.
|
|
309
|
+
"context": s.get("skill", "") + " " + " ".join(reasons),
|
|
310
|
+
"tokens": _tokens(s.get("skill", "")),
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
# Greedy clustering: each new doc joins the first cluster whose
|
|
314
|
+
# representative has Jaccard >= threshold; else starts a new cluster.
|
|
315
|
+
clusters: list[list[dict]] = []
|
|
316
|
+
for d in docs:
|
|
317
|
+
placed = False
|
|
318
|
+
for cluster in clusters:
|
|
319
|
+
rep_tokens = cluster[0]["tokens"]
|
|
320
|
+
if _jaccard(d["tokens"], rep_tokens) >= JACCARD_THRESHOLD:
|
|
321
|
+
cluster.append(d)
|
|
322
|
+
placed = True
|
|
323
|
+
break
|
|
324
|
+
if not placed:
|
|
325
|
+
clusters.append([d])
|
|
326
|
+
|
|
327
|
+
evidence_clusters = [c for c in clusters if len(c) >= MIN_CLUSTER_SIZE]
|
|
328
|
+
rows = []
|
|
329
|
+
for i, cluster in enumerate(evidence_clusters):
|
|
330
|
+
rows.append({
|
|
331
|
+
"cluster_id": f"C-{i + 1}",
|
|
332
|
+
"size": len(cluster),
|
|
333
|
+
"representative_context": (cluster[0]["context"] or "")[:300],
|
|
334
|
+
"member_refs": [d["ref"] for d in cluster],
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
"pattern_id": "KF-P2",
|
|
339
|
+
"pattern_name": "halt-reason-cluster-uncovered-by-enum",
|
|
340
|
+
"fired": len(evidence_clusters) > 0,
|
|
341
|
+
"evidence_row_count": len(evidence_clusters),
|
|
342
|
+
"jaccard_threshold": JACCARD_THRESHOLD,
|
|
343
|
+
"min_cluster_size": MIN_CLUSTER_SIZE,
|
|
344
|
+
"total_other_halts": len(docs),
|
|
345
|
+
"evidence_rows": rows,
|
|
346
|
+
"note": ("HSTACK-HALT sentinels with reason=other clustered by surrounding-prose "
|
|
347
|
+
f"token overlap (Jaccard >= {JACCARD_THRESHOLD}). A cluster of "
|
|
348
|
+
f">= {MIN_CLUSTER_SIZE} similar halts means the enum is missing a case."),
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ---------------- KF-P3 ----------------
|
|
353
|
+
|
|
354
|
+
def _commits_by_sha_prefix(commits: list[dict]) -> dict[str, dict]:
|
|
355
|
+
"""Index commits by short sha (8 chars) for quick lookup. Falls back to
|
|
356
|
+
full sha if entries collide (rare with realistic repo sizes)."""
|
|
357
|
+
out: dict[str, dict] = {}
|
|
358
|
+
for c in commits:
|
|
359
|
+
sha = c.get("sha") or ""
|
|
360
|
+
if not sha:
|
|
361
|
+
continue
|
|
362
|
+
out[sha] = c
|
|
363
|
+
if len(sha) >= 8:
|
|
364
|
+
out[sha[:8]] = c
|
|
365
|
+
return out
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _kf_p3(changes: dict, commits: list[dict]) -> dict:
|
|
369
|
+
"""KF-P3 — spec-compliance adversarial findings resolved via a commit
|
|
370
|
+
whose message reveals a kernel-gate keyword. This is the pattern that
|
|
371
|
+
produced ADR-0002 (the missed `ready-for-review` transition).
|
|
372
|
+
"""
|
|
373
|
+
commit_index = _commits_by_sha_prefix(commits)
|
|
374
|
+
rows: list[dict] = []
|
|
375
|
+
for cid, arts in changes.items():
|
|
376
|
+
ar = arts.get("adversarial-review")
|
|
377
|
+
if not ar:
|
|
378
|
+
continue
|
|
379
|
+
ar_fm = ar["fm"]
|
|
380
|
+
if ar_fm.get("status") != "findings-resolved":
|
|
381
|
+
continue
|
|
382
|
+
# Findings array on frontmatter is authoritative; fall back to body
|
|
383
|
+
# parser when the array is missing or absent.
|
|
384
|
+
findings = ar_fm.get("findings") or []
|
|
385
|
+
if not findings:
|
|
386
|
+
sections = split_sections(ar["body"] or "")
|
|
387
|
+
findings_section = sections.get("Findings", "")
|
|
388
|
+
findings = parse_findings_section(findings_section)
|
|
389
|
+
for f in findings:
|
|
390
|
+
if not isinstance(f, dict):
|
|
391
|
+
continue
|
|
392
|
+
category = (f.get("category") or "").lower()
|
|
393
|
+
if category != "spec-compliance":
|
|
394
|
+
continue
|
|
395
|
+
resolution = (f.get("resolution") or "").strip()
|
|
396
|
+
if not resolution.startswith("commit:"):
|
|
397
|
+
continue
|
|
398
|
+
sha_token = resolution.split(":", 1)[1].strip().split()[0]
|
|
399
|
+
commit = commit_index.get(sha_token) or commit_index.get(sha_token[:8])
|
|
400
|
+
if not commit:
|
|
401
|
+
# Still record the candidate — the analyst can decide whether
|
|
402
|
+
# missing-commit-context is itself a signal.
|
|
403
|
+
if KERNEL_GATE_KEYWORDS.search(resolution):
|
|
404
|
+
rows.append({
|
|
405
|
+
"change": cid,
|
|
406
|
+
"finding_id": f.get("id"),
|
|
407
|
+
"category": category,
|
|
408
|
+
"resolution": resolution,
|
|
409
|
+
"commit_subject": None,
|
|
410
|
+
"matched_keywords": [],
|
|
411
|
+
"commit_resolved": False,
|
|
412
|
+
})
|
|
413
|
+
continue
|
|
414
|
+
haystack = (commit.get("subject") or "") + "\n" + (commit.get("body") or "")
|
|
415
|
+
matches = KERNEL_GATE_KEYWORDS.findall(haystack)
|
|
416
|
+
if not matches:
|
|
417
|
+
continue
|
|
418
|
+
rows.append({
|
|
419
|
+
"change": cid,
|
|
420
|
+
"finding_id": f.get("id"),
|
|
421
|
+
"category": category,
|
|
422
|
+
"resolution": resolution,
|
|
423
|
+
"commit_subject": commit.get("subject"),
|
|
424
|
+
"matched_keywords": [m.lower() for m in matches],
|
|
425
|
+
"commit_resolved": True,
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
"pattern_id": "KF-P3",
|
|
430
|
+
"pattern_name": "skill-precondition-violated-and-recoverable",
|
|
431
|
+
"fired": len(rows) >= 1,
|
|
432
|
+
"evidence_row_count": len(rows),
|
|
433
|
+
"evidence_rows": rows,
|
|
434
|
+
"note": ("Adversarial spec-compliance findings whose resolving commit message "
|
|
435
|
+
"mentions a kernel gate or precondition. Each row is a candidate "
|
|
436
|
+
"'a Skill precondition should have halted earlier' signal — the "
|
|
437
|
+
"ADR-0002 pattern."),
|
|
438
|
+
}
|
|
@@ -23,6 +23,7 @@ def render_report(metrics: dict, repo_name: str, window_days: int | None) -> str
|
|
|
23
23
|
_render_quality_outcomes(lines, metrics.get("quality_outcomes", {}))
|
|
24
24
|
_render_overengineering(lines, metrics.get("overengineering", {}))
|
|
25
25
|
_render_contract_drift(lines, metrics.get("contract_drift", {}))
|
|
26
|
+
_render_kernel_fit(lines, metrics.get("kernel_fit", {}))
|
|
26
27
|
|
|
27
28
|
_render_watch_list(lines, metrics)
|
|
28
29
|
|
|
@@ -271,6 +272,65 @@ def _render_contract_drift(lines: list[str], cd: dict) -> None:
|
|
|
271
272
|
)
|
|
272
273
|
|
|
273
274
|
|
|
275
|
+
def _render_kernel_fit(lines: list[str], kf: dict) -> None:
|
|
276
|
+
_h(lines, 2, "Kernel-fit candidates")
|
|
277
|
+
_p(lines, "Patterns suggesting the kernel itself (CLAUDE.md, templates, validators, Skill "
|
|
278
|
+
"flows) may need revision. Each fired pattern is also written as a durable finding "
|
|
279
|
+
"by `/hstack:kernel-fit-scan` at `hstack/kernel-fit/findings/KF-NNNN-*.md`. The "
|
|
280
|
+
"table below is a rollup; the findings are the canonical artifact. See ADR-0004.")
|
|
281
|
+
|
|
282
|
+
existing = kf.get("existing_open_findings_by_pattern", {})
|
|
283
|
+
if existing:
|
|
284
|
+
total_open = sum(len(v) for v in existing.values())
|
|
285
|
+
_p(lines, f"**Currently open findings:** {total_open} "
|
|
286
|
+
+ "(" + ", ".join(f"{p}: {len(ids)}" for p, ids in sorted(existing.items())) + ")")
|
|
287
|
+
|
|
288
|
+
patterns = [
|
|
289
|
+
("kf_p1_category_a_claim_spans_production_paths", "KF-P1 — category-a-claim-spans-production-paths"),
|
|
290
|
+
("kf_p2_halt_reason_cluster_uncovered_by_enum", "KF-P2 — halt-reason-cluster-uncovered-by-enum"),
|
|
291
|
+
("kf_p3_skill_precondition_violated_and_recoverable", "KF-P3 — skill-precondition-violated-and-recoverable"),
|
|
292
|
+
]
|
|
293
|
+
for key, heading in patterns:
|
|
294
|
+
block = kf.get(key, {})
|
|
295
|
+
_h(lines, 3, heading)
|
|
296
|
+
_p(lines, block.get("note", ""))
|
|
297
|
+
fired = block.get("fired", False)
|
|
298
|
+
rc = block.get("evidence_row_count", 0)
|
|
299
|
+
if fired:
|
|
300
|
+
_p(lines, f"**Fired** — {rc} evidence row(s).")
|
|
301
|
+
else:
|
|
302
|
+
_p(lines, f"_(not fired — {rc} evidence row(s); threshold not met)_")
|
|
303
|
+
|
|
304
|
+
# Per-pattern row rendering.
|
|
305
|
+
if key == "kf_p1_category_a_claim_spans_production_paths":
|
|
306
|
+
rows = block.get("evidence_rows", [])
|
|
307
|
+
_table(
|
|
308
|
+
lines,
|
|
309
|
+
["change", "production paths", "enables", "downstream consumers", "classification"],
|
|
310
|
+
[[r["change"], r["production_paths_count"], r["enables_count"],
|
|
311
|
+
", ".join(r["downstream_consumers"][:3]) + ("…" if len(r["downstream_consumers"]) > 3 else ""),
|
|
312
|
+
r["classification_candidate"]]
|
|
313
|
+
for r in rows[:10]],
|
|
314
|
+
)
|
|
315
|
+
elif key == "kf_p2_halt_reason_cluster_uncovered_by_enum":
|
|
316
|
+
rows = block.get("evidence_rows", [])
|
|
317
|
+
_table(
|
|
318
|
+
lines,
|
|
319
|
+
["cluster", "size", "representative context (truncated)"],
|
|
320
|
+
[[r["cluster_id"], r["size"], r["representative_context"][:120]]
|
|
321
|
+
for r in rows[:10]],
|
|
322
|
+
)
|
|
323
|
+
elif key == "kf_p3_skill_precondition_violated_and_recoverable":
|
|
324
|
+
rows = block.get("evidence_rows", [])
|
|
325
|
+
_table(
|
|
326
|
+
lines,
|
|
327
|
+
["change", "finding", "matched keywords", "commit subject (truncated)"],
|
|
328
|
+
[[r["change"], r["finding_id"], ", ".join(r["matched_keywords"]),
|
|
329
|
+
(r["commit_subject"] or "-")[:80]]
|
|
330
|
+
for r in rows[:10]],
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
274
334
|
def _render_watch_list(lines: list[str], metrics: dict) -> None:
|
|
275
335
|
_h(lines, 2, "Watch list")
|
|
276
336
|
items = []
|
|
@@ -303,6 +363,19 @@ def _render_watch_list(lines: list[str], metrics: dict) -> None:
|
|
|
303
363
|
if r["drift_flag"]:
|
|
304
364
|
items.append(f"Module-spec drift: `{r['module']}` is `needs-refresh` with {r['recent_commits_touching_module']} recent commits.")
|
|
305
365
|
|
|
366
|
+
# Kernel-fit fired patterns
|
|
367
|
+
kf = metrics.get("kernel_fit", {})
|
|
368
|
+
for key, label in (
|
|
369
|
+
("kf_p1_category_a_claim_spans_production_paths", "KF-P1"),
|
|
370
|
+
("kf_p2_halt_reason_cluster_uncovered_by_enum", "KF-P2"),
|
|
371
|
+
("kf_p3_skill_precondition_violated_and_recoverable", "KF-P3"),
|
|
372
|
+
):
|
|
373
|
+
block = kf.get(key, {})
|
|
374
|
+
if block.get("fired"):
|
|
375
|
+
rc = block.get("evidence_row_count", 0)
|
|
376
|
+
items.append(f"Kernel-fit {label} fired with {rc} evidence row(s) — "
|
|
377
|
+
f"run `/hstack:kernel-fit-scan` to synthesize findings.")
|
|
378
|
+
|
|
306
379
|
if not items:
|
|
307
380
|
_p(lines, "_Nothing flagged. Either everything is healthy, or the metrics need tuning._")
|
|
308
381
|
return
|
|
@@ -39,7 +39,7 @@ if str(_SCRIPTS) not in sys.path:
|
|
|
39
39
|
from telemetry.parsers import frontmatter, commits, transcripts # noqa: E402
|
|
40
40
|
from telemetry.insights import ( # noqa: E402
|
|
41
41
|
token_economics, workflow_shape, quality_outcomes,
|
|
42
|
-
overengineering, contract_drift,
|
|
42
|
+
overengineering, contract_drift, kernel_fit,
|
|
43
43
|
)
|
|
44
44
|
from telemetry import render # noqa: E402
|
|
45
45
|
|
|
@@ -85,12 +85,15 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
85
85
|
session_rows = transcripts.collect_session_rows([repo], since=since_dt)
|
|
86
86
|
print(f"telemetry: {len(session_rows)} sessions in window", file=sys.stderr)
|
|
87
87
|
|
|
88
|
+
findings_dir = hstack_root / "kernel-fit" / "findings"
|
|
88
89
|
metrics = {
|
|
89
90
|
"token_economics": token_economics.compute(session_rows, changes),
|
|
90
91
|
"workflow_shape": workflow_shape.compute(git_commits, changes, session_rows),
|
|
91
92
|
"quality_outcomes": quality_outcomes.compute(git_commits, changes),
|
|
92
93
|
"overengineering": overengineering.compute(git_commits, changes, session_rows, repo),
|
|
93
94
|
"contract_drift": contract_drift.compute(git_commits, changes, tech_debt, adrs, module_specs),
|
|
95
|
+
"kernel_fit": kernel_fit.compute(git_commits, changes, tech_debt, adrs, module_specs,
|
|
96
|
+
session_rows, findings_dir),
|
|
94
97
|
}
|
|
95
98
|
|
|
96
99
|
report_md = render.render_report(metrics, repo_name=repo.name, window_days=window_days)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run the kernel-fit detection layer in isolation and dump JSON to stdout.
|
|
3
|
+
|
|
4
|
+
Thin wrapper around `telemetry.insights.kernel_fit.compute()`. Used by
|
|
5
|
+
`/hstack:kernel-fit-scan` to obtain the structured evidence blob the
|
|
6
|
+
`kernel-fit-analyst` subagent consumes, without producing the full
|
|
7
|
+
telemetry report.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python scripts/telemetry/run_kernel_fit.py [--repo <path>] [--window <days>]
|
|
11
|
+
|
|
12
|
+
Defaults match `report.py`: `--repo` is cwd; `--window` is 30 days.
|
|
13
|
+
|
|
14
|
+
The output is a single JSON object whose top-level keys mirror the
|
|
15
|
+
`compute()` return value (`existing_open_findings_by_pattern`, and one
|
|
16
|
+
key per pattern). The scan Skill reads stdout, parses, and passes the
|
|
17
|
+
blob to the subagent.
|
|
18
|
+
|
|
19
|
+
Read-only. No writes, no git side-effects.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import sys
|
|
27
|
+
from datetime import datetime, timedelta, timezone
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
# Match report.py's import-path bootstrap so this script can be invoked
|
|
31
|
+
# from any directory.
|
|
32
|
+
_THIS = Path(__file__).resolve()
|
|
33
|
+
_SCRIPTS = _THIS.parent.parent
|
|
34
|
+
if str(_SCRIPTS) not in sys.path:
|
|
35
|
+
sys.path.insert(0, str(_SCRIPTS))
|
|
36
|
+
|
|
37
|
+
from telemetry.parsers import frontmatter, commits, transcripts # noqa: E402
|
|
38
|
+
from telemetry.insights import kernel_fit # noqa: E402
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main(argv: list[str] | None = None) -> int:
|
|
42
|
+
parser = argparse.ArgumentParser(description="Run kernel-fit detection and dump JSON.")
|
|
43
|
+
parser.add_argument("--repo", type=Path, default=Path.cwd(),
|
|
44
|
+
help="Consuming-repo root (default: cwd).")
|
|
45
|
+
parser.add_argument("--window", type=int, default=30,
|
|
46
|
+
help="Limit git/transcript history to last N days (default: 30; 0 = all).")
|
|
47
|
+
args = parser.parse_args(argv)
|
|
48
|
+
|
|
49
|
+
repo = args.repo.resolve()
|
|
50
|
+
hstack_root = repo / "hstack"
|
|
51
|
+
if not hstack_root.is_dir():
|
|
52
|
+
# Permit running against the template repo itself.
|
|
53
|
+
if (repo / "specs").is_dir() and (repo / "CLAUDE.md").is_file():
|
|
54
|
+
hstack_root = repo
|
|
55
|
+
else:
|
|
56
|
+
print(f"error: no hstack/ directory at {repo}", file=sys.stderr)
|
|
57
|
+
return 1
|
|
58
|
+
|
|
59
|
+
window_days: int | None = args.window if args.window > 0 else None
|
|
60
|
+
since_dt: datetime | None = (
|
|
61
|
+
datetime.now(timezone.utc) - timedelta(days=window_days)
|
|
62
|
+
) if window_days else None
|
|
63
|
+
|
|
64
|
+
changes = frontmatter.load_change_artifacts(hstack_root)
|
|
65
|
+
tech_debt = frontmatter.load_tech_debt(hstack_root)
|
|
66
|
+
adrs = frontmatter.load_adrs(hstack_root)
|
|
67
|
+
module_specs = frontmatter.load_module_specs(hstack_root)
|
|
68
|
+
git_commits = commits.parse_commits(repo, since_days=window_days)
|
|
69
|
+
session_rows = transcripts.collect_session_rows([repo], since=since_dt)
|
|
70
|
+
|
|
71
|
+
findings_dir = hstack_root / "kernel-fit" / "findings"
|
|
72
|
+
|
|
73
|
+
result = kernel_fit.compute(
|
|
74
|
+
commits=git_commits,
|
|
75
|
+
changes=changes,
|
|
76
|
+
tech_debt=tech_debt,
|
|
77
|
+
adrs=adrs,
|
|
78
|
+
module_specs=module_specs,
|
|
79
|
+
session_rows=session_rows,
|
|
80
|
+
findings_dir=findings_dir,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Path objects are not JSON-serializable; strip them where they appear
|
|
84
|
+
# (existing-findings paths get re-derived by the analyst from the id).
|
|
85
|
+
json.dump(result, sys.stdout, default=str, indent=2)
|
|
86
|
+
sys.stdout.write("\n")
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
sys.exit(main())
|
|
@@ -8,9 +8,10 @@ supersedes: null # ADR id when this ADR replaces another
|
|
|
8
8
|
superseded-by: null # ADR id when this ADR has been replaced; reciprocal with supersedes
|
|
9
9
|
related-change-specs: []
|
|
10
10
|
related-modules: []
|
|
11
|
+
promoted-from-kernel-fit: [] # KF-NNNN ids that motivated this ADR; reciprocal with kernel-fit-finding.promoted-to (KF-04)
|
|
11
12
|
created: <YYYY-MM-DD>
|
|
12
13
|
updated: <YYYY-MM-DD>
|
|
13
|
-
schema-version:
|
|
14
|
+
schema-version: 2
|
|
14
15
|
---
|
|
15
16
|
|
|
16
17
|
## Title
|
|
@@ -13,7 +13,9 @@ resolves-tech-debt: [] # tech-debt ids this change is intended t
|
|
|
13
13
|
parent-change: null
|
|
14
14
|
children: []
|
|
15
15
|
revisits-change: [] # change-spec ids this change is filed to repair (defects, regressions, missed findings). Informational, not gating.
|
|
16
|
-
internal-tooling: false
|
|
16
|
+
internal-tooling: false # Category A — engineering-only, never on a user path
|
|
17
|
+
enables: [] # Category B — downstream change-spec ids that realize user value from this change
|
|
18
|
+
enabled-by: [] # reciprocal of upstream specs' `enables` arrays; written by /hstack:change-new at scaffold time
|
|
17
19
|
trivial: false
|
|
18
20
|
in-scope: [] # repo-relative globs; must be non-empty
|
|
19
21
|
out-of-scope: [] # required, may be empty
|