hstack 0.7.0 → 0.7.1

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 CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to hstack are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [SemVer](https://semver.org/).
4
4
 
5
+ ## [0.7.1] - 2026-07-25
6
+
7
+ ### Fixed
8
+
9
+ - **Coord scan cost ~6 s per prompt — now ~50 ms when nothing changed (~120× on real repos).** First-day telemetry from `events.jsonl` (190 hook runs across 4 worktrees) showed the hook's branch walk costing median 5.9 s / p90 8.2 s per prompt — the ADR-0006 "sub-second" assumption was off by 10× on branch-heavy repos, and this ran on *every* prompt. This is the "cache keyed on ref state" mitigation ADR-0007 named. `coord_scan.py` now keeps a per-worktree cache at `hstack/.session-state/coord-scan-cache.json` keyed on a fingerprint of every source repo's local refs (plus identity, branch, horizon, and the current date, bounding cache life at one day). Messages only appear via commits and commits only move refs, so an unchanged fingerprint proves the walk would return the same set — the cache changes how fast, never what surfaces. Acks filter after collection and never invalidate; a corrupt or stale cache fails open to the full walk; deleting the file costs one re-walk. `scan`/`hook` telemetry events now carry `"cache": "hit"|"miss"` to keep the improvement measurable.
10
+
5
11
  ## [0.7.0] - 2026-07-24
6
12
 
7
13
  Hook-driven coord notification (ADR-0007): sessions now learn about unread coord-messages automatically instead of waiting for the engineer to hand-carry a "check your messages" nudge between workspaces. Discovery stays pull-over-committed-state per ADR-0006 — the harness merely schedules the scan.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.7.0
1
+ 0.7.1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hstack",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "A spec-driven engineering workflow that ships as Claude Code Skills and subagents.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -35,6 +35,14 @@ addressing). The two local files this script touches are never authoritative:
35
35
  hstack/.session-state/coord-cursor per-worktree acked-id list, shared by all
36
36
  sessions in that worktree (derivative; losing
37
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.
38
46
  hstack/.telemetry/coord/events.jsonl per-worktree usage log (gitignored via the
39
47
  consumer's `**/.telemetry/` line; measurement
40
48
  only, never authoritative, safe to delete)
@@ -46,6 +54,7 @@ No network calls. Reads git only via `git show` / `git ls-tree` /
46
54
  from __future__ import annotations
47
55
 
48
56
  import argparse
57
+ import hashlib
49
58
  import json
50
59
  import os
51
60
  import re
@@ -59,6 +68,7 @@ from pathlib import Path
59
68
  MESSAGES_DIR = "hstack/coord/messages"
60
69
  NAME_RELPATH = "hstack/coord/NAME"
61
70
  CURSOR_RELPATH = "hstack/.session-state/coord-cursor"
71
+ CACHE_RELPATH = "hstack/.session-state/coord-scan-cache.json"
62
72
  TELEMETRY_RELPATH = "hstack/.telemetry/coord/events.jsonl"
63
73
  DEFAULT_HORIZON_DAYS = 30
64
74
  # Cursor entries older than twice the default horizon are pruned on ack.
@@ -279,13 +289,8 @@ def sanitize_ref(text: str, limit: int = 60) -> str:
279
289
  return re.sub(r"[^A-Za-z0-9._/-]", "_", text)[:limit]
280
290
 
281
291
 
282
- def collect_messages(
283
- self_name: str,
284
- self_main: str,
285
- current_branch: str,
286
- horizon_days: int,
287
- ) -> list[dict[str, str]]:
288
- """Return unacked-agnostic candidate messages addressed to this repo."""
292
+ def resolve_sources(self_name: str, self_main: str) -> list[tuple[str, str]]:
293
+ """Scan sources: this repo plus every reachable registered peer."""
289
294
  registry = load_registry()
290
295
  sources: list[tuple[str, str]] = [(self_name, self_main)]
291
296
  self_real = os.path.realpath(self_main)
@@ -299,6 +304,19 @@ def collect_messages(
299
304
  )
300
305
  continue
301
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)
302
320
 
303
321
  horizon = datetime.now() - timedelta(days=horizon_days)
304
322
  seen_ids: set[str] = set()
@@ -370,6 +388,88 @@ def collect_messages(
370
388
  return found
371
389
 
372
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
+
373
473
  def cmd_scan(horizon_days: int) -> int:
374
474
  started = time.monotonic()
375
475
  root = repo_root()
@@ -378,16 +478,16 @@ def cmd_scan(horizon_days: int) -> int:
378
478
  self_name = resolve_self_name(root, self_main, load_registry())
379
479
  acked = load_acked(root)
380
480
 
381
- new = [
382
- m
383
- for m in collect_messages(self_name, self_main, current_branch, horizon_days)
384
- if m["id"] not in acked
385
- ]
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]
386
485
  log_usage(
387
486
  root,
388
487
  "scan",
389
488
  new_count=len(new),
390
489
  duration_ms=int((time.monotonic() - started) * 1000),
490
+ cache="hit" if cache_hit else "miss",
391
491
  )
392
492
  if not new:
393
493
  return 0 # silent — the zero-cost path
@@ -437,11 +537,10 @@ def cmd_hook(horizon_days: int) -> int:
437
537
  current_branch = try_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=root) or "HEAD"
438
538
  self_name = resolve_self_name(root, self_main, load_registry())
439
539
  acked = load_acked(root)
440
- new = [
441
- m
442
- for m in collect_messages(self_name, self_main, current_branch, horizon_days)
443
- if m["id"] not in acked
444
- ]
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]
445
544
  log_usage(
446
545
  root,
447
546
  "hook",
@@ -449,6 +548,7 @@ def cmd_hook(horizon_days: int) -> int:
449
548
  session_id=session_id,
450
549
  new_count=len(new),
451
550
  duration_ms=int((time.monotonic() - started) * 1000),
551
+ cache="hit" if cache_hit else "miss",
452
552
  )
453
553
  if new:
454
554
  print(
@@ -469,11 +569,10 @@ def cmd_ack(ids: list[str], ack_all: bool, horizon_days: int) -> int:
469
569
  self_main = main_worktree(root)
470
570
  current_branch = try_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=root) or "HEAD"
471
571
  self_name = resolve_self_name(root, self_main, load_registry())
472
- ids = [
473
- m["id"]
474
- for m in collect_messages(self_name, self_main, current_branch, horizon_days)
475
- if m["id"] not in acked
476
- ]
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]
477
576
  if not ids:
478
577
  print("hstack-coord: nothing to ack")
479
578
  return 0