hstack 0.7.1 → 0.16.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 (98) hide show
  1. package/CHANGELOG.md +271 -0
  2. package/README.md +39 -13
  3. package/VERSION +1 -1
  4. package/dist/commands/doctor.js +51 -1
  5. package/dist/commands/doctor.js.map +1 -1
  6. package/dist/commands/update.js +8 -2
  7. package/dist/commands/update.js.map +1 -1
  8. package/dist/lib/descriptions.js +167 -0
  9. package/dist/lib/descriptions.js.map +1 -0
  10. package/dist/lib/diff.js +1 -1
  11. package/dist/lib/git.js +16 -0
  12. package/dist/lib/git.js.map +1 -1
  13. package/dist/lib/wire.js +108 -4
  14. package/dist/lib/wire.js.map +1 -1
  15. package/dist/manifest.js +17 -2
  16. package/dist/manifest.js.map +1 -1
  17. package/package.json +3 -1
  18. package/template/.claude/agents/adversarial-reviewer.md +16 -64
  19. package/template/.claude/agents/app-architect.md +12 -49
  20. package/template/.claude/agents/data-architect.md +13 -51
  21. package/template/.claude/agents/data-specialist.md +5 -50
  22. package/template/.claude/agents/implementer.md +8 -65
  23. package/template/.claude/agents/kernel-fit-analyst.md +7 -68
  24. package/template/.claude/agents/planner.md +7 -42
  25. package/template/.claude/agents/product-discovery.md +12 -48
  26. package/template/.claude/agents/product-manager.md +8 -43
  27. package/template/.claude/agents/researcher.md +5 -41
  28. package/template/.claude/agents/security-reviewer.md +19 -54
  29. package/template/.claude/agents/spec-author.md +18 -52
  30. package/template/.claude/agents/stack-architect.md +14 -43
  31. package/template/.claude/agents/test-strategist.md +16 -57
  32. package/template/.claude/agents/ui-ux-briefer.md +6 -36
  33. package/template/.claude/agents/verifier.md +13 -45
  34. package/template/.claude/skills/hstack-adr-new/SKILL.md +6 -33
  35. package/template/.claude/skills/hstack-adversarial-review/SKILL.md +31 -52
  36. package/template/.claude/skills/hstack-adversarial-review/references/finding-categories.md +157 -0
  37. package/template/.claude/skills/hstack-app-architecture/SKILL.md +2 -29
  38. package/template/.claude/skills/hstack-branch/SKILL.md +4 -31
  39. package/template/.claude/skills/hstack-brownfield-init/SKILL.md +10 -37
  40. package/template/.claude/skills/hstack-change-new/SKILL.md +4 -31
  41. package/template/.claude/skills/hstack-change-plan/SKILL.md +21 -32
  42. package/template/.claude/skills/hstack-commit/SKILL.md +7 -35
  43. package/template/.claude/skills/hstack-configure/SKILL.md +7 -34
  44. package/template/.claude/skills/hstack-coord/SKILL.md +3 -39
  45. package/template/.claude/skills/hstack-data-architecture/SKILL.md +4 -30
  46. package/template/.claude/skills/hstack-data-review/SKILL.md +3 -42
  47. package/template/.claude/skills/hstack-finalize/SKILL.md +30 -49
  48. package/template/.claude/skills/hstack-flag/SKILL.md +9 -48
  49. package/template/.claude/skills/hstack-greenfield-init/SKILL.md +9 -36
  50. package/template/.claude/skills/hstack-help/SKILL.md +11 -37
  51. package/template/.claude/skills/hstack-implement/SKILL.md +28 -58
  52. package/template/.claude/skills/hstack-kernel-fit-promote/SKILL.md +7 -46
  53. package/template/.claude/skills/hstack-kernel-fit-scan/SKILL.md +5 -60
  54. package/template/.claude/skills/hstack-kernel-fit-scan/references/slack-setup.md +42 -0
  55. package/template/.claude/skills/hstack-kernel-fit-triage/SKILL.md +12 -50
  56. package/template/.claude/skills/hstack-module-spec/SKILL.md +5 -32
  57. package/template/.claude/skills/hstack-product-discovery/SKILL.md +5 -31
  58. package/template/.claude/skills/hstack-research/SKILL.md +3 -33
  59. package/template/.claude/skills/hstack-scaffold/SKILL.md +2 -29
  60. package/template/.claude/skills/hstack-security-review/SKILL.md +5 -43
  61. package/template/.claude/skills/hstack-ship/SKILL.md +43 -53
  62. package/template/.claude/skills/hstack-stack-decide/SKILL.md +3 -30
  63. package/template/.claude/skills/hstack-story-draft/SKILL.md +6 -33
  64. package/template/.claude/skills/hstack-tech-debt-new/SKILL.md +4 -31
  65. package/template/.claude/skills/hstack-tech-debt-resolve/SKILL.md +9 -44
  66. package/template/.claude/skills/hstack-tech-debt-stale/SKILL.md +10 -37
  67. package/template/.claude/skills/hstack-tech-debt-wontfix/SKILL.md +8 -35
  68. package/template/.claude/skills/hstack-telemetry/SKILL.md +5 -30
  69. package/template/.claude/skills/hstack-test-plan/SKILL.md +23 -46
  70. package/template/.claude/skills/hstack-ui-brief/SKILL.md +3 -30
  71. package/template/.claude/skills/hstack-verify/SKILL.md +26 -48
  72. package/template/KERNEL.md +410 -0
  73. package/template/scripts/compute-merge-readiness.mjs +780 -0
  74. package/template/scripts/run-gates.sh +388 -0
  75. package/template/scripts/telemetry/insights/kernel_fit.py +1 -1
  76. package/template/scripts/telemetry/insights/token_economics.py +181 -8
  77. package/template/scripts/telemetry/parsers/sidecars.py +61 -0
  78. package/template/scripts/telemetry/parsers/transcripts.py +135 -22
  79. package/template/scripts/telemetry/render.py +68 -3
  80. package/template/scripts/telemetry/report.py +16 -4
  81. package/template/scripts/telemetry/run_kernel_fit.py +6 -2
  82. package/template/scripts/telemetry/session_id.py +139 -0
  83. package/template/scripts/validate-spec.mjs +3303 -0
  84. package/template/templates/adr.md +7 -0
  85. package/template/templates/adversarial-review.md +5 -5
  86. package/template/templates/ci-cd.md +14 -0
  87. package/template/templates/coord-message.md +3 -2
  88. package/template/templates/data-architecture.md +3 -6
  89. package/template/templates/kernel-fit-finding.md +2 -2
  90. package/template/templates/kernel-fit-flag.md +2 -2
  91. package/template/templates/plan.md +4 -0
  92. package/template/templates/product-brief.md +2 -2
  93. package/template/templates/roadmap.md +41 -0
  94. package/template/templates/security-review.md +1 -1
  95. package/template/templates/telemetry-sidecar.md +56 -13
  96. package/template/templates/test-plan.md +1 -1
  97. package/template/CLAUDE.md +0 -443
  98. package/template/templates/mvp-scope.md +0 -34
@@ -0,0 +1,61 @@
1
+ """Load the per-change telemetry sidecars written by the five emitting Skills.
2
+
3
+ Sidecars live at `hstack/specs/changes/<change-id>/.telemetry/*.json` and are
4
+ derivative, gitignored, and never authoritative (ADR-0001). This parser only
5
+ reads them; a malformed or absent sidecar is skipped, never repaired.
6
+
7
+ Schema versions (see `templates/telemetry-sidecar.md`):
8
+ 1 — no phase window. Reads as *unmeasured*: `phase_usage` returns None.
9
+ 2 — carries `session_id`, `phase_opened_at`, `phase_closed_at` (ADR-0009).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+
17
+
18
+ def load_sidecars(hstack_root: Path) -> list[dict]:
19
+ """Every sidecar under every change folder, sorted by change then filename.
20
+
21
+ Each entry: {path, change_id, file, skill, phase_id, schema_version, data}.
22
+ `skill` and `phase_id` fall back to the filename when the payload omits them
23
+ (`implement-<phase-id>.json`), so a hand-truncated sidecar still groups.
24
+ """
25
+ out: list[dict] = []
26
+ changes_dir = hstack_root / "specs" / "changes"
27
+ if not changes_dir.is_dir():
28
+ return out
29
+ for change_dir in sorted(changes_dir.iterdir()):
30
+ if not change_dir.is_dir():
31
+ continue
32
+ telemetry_dir = change_dir / ".telemetry"
33
+ if not telemetry_dir.is_dir():
34
+ continue
35
+ for f in sorted(telemetry_dir.glob("*.json")):
36
+ data = _read_json(f)
37
+ if data is None:
38
+ continue
39
+ stem = f.stem
40
+ skill = data.get("skill") or f"hstack-{stem.split('-')[0]}"
41
+ phase_id = data.get("phase_id")
42
+ if phase_id is None and stem.startswith("implement-"):
43
+ phase_id = stem[len("implement-"):]
44
+ out.append({
45
+ "path": f,
46
+ "file": f.name,
47
+ "change_id": data.get("change_id") or change_dir.name,
48
+ "skill": skill,
49
+ "phase_id": phase_id,
50
+ "schema_version": data.get("schema_version"),
51
+ "data": data,
52
+ })
53
+ return out
54
+
55
+
56
+ def _read_json(path: Path) -> dict | None:
57
+ try:
58
+ loaded = json.loads(path.read_text(encoding="utf-8", errors="replace"))
59
+ except (OSError, json.JSONDecodeError):
60
+ return None
61
+ return loaded if isinstance(loaded, dict) else None
@@ -1,8 +1,15 @@
1
1
  """Walk Claude Code transcript JSONL files for token economics and halt signals.
2
2
 
3
- Generalized from scripts/audit-token-usage.py. Same cost-score weighting; same
4
- classify-by-Skill heuristic. Adds: halt-sentinel detection, per-session
5
- start/end timestamps, cwd grouping for Conductor worktree dedup.
3
+ Generalized from scripts/audit-token-usage.py. Same cost-score weighting. Adds:
4
+ halt-sentinel detection, per-session start/end timestamps, cwd grouping for
5
+ Conductor worktree dedup, and (ADR-0009) per-phase usage summation bounded by a
6
+ sidecar's phase window.
7
+
8
+ Session→Skill attribution reads *structured invocation markers only* — the
9
+ `<command-name>` tag the harness writes for a slash command, and `Skill`
10
+ tool_use blocks. Free text is never matched: a prompt that merely mentions
11
+ `/hstack:coord` used to capture the whole session, which credited `coord` with
12
+ roughly half of all measured cache-read tokens (ADR-0009 § Context, defect 2).
6
13
  """
7
14
 
8
15
  from __future__ import annotations
@@ -10,18 +17,27 @@ from __future__ import annotations
10
17
  import json
11
18
  import re
12
19
  from collections import defaultdict
13
- from datetime import datetime
20
+ from datetime import datetime, timezone
14
21
  from pathlib import Path
15
22
  from typing import Iterable
16
23
 
24
+ from telemetry.session_id import transcript_for_session
25
+
17
26
 
18
27
  PROJECTS_ROOT = Path.home() / ".claude" / "projects"
19
28
 
20
- HSTACK_SLASH = re.compile(r"/hstack:([a-z][a-z0-9\-]*)")
21
- HSTACK_CMD_TAG = re.compile(r"<command-name>/?hstack:?-?([a-z][a-z0-9\-]*)</command-name>")
29
+ # Structured markers only. `<command-name>` is emitted by the harness around a
30
+ # slash-command invocation; both the `/hstack:foo` and `/hstack-foo` spellings
31
+ # appear in real transcripts.
32
+ HSTACK_CMD_TAG = re.compile(r"<command-name>/?hstack[:\-]([a-z][a-z0-9\-]*)</command-name>")
33
+ # `Skill` tool_use input, optionally namespaced by a plugin prefix.
34
+ HSTACK_SKILL_NAME = re.compile(r"^(?:[A-Za-z0-9_.\-]+:)?hstack[:\-]([a-z][a-z0-9\-]*)$")
22
35
  SUBAGENT_TYPE = re.compile(r'"subagent_type"\s*:\s*"([a-z][a-z0-9\-]*)"')
23
36
  HALT_SENTINEL = re.compile(r"HSTACK-HALT:\s*reason=([a-z-]+)", re.IGNORECASE)
24
37
 
38
+ USAGE_KEYS = ("input_tokens", "cache_creation_input_tokens",
39
+ "cache_read_input_tokens", "output_tokens")
40
+
25
41
 
26
42
  def message_text(msg) -> str:
27
43
  if msg is None:
@@ -91,20 +107,58 @@ def load_session(path: Path) -> list[dict]:
91
107
  return out
92
108
 
93
109
 
110
+ def skill_tool_uses(msg) -> list[str]:
111
+ """hstack Skill names invoked via a `Skill` tool_use block in this message.
112
+
113
+ Structural walk, not a regex over flattened text: the marker is the block's
114
+ `type`/`name`/`input.skill` triple, which prose cannot forge.
115
+ """
116
+ if not isinstance(msg, dict):
117
+ return []
118
+ content = msg.get("content")
119
+ if not isinstance(content, list):
120
+ return []
121
+ out = []
122
+ for blk in content:
123
+ if not isinstance(blk, dict):
124
+ continue
125
+ if blk.get("type") != "tool_use" or blk.get("name") != "Skill":
126
+ continue
127
+ inp = blk.get("input")
128
+ name = inp.get("skill") if isinstance(inp, dict) else None
129
+ if not isinstance(name, str):
130
+ continue
131
+ m = HSTACK_SKILL_NAME.match(name.strip())
132
+ if m:
133
+ out.append(m.group(1))
134
+ return out
135
+
136
+
94
137
  def classify_session(records: list[dict]) -> tuple[str | None, set[str], list[str]]:
95
- """Return (skill_name, set of subagent types invoked, halt reasons)."""
138
+ """Return (skill_name, set of subagent types invoked, halt reasons).
139
+
140
+ `skill_name` is the first hstack Skill invoked through a structured marker —
141
+ a `<command-name>` tag or a `Skill` tool_use block. A session carrying
142
+ neither returns `None` and is reported as *unattributed* (ADR-0009 piece 3);
143
+ it is never captured by whatever hstack command its prompt happened to name.
144
+ """
96
145
  skill = None
97
146
  subagents: set[str] = set()
98
147
  halts: list[str] = []
99
148
  for r in records:
100
149
  t = r.get("type")
101
- if t == "user" and skill is None:
102
- text = message_text(r.get("message"))
103
- m = HSTACK_CMD_TAG.search(text) or HSTACK_SLASH.search(text)
104
- if m:
105
- skill = m.group(1)
150
+ if t == "user":
151
+ if skill is None:
152
+ m = HSTACK_CMD_TAG.search(message_text(r.get("message")))
153
+ if m:
154
+ skill = m.group(1)
106
155
  elif t == "assistant":
107
- text = message_text(r.get("message"))
156
+ msg = r.get("message")
157
+ if skill is None:
158
+ invoked = skill_tool_uses(msg)
159
+ if invoked:
160
+ skill = invoked[0]
161
+ text = message_text(msg)
108
162
  for sa in SUBAGENT_TYPE.findall(text):
109
163
  subagents.add(sa)
110
164
  for h in HALT_SENTINEL.findall(text):
@@ -112,15 +166,24 @@ def classify_session(records: list[dict]) -> tuple[str | None, set[str], list[st
112
166
  return skill, subagents, halts
113
167
 
114
168
 
169
+ def parse_ts(value) -> datetime | None:
170
+ """Parse an ISO-8601 stamp to an aware UTC datetime. None on anything else."""
171
+ if not isinstance(value, str) or not value:
172
+ return None
173
+ try:
174
+ dt = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
175
+ except ValueError:
176
+ return None
177
+ if dt.tzinfo is None:
178
+ dt = dt.replace(tzinfo=timezone.utc)
179
+ return dt.astimezone(timezone.utc)
180
+
181
+
115
182
  def session_bounds(records: list[dict]) -> tuple[datetime | None, datetime | None]:
116
183
  first = last = None
117
184
  for r in records:
118
- ts = r.get("timestamp")
119
- if not ts:
120
- continue
121
- try:
122
- dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
123
- except (ValueError, AttributeError):
185
+ dt = parse_ts(r.get("timestamp"))
186
+ if dt is None:
124
187
  continue
125
188
  if first is None or dt < first:
126
189
  first = dt
@@ -129,19 +192,69 @@ def session_bounds(records: list[dict]) -> tuple[datetime | None, datetime | Non
129
192
  return first, last
130
193
 
131
194
 
132
- def tally_usage(records: list[dict]) -> dict[str, int]:
195
+ def tally_usage(records: list[dict], start: datetime | None = None,
196
+ end: datetime | None = None) -> dict[str, int]:
197
+ """Sum assistant-turn usage. With `start`/`end`, only turns whose timestamp
198
+ falls inside the closed interval are counted."""
133
199
  totals: dict[str, int] = defaultdict(int)
200
+ for k in USAGE_KEYS:
201
+ totals[k] = 0
202
+ totals["turns"] = 0
134
203
  for r in records:
135
204
  if r.get("type") != "assistant":
136
205
  continue
206
+ if start is not None or end is not None:
207
+ ts = parse_ts(r.get("timestamp"))
208
+ if ts is None:
209
+ continue
210
+ if start is not None and ts < start:
211
+ continue
212
+ if end is not None and ts > end:
213
+ continue
137
214
  usage = (r.get("message") or {}).get("usage") or {}
138
- for k in ("input_tokens", "cache_creation_input_tokens",
139
- "cache_read_input_tokens", "output_tokens"):
215
+ for k in USAGE_KEYS:
140
216
  totals[k] += usage.get(k, 0) or 0
141
217
  totals["turns"] += 1
142
218
  return dict(totals)
143
219
 
144
220
 
221
+ def phase_usage(sidecar: dict, projects_root: Path | None = None) -> dict | None:
222
+ """Token usage inside one phase window, per ADR-0009 piece 2.
223
+
224
+ Reads the transcript named by `sidecar["session_id"]` and sums assistant-turn
225
+ usage over records whose `timestamp` falls in
226
+ `[phase_opened_at, phase_closed_at]`.
227
+
228
+ Returns `None` — *unmeasured* — whenever the window cannot be honoured: a
229
+ schema_version-1 sidecar with no window, a null session id, a transcript
230
+ swept by `cleanupPeriodDays` or living on another machine, an unparseable or
231
+ inverted window. Never zero: zero is a measurement, and a phase whose
232
+ transcript is gone was not measured. Read-only; nothing is written.
233
+ """
234
+ if not isinstance(sidecar, dict):
235
+ return None
236
+ opened = parse_ts(sidecar.get("phase_opened_at"))
237
+ closed = parse_ts(sidecar.get("phase_closed_at"))
238
+ if opened is None or closed is None or closed < opened:
239
+ return None
240
+ path = transcript_for_session(sidecar.get("session_id"), projects_root=projects_root)
241
+ if path is None:
242
+ return None
243
+ records = load_session(path)
244
+ if not records:
245
+ return None
246
+ totals = tally_usage(records, start=opened, end=closed)
247
+ total_tokens = sum(totals.get(k, 0) for k in USAGE_KEYS)
248
+ return {
249
+ **totals,
250
+ "total_tokens": total_tokens,
251
+ "cost_score": cost_score(totals),
252
+ "wall_clock_s": (closed - opened).total_seconds(),
253
+ "session_id": sidecar.get("session_id"),
254
+ "transcript": str(path),
255
+ }
256
+
257
+
145
258
  def cost_score(totals: dict[str, int]) -> int:
146
259
  """Same weighting as scripts/audit-token-usage.py: input + 1.25*cache_creation
147
260
  + 0.10*cache_read + 5*output. Approximates billable $-weight for ranking."""
@@ -61,7 +61,7 @@ def _render_token_economics(lines: list[str], te: dict) -> None:
61
61
  _h(lines, 2, "Token economics")
62
62
 
63
63
  te1 = te.get("te_1_cost_per_change", {})
64
- _h(lines, 3, "TE-1 — cost-score per Skill (proxy for per-change cost)")
64
+ _h(lines, 3, "TE-1 — cost-score per Skill (session-scoped)")
65
65
  _p(lines, te1.get("note", ""))
66
66
  _table(
67
67
  lines,
@@ -71,9 +71,10 @@ def _render_token_economics(lines: list[str], te: dict) -> None:
71
71
  )
72
72
 
73
73
  te2 = te.get("te_2_cache_hit_per_subagent", {})
74
- _h(lines, 3, "TE-2 — cache-hit ratio (per Skill, v1 coarse grain)")
74
+ _h(lines, 3, "TE-2 — cache-hit ratio (per Skill, session-scoped)")
75
75
  _p(lines, "ratio = cache_read / (cache_read + cache_creation). Below 0.5 → context "
76
76
  "is being rebuilt rather than reused. Above 0.8 → cross-session reuse is healthy.")
77
+ _p(lines, te2.get("note", ""))
77
78
  _table(
78
79
  lines,
79
80
  ["skill", "turns", "cache_read", "cache_creation", "ratio"],
@@ -93,6 +94,61 @@ def _render_token_economics(lines: list[str], te: dict) -> None:
93
94
  for r in te3.get("rows", [])],
94
95
  )
95
96
 
97
+ te4 = te.get("te_4_cost_per_phase", {})
98
+ _h(lines, 3, "TE-4 — cost per phase (sidecar phase window)")
99
+ _p(lines, _coverage_line(te4, "phase"))
100
+ _p(lines, te4.get("note", ""))
101
+ rows4 = te4.get("rows", [])
102
+ measured4 = [r for r in rows4 if r["measured"]]
103
+ _table(
104
+ lines,
105
+ ["skill", "change", "phase", "tokens", "turns", "wall-clock (h)"],
106
+ [[r["skill"], r["change"], r["phase_id"] or "-",
107
+ f"{r['tokens']:,}", r["turns"], r["wall_clock_h"]]
108
+ for r in measured4[:40]],
109
+ )
110
+ unmeasured4 = [r for r in rows4 if not r["measured"]]
111
+ if unmeasured4:
112
+ by_reason: dict[str, int] = {}
113
+ for r in unmeasured4:
114
+ by_reason[r["unmeasured_reason"] or "unknown"] = by_reason.get(r["unmeasured_reason"] or "unknown", 0) + 1
115
+ _p(lines, f"**{len(unmeasured4)} unmeasured phase(s)** — counted as nothing, never as zero:")
116
+ _table(
117
+ lines,
118
+ ["reason", "phases"],
119
+ [[reason, count] for reason, count in sorted(by_reason.items(), key=lambda kv: -kv[1])],
120
+ )
121
+
122
+ te5 = te.get("te_5_cost_per_change", {})
123
+ _h(lines, 3, "TE-5 — cost per change (sum of measured phases)")
124
+ _p(lines, _coverage_line(te5, "phase"))
125
+ _p(lines, te5.get("note", ""))
126
+ _table(
127
+ lines,
128
+ ["change", "tokens", "turns", "wall-clock (h)", "phases measured / emitted", "coverage"],
129
+ [[r["change"],
130
+ f"{r['tokens']:,}" if r["tokens"] is not None else "unmeasured",
131
+ r["turns"] if r["turns"] is not None else "-",
132
+ r["wall_clock_h"] if r["wall_clock_h"] is not None else "-",
133
+ f"{r['phases_measured']} / {r['phases_emitted']}",
134
+ f"{r['coverage_fraction']:.0%}" if r["coverage_fraction"] is not None else "-"]
135
+ for r in te5.get("rows", [])[:20]],
136
+ )
137
+
138
+
139
+ def _coverage_line(block: dict, unit: str) -> str:
140
+ emitted = block.get("phases_emitted", 0)
141
+ measured = block.get("phases_measured", 0)
142
+ frac = block.get("coverage_fraction")
143
+ if not emitted:
144
+ return (f"**Coverage: 0 sidecars in this repo.** No {unit} is measurable — "
145
+ "either no change has reached a sidecar-emitting terminal state yet, "
146
+ "or the sidecars pre-date ADR-0009's phase window.")
147
+ return (f"**Coverage: {measured} of {emitted} emitted sidecars measurable"
148
+ + (f" ({frac:.0%})" if frac is not None else "") + ".** "
149
+ "Emitted sidecars come from five Skills only, so this is a subset of the "
150
+ "change's real cost — read the fraction before reading the totals.")
151
+
96
152
 
97
153
  def _render_workflow_shape(lines: list[str], ws: dict) -> None:
98
154
  _h(lines, 2, "Workflow shape")
@@ -274,7 +330,7 @@ def _render_contract_drift(lines: list[str], cd: dict) -> None:
274
330
 
275
331
  def _render_kernel_fit(lines: list[str], kf: dict) -> None:
276
332
  _h(lines, 2, "Kernel-fit candidates")
277
- _p(lines, "Patterns suggesting the kernel itself (CLAUDE.md, templates, validators, Skill "
333
+ _p(lines, "Patterns suggesting the kernel itself (KERNEL.md, templates, validators, Skill "
278
334
  "flows) may need revision. Each fired pattern is also written as a durable finding "
279
335
  "by `/hstack:kernel-fit-scan` at `hstack/kernel-fit/findings/KF-NNNN-*.md`. The "
280
336
  "table below is a rollup; the findings are the canonical artifact. See ADR-0004.")
@@ -345,6 +401,15 @@ def watch_items(metrics: dict) -> list[str]:
345
401
  if r.get("ratio") is not None and r["ratio"] < 0.5 and r["turns"] > 5:
346
402
  items.append(f"Low cache-hit on `{r['skill']}` ({r['ratio']:.0%}) — context is being rebuilt.")
347
403
 
404
+ # TE-4: emitted sidecars the parser could not measure
405
+ te4 = metrics.get("token_economics", {}).get("te_4_cost_per_phase", {})
406
+ emitted = te4.get("phases_emitted", 0)
407
+ measured = te4.get("phases_measured", 0)
408
+ if emitted and measured < emitted:
409
+ items.append(f"{emitted - measured} of {emitted} phase sidecars are unmeasured "
410
+ "(no phase window, or the session transcript is gone) — TE-4/TE-5 "
411
+ "totals are a subset, not a total.")
412
+
348
413
  # QO-2 smells
349
414
  smells = metrics.get("quality_outcomes", {}).get("qo_2_severity_resolution_mix", {}).get("high_severity_in_prose_smells", [])
350
415
  if smells:
@@ -37,7 +37,7 @@ _SCRIPTS = _THIS.parent.parent
37
37
  if str(_SCRIPTS) not in sys.path:
38
38
  sys.path.insert(0, str(_SCRIPTS))
39
39
 
40
- from telemetry.parsers import frontmatter, commits, transcripts # noqa: E402
40
+ from telemetry.parsers import frontmatter, commits, transcripts, sidecars # noqa: E402
41
41
  from telemetry.insights import ( # noqa: E402
42
42
  token_economics, workflow_shape, quality_outcomes,
43
43
  overengineering, contract_drift, kernel_fit,
@@ -59,7 +59,11 @@ def main(argv: list[str] | None = None) -> int:
59
59
  hstack_root = repo / "hstack"
60
60
  if not hstack_root.is_dir():
61
61
  # Permit running against the template repo itself (no hstack/ prefix).
62
- if (repo / "specs").is_dir() and (repo / "CLAUDE.md").is_file():
62
+ # CLAUDE.md is the pre-ADR-0010 kernel name — still accepted so an
63
+ # un-migrated tree keeps reporting.
64
+ if (repo / "specs").is_dir() and (
65
+ (repo / "KERNEL.md").is_file() or (repo / "CLAUDE.md").is_file()
66
+ ):
63
67
  hstack_root = repo
64
68
  else:
65
69
  print(f"error: no hstack/ directory at {repo}", file=sys.stderr)
@@ -86,9 +90,14 @@ def main(argv: list[str] | None = None) -> int:
86
90
  session_rows = transcripts.collect_session_rows([repo], since=since_dt)
87
91
  print(f"telemetry: {len(session_rows)} sessions in window", file=sys.stderr)
88
92
 
93
+ # Phase sidecars (ADR-0009). Gitignored and derivative: absent on a fresh
94
+ # clone, which reports as zero coverage rather than as zero cost.
95
+ phase_sidecars = sidecars.load_sidecars(hstack_root)
96
+ print(f"telemetry: {len(phase_sidecars)} phase sidecars", file=sys.stderr)
97
+
89
98
  findings_dir = hstack_root / "kernel-fit" / "findings"
90
99
  metrics = {
91
- "token_economics": token_economics.compute(session_rows, changes),
100
+ "token_economics": token_economics.compute(session_rows, changes, phase_sidecars),
92
101
  "workflow_shape": workflow_shape.compute(git_commits, changes, session_rows),
93
102
  "quality_outcomes": quality_outcomes.compute(git_commits, changes),
94
103
  "overengineering": overengineering.compute(git_commits, changes, session_rows, repo),
@@ -112,7 +121,9 @@ def main(argv: list[str] | None = None) -> int:
112
121
  # Consumed by the telemetry UI; carries the same derivative-only guarantee.
113
122
  json_path = out_path.with_suffix(".json")
114
123
  payload = {
115
- "schema_version": 1,
124
+ # 2 — token_economics gains te_4_cost_per_phase / te_5_cost_per_change
125
+ # (ADR-0009). Additive: every schema_version-1 key keeps its shape.
126
+ "schema_version": 2,
116
127
  "repo": repo.name,
117
128
  "generated": date.today().isoformat(),
118
129
  "window_days": window_days,
@@ -123,6 +134,7 @@ def main(argv: list[str] | None = None) -> int:
123
134
  "module_specs": len(module_specs),
124
135
  "commits": len(git_commits),
125
136
  "sessions": len(session_rows),
137
+ "phase_sidecars": len(phase_sidecars),
126
138
  },
127
139
  "watch_list": render.watch_items(metrics),
128
140
  "metrics": metrics,
@@ -49,8 +49,12 @@ def main(argv: list[str] | None = None) -> int:
49
49
  repo = args.repo.resolve()
50
50
  hstack_root = repo / "hstack"
51
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():
52
+ # Permit running against the template repo itself. CLAUDE.md is the
53
+ # pre-ADR-0010 kernel name still accepted so an un-migrated tree
54
+ # keeps scanning.
55
+ if (repo / "specs").is_dir() and (
56
+ (repo / "KERNEL.md").is_file() or (repo / "CLAUDE.md").is_file()
57
+ ):
54
58
  hstack_root = repo
55
59
  else:
56
60
  print(f"error: no hstack/ directory at {repo}", file=sys.stderr)
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve the active Claude Code session id — the one heuristic, in one place.
3
+
4
+ Per ADR-0009 § Neutral: `/hstack:flag` resolved the session id inline in its own
5
+ prose; the five sidecar-emitting Skills would have duplicated that prose a sixth
6
+ time. They call this module instead.
7
+
8
+ The heuristic (unchanged from ADR-0005): Claude Code writes one `*.jsonl`
9
+ transcript per session under `~/.claude/projects/<encoded-cwd>/`, where
10
+ `<encoded-cwd>` is the absolute working directory with `/` replaced by `-`. The
11
+ most recently modified file in that directory is the active session; the session
12
+ id is its basename.
13
+
14
+ Both the heuristic and the transcript layout are harness implementation details,
15
+ not a contract (ADR-0009 § Negative). Every failure mode resolves to
16
+ `session_id: null` — never a guess, never a halt. A null session id means the
17
+ phase reports as *unmeasured*, which is the whole point of the null-not-zero
18
+ rule.
19
+
20
+ CLI:
21
+ python3 scripts/telemetry/session_id.py # JSON on stdout, always exit 0
22
+ python3 scripts/telemetry/session_id.py --cwd DIR # resolve for another dir
23
+
24
+ Output:
25
+ {"session_id": "062b8fe8-…" | null,
26
+ "transcript_path": "/Users/…/062b8fe8-….jsonl" | null,
27
+ "message_count": 1423,
28
+ "source": "transcript" | "unresolved",
29
+ "fallback_id": "a3f9c1d2",
30
+ "now": "2026-08-15T09:12:44Z"}
31
+
32
+ `now` is the UTC stamp at resolution time, so a Skill opening a phase gets its
33
+ `session_id` and its `phase_opened_at` from one call. `fallback_id` is short
34
+ random hex for callers that need a non-null id anyway (`/hstack:flag` writes
35
+ `fallback-<id>`); sidecar emitters ignore it and write `null`.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import argparse
41
+ import json
42
+ import os
43
+ import sys
44
+ from datetime import datetime, timezone
45
+ from pathlib import Path
46
+
47
+
48
+ PROJECTS_ROOT = Path.home() / ".claude" / "projects"
49
+
50
+
51
+ def encoded_cwd(cwd: Path | str | None = None) -> str:
52
+ """`/Users/jane/code/moso` → `-Users-jane-code-moso`."""
53
+ p = Path(cwd) if cwd is not None else Path.cwd()
54
+ return str(p.resolve()).replace("/", "-")
55
+
56
+
57
+ def project_dir(cwd: Path | str | None = None, projects_root: Path | None = None) -> Path:
58
+ root = projects_root or PROJECTS_ROOT
59
+ return root / encoded_cwd(cwd)
60
+
61
+
62
+ def utc_now_iso() -> str:
63
+ """ISO-8601 UTC, second precision, `Z` suffix — the sidecar timestamp format."""
64
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
65
+
66
+
67
+ def resolve_session(cwd: Path | str | None = None,
68
+ projects_root: Path | None = None) -> dict:
69
+ """Resolve the active session for `cwd`. Never raises; never halts."""
70
+ out = {
71
+ "session_id": None,
72
+ "transcript_path": None,
73
+ "message_count": 0,
74
+ "source": "unresolved",
75
+ "fallback_id": os.urandom(4).hex(),
76
+ "now": utc_now_iso(),
77
+ }
78
+ d = project_dir(cwd, projects_root)
79
+ try:
80
+ candidates = [f for f in d.glob("*.jsonl") if f.is_file()]
81
+ except OSError:
82
+ return out
83
+ if not candidates:
84
+ return out
85
+ try:
86
+ newest = max(candidates, key=lambda f: f.stat().st_mtime)
87
+ except OSError:
88
+ return out
89
+ out["session_id"] = newest.stem
90
+ out["transcript_path"] = str(newest)
91
+ out["source"] = "transcript"
92
+ out["message_count"] = _count_lines(newest)
93
+ return out
94
+
95
+
96
+ def _count_lines(path: Path) -> int:
97
+ try:
98
+ with open(path, "rb") as fh:
99
+ return sum(1 for _ in fh)
100
+ except OSError:
101
+ return 0
102
+
103
+
104
+ def transcript_for_session(session_id: str | None,
105
+ projects_root: Path | None = None) -> Path | None:
106
+ """Locate a transcript by session id, across every project directory.
107
+
108
+ The sidecar carries the session id, not the path: a Conductor worktree can be
109
+ renamed or removed between the write and the read, and the encoded-cwd
110
+ directory with it. Returns None when no transcript is found — retention
111
+ sweep, another machine, or a harness layout change. None means *unmeasured*.
112
+ """
113
+ if not session_id or not isinstance(session_id, str):
114
+ return None
115
+ if session_id.startswith("fallback-"):
116
+ return None
117
+ root = projects_root or PROJECTS_ROOT
118
+ if not root.is_dir():
119
+ return None
120
+ for d in sorted(root.iterdir()):
121
+ if not d.is_dir():
122
+ continue
123
+ candidate = d / f"{session_id}.jsonl"
124
+ if candidate.is_file():
125
+ return candidate
126
+ return None
127
+
128
+
129
+ def main(argv: list[str] | None = None) -> int:
130
+ parser = argparse.ArgumentParser(description="Resolve the active Claude Code session id.")
131
+ parser.add_argument("--cwd", type=Path, default=None,
132
+ help="Directory to resolve for (default: current working directory).")
133
+ args = parser.parse_args(argv)
134
+ print(json.dumps(resolve_session(args.cwd), indent=1))
135
+ return 0
136
+
137
+
138
+ if __name__ == "__main__":
139
+ sys.exit(main())