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.
@@ -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: 1
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