hstack 0.2.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.
@@ -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
@@ -0,0 +1,62 @@
1
+ ---
2
+ id: KF-<NNNN>-<slug>
3
+ type: kernel-fit-finding
4
+ status: open # open | acknowledged | dismissed | promoted | superseded | archived
5
+ owner: null # git-handle of the triager; null until first triage
6
+ pattern: <KF-P1 | KF-P2 | KF-P3 | …> # detector pattern that fired; enumerated in scripts/telemetry/insights/kernel_fit.py
7
+ confidence: medium # high | medium | low
8
+ detected-by: kernel-fit-analyst
9
+ detected-at: <ISO-8601 timestamp>
10
+ evidence-row-count: 0 # integer; must equal len(evidence-rows) per KF-01
11
+ evidence-rows: [] # YAML array of {change|adr|td, signal} dicts; one entry per row counted above
12
+ related-findings: [] # KF ids — prior or adjacent findings on the same kernel surface
13
+ promoted-to: null # `adr:<ADR-NNNN-slug>` | `tech-debt:<TD-NNNN-slug>` | null; reciprocal with the target artifact's `promoted-from-kernel-fit`; required when status: promoted
14
+ dismissed-reason: null # ≥50 chars of prose; required when status: dismissed (per KF-05)
15
+ superseded-by: null # KF id when status: superseded
16
+ created: <YYYY-MM-DD>
17
+ updated: <YYYY-MM-DD>
18
+ schema-version: 1
19
+ ---
20
+
21
+ ## Title
22
+
23
+ _Short noun phrase naming the kernel-fit gap. Example: "Category-A claim spans production paths — engineer likely meant Category B."_
24
+
25
+ ## Pattern fired
26
+
27
+ _Name the detector pattern (KF-P1 / KF-P2 / KF-P3 / …) and one paragraph describing what the detector found. Quote the pattern's defining condition from `kernel_fit.py` if helpful._
28
+
29
+ ## Evidence
30
+
31
+ _Per evidence row, a 2–3 sentence prose summary with at least one inline citation (change-id, ADR-id, TD-id, commit-sha, kernel section). KF-01 requires `len(evidence-rows)` in frontmatter to equal `evidence-row-count`; the prose here must cite each row at least once. No prose without a citation._
32
+
33
+ 1. ...
34
+ 2. ...
35
+
36
+ ## Kernel surface implicated
37
+
38
+ _Single-sentence pointer to the kernel section, template, validator rule, or Skill flow that the finding suggests revising. Examples: "`template/CLAUDE.md § Frontmatter contract` — the `internal-tooling` field"; "`template/templates/change-spec.md` frontmatter — `surfaces` enum"; "`/hstack:adversarial-review` precondition check at SKILL.md line 61"._
39
+
40
+ ## Proposed direction
41
+
42
+ _One paragraph. Name a direction the kernel revision could take — split a flag, add an enum case, add a Skill precondition, amend a section. This is NOT a full ADR — that work is done by `spec-author` if and when the engineer invokes `/hstack:kernel-fit-promote`. Keep this as a sketch, not a specification._
43
+
44
+ ## Counter-explanations (challenge prompt — mandatory)
45
+
46
+ _Two reasons this finding might NOT warrant a kernel change. If you cannot produce two, the analyst auto-downgrades `confidence` to `low` per KF-03. The challenge defends against false-positives the same way `## Consequences` § "name two consequences that look bad" defends ADRs._
47
+
48
+ 1. ...
49
+ 2. ...
50
+
51
+ ## Confidence rationale
52
+
53
+ _One paragraph defending the `confidence` enum value against the validator rules. `high` requires `evidence-row-count >= 3` AND ≥2 distinct change-specs cited (KF-02). `medium` is the conservative default. `low` carries no notification and is appropriate when evidence is thin or the challenge prompts substantially weaken the finding._
54
+
55
+ ## Triage Log
56
+
57
+ _Populated by `/hstack:kernel-fit-triage` and `/hstack:kernel-fit-promote` as the finding's status transitions. Section is empty until the first transition out of `open`._
58
+
59
+ - `status: open → acknowledged` on `<YYYY-MM-DD>` by `<owner>`. Triggered by `/hstack:kernel-fit-triage <id> --action acknowledge`.
60
+ - `status: open → dismissed` on `<YYYY-MM-DD>` by `<owner>`. Reason: `<dismissed-reason>`.
61
+ - `status: acknowledged → promoted` on `<YYYY-MM-DD>` by `<owner>`. Promoted to: `<promoted-to>`. Triggered by `/hstack:kernel-fit-promote <id> --slug <adr-slug>`.
62
+ - `status: open → superseded` on `<YYYY-MM-DD>` by the next `kernel-fit-analyst` run. Superseded by: `<superseded-by>`.