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,681 +0,0 @@
1
- #!/usr/bin/env python3
2
- """hstack-coord — pull-based cross-session / cross-repo coordination scan.
3
-
4
- Usage:
5
- python3 hstack/scripts/coord/coord_scan.py [scan] [--horizon-days N]
6
- python3 hstack/scripts/coord/coord_scan.py hook
7
- python3 hstack/scripts/coord/coord_scan.py ack <id> [<id> ...]
8
- python3 hstack/scripts/coord/coord_scan.py ack --all
9
- python3 hstack/scripts/coord/coord_scan.py register [--name N] [--path P]
10
- python3 hstack/scripts/coord/coord_scan.py peers
11
-
12
- `scan` (the default) walks every local branch of this repo plus every
13
- registered peer repo's local branches for committed coord-messages
14
- (hstack/coord/messages/*.md) addressed to this repo, filters out acked /
15
- expired / own-sent messages, and prints one line per new message. Silent
16
- with exit 0 when there is nothing — the zero-cost path.
17
-
18
- `hook` is the Claude Code hook entry point (SessionStart / UserPromptSubmit,
19
- per ADR-0007 in the hstack dev repo): the same scan, but the output contract
20
- is hook-shaped — a single count-only pointer line when new messages exist
21
- (hook stdout is injected into the session's context), silence otherwise,
22
- and exit 0 no matter what: a coordination failure must never break the
23
- engineer's prompt. Peer-authored content (subjects, ids, bodies) is
24
- deliberately NOT printed by `hook`; surfacing stays in /hstack:coord,
25
- frontmatter-first, per CM-03.
26
-
27
- Authoritative state is ONLY committed files (see ADR-0006 in the hstack dev
28
- repo): the messages themselves, and the repo's canonical identity at
29
- hstack/coord/NAME (a committed one-line file — the string senders address
30
- with `to-repo` and receivers filter on; registry names are machine-local
31
- aliases that can diverge between machines and MUST NOT be relied on for
32
- addressing). The two local files this script touches are never authoritative:
33
-
34
- ~/.hstack/registry.yaml machine config: name -> path -> default-branch
35
- hstack/.session-state/coord-cursor per-worktree acked-id list, shared by all
36
- sessions in that worktree (derivative; losing
37
- it re-surfaces messages — at-least-once)
38
- hstack/.session-state/coord-scan-cache.json
39
- per-worktree scan cache keyed on a refs-state
40
- fingerprint (derivative; losing it costs one
41
- full branch walk). Messages only appear via
42
- commits and commits only move refs, so an
43
- unchanged fingerprint proves the walk would
44
- find the same set — the cache never changes
45
- WHAT surfaces, only how fast.
46
- hstack/.telemetry/coord/events.jsonl per-worktree usage log (gitignored via the
47
- consumer's `**/.telemetry/` line; measurement
48
- only, never authoritative, safe to delete)
49
-
50
- No network calls. Reads git only via `git show` / `git ls-tree` /
51
- `git for-each-ref` — committed state, never a peer's working tree.
52
- """
53
-
54
- from __future__ import annotations
55
-
56
- import argparse
57
- import hashlib
58
- import json
59
- import os
60
- import re
61
- import shlex
62
- import subprocess
63
- import sys
64
- import time
65
- from datetime import date, datetime, timedelta, timezone
66
- from pathlib import Path
67
-
68
- MESSAGES_DIR = "hstack/coord/messages"
69
- NAME_RELPATH = "hstack/coord/NAME"
70
- CURSOR_RELPATH = "hstack/.session-state/coord-cursor"
71
- CACHE_RELPATH = "hstack/.session-state/coord-scan-cache.json"
72
- TELEMETRY_RELPATH = "hstack/.telemetry/coord/events.jsonl"
73
- DEFAULT_HORIZON_DAYS = 30
74
- # Cursor entries older than twice the default horizon are pruned on ack.
75
- CURSOR_PRUNE_DAYS = DEFAULT_HORIZON_DAYS * 2
76
- ID_TS_RE = re.compile(r"^msg-(\d{8}T\d{6})-")
77
-
78
-
79
- def registry_path() -> Path:
80
- override = os.environ.get("HSTACK_REGISTRY")
81
- if override:
82
- return Path(override)
83
- return Path.home() / ".hstack" / "registry.yaml"
84
-
85
-
86
- # ---------------------------------------------------------------- git helpers
87
-
88
-
89
- def run_git(args: list[str], cwd: str | None = None) -> str:
90
- out = subprocess.run(
91
- ["git", *args],
92
- cwd=cwd,
93
- capture_output=True,
94
- text=True,
95
- check=True,
96
- )
97
- return out.stdout.strip()
98
-
99
-
100
- def try_git(args: list[str], cwd: str | None = None) -> str | None:
101
- try:
102
- return run_git(args, cwd=cwd)
103
- except (subprocess.CalledProcessError, FileNotFoundError):
104
- return None
105
-
106
-
107
- def repo_root() -> str:
108
- root = try_git(["rev-parse", "--show-toplevel"])
109
- if not root:
110
- print("hstack-coord: not inside a git repository", file=sys.stderr)
111
- sys.exit(1)
112
- return root
113
-
114
-
115
- def main_worktree(path: str) -> str:
116
- """Resolve the main working tree for `path` (stable across worktrees)."""
117
- common = try_git(
118
- ["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd=path
119
- )
120
- if common and common.endswith("/.git"):
121
- return str(Path(common).parent)
122
- # Bare repo or unusual layout — fall back to the worktree itself.
123
- return try_git(["rev-parse", "--show-toplevel"], cwd=path) or path
124
-
125
-
126
- def local_branches(path: str) -> list[str]:
127
- out = try_git(["for-each-ref", "--format=%(refname:short)", "refs/heads"], cwd=path)
128
- return [b for b in (out or "").splitlines() if b]
129
-
130
-
131
- def list_message_paths(path: str, branch: str) -> list[str]:
132
- out = try_git(
133
- ["ls-tree", "-r", "--name-only", branch, "--", MESSAGES_DIR], cwd=path
134
- )
135
- return [p for p in (out or "").splitlines() if p.endswith(".md")]
136
-
137
-
138
- def show_file(path: str, branch: str, relpath: str) -> str | None:
139
- return try_git(["show", f"{branch}:{relpath}"], cwd=path)
140
-
141
-
142
- # ------------------------------------------------------------- frontmatter
143
-
144
-
145
- def parse_frontmatter(text: str) -> dict[str, str | None]:
146
- """Minimal flat `key: value` frontmatter parser (stdlib only)."""
147
- lines = text.splitlines()
148
- if not lines or lines[0].strip() != "---":
149
- return {}
150
- fm: dict[str, str | None] = {}
151
- for line in lines[1:]:
152
- if line.strip() == "---":
153
- break
154
- if ":" not in line or line.startswith((" ", "\t", "#")):
155
- continue
156
- key, _, raw = line.partition(":")
157
- value = raw.split(" #", 1)[0].strip().strip("'\"")
158
- fm[key.strip()] = None if value in ("", "null", "~") else value
159
- return fm
160
-
161
-
162
- # ---------------------------------------------------------------- registry
163
-
164
-
165
- def load_registry() -> list[dict[str, str]]:
166
- path = registry_path()
167
- if not path.exists():
168
- return []
169
- repos: list[dict[str, str]] = []
170
- cur: dict[str, str] | None = None
171
- for raw in path.read_text().splitlines():
172
- s = raw.strip()
173
- if s.startswith("- name:"):
174
- cur = {"name": s.partition(":")[2].strip().strip("'\"")}
175
- repos.append(cur)
176
- elif cur is not None and s.startswith("path:"):
177
- cur["path"] = s.partition(":")[2].strip().strip("'\"")
178
- elif cur is not None and s.startswith("default-branch:"):
179
- cur["default-branch"] = s.partition(":")[2].strip().strip("'\"")
180
- return [r for r in repos if "path" in r]
181
-
182
-
183
- def write_registry(repos: list[dict[str, str]]) -> None:
184
- path = registry_path()
185
- path.parent.mkdir(parents=True, exist_ok=True)
186
- lines = ["schema-version: 1", "repos:"]
187
- for r in repos:
188
- lines.append(f" - name: {r['name']}")
189
- lines.append(f" path: {r['path']}")
190
- lines.append(f" default-branch: {r.get('default-branch', 'main')}")
191
- path.write_text("\n".join(lines) + "\n")
192
-
193
-
194
- def resolve_self_name(root: str, self_main: str, registry: list[dict[str, str]]) -> str:
195
- """Canonical identity, in precedence order: the committed one-line file
196
- hstack/coord/NAME (the only source both sender and receiver can resolve
197
- to the same string), then this machine's registry entry, then basename."""
198
- name_file = Path(root) / NAME_RELPATH
199
- if name_file.is_file():
200
- first = name_file.read_text().strip().splitlines()
201
- if first and first[0].strip():
202
- return sanitize(first[0].strip(), 64)
203
- self_real = os.path.realpath(self_main)
204
- for r in registry:
205
- if os.path.realpath(r["path"]) == self_real:
206
- return r["name"]
207
- return os.path.basename(self_real)
208
-
209
-
210
- # ------------------------------------------------------------------ cursor
211
-
212
-
213
- def cursor_path(root: str) -> Path:
214
- return Path(root) / CURSOR_RELPATH
215
-
216
-
217
- def load_acked(root: str) -> set[str]:
218
- p = cursor_path(root)
219
- if not p.exists():
220
- return set()
221
- return {line.strip() for line in p.read_text().splitlines() if line.strip()}
222
-
223
-
224
- def id_timestamp(msg_id: str) -> datetime | None:
225
- m = ID_TS_RE.match(msg_id)
226
- if not m:
227
- return None
228
- try:
229
- return datetime.strptime(m.group(1), "%Y%m%dT%H%M%S")
230
- except ValueError:
231
- return None
232
-
233
-
234
- def write_acked(root: str, ids: set[str]) -> None:
235
- # Ids without a parseable timestamp are pruned too — the scan skips
236
- # malformed ids (fail-closed), so keeping them would grow the cursor forever.
237
- prune_before = datetime.now() - timedelta(days=CURSOR_PRUNE_DAYS)
238
- kept = sorted(
239
- i for i in ids if (ts := id_timestamp(i)) is not None and ts >= prune_before
240
- )
241
- p = cursor_path(root)
242
- p.parent.mkdir(parents=True, exist_ok=True)
243
- # Atomic replace: concurrent acks from parallel sessions in the same
244
- # worktree race last-write-wins, never a torn file. A lost ack merely
245
- # re-surfaces a message next scan (at-least-once).
246
- tmp = p.with_suffix(".tmp")
247
- tmp.write_text("\n".join(kept) + ("\n" if kept else ""))
248
- os.replace(tmp, p)
249
-
250
-
251
- # --------------------------------------------------------- usage telemetry
252
-
253
-
254
- def log_usage(root: str, event: str, **fields: object) -> None:
255
- """Append one usage event to the per-worktree JSONL log.
256
-
257
- Measurement only — same discipline as the `.telemetry/` sidecars:
258
- gitignored, never authoritative, safe to delete. Best-effort by
259
- contract: a telemetry failure must never fail the scan, and above all
260
- never fail the hook path that runs on every prompt.
261
- """
262
- try:
263
- path = Path(root) / TELEMETRY_RELPATH
264
- path.parent.mkdir(parents=True, exist_ok=True)
265
- record: dict[str, object] = {
266
- "schema_version": 1,
267
- "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
268
- "event": event,
269
- **fields,
270
- }
271
- with path.open("a", encoding="utf-8") as f:
272
- f.write(json.dumps(record, ensure_ascii=False) + "\n")
273
- except Exception:
274
- pass
275
-
276
-
277
- # -------------------------------------------------------------------- scan
278
-
279
-
280
- def sanitize(text: str, limit: int = 80) -> str:
281
- clean = "".join(ch for ch in text if ch.isprintable())
282
- return clean[:limit]
283
-
284
-
285
- def sanitize_ref(text: str, limit: int = 60) -> str:
286
- """Identifier fields (ids, repo names, branch names) collapse to a strict
287
- ref charset — peer-authored punctuation/whitespace cannot mimic this
288
- tool's own output lines or smuggle shell syntax."""
289
- return re.sub(r"[^A-Za-z0-9._/-]", "_", text)[:limit]
290
-
291
-
292
- def resolve_sources(self_name: str, self_main: str) -> list[tuple[str, str]]:
293
- """Scan sources: this repo plus every reachable registered peer."""
294
- registry = load_registry()
295
- sources: list[tuple[str, str]] = [(self_name, self_main)]
296
- self_real = os.path.realpath(self_main)
297
- for r in registry:
298
- if os.path.realpath(r["path"]) == self_real:
299
- continue
300
- if not Path(r["path"]).is_dir():
301
- print(
302
- f"hstack-coord: registered repo '{r['name']}' missing at {r['path']} — skipped",
303
- file=sys.stderr,
304
- )
305
- continue
306
- sources.append((r["name"], r["path"]))
307
- return sources
308
-
309
-
310
- def collect_messages(
311
- self_name: str,
312
- self_main: str,
313
- current_branch: str,
314
- horizon_days: int,
315
- sources: list[tuple[str, str]] | None = None,
316
- ) -> list[dict[str, str]]:
317
- """Return unacked-agnostic candidate messages addressed to this repo."""
318
- if sources is None:
319
- sources = resolve_sources(self_name, self_main)
320
-
321
- horizon = datetime.now() - timedelta(days=horizon_days)
322
- seen_ids: set[str] = set()
323
- found: list[dict[str, str]] = []
324
-
325
- for source_name, source_path in sources:
326
- for branch in local_branches(source_path):
327
- for relpath in list_message_paths(source_path, branch):
328
- msg_id = Path(relpath).stem
329
- if msg_id in seen_ids:
330
- continue
331
- ts = id_timestamp(msg_id)
332
- if ts is None:
333
- # Fail closed: an id outside the msg-<ts>-... contract is
334
- # skipped, not surfaced — it would bypass the horizon and
335
- # pin the cursor forever.
336
- print(
337
- f"hstack-coord: skipping malformed message id '{sanitize(msg_id, 60)}' "
338
- f"on {sanitize(source_name, 40)}:{sanitize(branch, 60)}",
339
- file=sys.stderr,
340
- )
341
- continue
342
- if ts < horizon:
343
- continue
344
- body = show_file(source_path, branch, relpath)
345
- if body is None:
346
- continue
347
- fm = parse_frontmatter(body)
348
- if fm.get("type") != "coord-message":
349
- continue
350
- if fm.get("to-repo") != self_name:
351
- continue
352
- to_branch = fm.get("to-branch")
353
- if to_branch is not None and to_branch != current_branch:
354
- continue
355
- # Own-sent: never surface a message to the session that wrote it.
356
- if source_name == self_name and fm.get("from-branch") == current_branch:
357
- continue
358
- expires = fm.get("expires")
359
- if expires is not None:
360
- try:
361
- if date.fromisoformat(expires) < date.today():
362
- continue
363
- except ValueError:
364
- # Fail closed: a malformed expiry means the sender's
365
- # intent is unknowable — skip rather than surface forever.
366
- print(
367
- f"hstack-coord: skipping '{sanitize(msg_id, 60)}' — malformed expires",
368
- file=sys.stderr,
369
- )
370
- continue
371
- seen_ids.add(msg_id)
372
- # Every frontmatter-derived field below is peer-authored
373
- # (untrusted) — sanitize before it can reach a session's context:
374
- # identifiers collapse to a strict ref charset, free text is
375
- # printable-only and quote-delimited at print time.
376
- found.append(
377
- {
378
- "id": sanitize_ref(msg_id, 80),
379
- "from-repo": sanitize_ref(fm.get("from-repo") or source_name, 40),
380
- "from-branch": sanitize_ref(fm.get("from-branch") or branch, 60),
381
- "subject": sanitize(fm.get("subject") or "(no subject)"),
382
- "source-path": source_path,
383
- "source-branch": branch,
384
- "relpath": relpath,
385
- }
386
- )
387
- found.sort(key=lambda m: m["id"])
388
- return found
389
-
390
-
391
- # -------------------------------------------------------- refs-state cache
392
-
393
-
394
- def scan_fingerprint(
395
- sources: list[tuple[str, str]],
396
- self_name: str,
397
- current_branch: str,
398
- horizon_days: int,
399
- ) -> str:
400
- """Fingerprint of everything the branch walk's result depends on.
401
-
402
- Messages exist only as committed files, and commits only become visible
403
- by moving a ref — so if no local ref of any source repo moved, the walk
404
- would return byte-identical results. The remaining inputs (identity,
405
- branch, horizon, and today's date for the expires/horizon filters) are
406
- folded in; the date term bounds cache lifetime at one day.
407
-
408
- The cursor (ack state) is deliberately NOT part of the fingerprint —
409
- acked-filtering happens after collection, so acks never require a
410
- re-walk.
411
- """
412
- parts = [
413
- "schema=1",
414
- f"self={self_name}",
415
- f"branch={current_branch}",
416
- f"horizon={horizon_days}",
417
- f"date={date.today().isoformat()}",
418
- ]
419
- for name, path in sources:
420
- refs = try_git(
421
- ["for-each-ref", "--format=%(refname:short) %(objectname)", "refs/heads"],
422
- cwd=path,
423
- )
424
- parts.append(f"repo={name}:{os.path.realpath(path)}\n{refs or ''}")
425
- return hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
426
-
427
-
428
- def collect_messages_cached(
429
- root: str,
430
- self_name: str,
431
- self_main: str,
432
- current_branch: str,
433
- horizon_days: int,
434
- ) -> tuple[list[dict[str, str]], bool]:
435
- """collect_messages behind the refs-state cache.
436
-
437
- Returns (messages, cache_hit). The cache is derivative in the strict
438
- sense: deleting it costs one full branch walk and changes nothing else.
439
- Corrupt or mismatched cache falls through to a full walk (fail open to
440
- the slow-but-correct path).
441
- """
442
- sources = resolve_sources(self_name, self_main)
443
- fingerprint = scan_fingerprint(sources, self_name, current_branch, horizon_days)
444
- cache_file = Path(root) / CACHE_RELPATH
445
- try:
446
- cached = json.loads(cache_file.read_text())
447
- if (
448
- cached.get("schema_version") == 1
449
- and cached.get("fingerprint") == fingerprint
450
- and isinstance(cached.get("messages"), list)
451
- ):
452
- return cached["messages"], True
453
- except Exception:
454
- pass
455
- found = collect_messages(
456
- self_name, self_main, current_branch, horizon_days, sources=sources
457
- )
458
- try:
459
- cache_file.parent.mkdir(parents=True, exist_ok=True)
460
- tmp = cache_file.with_suffix(".tmp")
461
- tmp.write_text(
462
- json.dumps(
463
- {"schema_version": 1, "fingerprint": fingerprint, "messages": found},
464
- ensure_ascii=False,
465
- )
466
- )
467
- os.replace(tmp, cache_file) # atomic; concurrent scans race last-write-wins
468
- except Exception:
469
- pass # cache write failure only costs the next caller a full walk
470
- return found, False
471
-
472
-
473
- def cmd_scan(horizon_days: int) -> int:
474
- started = time.monotonic()
475
- root = repo_root()
476
- self_main = main_worktree(root)
477
- current_branch = try_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=root) or "HEAD"
478
- self_name = resolve_self_name(root, self_main, load_registry())
479
- acked = load_acked(root)
480
-
481
- found, cache_hit = collect_messages_cached(
482
- root, self_name, self_main, current_branch, horizon_days
483
- )
484
- new = [m for m in found if m["id"] not in acked]
485
- log_usage(
486
- root,
487
- "scan",
488
- new_count=len(new),
489
- duration_ms=int((time.monotonic() - started) * 1000),
490
- cache="hit" if cache_hit else "miss",
491
- )
492
- if not new:
493
- return 0 # silent — the zero-cost path
494
-
495
- print(f"HSTACK-COORD: {len(new)} new message(s) for {self_name} [branch {current_branch}]")
496
- for m in new:
497
- # Quoted subject + shell-quoted read command: peer-authored content is
498
- # delimited so it cannot masquerade as this tool's own output lines.
499
- print(f' {m["id"]} | from {m["from-repo"]}:{m["from-branch"]} | subject: "{m["subject"]}"')
500
- spec = shlex.quote(f"{m['source-branch']}:{m['relpath']}")
501
- print(f" read: git -C {shlex.quote(m['source-path'])} show {spec}")
502
- print(" ack after surfacing: python3 hstack/scripts/coord/coord_scan.py ack --all")
503
- return 0
504
-
505
-
506
- def cmd_hook(horizon_days: int) -> int:
507
- """Claude Code hook entry (SessionStart / UserPromptSubmit, ADR-0007).
508
-
509
- Contract, in order of importance:
510
- 1. Exit 0 no matter what. A broken registry, a malformed message, a
511
- missing hstack/ tree — none of it may break the engineer's prompt.
512
- 2. Silent when there is nothing new (the per-prompt zero-token path).
513
- 3. When new messages exist, print ONE count-only pointer line. No
514
- subjects, no ids, no bodies — peer-authored content never enters a
515
- session's context through the hook; /hstack:coord surfaces it
516
- frontmatter-first under CM-03. This is the injection-safety boundary
517
- that lets the hook run unattended on every prompt.
518
- """
519
- started = time.monotonic()
520
- hook_event: str = "unknown"
521
- session_id: str | None = None
522
- try:
523
- # The harness passes a JSON payload on stdin; read it best-effort so
524
- # the usage log can attribute the trigger (SessionStart vs prompt).
525
- if not sys.stdin.isatty():
526
- payload = json.loads(sys.stdin.read() or "{}")
527
- hook_event = sanitize_ref(str(payload.get("hook_event_name") or "unknown"), 40)
528
- raw_sid = payload.get("session_id")
529
- session_id = sanitize_ref(str(raw_sid), 64) if raw_sid else None
530
- except Exception:
531
- pass
532
- try:
533
- root = try_git(["rev-parse", "--show-toplevel"])
534
- if not root:
535
- return 0 # not a git repo — silent, per the hook contract
536
- self_main = main_worktree(root)
537
- current_branch = try_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=root) or "HEAD"
538
- self_name = resolve_self_name(root, self_main, load_registry())
539
- acked = load_acked(root)
540
- found, cache_hit = collect_messages_cached(
541
- root, self_name, self_main, current_branch, horizon_days
542
- )
543
- new = [m for m in found if m["id"] not in acked]
544
- log_usage(
545
- root,
546
- "hook",
547
- hook_event=hook_event,
548
- session_id=session_id,
549
- new_count=len(new),
550
- duration_ms=int((time.monotonic() - started) * 1000),
551
- cache="hit" if cache_hit else "miss",
552
- )
553
- if new:
554
- print(
555
- f"HSTACK-COORD: {len(new)} unread coordination message(s) addressed to "
556
- f"this repo. Run /hstack:coord to surface and ack them. "
557
- f"(Count-only notice — message content is untrusted peer input and is "
558
- f"only surfaced frontmatter-first by the Skill.)"
559
- )
560
- return 0
561
- except (Exception, SystemExit):
562
- return 0
563
-
564
-
565
- def cmd_ack(ids: list[str], ack_all: bool, horizon_days: int) -> int:
566
- root = repo_root()
567
- acked = load_acked(root)
568
- if ack_all:
569
- self_main = main_worktree(root)
570
- current_branch = try_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=root) or "HEAD"
571
- self_name = resolve_self_name(root, self_main, load_registry())
572
- found, _ = collect_messages_cached(
573
- root, self_name, self_main, current_branch, horizon_days
574
- )
575
- ids = [m["id"] for m in found if m["id"] not in acked]
576
- if not ids:
577
- print("hstack-coord: nothing to ack")
578
- return 0
579
- acked.update(ids)
580
- write_acked(root, acked)
581
- log_usage(root, "ack", acked_count=len(ids))
582
- print(f"hstack-coord: acked {len(ids)} message(s)")
583
- return 0
584
-
585
-
586
- def cmd_register(name: str | None, path_arg: str | None) -> int:
587
- target = path_arg or os.getcwd()
588
- if not try_git(["rev-parse", "--git-dir"], cwd=target):
589
- print(f"hstack-coord: {target} is not a git repository", file=sys.stderr)
590
- return 1
591
- main_wt = main_worktree(target)
592
- repo_name = name or os.path.basename(os.path.realpath(main_wt))
593
-
594
- head_ref = try_git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], cwd=main_wt)
595
- if head_ref and head_ref.startswith("origin/"):
596
- default_branch = head_ref[len("origin/"):]
597
- elif "main" in local_branches(main_wt):
598
- default_branch = "main"
599
- elif "master" in local_branches(main_wt):
600
- default_branch = "master"
601
- else:
602
- default_branch = try_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=main_wt) or "main"
603
-
604
- repos = load_registry()
605
- real = os.path.realpath(main_wt)
606
- for r in repos:
607
- if os.path.realpath(r["path"]) == real:
608
- print(f"hstack-coord: already registered as '{r['name']}' ({r['path']})")
609
- return 0
610
- if r["name"] == repo_name:
611
- print(
612
- f"hstack-coord: name '{repo_name}' already registered for {r['path']} — pass --name",
613
- file=sys.stderr,
614
- )
615
- return 1
616
- repos.append({"name": repo_name, "path": main_wt, "default-branch": default_branch})
617
- write_registry(repos)
618
- print(f"hstack-coord: registered '{repo_name}' -> {main_wt} (default-branch {default_branch})")
619
- if not (Path(main_wt) / NAME_RELPATH).is_file():
620
- print(
621
- f"hstack-coord: no {NAME_RELPATH} in this repo — commit one containing "
622
- f"'{repo_name}' so senders and receivers resolve the same identity "
623
- f"(registry names are machine-local and can diverge)",
624
- )
625
- return 0
626
-
627
-
628
- def cmd_peers() -> int:
629
- repos = load_registry()
630
- if not repos:
631
- print(f"hstack-coord: no registry at {registry_path()} — run `register` from each repo")
632
- return 0
633
- for r in repos:
634
- marker = "ok" if Path(r["path"]).is_dir() else "MISSING"
635
- print(f" {r['name']:<24} {r['path']} [{r.get('default-branch', 'main')}] ({marker})")
636
- return 0
637
-
638
-
639
- def main(argv: list[str]) -> int:
640
- parser = argparse.ArgumentParser(prog="coord_scan.py", add_help=True)
641
- sub = parser.add_subparsers(dest="cmd")
642
-
643
- p_scan = sub.add_parser("scan", help="list new messages addressed to this repo (default)")
644
- p_scan.add_argument("--horizon-days", type=int, default=DEFAULT_HORIZON_DAYS)
645
-
646
- p_hook = sub.add_parser(
647
- "hook",
648
- help="Claude Code hook entry: count-only pointer line, always exit 0 (ADR-0007)",
649
- )
650
- p_hook.add_argument("--horizon-days", type=int, default=DEFAULT_HORIZON_DAYS)
651
-
652
- p_ack = sub.add_parser("ack", help="mark message ids as surfaced")
653
- p_ack.add_argument("ids", nargs="*")
654
- p_ack.add_argument("--all", action="store_true", dest="ack_all")
655
- p_ack.add_argument("--horizon-days", type=int, default=DEFAULT_HORIZON_DAYS)
656
-
657
- p_reg = sub.add_parser("register", help="add this repo (or --path) to the machine registry")
658
- p_reg.add_argument("--name")
659
- p_reg.add_argument("--path")
660
-
661
- sub.add_parser("peers", help="list registered repos and their reachability")
662
-
663
- args = parser.parse_args(argv or ["scan"])
664
- if args.cmd in (None, "scan"):
665
- return cmd_scan(getattr(args, "horizon_days", DEFAULT_HORIZON_DAYS))
666
- if args.cmd == "hook":
667
- return cmd_hook(args.horizon_days)
668
- if args.cmd == "ack":
669
- if not args.ids and not args.ack_all:
670
- print("hstack-coord: ack requires ids or --all", file=sys.stderr)
671
- return 1
672
- return cmd_ack(args.ids, args.ack_all, args.horizon_days)
673
- if args.cmd == "register":
674
- return cmd_register(args.name, args.path)
675
- if args.cmd == "peers":
676
- return cmd_peers()
677
- return 1
678
-
679
-
680
- if __name__ == "__main__":
681
- sys.exit(main(sys.argv[1:]))
@@ -1,6 +0,0 @@
1
- """hstack-telemetry — retrospective observability for the hstack workflow.
2
-
3
- Reads on-disk artifacts (frontmatter + bodies), git history, and Claude Code
4
- transcripts to compute a small set of metrics that drive the hstack
5
- learning/improvement loop. Pure derivation; never the authoritative source.
6
- """
File without changes