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.
- package/CHANGELOG.md +308 -0
- package/README.md +90 -154
- package/VERSION +1 -1
- package/dist/commands/doctor.js +69 -9
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.js +7 -3
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/update.js +17 -4
- package/dist/commands/update.js.map +1 -1
- package/dist/lib/descriptions.js +160 -0
- package/dist/lib/descriptions.js.map +1 -0
- package/dist/lib/diff.js +1 -1
- package/dist/lib/git.js +16 -0
- package/dist/lib/git.js.map +1 -1
- package/dist/lib/wire.js +274 -142
- package/dist/lib/wire.js.map +1 -1
- package/dist/manifest.js +49 -22
- package/dist/manifest.js.map +1 -1
- package/package.json +4 -3
- package/template/.claude/agents/adversarial-reviewer.md +42 -98
- package/template/.claude/agents/app-architect.md +44 -133
- package/template/.claude/agents/data-architect.md +46 -127
- package/template/.claude/agents/test-strategist.md +39 -109
- package/template/.claude/skills/hstack-adr-new/SKILL.md +23 -100
- package/template/.claude/skills/hstack-adversarial-review/SKILL.md +47 -174
- package/template/.claude/skills/hstack-adversarial-review/references/finding-categories.md +134 -0
- package/template/.claude/skills/hstack-app-architecture/SKILL.md +43 -95
- package/template/.claude/skills/hstack-commit/SKILL.md +28 -97
- package/template/.claude/skills/hstack-data-architecture/SKILL.md +34 -82
- package/template/.claude/skills/hstack-promote/SKILL.md +55 -0
- package/template/.claude/skills/hstack-story/SKILL.md +58 -0
- package/template/.claude/skills/hstack-test-audit/SKILL.md +64 -0
- package/template/.claude/skills/hstack-wrap/SKILL.md +48 -0
- package/template/.claude/skills/hstack-wrap/references/pr-description.md +45 -0
- package/template/KERNEL.md +167 -0
- package/template/templates/adr.md +9 -18
- package/template/templates/app-architecture.md +38 -50
- package/template/templates/data-architecture.md +39 -93
- package/template/templates/infrastructure.md +54 -148
- package/template/templates/roadmap.md +35 -0
- package/template/templates/story.md +7 -13
- package/template/templates/tech-debt.md +2 -37
- package/template/templates/tech-stack.md +2 -12
- package/template/.claude/agents/data-specialist.md +0 -126
- package/template/.claude/agents/implementer.md +0 -154
- package/template/.claude/agents/kernel-fit-analyst.md +0 -190
- package/template/.claude/agents/planner.md +0 -110
- package/template/.claude/agents/product-discovery.md +0 -165
- package/template/.claude/agents/product-manager.md +0 -111
- package/template/.claude/agents/researcher.md +0 -123
- package/template/.claude/agents/security-reviewer.md +0 -123
- package/template/.claude/agents/spec-author.md +0 -123
- package/template/.claude/agents/stack-architect.md +0 -137
- package/template/.claude/agents/ui-ux-briefer.md +0 -105
- package/template/.claude/agents/verifier.md +0 -113
- package/template/.claude/skills/hstack-branch/SKILL.md +0 -103
- package/template/.claude/skills/hstack-brownfield-init/SKILL.md +0 -158
- package/template/.claude/skills/hstack-change-new/SKILL.md +0 -111
- package/template/.claude/skills/hstack-change-plan/SKILL.md +0 -119
- package/template/.claude/skills/hstack-configure/SKILL.md +0 -126
- package/template/.claude/skills/hstack-coord/SKILL.md +0 -131
- package/template/.claude/skills/hstack-data-review/SKILL.md +0 -132
- package/template/.claude/skills/hstack-finalize/SKILL.md +0 -159
- package/template/.claude/skills/hstack-flag/SKILL.md +0 -138
- package/template/.claude/skills/hstack-greenfield-init/SKILL.md +0 -175
- package/template/.claude/skills/hstack-help/SKILL.md +0 -179
- package/template/.claude/skills/hstack-implement/SKILL.md +0 -185
- package/template/.claude/skills/hstack-kernel-fit-promote/SKILL.md +0 -164
- package/template/.claude/skills/hstack-kernel-fit-scan/SKILL.md +0 -197
- package/template/.claude/skills/hstack-kernel-fit-triage/SKILL.md +0 -159
- package/template/.claude/skills/hstack-module-spec/SKILL.md +0 -105
- package/template/.claude/skills/hstack-product-discovery/SKILL.md +0 -101
- package/template/.claude/skills/hstack-research/SKILL.md +0 -145
- package/template/.claude/skills/hstack-scaffold/SKILL.md +0 -140
- package/template/.claude/skills/hstack-security-review/SKILL.md +0 -133
- package/template/.claude/skills/hstack-ship/SKILL.md +0 -129
- package/template/.claude/skills/hstack-stack-decide/SKILL.md +0 -108
- package/template/.claude/skills/hstack-story-draft/SKILL.md +0 -119
- package/template/.claude/skills/hstack-tech-debt-new/SKILL.md +0 -122
- package/template/.claude/skills/hstack-tech-debt-resolve/SKILL.md +0 -158
- package/template/.claude/skills/hstack-tech-debt-stale/SKILL.md +0 -113
- package/template/.claude/skills/hstack-tech-debt-wontfix/SKILL.md +0 -104
- package/template/.claude/skills/hstack-telemetry/SKILL.md +0 -98
- package/template/.claude/skills/hstack-test-plan/SKILL.md +0 -182
- package/template/.claude/skills/hstack-ui-brief/SKILL.md +0 -108
- package/template/.claude/skills/hstack-verify/SKILL.md +0 -150
- package/template/CLAUDE.md +0 -443
- package/template/scripts/coord/coord_scan.py +0 -681
- package/template/scripts/telemetry/__init__.py +0 -6
- package/template/scripts/telemetry/insights/__init__.py +0 -0
- package/template/scripts/telemetry/insights/contract_drift.py +0 -137
- package/template/scripts/telemetry/insights/kernel_fit.py +0 -438
- package/template/scripts/telemetry/insights/overengineering.py +0 -115
- package/template/scripts/telemetry/insights/quality_outcomes.py +0 -131
- package/template/scripts/telemetry/insights/token_economics.py +0 -129
- package/template/scripts/telemetry/insights/workflow_shape.py +0 -198
- package/template/scripts/telemetry/parsers/__init__.py +0 -0
- package/template/scripts/telemetry/parsers/bodies.py +0 -87
- package/template/scripts/telemetry/parsers/commits.py +0 -219
- package/template/scripts/telemetry/parsers/frontmatter.py +0 -322
- package/template/scripts/telemetry/parsers/transcripts.py +0 -181
- package/template/scripts/telemetry/render.py +0 -395
- package/template/scripts/telemetry/report.py +0 -138
- package/template/scripts/telemetry/run_kernel_fit.py +0 -91
- package/template/templates/adversarial-review.md +0 -54
- package/template/templates/bootstrap.md +0 -105
- package/template/templates/change-spec.md +0 -82
- package/template/templates/ci-cd.md +0 -27
- package/template/templates/coord-message.md +0 -55
- package/template/templates/data-review.md +0 -54
- package/template/templates/discovery/brainstorm.md +0 -69
- package/template/templates/discovery/forcing-questions.md +0 -76
- package/template/templates/discovery/project-brief.md +0 -53
- package/template/templates/figma-handoff.md +0 -38
- package/template/templates/glossary.md +0 -20
- package/template/templates/hardening-checklist.md +0 -73
- package/template/templates/incident-runbook.md +0 -57
- package/template/templates/kernel-fit-finding.md +0 -63
- package/template/templates/kernel-fit-flag.md +0 -52
- package/template/templates/module-spec.md +0 -49
- package/template/templates/mvp-scope.md +0 -34
- package/template/templates/persona.md +0 -38
- package/template/templates/plan.md +0 -49
- package/template/templates/product-brief.md +0 -56
- package/template/templates/security-review.md +0 -63
- package/template/templates/telemetry-sidecar.md +0 -184
- package/template/templates/test-plan.md +0 -119
- package/template/templates/threat-model.md +0 -54
- package/template/templates/ui-brief.md +0 -49
- package/template/templates/verification.md +0 -63
- 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
|