hstack 0.7.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +308 -0
  2. package/README.md +90 -154
  3. package/VERSION +1 -1
  4. package/dist/commands/doctor.js +69 -9
  5. package/dist/commands/doctor.js.map +1 -1
  6. package/dist/commands/init.js +7 -3
  7. package/dist/commands/init.js.map +1 -1
  8. package/dist/commands/update.js +17 -4
  9. package/dist/commands/update.js.map +1 -1
  10. package/dist/lib/descriptions.js +160 -0
  11. package/dist/lib/descriptions.js.map +1 -0
  12. package/dist/lib/diff.js +1 -1
  13. package/dist/lib/git.js +16 -0
  14. package/dist/lib/git.js.map +1 -1
  15. package/dist/lib/wire.js +274 -142
  16. package/dist/lib/wire.js.map +1 -1
  17. package/dist/manifest.js +49 -22
  18. package/dist/manifest.js.map +1 -1
  19. package/package.json +4 -3
  20. package/template/.claude/agents/adversarial-reviewer.md +42 -98
  21. package/template/.claude/agents/app-architect.md +44 -133
  22. package/template/.claude/agents/data-architect.md +46 -127
  23. package/template/.claude/agents/test-strategist.md +39 -109
  24. package/template/.claude/skills/hstack-adr-new/SKILL.md +23 -100
  25. package/template/.claude/skills/hstack-adversarial-review/SKILL.md +47 -174
  26. package/template/.claude/skills/hstack-adversarial-review/references/finding-categories.md +134 -0
  27. package/template/.claude/skills/hstack-app-architecture/SKILL.md +43 -95
  28. package/template/.claude/skills/hstack-commit/SKILL.md +28 -97
  29. package/template/.claude/skills/hstack-data-architecture/SKILL.md +34 -82
  30. package/template/.claude/skills/hstack-promote/SKILL.md +55 -0
  31. package/template/.claude/skills/hstack-story/SKILL.md +58 -0
  32. package/template/.claude/skills/hstack-test-audit/SKILL.md +64 -0
  33. package/template/.claude/skills/hstack-wrap/SKILL.md +48 -0
  34. package/template/.claude/skills/hstack-wrap/references/pr-description.md +45 -0
  35. package/template/KERNEL.md +167 -0
  36. package/template/templates/adr.md +9 -18
  37. package/template/templates/app-architecture.md +38 -50
  38. package/template/templates/data-architecture.md +39 -93
  39. package/template/templates/infrastructure.md +54 -148
  40. package/template/templates/roadmap.md +35 -0
  41. package/template/templates/story.md +7 -13
  42. package/template/templates/tech-debt.md +2 -37
  43. package/template/templates/tech-stack.md +2 -12
  44. package/template/.claude/agents/data-specialist.md +0 -126
  45. package/template/.claude/agents/implementer.md +0 -154
  46. package/template/.claude/agents/kernel-fit-analyst.md +0 -190
  47. package/template/.claude/agents/planner.md +0 -110
  48. package/template/.claude/agents/product-discovery.md +0 -165
  49. package/template/.claude/agents/product-manager.md +0 -111
  50. package/template/.claude/agents/researcher.md +0 -123
  51. package/template/.claude/agents/security-reviewer.md +0 -123
  52. package/template/.claude/agents/spec-author.md +0 -123
  53. package/template/.claude/agents/stack-architect.md +0 -137
  54. package/template/.claude/agents/ui-ux-briefer.md +0 -105
  55. package/template/.claude/agents/verifier.md +0 -113
  56. package/template/.claude/skills/hstack-branch/SKILL.md +0 -103
  57. package/template/.claude/skills/hstack-brownfield-init/SKILL.md +0 -158
  58. package/template/.claude/skills/hstack-change-new/SKILL.md +0 -111
  59. package/template/.claude/skills/hstack-change-plan/SKILL.md +0 -119
  60. package/template/.claude/skills/hstack-configure/SKILL.md +0 -126
  61. package/template/.claude/skills/hstack-coord/SKILL.md +0 -131
  62. package/template/.claude/skills/hstack-data-review/SKILL.md +0 -132
  63. package/template/.claude/skills/hstack-finalize/SKILL.md +0 -159
  64. package/template/.claude/skills/hstack-flag/SKILL.md +0 -138
  65. package/template/.claude/skills/hstack-greenfield-init/SKILL.md +0 -175
  66. package/template/.claude/skills/hstack-help/SKILL.md +0 -179
  67. package/template/.claude/skills/hstack-implement/SKILL.md +0 -185
  68. package/template/.claude/skills/hstack-kernel-fit-promote/SKILL.md +0 -164
  69. package/template/.claude/skills/hstack-kernel-fit-scan/SKILL.md +0 -197
  70. package/template/.claude/skills/hstack-kernel-fit-triage/SKILL.md +0 -159
  71. package/template/.claude/skills/hstack-module-spec/SKILL.md +0 -105
  72. package/template/.claude/skills/hstack-product-discovery/SKILL.md +0 -101
  73. package/template/.claude/skills/hstack-research/SKILL.md +0 -145
  74. package/template/.claude/skills/hstack-scaffold/SKILL.md +0 -140
  75. package/template/.claude/skills/hstack-security-review/SKILL.md +0 -133
  76. package/template/.claude/skills/hstack-ship/SKILL.md +0 -129
  77. package/template/.claude/skills/hstack-stack-decide/SKILL.md +0 -108
  78. package/template/.claude/skills/hstack-story-draft/SKILL.md +0 -119
  79. package/template/.claude/skills/hstack-tech-debt-new/SKILL.md +0 -122
  80. package/template/.claude/skills/hstack-tech-debt-resolve/SKILL.md +0 -158
  81. package/template/.claude/skills/hstack-tech-debt-stale/SKILL.md +0 -113
  82. package/template/.claude/skills/hstack-tech-debt-wontfix/SKILL.md +0 -104
  83. package/template/.claude/skills/hstack-telemetry/SKILL.md +0 -98
  84. package/template/.claude/skills/hstack-test-plan/SKILL.md +0 -182
  85. package/template/.claude/skills/hstack-ui-brief/SKILL.md +0 -108
  86. package/template/.claude/skills/hstack-verify/SKILL.md +0 -150
  87. package/template/CLAUDE.md +0 -443
  88. package/template/scripts/coord/coord_scan.py +0 -681
  89. package/template/scripts/telemetry/__init__.py +0 -6
  90. package/template/scripts/telemetry/insights/__init__.py +0 -0
  91. package/template/scripts/telemetry/insights/contract_drift.py +0 -137
  92. package/template/scripts/telemetry/insights/kernel_fit.py +0 -438
  93. package/template/scripts/telemetry/insights/overengineering.py +0 -115
  94. package/template/scripts/telemetry/insights/quality_outcomes.py +0 -131
  95. package/template/scripts/telemetry/insights/token_economics.py +0 -129
  96. package/template/scripts/telemetry/insights/workflow_shape.py +0 -198
  97. package/template/scripts/telemetry/parsers/__init__.py +0 -0
  98. package/template/scripts/telemetry/parsers/bodies.py +0 -87
  99. package/template/scripts/telemetry/parsers/commits.py +0 -219
  100. package/template/scripts/telemetry/parsers/frontmatter.py +0 -322
  101. package/template/scripts/telemetry/parsers/transcripts.py +0 -181
  102. package/template/scripts/telemetry/render.py +0 -395
  103. package/template/scripts/telemetry/report.py +0 -138
  104. package/template/scripts/telemetry/run_kernel_fit.py +0 -91
  105. package/template/templates/adversarial-review.md +0 -54
  106. package/template/templates/bootstrap.md +0 -105
  107. package/template/templates/change-spec.md +0 -82
  108. package/template/templates/ci-cd.md +0 -27
  109. package/template/templates/coord-message.md +0 -55
  110. package/template/templates/data-review.md +0 -54
  111. package/template/templates/discovery/brainstorm.md +0 -69
  112. package/template/templates/discovery/forcing-questions.md +0 -76
  113. package/template/templates/discovery/project-brief.md +0 -53
  114. package/template/templates/figma-handoff.md +0 -38
  115. package/template/templates/glossary.md +0 -20
  116. package/template/templates/hardening-checklist.md +0 -73
  117. package/template/templates/incident-runbook.md +0 -57
  118. package/template/templates/kernel-fit-finding.md +0 -63
  119. package/template/templates/kernel-fit-flag.md +0 -52
  120. package/template/templates/module-spec.md +0 -49
  121. package/template/templates/mvp-scope.md +0 -34
  122. package/template/templates/persona.md +0 -38
  123. package/template/templates/plan.md +0 -49
  124. package/template/templates/product-brief.md +0 -56
  125. package/template/templates/security-review.md +0 -63
  126. package/template/templates/telemetry-sidecar.md +0 -184
  127. package/template/templates/test-plan.md +0 -119
  128. package/template/templates/threat-model.md +0 -54
  129. package/template/templates/ui-brief.md +0 -49
  130. package/template/templates/verification.md +0 -63
  131. package/template/templates/vision.md +0 -34
@@ -1,137 +0,0 @@
1
- """Contract-drift insights: module-spec staleness, ADR supersession lag,
2
- tech-debt half-life by exit path."""
3
-
4
- from __future__ import annotations
5
-
6
- from collections import defaultdict
7
- from datetime import date, datetime
8
-
9
-
10
- def compute(commits: list[dict], changes: dict, tech_debt: list[dict],
11
- adrs: list[dict], module_specs: list[dict]) -> dict:
12
- return {
13
- "module_spec_staleness": _module_staleness(module_specs, commits),
14
- "adr_supersession_lag": _adr_lag(adrs),
15
- "tech_debt_half_life": _td_half_life(tech_debt),
16
- }
17
-
18
-
19
- def _parse_date(value) -> date | None:
20
- if value is None:
21
- return None
22
- if isinstance(value, date):
23
- return value
24
- if isinstance(value, datetime):
25
- return value.date()
26
- if isinstance(value, str):
27
- try:
28
- return datetime.fromisoformat(value).date()
29
- except ValueError:
30
- return None
31
- return None
32
-
33
-
34
- def _module_staleness(module_specs: list[dict], commits: list[dict]) -> dict:
35
- """Per module: spec status + count of recent commits touching that module's
36
- files. A `needs-refresh` module with high recent activity is the drift
37
- pathology."""
38
- # Count commits touching each module dir.
39
- module_activity: dict[str, int] = defaultdict(int)
40
- for c in commits:
41
- for f in c.get("files", []):
42
- for ms in module_specs:
43
- module = ms["module"]
44
- # Use a heuristic — count when a commit touches files outside
45
- # hstack/ in directories named like the module. Imperfect, but
46
- # avoids requiring a module→file-path map.
47
- if module in f and not f.startswith("hstack/"):
48
- module_activity[module] += 1
49
- break
50
- rows = []
51
- for ms in module_specs:
52
- status = ms["fm"].get("status", "-")
53
- updated = _parse_date(ms["fm"].get("updated"))
54
- rows.append({
55
- "module": ms["module"],
56
- "status": status,
57
- "updated": str(updated) if updated else "-",
58
- "recent_commits_touching_module": module_activity.get(ms["module"], 0),
59
- "drift_flag": status == "needs-refresh" and module_activity.get(ms["module"], 0) > 0,
60
- })
61
- rows.sort(key=lambda r: -r["recent_commits_touching_module"])
62
- return {"rows": rows}
63
-
64
-
65
- def _adr_lag(adrs: list[dict]) -> dict:
66
- """Among ADRs at `superseded` status, how long they lived before the
67
- superseding ADR landed."""
68
- by_id = {a["fm"].get("id") or a["path"].stem: a for a in adrs}
69
- lags = []
70
- for a in adrs:
71
- status = a["fm"].get("status")
72
- if status != "superseded":
73
- continue
74
- sup = a["fm"].get("superseded-by")
75
- a_created = _parse_date(a["fm"].get("created"))
76
- if not sup or not a_created:
77
- continue
78
- sup_adr = by_id.get(sup)
79
- if not sup_adr:
80
- continue
81
- sup_created = _parse_date(sup_adr["fm"].get("created"))
82
- if not sup_created:
83
- continue
84
- days = (sup_created - a_created).days
85
- lags.append({
86
- "adr": a["fm"].get("id"),
87
- "superseded_by": sup,
88
- "lag_days": days,
89
- })
90
- return {"rows": lags}
91
-
92
-
93
- def _td_half_life(tech_debt: list[dict]) -> dict:
94
- """For each TD, compute days from created → exit (resolved | wontfix |
95
- stale-no-longer-reproducible). Surface by exit-path histogram + per-TD
96
- rows."""
97
- today = date.today()
98
- by_exit = defaultdict(list)
99
- rows = []
100
- for td in tech_debt:
101
- fm = td["fm"]
102
- status = fm.get("status")
103
- created = _parse_date(fm.get("created"))
104
- if not created:
105
- continue
106
- exit_date = None
107
- if status == "resolved":
108
- exit_date = _parse_date(fm.get("updated"))
109
- elif status == "wontfix":
110
- exit_date = _parse_date(fm.get("updated"))
111
- elif status == "stale-no-longer-reproducible":
112
- exit_date = _parse_date(fm.get("stale-verified-at") or fm.get("updated"))
113
- if exit_date:
114
- days = (exit_date - created).days
115
- by_exit[status].append(days)
116
- elif status == "open" or status == "in-progress":
117
- days = (today - created).days
118
- by_exit[f"{status} (still open)"].append(days)
119
- exit_date = None
120
- rows.append({
121
- "id": fm.get("id"),
122
- "status": status,
123
- "created": str(created),
124
- "days_to_exit": (exit_date - created).days if exit_date else (today - created).days,
125
- "exit_path": status if exit_date else None,
126
- })
127
- summary = {}
128
- for exit_path, days_list in by_exit.items():
129
- if not days_list:
130
- continue
131
- days_list.sort()
132
- summary[exit_path] = {
133
- "count": len(days_list),
134
- "median_days": days_list[len(days_list) // 2],
135
- "max_days": max(days_list),
136
- }
137
- return {"summary": summary, "rows": rows}
@@ -1,438 +0,0 @@
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
- }
@@ -1,115 +0,0 @@
1
- """Overengineering insights: OE-1 artifact/diff ratio, OE-3 context-load ×
2
- invocations × downstream-refs, OE-5 trivial-eligible that ran the gauntlet."""
3
-
4
- from __future__ import annotations
5
-
6
- from collections import defaultdict
7
-
8
- from telemetry.parsers.bodies import approx_token_count
9
- from telemetry.parsers.commits import diff_line_count
10
-
11
-
12
- def compute(commits: list[dict], changes: dict, session_rows: list[dict], repo) -> dict:
13
- return {
14
- "oe_1_artifact_to_diff_ratio": _oe_1(changes, commits, repo),
15
- "oe_3_subagent_context_load_amortization": _oe_3(session_rows, changes),
16
- "oe_5_trivial_eligible_full_gauntlet": _oe_5(changes, commits),
17
- }
18
-
19
-
20
- def _oe_1(changes: dict, commits: list[dict], repo) -> dict:
21
- """OE-1: artifact tokens / code-diff lines.
22
-
23
- Sums artifact-body token counts (approx 4 chars/token) per change, divides
24
- by total diff lines on implement(<change-id>) commits for that change.
25
- Ratio above ~50 is a strong overengineering signal; below ~5 the change is
26
- likely under-specified for its complexity.
27
- """
28
- rows = []
29
- for cid, arts in sorted(changes.items()):
30
- artifact_tokens = 0
31
- for atype, data in arts.items():
32
- artifact_tokens += approx_token_count(data["body"])
33
- # Sum implement-commit diffs for this change.
34
- diff_lines = 0
35
- for c in commits:
36
- if c["artifact_type"] != "implement" or c["artifact_id"] != cid:
37
- continue
38
- added, removed = diff_line_count(repo, c["sha"])
39
- diff_lines += added + removed
40
- ratio = (artifact_tokens / diff_lines) if diff_lines > 0 else None
41
- rows.append({
42
- "change": cid,
43
- "artifact_tokens": artifact_tokens,
44
- "diff_lines": diff_lines,
45
- "tokens_per_diff_line": round(ratio, 1) if ratio is not None else None,
46
- })
47
- rows.sort(key=lambda r: r["tokens_per_diff_line"] or 0, reverse=True)
48
- return {"rows": rows}
49
-
50
-
51
- def _oe_3(session_rows: list[dict], changes: dict) -> dict:
52
- """OE-3: per subagent, invocations × estimated context-load size.
53
-
54
- Without sidecars to attribute downstream-reference-count, this v1 metric
55
- surfaces just the subagent × invocation-count × cost dimension and lets
56
- the reader eyeball which subagents are paying repeated entry-tax.
57
- """
58
- appearances = defaultdict(int)
59
- total_cost_in_host = defaultdict(int)
60
- for s in session_rows:
61
- for sa in s["subagents"]:
62
- appearances[sa] += 1
63
- total_cost_in_host[sa] += s["cost_score"]
64
- rows = []
65
- for sa, n in sorted(appearances.items(), key=lambda kv: -kv[1]):
66
- rows.append({
67
- "subagent": sa,
68
- "invocations": n,
69
- "host_cost_score_total": total_cost_in_host[sa],
70
- "cost_per_invocation": total_cost_in_host[sa] // n if n else 0,
71
- })
72
- return {"rows": rows}
73
-
74
-
75
- def _oe_5(changes: dict, commits: list[dict]) -> dict:
76
- """OE-5: trivial-eligible changes that ran the full gauntlet.
77
-
78
- Heuristic: change-spec at `shipped` (or any terminal state) with `trivial:
79
- false`, empty `surfaces`, zero or one invariant. These are candidates that
80
- might have qualified for the trivial-tag escape hatch but ran every gate.
81
- """
82
- candidates = []
83
- for cid, arts in sorted(changes.items()):
84
- spec = arts.get("change-spec")
85
- if not spec:
86
- continue
87
- fm = spec["fm"]
88
- if fm.get("trivial") is True:
89
- continue
90
- status = fm.get("status")
91
- if status not in ("shipped", "ready-to-ship", "archived"):
92
- continue
93
- surfaces = fm.get("surfaces") or []
94
- if surfaces: # any declared surface disqualifies trivial-eligibility
95
- continue
96
- # diff size heuristic: a "trivial-eligible" change usually has <50 added lines
97
- diff_total = 0
98
- for c in commits:
99
- if c["artifact_type"] == "implement" and c["artifact_id"] == cid:
100
- # We can't get accurate line counts here without git access — use file count proxy
101
- diff_total += len(c.get("files", []))
102
- candidates.append({
103
- "change": cid,
104
- "status": status,
105
- "surfaces": surfaces,
106
- "files_touched_in_implement_commits": diff_total,
107
- })
108
- return {
109
- "rows": candidates,
110
- "note": (
111
- "Heuristic only. A change with empty `surfaces` AND no `trivial: "
112
- "true` tag is a candidate for retrospective trivial classification "
113
- "— or a sign the surfaces list was under-declared."
114
- ),
115
- }