hstack 0.7.0 → 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 (99) hide show
  1. package/CHANGELOG.md +277 -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/coord/coord_scan.py +121 -22
  75. package/template/scripts/run-gates.sh +388 -0
  76. package/template/scripts/telemetry/insights/kernel_fit.py +1 -1
  77. package/template/scripts/telemetry/insights/token_economics.py +181 -8
  78. package/template/scripts/telemetry/parsers/sidecars.py +61 -0
  79. package/template/scripts/telemetry/parsers/transcripts.py +135 -22
  80. package/template/scripts/telemetry/render.py +68 -3
  81. package/template/scripts/telemetry/report.py +16 -4
  82. package/template/scripts/telemetry/run_kernel_fit.py +6 -2
  83. package/template/scripts/telemetry/session_id.py +139 -0
  84. package/template/scripts/validate-spec.mjs +3303 -0
  85. package/template/templates/adr.md +7 -0
  86. package/template/templates/adversarial-review.md +5 -5
  87. package/template/templates/ci-cd.md +14 -0
  88. package/template/templates/coord-message.md +3 -2
  89. package/template/templates/data-architecture.md +3 -6
  90. package/template/templates/kernel-fit-finding.md +2 -2
  91. package/template/templates/kernel-fit-flag.md +2 -2
  92. package/template/templates/plan.md +4 -0
  93. package/template/templates/product-brief.md +2 -2
  94. package/template/templates/roadmap.md +41 -0
  95. package/template/templates/security-review.md +1 -1
  96. package/template/templates/telemetry-sidecar.md +56 -13
  97. package/template/templates/test-plan.md +1 -1
  98. package/template/CLAUDE.md +0 -443
  99. package/template/templates/mvp-scope.md +0 -34
@@ -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
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # hstack gate runner — the verifier's machine hands, and the thing
4
+ # {{TODO-SCRIPT: hstack/scripts/run-gates.sh}} stood in for.
5
+ #
6
+ # hstack/scripts/run-gates.sh --change <change-id>
7
+ # hstack/scripts/run-gates.sh --change <id> --suite unit --suite lint
8
+ # hstack/scripts/run-gates.sh --list
9
+ # hstack/scripts/run-gates.sh --change <id> --json
10
+ #
11
+ # It reads the canonical commands declared in hstack/context/ci-cd.md, runs
12
+ # every one of them, captures combined stdout/stderr to the pointer file the
13
+ # verification artifact references, and emits an observed-test-count PER SUITE
14
+ # so V-05 ("a suite that executed zero tests cannot be recorded as pass") is a
15
+ # measurement rather than a paragraph of parsing instructions in a prompt.
16
+ #
17
+ # Exit codes:
18
+ # 0 every suite ran, exited 0, and every test suite observed > 0 tests
19
+ # 1 a suite failed, or a test suite observed zero tests (V-05)
20
+ # 2 usage / environment error (no ci-cd.md, no canonical-commands block)
21
+ #
22
+ # Dependency-free by construction: POSIX tools only, no jq, no node. The
23
+ # consuming repo has no node_modules for hstack — same constraint that made
24
+ # validate-spec.mjs plain ESM (ADR-0001).
25
+
26
+ set -uo pipefail
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # 1. Argument parsing
30
+ # ---------------------------------------------------------------------------
31
+
32
+ CHANGE_ID=""
33
+ OUT=""
34
+ ROOT=""
35
+ JSON=0
36
+ LIST=0
37
+ SUITES_REQUESTED=""
38
+
39
+ usage() {
40
+ cat <<'EOF'
41
+ hstack run-gates — run the canonical test / lint / typecheck suites
42
+
43
+ hstack/scripts/run-gates.sh [options]
44
+
45
+ Options
46
+ --change ID change-spec id; the pointer file defaults to
47
+ hstack/specs/changes/<ID>/test-output.txt
48
+ --out PATH pointer file path (overrides --change)
49
+ --suite NAME run only this suite; repeatable
50
+ --list print the parsed canonical commands and exit
51
+ --json emit the per-suite summary as JSON on stdout
52
+ --root DIR repo root to resolve hstack/ from (default: search upward)
53
+ -h, --help this text
54
+
55
+ Exit codes: 0 all green with a non-zero test count per test suite,
56
+ 1 a suite failed or observed zero tests (V-05), 2 usage / environment error.
57
+ EOF
58
+ }
59
+
60
+ while [ $# -gt 0 ]; do
61
+ case "$1" in
62
+ --change) CHANGE_ID="${2:-}"; shift 2 ;;
63
+ --out) OUT="${2:-}"; shift 2 ;;
64
+ --suite) SUITES_REQUESTED="$SUITES_REQUESTED ${2:-}"; shift 2 ;;
65
+ --root) ROOT="${2:-}"; shift 2 ;;
66
+ --json) JSON=1; shift ;;
67
+ --list) LIST=1; shift ;;
68
+ -h|--help) usage; exit 0 ;;
69
+ *) echo "run-gates: unknown option $1" >&2; usage >&2; exit 2 ;;
70
+ esac
71
+ done
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # 2. Locate the hstack tree
75
+ # ---------------------------------------------------------------------------
76
+
77
+ find_hstack_root() {
78
+ dir="${1:-$PWD}"
79
+ dir=$(cd "$dir" 2>/dev/null && pwd) || return 1
80
+ while :; do
81
+ if [ -f "$dir/hstack/KERNEL.md" ] || [ -f "$dir/hstack/CLAUDE.md" ] || [ -f "$dir/hstack/config.yaml" ]; then
82
+ printf '%s\n' "$dir"
83
+ return 0
84
+ fi
85
+ parent=$(dirname "$dir")
86
+ [ "$parent" = "$dir" ] && return 1
87
+ dir="$parent"
88
+ done
89
+ }
90
+
91
+ REPO_ROOT=$(find_hstack_root "${ROOT:-$PWD}") || {
92
+ echo "run-gates: no hstack/ tree found (looked for hstack/KERNEL.md upward from ${ROOT:-$PWD}). Pass --root <repo>." >&2
93
+ exit 2
94
+ }
95
+ HSTACK="$REPO_ROOT/hstack"
96
+ CI_CD="$HSTACK/context/ci-cd.md"
97
+
98
+ [ -f "$CI_CD" ] || {
99
+ echo "run-gates: $CI_CD not found. The canonical commands live there; run \`/hstack:configure --interview ci-cd\` first." >&2
100
+ exit 2
101
+ }
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # 3. Parse the canonical commands
105
+ # ---------------------------------------------------------------------------
106
+ #
107
+ # ci-cd.md declares them in a fenced block with the info string `hstack-gates`,
108
+ # one `suite: command` pair per line. The fence is the contract: everything
109
+ # else in ci-cd.md is prose written for humans, and a runner that guessed at
110
+ # prose would produce a confident wrong answer about what the repo's tests are.
111
+
112
+ CANONICAL=$(awk '
113
+ /^```[[:space:]]*hstack-gates[[:space:]]*$/ { inblock=1; next }
114
+ inblock && /^```/ { inblock=0; next }
115
+ inblock {
116
+ line=$0
117
+ sub(/#.*$/, "", line) # trailing comment
118
+ if (line ~ /^[[:space:]]*$/) next
119
+ idx = index(line, ":")
120
+ if (idx == 0) next
121
+ key = substr(line, 1, idx-1)
122
+ val = substr(line, idx+1)
123
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", key)
124
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", val)
125
+ if (val == "" || val == "none" || val == "null") next # declared absent
126
+ printf "%s\t%s\n", key, val
127
+ }
128
+ ' "$CI_CD")
129
+
130
+ if [ -z "$CANONICAL" ]; then
131
+ echo "run-gates: no \`hstack-gates\` fenced block in hstack/context/ci-cd.md." >&2
132
+ echo " Declare the canonical commands there — see hstack/templates/ci-cd.md § Canonical Commands." >&2
133
+ exit 2
134
+ fi
135
+
136
+ # Suites that are evidence of behaviour, and therefore subject to V-05. Lint
137
+ # and typecheck are exempt: both produce a diagnostic count whose floor is
138
+ # naturally zero on a clean repo, so zero is not a signal of a skipped run.
139
+ is_test_suite() {
140
+ case "$1" in
141
+ unit|integration|e2e) return 0 ;;
142
+ *) return 1 ;;
143
+ esac
144
+ }
145
+
146
+ wanted() {
147
+ [ -z "$SUITES_REQUESTED" ] && return 0
148
+ for s in $SUITES_REQUESTED; do [ "$s" = "$1" ] && return 0; done
149
+ return 1
150
+ }
151
+
152
+ if [ "$LIST" -eq 1 ]; then
153
+ echo "hstack run-gates — canonical commands from hstack/context/ci-cd.md"
154
+ echo ""
155
+ printf '%s\n' "$CANONICAL" | while IFS="$(printf '\t')" read -r suite cmd; do
156
+ printf ' %-12s %s\n' "$suite" "$cmd"
157
+ done
158
+ exit 0
159
+ fi
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # 4. Pointer file
163
+ # ---------------------------------------------------------------------------
164
+
165
+ if [ -z "$OUT" ]; then
166
+ if [ -n "$CHANGE_ID" ]; then
167
+ OUT="$HSTACK/specs/changes/$CHANGE_ID/test-output.txt"
168
+ [ -d "$HSTACK/specs/changes/$CHANGE_ID" ] || {
169
+ echo "run-gates: no change folder at hstack/specs/changes/$CHANGE_ID/" >&2
170
+ exit 2
171
+ }
172
+ else
173
+ OUT="$REPO_ROOT/hstack-gates-output.txt"
174
+ fi
175
+ fi
176
+ mkdir -p "$(dirname "$OUT")" || exit 2
177
+ : > "$OUT" || { echo "run-gates: cannot write $OUT" >&2; exit 2; }
178
+
179
+ {
180
+ echo "hstack run-gates"
181
+ echo "repo: $REPO_ROOT"
182
+ echo "source: hstack/context/ci-cd.md"
183
+ echo "====================================================================="
184
+ } >> "$OUT"
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # 5. Observed-test-count extraction
188
+ # ---------------------------------------------------------------------------
189
+ #
190
+ # One awk pass per suite over that suite's captured output. The runners hstack
191
+ # meets in practice all print a summary line; the point is not to understand
192
+ # every runner, it is to answer one question honestly: did this suite execute
193
+ # anything? When no known pattern matches, the answer is "unknown" — and
194
+ # unknown is treated as zero, because a count nobody could read is not evidence.
195
+ #
196
+ # "Executed" is passed + failed, NOT total. A run that collected fifteen tests
197
+ # and skipped all fifteen executed nothing, and V-05 exists precisely for that
198
+ # case: `Tests: 15 skipped, 15 total` is a non-zero total with zero assertions.
199
+ #
200
+ # Jest / Vitest Tests: 12 passed, 3 skipped, 15 total | No tests found
201
+ # Mocha 12 passing / 3 pending / 1 failing
202
+ # Playwright 12 passed (4.2s) / 1 failed / 3 skipped
203
+ # pytest collected 15 items | 12 passed, 3 skipped | no tests ran
204
+ # go test ok pkg 0.4s | testing: warning: no tests to run
205
+
206
+ count_tests() {
207
+ # $1 = file holding this suite's output
208
+ awk '
209
+ function num(s) { return s + 0 }
210
+ # --- explicit zero-collection statements, strongest signal ----------------
211
+ /[Nn]o tests found/ { zero=1 }
212
+ /collected 0 items/ { zero=1; seen=1 }
213
+ /no tests ran/ { zero=1; seen=1 }
214
+ /no tests to run/ { zero=1 }
215
+ /^[[:space:]]*Test Files[[:space:]]+no tests/ { zero=1 }
216
+
217
+ # --- Jest / Vitest summary ----------------------------------------------
218
+ /^[[:space:]]*Tests:?[[:space:]]/ {
219
+ seen=1
220
+ line=$0
221
+ if (match(line, /[0-9]+ passed/)) { s=substr(line, RSTART, RLENGTH); passed=num(s) }
222
+ if (match(line, /[0-9]+ failed/)) { s=substr(line, RSTART, RLENGTH); failed=num(s) }
223
+ if (match(line, /[0-9]+ skipped/)) { s=substr(line, RSTART, RLENGTH); skipped=num(s) }
224
+ if (match(line, /[0-9]+ todo/)) { s=substr(line, RSTART, RLENGTH); skipped+=num(s) }
225
+ if (match(line, /[0-9]+ total/)) { s=substr(line, RSTART, RLENGTH); total=num(s) }
226
+ }
227
+
228
+ # --- pytest short summary ------------------------------------------------
229
+ /=+ .*(passed|failed|error|skipped).* =+/ {
230
+ seen=1
231
+ line=$0
232
+ if (match(line, /[0-9]+ passed/)) { s=substr(line, RSTART, RLENGTH); passed=num(s) }
233
+ if (match(line, /[0-9]+ failed/)) { s=substr(line, RSTART, RLENGTH); failed=num(s) }
234
+ if (match(line, /[0-9]+ error/)) { s=substr(line, RSTART, RLENGTH); failed+=num(s) }
235
+ if (match(line, /[0-9]+ skipped/)) { s=substr(line, RSTART, RLENGTH); skipped=num(s) }
236
+ }
237
+ /collected [0-9]+ item/ {
238
+ seen=1
239
+ if (match($0, /collected [0-9]+/)) { s=substr($0, RSTART+10, RLENGTH-10); collected=num(s) }
240
+ }
241
+
242
+ # --- Mocha ---------------------------------------------------------------
243
+ /^[[:space:]]*[0-9]+ passing/ { seen=1; if (match($0, /[0-9]+/)) { passed=num(substr($0, RSTART, RLENGTH)) } }
244
+ /^[[:space:]]*[0-9]+ pending/ { seen=1; if (match($0, /[0-9]+/)) { skipped=num(substr($0, RSTART, RLENGTH)) } }
245
+ /^[[:space:]]*[0-9]+ failing/ { seen=1; if (match($0, /[0-9]+/)) { failed=num(substr($0, RSTART, RLENGTH)) } }
246
+
247
+ # --- Playwright ("12 passed (4.2s)") and bare runner tallies -------------
248
+ # No \b here: POSIX awk reads it as a backspace, not a word boundary.
249
+ /^[[:space:]]*[0-9]+ (passed|failed|skipped|flaky)([^a-z]|$)/ {
250
+ seen=1
251
+ line=$0
252
+ if (match(line, /[0-9]+ passed/)) { s=substr(line, RSTART, RLENGTH); passed=num(s) }
253
+ if (match(line, /[0-9]+ failed/)) { s=substr(line, RSTART, RLENGTH); failed=num(s) }
254
+ if (match(line, /[0-9]+ skipped/)) { s=substr(line, RSTART, RLENGTH); skipped=num(s) }
255
+ if (match(line, /[0-9]+ flaky/)) { s=substr(line, RSTART, RLENGTH); passed+=num(s) }
256
+ }
257
+
258
+ END {
259
+ if (total == 0) total = passed + failed + skipped
260
+ if (total == 0 && collected > 0) { total = collected }
261
+ if (zero) { total = 0; passed = 0; failed = 0 }
262
+ executed = passed + failed
263
+ # `known` says whether any pattern matched at all. An unreadable summary
264
+ # is reported as unknown and treated as zero downstream — a count nobody
265
+ # could read is not evidence that tests ran.
266
+ known = (seen || zero) ? 1 : 0
267
+ printf "%d %d %d %d %d %d\n", passed+0, failed+0, skipped+0, total+0, executed+0, known
268
+ }
269
+ ' "$1"
270
+ }
271
+
272
+ # ---------------------------------------------------------------------------
273
+ # 6. Run
274
+ # ---------------------------------------------------------------------------
275
+
276
+ TMPDIR_RUN=$(mktemp -d "${TMPDIR:-/tmp}/hstack-run-gates.XXXXXX") || exit 2
277
+ trap 'rm -rf "$TMPDIR_RUN"' EXIT
278
+
279
+ SUMMARY="$TMPDIR_RUN/summary"
280
+ : > "$SUMMARY"
281
+ OVERALL=0
282
+ RAN_ANY=0
283
+
284
+ while IFS="$(printf '\t')" read -r suite cmd; do
285
+ [ -n "$suite" ] || continue
286
+ wanted "$suite" || continue
287
+ RAN_ANY=1
288
+
289
+ suite_out="$TMPDIR_RUN/$suite.out"
290
+ {
291
+ echo ""
292
+ echo "--- suite: $suite ------------------------------------------------"
293
+ echo "\$ $cmd"
294
+ } >> "$OUT"
295
+
296
+ # stdin from /dev/null, not inherited: the loop below is fed by a heredoc of
297
+ # the canonical commands, and a suite that reads stdin (an interactive watch
298
+ # mode, a prompt) would otherwise eat the remaining suites.
299
+ ( cd "$REPO_ROOT" && eval "$cmd" ) > "$suite_out" 2>&1 < /dev/null
300
+ code=$?
301
+ cat "$suite_out" >> "$OUT"
302
+
303
+ if is_test_suite "$suite"; then
304
+ read -r passed failed skipped total executed known <<EOF
305
+ $(count_tests "$suite_out")
306
+ EOF
307
+ else
308
+ passed=0; failed=0; skipped=0; total=0; executed=0; known=1
309
+ fi
310
+
311
+ # V-05: zero executed tests is `not-run`, never `pass`. "Zero failures" is
312
+ # not evidence of correctness when there were zero assertions to fail.
313
+ if [ "$code" -ne 0 ]; then
314
+ verdict="fail"
315
+ reason="command exited $code"
316
+ OVERALL=1
317
+ elif is_test_suite "$suite" && [ "$executed" -eq 0 ]; then
318
+ verdict="not-run"
319
+ if [ "$known" -eq 0 ]; then
320
+ reason="no test count could be read from the runner's output (unrecognised summary format)"
321
+ elif [ "$skipped" -gt 0 ]; then
322
+ reason="the runner reported zero executed tests — $skipped skipped of $total collected (all-skipped, or a filter that collapsed the set)"
323
+ else
324
+ reason="the runner reported zero executed tests (env-gated, empty-collection, or filter-collapse)"
325
+ fi
326
+ OVERALL=1
327
+ else
328
+ verdict="pass"
329
+ reason=""
330
+ fi
331
+
332
+ printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
333
+ "$suite" "$cmd" "$code" "$verdict" "$passed" "$failed" "$skipped" "$total" "$executed" "$reason" >> "$SUMMARY"
334
+
335
+ {
336
+ echo "--- suite: $suite → $verdict (exit $code, $executed of $total test(s) executed)"
337
+ } >> "$OUT"
338
+ done <<EOF
339
+ $CANONICAL
340
+ EOF
341
+
342
+ if [ "$RAN_ANY" -eq 0 ]; then
343
+ echo "run-gates: no suite matched --suite${SUITES_REQUESTED}" >&2
344
+ exit 2
345
+ fi
346
+
347
+ # ---------------------------------------------------------------------------
348
+ # 7. Report
349
+ # ---------------------------------------------------------------------------
350
+
351
+ REL_OUT="${OUT#"$REPO_ROOT"/}"
352
+
353
+ if [ "$JSON" -eq 1 ]; then
354
+ printf '{\n'
355
+ printf ' "ok": %s,\n' "$([ "$OVERALL" -eq 0 ] && echo true || echo false)"
356
+ printf ' "test-output": "%s",\n' "$REL_OUT"
357
+ printf ' "suites": [\n'
358
+ first=1
359
+ while IFS="$(printf '\t')" read -r suite cmd code verdict passed failed skipped total executed reason; do
360
+ [ "$first" -eq 1 ] || printf ',\n'
361
+ first=0
362
+ esc_cmd=$(printf '%s' "$cmd" | sed 's/\\/\\\\/g; s/"/\\"/g')
363
+ esc_reason=$(printf '%s' "$reason" | sed 's/\\/\\\\/g; s/"/\\"/g')
364
+ printf ' {"suite": "%s", "command": "%s", "exit": %s, "verdict": "%s", "observed": {"passed": %s, "failed": %s, "skipped": %s, "total": %s, "executed": %s}, "reason": "%s"}' \
365
+ "$suite" "$esc_cmd" "$code" "$verdict" "$passed" "$failed" "$skipped" "$total" "$executed" "$esc_reason"
366
+ done < "$SUMMARY"
367
+ printf '\n ]\n}\n'
368
+ else
369
+ echo ""
370
+ echo "hstack run-gates — $REPO_ROOT"
371
+ echo ""
372
+ printf ' %-12s %-9s %-6s %s\n' "suite" "verdict" "exit" "observed (passed/failed/skipped/total)"
373
+ while IFS="$(printf '\t')" read -r suite cmd code verdict passed failed skipped total executed reason; do
374
+ printf ' %-12s %-9s %-6s %s/%s/%s/%s\n' "$suite" "$verdict" "$code" "$passed" "$failed" "$skipped" "$total"
375
+ [ -n "$reason" ] && printf ' %s\n' "$reason"
376
+ done < "$SUMMARY"
377
+ echo ""
378
+ echo " captured output: $REL_OUT"
379
+ echo " → verification.artifacts.test-output: $REL_OUT"
380
+ echo ""
381
+ if [ "$OVERALL" -eq 0 ]; then
382
+ echo "run-gates: all suites green with a non-zero executed-test count."
383
+ else
384
+ echo "run-gates: at least one suite failed or executed zero tests (V-05). status: passed is blocked."
385
+ fi
386
+ fi
387
+
388
+ exit "$OVERALL"
@@ -4,7 +4,7 @@ This module is the detection layer of the kernel-fit closed-loop system. It
4
4
  pattern-matches across shipped artifacts and emits evidence rows; an LLM
5
5
  subagent (`kernel-fit-analyst`) then synthesizes findings from these rows.
6
6
 
7
- See ADR-0004 for the full design rationale and `template/CLAUDE.md` § How
7
+ See ADR-0004 for the full design rationale and `template/KERNEL.md` § How
8
8
  hstack improves itself for the loop contract.
9
9
 
10
10
  Three starter patterns: