Repository navigation
Expand file tree
/
Copy pathMakefile
More file actions
1712 lines (1615 loc) · 96.3 KB
/
Copy pathMakefile
File metadata and controls
1712 lines (1615 loc) · 96.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Harmonik Makefile
# Two gate targets and some helpers: `make fast` while you work, `make full`
# before anyone accepts the work. There is no third tier. Local and CI run the
# same two targets verbatim.
.DEFAULT_GOAL := fast
# Tool bin dir — keeps dev tools out of the global GOPATH.
#
# Resolved against the MAIN working tree rather than $(PWD). `.tools` holds
# built binaries and is gitignored, so a git worktree receives none of it, and
# every target that reached a tool there died with a bare ENOENT that never said
# "you are in a worktree and the tools live elsewhere". That covers fmt,
# fmt-check, lint and all three check tiers. Agents are routinely run in their
# own worktrees, so each one met this and improvised privately — and an agent
# that improvised by skipping the format step produced a commit that died at the
# fail-closed format gate much later, with no clue why.
#
# --git-common-dir returns the SHARED .git from inside a worktree as well as
# from the main checkout, so its parent is the main working tree either way.
# Falls back to $(PWD) outside a repository.
TOOLS_HOME := $(shell git rev-parse --path-format=absolute --git-common-dir 2>/dev/null | sed 's|/\.git/*$$||')
TOOLS_DIR := $(if $(TOOLS_HOME),$(TOOLS_HOME),$(PWD))/.tools
GOBIN_TOOLS := GOBIN=$(TOOLS_DIR)
# Module path (matches go.mod).
MODULE := github.com/gregberns/harmonik
# Twin-binary output directory (SH-009 in-tree default: <repo-root>/twins/).
TWINS_DIR := $(PWD)/twins
# Wall-clock budget for the agent-review pre-commit target (hk-pvcs.10).
# The pre-commit Tier 1 budget is <15s total; agent-review is an LLM call and
# gets its own hard cap. Override via: make agent-review AGENT_REVIEW_TIMEOUT=120
AGENT_REVIEW_TIMEOUT ?= 60
# `timeout` is GNU coreutils and absent from stock macOS; `gtimeout` is what
# `brew install coreutils` provides instead. Resolve whichever exists so
# agent-review doesn't hard-fail with "command not found" on a bare Mac
# (hk-x2spu: exposed once lefthook itself was made resolvable and pre-commit
# stopped silently no-op'ing). Empty means neither is installed — run
# unwrapped rather than block every commit on a missing dev dependency.
TIMEOUT_BIN := $(shell command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null)
# Commit hash stamped into twin binaries at build time (HC-043).
# Uses the shell form so the value is resolved at recipe execution time, not
# at Makefile parse time, which correctly reflects uncommitted state during
# incremental development.
COMMIT_HASH := $(shell git rev-parse HEAD)
# ---------------------------------------------------------------------------
# Core build / test
# ---------------------------------------------------------------------------
# build-harmonik: compile cmd/harmonik with the commit-hash ldflags stamp.
# The -X flag injects the current HEAD SHA into main.commitHash so that the
# daemon_started event payload carries a real git hash (hk-mz0x4).
# Output: /tmp/harmonik (matches the canonical smoke-test path).
.PHONY: build-harmonik
build-harmonik: ## Build cmd/harmonik → /tmp/harmonik with commit-hash stamp (hk-mz0x4)
go build -ldflags "-X main.commitHash=$(COMMIT_HASH)" -o /tmp/harmonik ./cmd/harmonik
# install-harmonik: install cmd/harmonik to $GOPATH/bin with the commit-hash ldflags
# stamp so daemon_started.binary_commit_hash is a real SHA (not "unknown").
# Use this instead of plain `go install ./cmd/harmonik` which omits the stamp.
# Bead ref: hk-mptxw (F8).
.PHONY: install-harmonik
install-harmonik: ## Install cmd/harmonik with commit-hash stamp; use instead of plain go install (hk-mptxw)
go install -ldflags "-X main.commitHash=$(COMMIT_HASH)" ./cmd/harmonik
.PHONY: build
build: build-harmonik ## go build ./... + cmd/harmonik stamped binary (hk-mz0x4)
go build ./...
.PHONY: test
test: ## go test ./... (no race; quick smoke)
go test ./...
# leakcheck: report processes this user abandoned — orphaned to init and of a
# shape that never legitimately outlives its parent (a bare shell, a Go test
# binary, or any non-system orphan burning CPU). Reports only, never kills.
#
# Run it after any test run that spawned helpers, and after any script that
# backgrounds a child. A test-support script once leaked 30 spin loops that ran
# 13 hours at ~551% CPU with nothing in the repo able to report it.
#
# Deliberately NOT wired into `test` or `check`. It observes the whole machine,
# not the build, so a failure would be unrelated to the code under test and
# would train people to ignore it. Keep it a thing you run, or a shell hook.
.PHONY: leakcheck
leakcheck: ## Report abandoned processes (orphaned shells, test binaries, busy orphans)
@./scripts/leakcheck.sh
# leakreap: kill the orphaned shells and test binaries leakcheck found. Only that
# one shape — a busy orphan of an unmodelled shape, or a live parent with a big
# child pool, is still reported and left alone for you to read.
.PHONY: leakreap
leakreap: ## Kill orphaned shells and test binaries (leakcheck rule 1 only)
@./scripts/leakcheck.sh --kill
# smoke-scratch: run harmonik smoke in a throw-away temp project so real-daemon
# validation never commits scratch files to the main trunk (logmine F17 / hk-nk9pu).
# Prereq: harmonik binary is built from source (this target builds it internally).
# Env overrides: HARMONIK_BIN, SMOKE_TIMEOUT, SKIP_BUILD, KEEP_DIR.
.PHONY: smoke-scratch
smoke-scratch: ## Run harmonik smoke in a throw-away temp project (never touches main trunk; hk-nk9pu)
scripts/smoke-scratch.sh
# test-e2e-real-claude: run the real-Claude single-mode E2E smoke test.
# Requires: claude, tmux, git, br, ntm on PATH; ANTHROPIC_API_KEY or
# CLAUDE_CODE_OAUTH_TOKEN set; harmonik buildable from source.
# Budget: 300s timeout (the agent interaction may take up to 180s).
.PHONY: test-e2e-real-claude
test-e2e-real-claude: ## Run real-Claude E2E smoke (requires credentials + binaries on PATH)
scripts/go-test-must-match.sh go test -tags e2e_real_claude -timeout 300s -v -run TestE2ERealClaudeSingleMode ./internal/daemon/...
# test-scenario: run the scenario tier with -race and the scenario build tag.
# Prereq: build-all compiles cmd/harmonik and the twins that daemon scenarios
# locate without a rebuild.
# Budget: 10 minutes, matching the scenario sub-run in `make full`.
#
# THE PACKAGE LIST IS DERIVED, by scripts/scenario-pkgs.sh, which asks `go list`
# what the scenario tag turns on rather than grepping for the tag line — read
# that script's header for why both greps that were tried are wrong. It used to
# be `./test/scenario/...
# ./internal/daemon/...` written by hand, under a comment claiming that "covers
# all packages that carry //go:build scenario files". That claim was false and
# had been for a long time. Scenario files had spread to four more packages, and
# 11 test functions there were compiled by NO target and run by NO target:
# cmd/harmonik 4 (init, decisions list, decisions gate, keeper enable)
# internal/runloop 4 (TestScenarioGateEfficacy_* — whether the scenario gate
# can tell a compile failure from a genuine red, which is
# the difference between a gate and a fail-open)
# internal/sentinel 2 (adversary fresh-context and at-most-once-per-window)
# internal/keeper 1 (orphaned-decision reap)
# `make vet-tagged` TYPECHECKS them, which is why they still compiled, but
# typechecking runs nothing, so they were free to rot — and one had.
# TestScenario_KeeperEnableOn058_HKF5Z was red: its fixture wrote three keeper
# scripts after runKeeperEnable started requiring four. The untagged sibling
# test was updated at the time. This one was not, because nothing ran it.
# The 11 tagged functions themselves cost under 8 seconds together. Refs hk-od9d4.
#
# WHAT THE WIDENING COSTS. `go test -tags=scenario ./cmd/harmonik` runs the WHOLE
# package, not only its tagged files, so four more packages means every ordinary
# test in them under -race too — about 950 more test functions. In isolation they
# cost 140 seconds, cmd/harmonik being 138 of it.
#
# In the tier they cost almost nothing, because `go test` runs packages
# concurrently and internal/daemon is 453 seconds on its own. Measured
# 2026-08-07, same box, same flags, back to back:
#
# old list (test/scenario + internal/daemon) 464s, 18 failures
# new list (six packages) 471s, 17 failures
#
# So seven seconds. Note the failure counts: THIS TIER IS ALREADY RED, and was
# before this change — see hk-97gcz and hk-ynohn. The 13 tests that fail in both
# runs are the same 13. Widening added exactly one new red,
# TestWatcher_WarnCooldown_SuppressesImmediateRefire, which is a pre-existing
# keeper flake that fails standalone under plain `-short` on an untouched
# checkout, now hk-keeper-warn-cooldown-clock-bet-c5umc. The rest of the
# difference is this tier's own run-to-run churn.
#
# Deriving the list rather than reaching for `./...` still matters. `./...` would
# add every remaining package in the tree on the same terms, and `make full` has
# already run all of them under -short in the step before this one.
#
# See docs/methodology/TESTING.md §Scenario fixture determinism recipe for the
# worktree-factory / merge-mutex / phase-aware-twin / Skip* recipe used here.
.PHONY: test-scenario
test-scenario: build-all ## Run scenario tier (-race, -tags=scenario, 10m budget; prereq: build-all)
@scenario_pkgs=$$(scripts/scenario-pkgs.sh) || exit 1; \
echo "test-scenario: packages carrying //go:build scenario files:"; \
echo "$$scenario_pkgs" | sed 's/^/ /'; \
scenario_log=$$(mktemp); \
status=0; \
go test -v -race -tags=scenario -timeout 10m $$scenario_pkgs >"$$scenario_log" 2>&1 || status=$$?; \
cat "$$scenario_log"; \
awk '/^--- SKIP:/ {n++} END {printf "scenario skips: %d\n", n+0}' "$$scenario_log"; \
rm -f "$$scenario_log"; \
exit $$status
# test-subprocess: WS2.4 non-docker subprocess daemon-boot smoke. Execs the real
# built harmonik binary as a separate process, waits for the daemon unix socket,
# submits one bead via the CLI, and asserts a terminal run outcome. Billing-free
# (dispatch routed to the generic twin via the codexdriver substrate; no real
# agent, no tmux, no network). Dedicated `subprocess` build tag isolates it from
# the default build/test (it is NOT part of -tags=scenario). Budget: 5 minutes.
# Cite: plans/2026-07-13-code-revamp/M6-PLAN.md §WS2.4.
.PHONY: test-subprocess
test-subprocess: ## Run WS2.4 non-docker subprocess boot smoke (-tags=subprocess; needs go+br+git on PATH)
scripts/go-test-must-match.sh go test -tags=subprocess -timeout 5m -count=1 ./cmd/harmonik -run TestSubprocessDaemonBootSmoke
# core-loop-lt: WS4-5 FORCED, single-entry LT-leg command. THE assessor's live-verify
# gate — drives the real task-processing loop on a scratch daemon across the core-loop
# matrix and returns non-zero unless EVERY cell is green (any red OR pending OR skip fails,
# the T9 zero-PENDING gate), so a partial matrix can never be mistaken for a pass. Emits a
# machine-readable per-cell grid (marker `MATRIX_JSON …` on the last stdout line) that the
# assessor folds into its LT verdict. Forced-LOCAL: real pi/codex (+claude, WS4-4) agents,
# never a CI required check (see docs/methodology/TESTING.md "Gate tiers & risk-tiering").
# Override the scratch dir with LT_SCRATCH=… . Cite: M6-PLAN §WS4-5.
#
# hk-xy9ym: this target must be SELF-SERVING for the CHECKED-OUT (pinned) code. It:
# 1. wipes + inits LT_SCRATCH from the LOCAL checkout ($(CURDIR)) at this checkout's
# exact HEAD, passed to `init --rev`. hk-xy9ym originally got this by wiping the
# directory first and cloning the local path, which worked only because of the wipe:
# `init` had no revision argument at all. hk-scratch-daemon-audits-wrong-tree-zljvm
# made the revision REQUIRED and made `init` force and verify the checkout, so the
# guarantee now comes from naming the commit rather than from the `rm -rf` above it.
# 2. seeds the fixture beads into the fresh scratch DB + writes the cell->bead_id map, so
# the scoped cell actually LAUNCHES (bare/un-seeded => every cell PENDING, no agent).
# 3. scopes to pi:local — the one leg that proves the full core-loop contract e2e with a
# real agent (single-mode dispatch -> real pi/ornith change -> per-bead-branch landing).
# codex (operator runs codex-minimal), claude (WS4-4, --enable-claude), remote (needs a
# reachable tcp:// worker) and the pi-dot round-trip (stronger convergence model,
# EXTRA_CELLS) stay behind their existing opt-in knobs, so a bare run never red/skip/
# pends on an intentionally-absent leg. Widen with LT_HARNESSES/LT_SUBSTRATES.
LT_SCRATCH ?= /tmp/h/core-loop-lt
LT_SPECS ?= scenarios/core-loop-proof/cells.json
# Per-cell seed map (cell<TAB>bead_id) that core-loop-seed.sh writes and core-loop-matrix.sh
# consumes via MATRIX_SEED_MAP. Lives under the (wiped-each-run) scratch so it is always
# regenerated against THIS run's freshly-created seed beads.
LT_SEED_MAP ?= $(LT_SCRATCH)/.harmonik/matrix-seed-map.tsv
LT_HARNESSES ?= pi
LT_SUBSTRATES ?= local
.PHONY: core-loop-lt
core-loop-lt: build-all ## WS4-5 forced LT gate: inits scratch from LOCAL checkout + seeds pi:local, non-zero on any non-green cell + JSON grid (hk-xy9ym)
@# hk-xy9ym: guard the `rm -rf` below — never wipe an empty/root/repo-root path.
@test -n "$(LT_SCRATCH)" || { echo "core-loop-lt: LT_SCRATCH is empty — refusing"; exit 1; }
@case "$(LT_SCRATCH)" in /|.|..) echo "core-loop-lt: unsafe LT_SCRATCH='$(LT_SCRATCH)' — refusing"; exit 1;; esac
@# Resolve symlinks on BOTH sides and refuse if LT_SCRATCH is the repo root, $$HOME,
@# /, or an ANCESTOR of the repo (wiping it would destroy the fleet checkout). Only
@# checked when the dir already exists — a not-yet-created scratch is a safe no-op wipe.
@if [ -d "$(LT_SCRATCH)" ]; then \
lt="$$(cd "$(LT_SCRATCH)" && pwd -P)"; \
repo="$$(cd "$(CURDIR)" && pwd -P)"; \
home="$$(cd "$$HOME" 2>/dev/null && pwd -P || echo /nonexistent-home)"; \
if [ "$$lt" = "$$repo" ] || [ "$$lt" = "$$home" ] || [ "$$lt" = "/" ]; then \
echo "core-loop-lt: LT_SCRATCH resolves to '$$lt' (repo root, \$$HOME, or /) — refusing to wipe"; exit 1; fi; \
case "$$repo/" in "$$lt"/*) echo "core-loop-lt: LT_SCRATCH '$$lt' is an ancestor of the repo — refusing to wipe"; exit 1;; esac; \
fi
@# Stop any stale scratch daemon (pidfile-scoped; fleet-safe) BEFORE wiping the dir so a
@# leftover run from a prior invocation is never orphaned. Tolerate a not-yet-created scratch.
bash scripts/scratch-daemon.sh down "$(LT_SCRATCH)" 2>/dev/null || true
@# Guarantee a FRESH clone of the PINNED code: wipe, then init from the LOCAL
@# checkout at THIS checkout's HEAD. --rev is what pins it; the wipe only saves
@# init from having to be told --reuse.
rm -rf "$(LT_SCRATCH)"
bash scripts/scratch-daemon.sh init "$(LT_SCRATCH)" --source "$(CURDIR)" --rev "$$(git -C "$(CURDIR)" rev-parse HEAD)"
@# Seed the fixture beads into the fresh scratch DB + emit the cell->bead_id map so the
@# scoped cell launches (isolates origin + pre-creates the pi landing branch).
@#
@# SEED BEFORE BUILD IS DELIBERATE, and it is safe only because of two things
@# (hk-48zdw). The seed writes an untracked review-loop.dot into the scratch
@# root, and the matrix step below builds after it, so this ordering used to
@# stamp vcs.modified=true on the gate binary and void every run. Reordering to
@# build-then-seed does NOT fix that: the file has to stay in the tree for the
@# whole run, so any rebuild the daemon triggers would dirty the stamp again.
@# What fixes it is that the file is IGNORED (root-anchored /review-loop.dot in
@# .gitignore, invisible to Go's stamp), and that the matrix step now asserts
@# the binary's stamp before it grades anything. Do not "tidy" this order.
bash scripts/core-loop-seed.sh "$(LT_SCRATCH)" "$(LT_SEED_MAP)"
@# Run the scoped matrix: MATRIX_SEED_MAP wires per-cell seeds; --harnesses/--substrates
@# scope to pi:local; --assert --gate --json keep the forced zero-PENDING gate + JSON grid.
MATRIX_SEED_MAP="$(LT_SEED_MAP)" bash scripts/core-loop-matrix.sh "$(LT_SCRATCH)" --harnesses "$(LT_HARNESSES)" --substrates "$(LT_SUBSTRATES)" --assert --gate --json --specs "$(LT_SPECS)"
# test-docker-e2e: WS2.3 remote-substrate E2E across two containers. Builds the
# daemon (box A) + worker images, brings them up on a compose bridge network (the
# worker reachable as `worker`), waits until `ssh worker true` from the daemon
# succeeds (the worker installs the daemon's client key from the shared `keys`
# volume once both are up), then execs the compiled scenario test binary baked
# into the daemon image against HARMONIK_E2E_SSH_HOST=worker. origin.git + the
# worker clone live on the shared volume at the identical /shared path in both
# containers (CRUX 2). ALWAYS tears the stack down with `down -v` (fresh keys +
# repos each run), preserving the drive's exit code. Needs NO host ~/.ssh setup —
# keys are generated inside the containers at boot. Cite: M6-PLAN §WS2.3.
COMPOSE_E2E := test/docker/compose.yml
.PHONY: test-docker-e2e
test-docker-e2e: ## WS2.3 remote-substrate E2E over ssh across daemon+worker containers (needs docker)
docker compose -f $(COMPOSE_E2E) up -d --build
@echo "test-docker-e2e: waiting for passwordless ssh daemon->worker …"
@ok=0; \
for i in $$(seq 1 30); do \
if docker compose -f $(COMPOSE_E2E) exec -T daemon \
ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5 worker true >/dev/null 2>&1; then \
ok=1; echo "test-docker-e2e: ssh worker reachable (attempt $$i)"; break; \
fi; \
sleep 0.5; \
done; \
if [ "$$ok" != "1" ]; then \
echo "test-docker-e2e: FATAL ssh daemon->worker never came up" >&2; \
docker compose -f $(COMPOSE_E2E) logs --no-color >&2 || true; \
docker compose -f $(COMPOSE_E2E) down -v || true; \
exit 1; \
fi; \
out=$$(docker compose -f $(COMPOSE_E2E) exec -T daemon \
/usr/local/bin/remote-substrate.test \
-test.run '^TestScenario_RemoteSubstrate_Localhost_DOT_E2E$$' -test.v 2>&1); \
rc=$$?; \
echo "$$out"; \
if [ "$$rc" = "0" ] && ! echo "$$out" | grep -q -- '--- PASS: TestScenario_RemoteSubstrate_Localhost_DOT_E2E'; then \
echo "test-docker-e2e: FATAL no PASS line for the e2e — -test.run matched nothing (a renamed or moved test exits 0 with 'no tests to run')" >&2; \
rc=1; \
fi; \
docker compose -f $(COMPOSE_E2E) down -v; \
exit $$rc
# ---------------------------------------------------------------------------
# codex-app-server test taxonomy (T5, hk-oe86p)
# Four tiers: L0 unit / L1 contract / L2 integration / L3 live.
# ---------------------------------------------------------------------------
# test-codex-l012: run L0+L1+L2 codex-app-server taxonomy tests + the structured
# INPUT-driver harness (agent-input-substrate M2, T9): codexinput reactor,
# codexdriver, and the L0–L3 input harness + 4×strata×EventN fault matrix +
# bounded-liveness oracle. CODEX_LIVE=0 (default) — captured corpus, no live
# process. GATE: must be green before any codex-app-server deploy or protocol
# change. Includes the pre-deploy drift canaries + the SC6 gate trio (forbidigo /
# depguard / capture-pane) via `make codex-sc6-gates`, the N=10 determinism
# oracle, and the per-file coverage floor — this is the C6-deletion gate, so it
# blocks a deploy, not merely a CI branch check.
.PHONY: test-codex-l012
test-codex-l012: ## Codex L0/L1/L2 + input-driver harness + fault matrix + N=10 + coverage + SC6 gates (CODEX_LIVE=0; hk-oe86p, T9)
go test -count=1 ./internal/codextest/... ./internal/codexwire/... ./internal/codexdigitaltwin/... ./internal/codexreactor/... ./internal/codexinput/... ./internal/codexdriver/...
scripts/codex-oracle-n10.sh 10
scripts/codex-coverage-gate.sh
$(MAKE) codex-capture-pane-gate
# comment-only-commit-gate: the comment-volume ratchet. A Go change may not be
# all comment and no code. Measured over the 300 commits before 2026-08-23,
# eight changed <=5 lines of Go code and >=20 lines of Go comment, five of them
# re-editing the same two daemon test files. A per-block LENGTH cap fires on
# none of them (the blocks are short) and cannot pass this tree without a
# path-keyed allow list, so the gate gets the SHAPE of the work instead. A net
# reduction always passes, so a commentcut sweep stays legal. Wired into
# `make fast` via freeze-gates.
.PHONY: comment-only-commit-gate
comment-only-commit-gate: ## forbid a Go change that is all comment and no code
scripts/comment-only-commit-gate.sh
# codex-capture-pane-gate: the SC6 capture-pane grep ratchet — the structured
# input driver must never scrape a tmux pane (capture-pane is an exec-arg string,
# not an import, so a grep gate is the cheapest enforcement; the forbidigo +
# depguard halves of the SC6 trio live in .golangci.yml).
.PHONY: codex-capture-pane-gate
codex-capture-pane-gate: ## SC6: forbid `capture-pane` in the structured input-driver packages (T9)
scripts/codex-capture-pane-gate.sh
# transport-freeze-gate: the P2 E4 extraction ratchet — the reverse-tunnel
# concern left internal/daemon for internal/transport/tunnel, and depguard can
# only fence the import edge, not the creation of a new file. This grep gate
# fails if a reverse-tunnel-shaped file or one of the moved symbols reappears in
# internal/daemon. Wired into `make fast` via freeze-gates.
.PHONY: transport-freeze-gate
transport-freeze-gate: ## P2 E4: forbid new reverse-tunnel files or moved symbols in internal/daemon
scripts/transport-freeze-gate.sh
# queuewiring-freeze-gate: the P2 E3 extraction ratchet — the queue-ownership
# concern (QueueStore, the brcli->queue.BeadLedger bridge, the operator
# pause/resume consumer) left internal/daemon for internal/queuewiring, and
# depguard can only fence the import edge, not the creation of a new file. This
# grep gate fails if a queue-ownership-shaped file or one of the moved symbols
# reappears in internal/daemon. Wired into `make fast` via freeze-gates.
.PHONY: queuewiring-freeze-gate
queuewiring-freeze-gate: ## P2 E3: forbid new queue-ownership files or moved symbols in internal/daemon
scripts/queuewiring-freeze-gate.sh
# crewrun-freeze-gate: the P2 E2 extraction ratchet — the crew launch contract
# (the crew-start/crew-stop RPC payloads, the persistent-session launch-spec
# builder, the crew-scoped harness resolver, the mission front-matter readers,
# the idle-crew reaper) left internal/daemon for internal/crewrun, and depguard
# can only fence the import edge, not the creation of a new file. This grep gate
# fails if a crew-launch-shaped file or one of the moved symbols reappears in
# internal/daemon. Wired into `make fast` via freeze-gates.
.PHONY: crewrun-freeze-gate
crewrun-freeze-gate: ## P2 E2: forbid new crew-launch files or moved symbols in internal/daemon
scripts/crewrun-freeze-gate.sh
# harnesscodex-freeze-gate: the P2 E1a extraction ratchet — the codex harness
# implementation (the Harness impl, the launch-spec builder, the JSONL parser,
# the stale-WAL and billing guards, the Refs-trailer fallback, the no-work
# detector) left internal/daemon for internal/harness/codex, and depguard can
# only fence the import edge, not the creation of a new file. This grep gate
# fails if a codex-harness-shaped file or one of the moved symbols reappears in
# internal/daemon. Wired into `make fast` via freeze-gates.
.PHONY: harnesscodex-freeze-gate
harnesscodex-freeze-gate: ## P2 E1a: forbid new codex-harness files or moved symbols in internal/daemon
scripts/harnesscodex-freeze-gate.sh
# harnessclaude-freeze-gate: the P2 E1b extraction ratchet — the claude harness
# implementation (the Harness impl + the claude-hook-bridge launch-spec builder)
# left internal/daemon for internal/harness/claude, and depguard can only fence
# the import edge, not the creation of a new file. This grep gate fails if a
# claude-harness-shaped file or one of the moved symbols reappears in
# internal/daemon. claudeheartbeat.go (harness-blind heartbeat emitter) and
# claudeworktreesweep.go (CLI-leftover janitor) are named exceptions — see the
# script header. Wired into `make fast` via freeze-gates.
.PHONY: harnessclaude-freeze-gate
harnessclaude-freeze-gate: ## P2 E1b: forbid new claude-harness files or moved symbols in internal/daemon
scripts/harnessclaude-freeze-gate.sh
# harnesspi-freeze-gate: the P2 E1c extraction ratchet — the pi harness
# implementation (Harness impl, launch-spec builder, NDJSON parser, billing
# guard, Refs:-trailer fallback) left internal/daemon for internal/harness/pi,
# and depguard can only fence the import edge, not the creation of a new file.
# This grep gate fails if a pi-harness-shaped file or one of the moved symbols
# reappears in internal/daemon. pi_profile_resolve.go (claim-time daemon wiring
# over projectconfig) is a named exception — see the script header. The gate
# also asserts test-pi-live below still points at a package that HAS the test,
# because `go test -run` exits 0 on an empty match. Wired into `make fast` via
# freeze-gates.
.PHONY: harnesspi-freeze-gate
harnesspi-freeze-gate: ## P2 E1c: forbid new pi-harness files or moved symbols in internal/daemon
scripts/harnesspi-freeze-gate.sh
# runmerge-freeze-gate: the P2 E5 RT13 extraction ratchet — the run-branch merge
# path (the EM-052/EM-053 merge-to-main sequence, the pre-rebase worktree
# hygiene, the gofumpt/gci format gate, the run-context strip, the review-trailer
# amend) left internal/daemon for internal/runmerge, and depguard can only fence
# the import edge, not the creation of a new file. This grep gate fails if a
# merge-path-shaped file or one of the moved symbols reappears in internal/daemon,
# or if the daemon re-acquires a raw `git merge` / `git rebase` exec.
# beadsmergedriver.go (git merge-DRIVER registration), scenariotest/ and
# branching.go (the WM-019b task-branch landing path) are named exceptions — see
# the script header. Wired into `make fast` via freeze-gates.
.PHONY: runmerge-freeze-gate
runmerge-freeze-gate: ## P2 E5 RT13: forbid new merge-path files or moved symbols in internal/daemon
scripts/runmerge-freeze-gate.sh
# projectconfig-freeze-gate: the P2 LIFT crit 5 extraction ratchet — the
# .harmonik/config.yaml loader + config value types left internal/daemon for
# internal/projectconfig (a pure leaf). depguard fences the import edge; this
# gate forbids re-creating a config-loader file OR re-declaring/re-aliasing a
# moved symbol back inside internal/daemon (the operator's no-alias ruling).
.PHONY: projectconfig-freeze-gate
projectconfig-freeze-gate: ## P2 LIFT crit 5: forbid new config-loader files, moved symbols, or aliases in internal/daemon
scripts/projectconfig-freeze-gate.sh
# runlaunch-freeze-gate: the P2 E5 RT19b extraction ratchet — the run path's
# launch-time effects (the CHB-018 pre-exec relay, the HC-056 readiness
# deadlines and their sentinel, the spawn-cap / tmux-window / agent-ready
# anomaly events, the implementer phase-complete report, the force-teardown
# backstop) left internal/daemon for internal/runlaunch, internal/substrate and
# internal/harness/shared. Symbol-anchored, not name-anchored: internal/daemon
# legitimately keeps ~25 other emit* helpers that are E5 LIFT targets, so a
# '*events*.go' file scan would fire on correct code. Wired into `make fast` via
# freeze-gates.
.PHONY: runlaunch-freeze-gate
runlaunch-freeze-gate: ## P2 E5 RT19b: forbid re-declaring the moved launch-effect symbols in internal/daemon
scripts/runlaunch-freeze-gate.sh
# runloop-freeze-gate: the P2 LIFT extraction ratchet (chunk L0 onward) — the run
# machine's boundary contract (the PORT interfaces LedgerPort … RunRegistryPort
# and the BUNDLES RunPorts / RunEnv / SharedHandles) left internal/daemon for
# internal/runloop. depguard fences the import edge; this ratchet forbids
# re-declaring a moved TYPE back in internal/daemon. The daemon KEEPS the concrete
# adapters + constructors (daemon → runloop, the legal direction); the `= runloop.`
# forwarding arm allows the daemon-local aliases. Ratcheted: later chunks append
# their moved run-path filenames/symbols. Wired into `make fast` via freeze-gates.
.PHONY: runloop-freeze-gate
runloop-freeze-gate: ## P2 LIFT L0: forbid re-declaring the moved run-path port/bundle types in internal/daemon
scripts/runloop-freeze-gate.sh
# readywait-freeze-gate: the P2 E5 RT14 extraction ratchet — the open-coded
# agent_ready WAIT left internal/daemon. Every launch/ready/brief segment now
# runs on the runexec Dispatch machine via dispatchSegment, whose ClockPort-timed
# TimerAgentReady is the one ready bound (FakeClock-drivable, which
# waitAgentReady's raw time.After never was). depguard cannot express "do not
# re-hand-roll a wall-clock wait", so this grep ratchet closes that door: no
# re-declaration of the retired symbols, no raw wall-clock in the run-path files
# that are clean today or inside beadRunOne, and all four launch sites still bind
# through the seam. Slice RT19c (landed f839121ff) clock-ported the Working-phase
# watchdogs (pasteinject.go, dot_gate.go's pasteInjectQuitOnGateFile,
# waitsocketgrace.go, postreadyhang.go) onto the injected ClockPort and added all
# four to check (2), so they are now IN scope, not out. Wired into `make fast` via
# freeze-gates.
.PHONY: readywait-freeze-gate
readywait-freeze-gate: ## P2 E5 RT14: forbid re-hand-rolling the agent_ready wait in internal/daemon
scripts/readywait-freeze-gate.sh
# workersbootwire-freeze-gate: the P2 E4c extraction ratchet — the remote-worker
# registry BOOT WIRING (BuildRegistry / BuildRegistryWithRunner /
# BootHealthRunner, formerly buildWorkerRegistry & friends in workloop.go) left
# internal/daemon for internal/workers/bootwire.go, and depguard can only fence
# the import edge, not the creation of a new file. This grep gate fails if a
# worker-registry-construction file or one of the moved symbols reappears in
# internal/daemon, or if the daemon re-acquires a direct workers.NewRegistry
# construction. Wired into `make fast` via freeze-gates.
.PHONY: workersbootwire-freeze-gate
workersbootwire-freeze-gate: ## P2 E4c: forbid new worker-registry boot-wiring files or moved symbols in internal/daemon
scripts/workersbootwire-freeze-gate.sh
# runloop-emitter-gate: the P2 E5 RT16 ratchet — the DOT run path reaches its
# event bus through EmitterPort (internal/daemon/runports.go), not through the
# workLoopDeps bus field. RT16 converted 108 direct field reads on the six mover
# files to 8 port reads so RT18's re-signature is an 8-line change, not a
# 108-line one. depguard cannot express "reach this dependency through its
# port", so this grep gate rations the field per file, asserts EmitterPort is
# still an alias, and pins each mover to the seam. Wired into `make fast` via
# freeze-gates.
.PHONY: runloop-emitter-gate
runloop-emitter-gate: ## P2 E5 RT16: forbid bypassing EmitterPort with a raw bus-field read in internal/daemon
scripts/runloop-emitter-gate.sh
# workloop-scheduler-freeze-gate: the Seam A split ratchet — the dispatch
# SCHEDULER (runWorkLoop plus the 25 helpers only it reaches) left
# internal/daemon/workloop.go for internal/daemon/scheduler.go. Both files are
# one Go package, so the compiler cannot stop a later edit from pasting the
# scheduler back beside beadRunOne. This grep gate fails if a scheduler symbol is
# declared in workloop.go, if a scheduler symbol is declared anywhere other than
# scheduler.go, or if either side of the seam is missing — the last case is named
# so a deleted target fails loudly instead of passing on an empty grep. Wired
# into `make fast` via freeze-gates.
.PHONY: workloop-scheduler-freeze-gate
workloop-scheduler-freeze-gate: ## Seam A: forbid moving the dispatch scheduler back into workloop.go
scripts/workloop-scheduler-freeze-gate.sh
# vet-tagged: typecheck the files that `go vet ./...` cannot see (hk-i1m20).
# Static analyzers and the default build compile ONLY the untagged build, so a
# call site behind a `//go:build <tag>` line emits zero signal when it breaks —
# and an analyzer claim like unparam's "parameter X always receives value V" is
# scoped to the build it analyzed, not to the repo. A real arity-increase break
# in cmd/harmonik behind `//go:build scenario` survived two weeks undetected,
# with five live assertions dead the whole time.
#
# ONE invocation, not a loop: build tags are ADDITIVE, so a single combined vet
# compiles the union of all tagged files in ~4.6s cold / ~1.1s warm, where the
# same tags as separate invocations cost ~25s for identical coverage.
# `go vet` typechecks _test.go files but runs nothing, which is all this needs.
#
# Tag set = every tag with real files in the repo, minus `ignore` (deliberately
# uncompiled) and the GOOS constraints (darwin/linux/windows), which are chosen
# by the toolchain rather than by -tags. Add a tag here whenever one is
# introduced, or its files go back to being invisible.
TAGGED_BUILD_TAGS := scenario,integration,e2e_real_claude,subprocess,crash
.PHONY: vet-tagged
vet-tagged: ## hk-i1m20: typecheck every build-tagged file (invisible to plain `go vet ./...`)
go vet -tags=$(TAGGED_BUILD_TAGS) ./internal/... ./cmd/... ./test/...
# test-codex-live: run L3 live tests against a real codex app-server process.
# Requires: CODEX_LIVE=1, codex binary on PATH (or CODEX_BIN=<path> set),
# valid codex auth (~/.codex/auth.json). Budget: 90s per test, 2 scenarios.
# L3 happy-path = PRE-DEPLOY E2E GATE for the codex-app-server integration.
.PHONY: test-codex-live
test-codex-live: ## Codex-app-server L3 live gate (CODEX_LIVE=1 required; token-capped; hk-oe86p)
CODEX_LIVE=1 scripts/go-test-must-match.sh go test -timeout 180s -count=1 -run TestL3_ ./internal/codextest/...
# capture-fixtures: deliberate, budget-capped corpus capture.
# Requires: CODEX_LIVE=1, codex binary on PATH, valid codex auth.
# Output: testdata/codex-app-server/corpus/<session>.jsonl
# Ledger: testdata/codex-app-server/corpus/CAPTURE-LOG.md (manual update required).
# Run deliberately — NOT part of make test or CI. Token budget: one minimal turn.
.PHONY: capture-fixtures
capture-fixtures: ## Capture new codex corpus (CODEX_LIVE=1 required; deliberate+token-capped; hk-oe86p)
@echo "capture-fixtures: launching budget-capped codex session via L3 live harness"
@echo " Corpus output: testdata/codex-app-server/corpus/"
@echo " Update testdata/codex-app-server/corpus/CAPTURE-LOG.md after capture."
CODEX_LIVE=1 scripts/go-test-must-match.sh go test -timeout 120s -count=1 -v -run TestL3_ ./internal/codextest/...
# capture-claude-fixtures: real-Claude twin-parity capture (WS3-Claude-A).
# Requires an AUTH'D, tmux-capable box: claude/tmux/git/br/ntm on PATH + a
# subscription OAuth session. On a box without those the test SKIPS cleanly.
# Output: testdata/twin-parity/claude/<scn>/{wire.ndjson,events.jsonl,meta.yaml}
# Credfence (codename:credfence, D2): run under `env -u ANTHROPIC_API_KEY
# -u ANTHROPIC_AUTH_TOKEN` so the run bills the subscription pool — NEVER an API
# key (mirrors scripts/scratch-daemon.sh:237-240). Distinct from capture-fixtures
# (codex corpus) — do not conflate.
.PHONY: capture-claude-fixtures
capture-claude-fixtures: ## Capture real-Claude twin-parity fixtures (e2e_real_claude; auth+tmux required; credfenced)
@echo "capture-claude-fixtures: real-Claude capture into testdata/twin-parity/claude/$${HARMONIK_CAPTURE_SCN:-happy-path}/"
@echo " SKIPS cleanly without claude/tmux/br/ntm binaries + auth."
env -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN \
HARMONIK_WIRE_CAPTURE_DIR=$(CURDIR)/testdata/twin-parity/claude \
HARMONIK_CAPTURE_COMMIT_SHA=$$(git rev-parse --short HEAD) \
scripts/go-test-must-match.sh go test -tags e2e_real_claude -timeout 300s -count=1 -v \
-run TestCaptureClaudeFixtures ./internal/daemon/...
# test-twin-parity-claude: the ROUTINE Claude twin-parity gate (WS3-Claude-D).
# Compares the canonical twin's --replay-path output (committed wire.ndjson)
# against the committed Claude reference capture using only F1's equivalence
# library: ordered kind-sequence + terminal outcome equivalent, hook/causal
# timing within tolerance, and a drifted twin caught with a first-divergence
# diff. Cheap, deterministic, zero-token — NO auth, tmux, or live model needed
# (distinct from capture-claude-fixtures, the separate PERIODIC live re-capture).
.PHONY: test-twin-parity-claude
test-twin-parity-claude: ## Routine Claude twin-parity gate (twin-vs-reference-capture; zero-token, deterministic)
scripts/go-test-must-match.sh go test -v -count=1 -run 'ClaudeParity|TwinParityCorpus' ./internal/twinparity/...
# test-pi-live: the REAL-BOX-GATED pi oracle (WS3-pi / pi-A). Drives a real
# `pi --mode json` single-turn, asserts the terminal NDJSON sequence
# (session → agent_end), and writes testdata/twin-parity/pi/<scn>/{ndjson,
# events.jsonl}. DEFAULT-SKIPPED without PI_LIVE=1; needs pi on PATH (or PI_BIN),
# PI_PROVIDER + PI_MODEL, and valid pi provider auth. Anti-false-green:
# HARMONIK_REQUIRE_PI_LIVE=1 turns a can't-run skip into a Fatalf.
.PHONY: test-pi-live
test-pi-live: ## Real-pi oracle gate (PI_LIVE=1 required; pi provider auth; writes pi twin-parity fixtures)
PI_LIVE=1 scripts/go-test-must-match.sh go test -timeout 180s -count=1 -run TestPiA_ ./internal/harness/pi/...
# test-twin-parity-pi: the ROUTINE pi twin-parity gate (WS3-pi / pi-C). Compares
# the pi twin's NDJSON (committed testdata/twin-parity/pi/happy-path-sample/ndjson
# — deterministic `harmonik-twin-pi --scenario happy-path` output) against the
# reference capture on the pi-native wire spine (session → agent_end) + the
# daemon-projected durable terminal triad, and proves a drifted twin is caught
# with a first-divergence diff. Cheap, deterministic, zero-token — NO auth or live
# pi needed (distinct from test-pi-live, the separate REAL-BOX re-capture).
.PHONY: test-twin-parity-pi
test-twin-parity-pi: ## Routine pi twin-parity gate (twin-vs-reference-capture; zero-token, deterministic)
scripts/go-test-must-match.sh go test -v -count=1 -run 'PiParity|TwinParityCorpus' ./internal/twinparity/...
# ---------------------------------------------------------------------------
# Keeper replay test taxonomy (T10; session-restart-substrate)
# Four tiers: L0 unit / L1 contract / L2 integration / L3 live, mirroring the
# codex pair (measurement-design §3; RS-017/018/019).
# ---------------------------------------------------------------------------
# test-keeper-l012: run L0+L1+L2 keeper taxonomy tests + the corpus drift
# canary. KEEPER_LIVE=0 (default) — corpus-driven, zero-token, no live pane.
# GATE: must be green before any keeper cycle change deploys.
.PHONY: test-keeper-l012
test-keeper-l012: ## Keeper replay L0/L1/L2 gate (KEEPER_LIVE=0; corpus-driven)
go test -count=1 ./internal/keepertest/... ./internal/keepertwin/... ./internal/keeper/...
# test-keeper-live: run the L3 one-cycle tmux smoke against a REAL tmux pane.
# Requires: KEEPER_LIVE=1, tmux on PATH. The pane runs a bare shell (no model,
# no daemon). No re-capture target exists (unlike codex capture-fixtures): the
# corpus source is the frozen baseline log — scripts/extract-keeper-corpus.py
# is a deterministic rebuild, not a token-capped capture.
.PHONY: test-keeper-live
test-keeper-live: ## Keeper L3 live gate (KEEPER_LIVE=1 required; one-cycle tmux smoke)
KEEPER_LIVE=1 scripts/go-test-must-match.sh go test -timeout 180s -count=1 -run TestL3_ ./internal/keepertest/...
# ---------------------------------------------------------------------------
# Twin-binary targets
# ---------------------------------------------------------------------------
# build-twin-generic: compile cmd/harmonik-twin-generic/ into twins/generic-twin.
# This is the generic test handler twin that emits harmonik-native NDJSON
# directly (testing the back half of the pipeline without simulating Claude's
# lifecycle). Renamed from harmonik-twin-claude per hk-w5vra.1.
# Output path satisfies SH-009 in-tree default (<repo-root>/twins/).
# Cite: specs/scenario-harness.md §4.3.SH-009;
# specs/handler-contract.md §4.8.HC-036(c).
.PHONY: build-twin-generic
build-twin-generic: ## Build cmd/harmonik-twin-generic → twins/generic-twin (SH-009 / HC-043)
@mkdir -p $(TWINS_DIR)
go build -ldflags "-X main.commitHash=$(COMMIT_HASH)" -o $(TWINS_DIR)/generic-twin ./cmd/harmonik-twin-generic
# build-twin-claude: compile the Claude lifecycle twin at the path used by
# daemon scenario fixtures.
.PHONY: build-twin-claude
build-twin-claude: ## Build cmd/harmonik-twin-claude → ./harmonik-twin-claude
go build -ldflags "-X main.commitHash=$(COMMIT_HASH)" -o ./harmonik-twin-claude ./cmd/harmonik-twin-claude
# build-twin-fail: compile the failure twin used by T2 daemon scenarios.
.PHONY: build-twin-fail
build-twin-fail: ## Build test/twins/fail-immediately → ./twin-fail
go build -ldflags "-X main.commitHash=$(COMMIT_HASH)" -o ./twin-fail ./test/twins/fail-immediately
# build-twin-hang: compile the hanging twin used by T2 daemon scenarios.
.PHONY: build-twin-hang
build-twin-hang: ## Build test/twins/hang → ./twin-hang
go build -ldflags "-X main.commitHash=$(COMMIT_HASH)" -o ./twin-hang ./test/twins/hang
# build-twin-pi: compile cmd/harmonik-twin-pi/ into twins/pi-twin. The pi test
# twin emits pi's `--mode json` NDJSON lifecycle (session → message_start/end →
# agent_end) deterministically so it can stand in for a real pi session in
# scenario tests and the twin-parity gate (WS3-pi). HC-043 commit stamp injected.
.PHONY: build-twin-pi
build-twin-pi: ## Build cmd/harmonik-twin-pi → twins/pi-twin (SH-009 / HC-043)
@mkdir -p $(TWINS_DIR)
go build -ldflags "-X main.commitHash=$(COMMIT_HASH)" -o $(TWINS_DIR)/pi-twin ./cmd/harmonik-twin-pi
# twins: build the scenario twin binaries.
.PHONY: twins
twins: build-twin-generic build-twin-claude build-twin-pi build-twin-fail build-twin-hang ## Build scenario twins
# build-all: build the module + all twin binaries.
# Suitable as a pre-scenario-test warmup target.
.PHONY: build-all
build-all: build twins ## go build ./... + all twins (full build artifact set)
# ---------------------------------------------------------------------------
# Secret scan — refuses content that adds API keys, credential patterns, or
# .env files.
#
# THIS TARGET IS THE STANDALONE ONE, and it is not where the scan runs. It reads
# the INDEX, for use before a commit is made. The gates run AFTER the commit,
# when the index is empty, so they call the script directly with a committed
# scope: `--head-only` from gate-static and `--range` from full. Nothing depends
# on this target, and from 2026-07-23, when lefthook was deleted, to 2026-08-12
# nothing depended on the scan at all while four documents said it ran —
# scripts/gate-fails-closed-test.sh now asserts both call sites are in the
# expanded step list.
# ---------------------------------------------------------------------------
.PHONY: secret-scan
secret-scan: ## Scan the staged index for API keys / credentials / .env files (gates use --head-only / --range)
scripts/secret-scan.sh
# ---------------------------------------------------------------------------
# Format write + fail-closed format check
#
# fmt: write gofumpt + gci formatting in-place (used by pre-commit hook and
# manually to fix a dirty tree).
# fmt-check: fail with a non-zero exit code if any file is unformatted (used
# by `make fast`, `make full`, and CI). gofumpt -l and gci diff both exit 0
# on format drift, so we wrap them with explicit output checks here.
# ---------------------------------------------------------------------------
# Order: gci first (import ordering), then gofumpt (blank-line rules). This
# ensures a single pass converges: gci may shift import blocks in ways that
# gofumpt wants to touch, but gofumpt never alters import order so gci stays
# satisfied after gofumpt runs.
.PHONY: fmt
fmt: ## Auto-format all Go files with gofumpt + gci (writes in-place)
scripts/go-format.sh write
.PHONY: fmt-check
fmt-check: ## Fail-closed: exit 1 if gofumpt or gci would change any file (run 'make fmt' to fix)
scripts/go-format.sh check
# ---------------------------------------------------------------------------
# THE GATE — two targets, and only two.
#
# make fast the inner loop. Run it while you work.
# make full the merge decision. Run it before you ask anyone to accept work.
#
# Every recipe line below is fail-closed. `make` stops at the first non-zero
# exit, and no line here carries a `-` prefix, a `|| true`, a `|| exit 0`, or a
# trailing `if` that can swallow a status. scripts/gate-fails-closed-test.sh
# holds that property: it re-derives the step list from `make -n` and it also
# drives a real `make full` with a stub `go` on PATH.
#
# WHY the delta-scoped gate is gone. It asked git which files changed since
# main and then tested only those packages. Two things were wrong with the
# answer. main is hundreds of commits behind this branch, so the file list was
# noise. And even from a fresh base the question cannot find a break in a
# package the change did not touch, which is the usual case here because
# internal/daemon imports eight other packages. Measured 2026-08-03 on this
# box: whole-repo `go test -short -count=1 ./...` costs about 13 seconds more
# than internal/daemon alone. The scoping bought 13 seconds and paid for it
# with the ability to see. scripts/scenario-gate.sh, which implemented it, is
# deleted: it had five ways to approve work that never passed (compile failure,
# timeout, signal kill, unrecognized exit code, and a retry that allowed when
# the second run passed) against one way to block.
#
# fast and full share every static step. They differ in exactly two ways: full
# tests EVERY package instead of the major set, and full adds the whole-tree
# lint allow list, the tagged scenario tier and the module hygiene checks. Keep
# it that way. A step that belongs to only one of them is how a
# third tier grows back.
# ---------------------------------------------------------------------------
# FAST_PKGS — the packages `make fast` runs unit tests for.
#
# internal/core, internal/daemon, internal/queue and internal/queuewiring are
# the named core set. The rest are on the same queue-and-bead path and a silent
# break in any of them is invisible in the four above:
#
# internal/brcli the bead-ledger adapter. Every bead state transition
# goes through it, so a break here loses work while the
# queue still reports progress.
# internal/eventbus the queue and the run loop are observed only through
# emitted events. A break here makes every other gate,
# watcher and status surface blind at once.
# internal/runloop owns the run state machine that carries a dispatched
# bead from launch to merge, and holds the merge-path
# scenario gate.
# internal/workflow parses and walks the workflow graph that decides what
# a bead does next. workflow.dot is data this package
# reads, so a graph edit is only as safe as this package.
# cmd/harmonik the only way an operator or an agent reaches the queue.
# It is also one of the two packages red today, so
# leaving it out would hide a known break from the inner
# loop.
#
# This set is a judgment, not a boundary the compiler enforces. `make full`
# tests every package and is the only thing a merge may rest on.
FAST_PKGS := \
./internal/core \
./internal/daemon \
./internal/queue \
./internal/queuewiring \
./internal/brcli \
./internal/eventbus \
./internal/runloop \
./internal/workflow \
./cmd/harmonik
# ---------------------------------------------------------------------------
# CORE_PKGS — the core set, and the only thing that has to be green.
#
# Operator, 2026-08-06: "This whole tool set revolves around the ability to run
# beads through the queue. That's the core. There's a dozen other tools in here
# — those are not core." Tests outside this set do not have to run, and may be
# ignored or disabled.
#
# This is NOT a new judgment. CHARTER.md §3 decided the set on 2026-07-28 and
# calls it DECIDED, not proposed. It is written there as a pipeline:
#
# config → event bus → queue → bead-ledger adapter → worktrees →
# harness registry + one substrate → work loop → merge
#
# The list below is that pipeline resolved to packages, one group per stage, in
# the charter's own order. Nothing is added: §3 says anything absent is deferred
# by default rather than by argument.
#
# WHAT IS DELIBERATELY OUT, and it is most of the tree. §3 names comms, crew,
# captain, keeper, dashboard, live-state, subscribe and the sentinel — AND the
# socket listener, by operator decision the same day. So internal/keeper,
# internal/crew, internal/crewrun, internal/dashboard, internal/sentinel,
# internal/presence, internal/digest, internal/watch, internal/schedule and
# internal/supervise are all out, along with the second and third substrates
# (internal/harness/codex, internal/harness/pi and the codex* packages) — §3
# says "one substrate", and claude is it.
#
# WHY THIS TARGET EXISTS. The daemon suite goes red under load with a different
# test each time, and that has been investigated four or five times without
# resolution. Scoping is the answer that was actually available: a flake in a
# package the queue does not depend on stops being a blocker by definition
# rather than by another investigation.
#
# THIS DOES NOT REPLACE `make full`. `make full` is still the merge decision and
# still tests every package. `make core` answers a different and narrower
# question — is the thing this tool exists to do working — and that is the
# question an assessor sign-off rests on.
CORE_PKGS := \
./internal/projectconfig \
./internal/branching \
./internal/daemon/bootconfig \
./internal/eventbus \
./internal/queue \
./internal/queue/cli \
./internal/queue/readiness \
./internal/queuewiring \
./internal/brcli \
./internal/workspace \
./internal/harness/shared \
./internal/harness/claude \
./internal/lifecycle \
./internal/lifecycle/tmux \
./internal/substrate \
./internal/daemon \
./internal/runloop \
./internal/runexec \
./internal/runlease \
./internal/runlaunch \
./internal/workflow \
./internal/workflow/dot \
./internal/runmerge \
./internal/mergeq \
./internal/core \
./internal/handler \
./internal/handlercontract \
./internal/gitprobe \
./cmd/harmonik
# Per-step wall-clock cap. A hung step must FAIL, never hang and never pass.
# Two caps, because they catch different hangs:
# GATE_GO_TIMEOUT goes to `go test -timeout`. A test that blocks forever
# panics the test binary and `go test` exits non-zero.
# GATE_STEP_SECS wraps the whole command in `timeout`, which catches a
# hang in the toolchain itself or in a child process the
# test leaked. `timeout` exits 124, which is non-zero, so
# make stops. Absent `timeout`/`gtimeout` the wrapper is
# empty and only the inner cap applies — a hang then hangs,
# which is loud, and it still never reports success.
GATE_GO_TIMEOUT ?= 25m
GATE_STEP_SECS ?= 1800
GATE_CAP := $(if $(TIMEOUT_BIN),$(TIMEOUT_BIN) --kill-after=30s $(GATE_STEP_SECS)s,)
# When neither `timeout` nor `gtimeout` is installed, GATE_CAP expands to
# nothing and the outer cap silently disappears. Silently is the problem: a
# missing guard that says nothing is indistinguishable from a guard that is
# working. gate-static says so out loud once per run.
# script-tests — the self-tests for the shell the gate itself depends on.
# Most shell scripts in this repo decide something and about a quarter of them
# have a test. These are those tests as they apply to the gate path. They guard
# the parts that fail silently. (The count used to be written here as a number
# and it was wrong by nine before anyone noticed.)
#
# Most cost a few seconds. reachability-gate-test.sh costs about 13 seconds of
# wall clock on a warm cache and about 46 of CPU across cores, because the only
# honest way to prove that gate can fail is to write an unreachable function into
# a linked package and run the real whole-program analysis against it. It calls
# the gate ten times; six of those reach the analysis and four are fail-closed
# setup cases that stop before it. That is the price of the claim, and the claim
# is the one every other gate here got wrong at least once. Move it to `make
# full` if the inner loop starts to hurt, but do not weaken it in place.
#
# About 4 of those 13 seconds bought hermeticity, and they are not optional. The
# earlier version wrote its canary into the live internal/lifecycle, so a second
# run started while the first was mid-analysis deleted the first one's canary and
# both runs went red with messages naming deadcode's roots and the build cache —
# neither of which was at fault. It now mirrors the working tree into a throwaway
# copy per run and measures its own baseline there. Refs hk-ky66d.
#
# The throwaway tree also costs about 46 MB of Go build cache per run that no
# later run can reuse: the scratch path is new each time and nothing passes
# -trimpath, so every first-party package compiles at an address seen once. Go's
# own 5-day trim bounds it, and docs/disk-reclaim.md is the cure if it does not.
# A scratch path that is stable per checkout would recover the disk and most of
# the 4 seconds, but it needs a lock or a per-run suffix, and shared mutable
# state between two runs is the defect this change removed. Refs hk-11fdr.
#
# It is wrapped because it compiles Go, and a lane sharing one GOCACHE with
# another lane is the collision with-lane-gocache.sh exists for. Two other
# script-tests here compile Go unwrapped and predate that wrapper.
.PHONY: script-tests
script-tests: ## Self-tests for the shell the gate depends on
scripts/go-format-test.sh
scripts/validate-commit-msg-test.sh
scripts/agent-reviewer-run-test.sh
scripts/agent-reviewer-prompt-parity-test.sh
scripts/with-lane-gocache-test.sh
scripts/go-test-must-match-test.sh
scripts/loadgen-test.sh
scripts/gate-fails-closed-test.sh
scripts/scratch-daemon-rev-pin-test.sh
scripts/scratch-daemon-toolchain-test.sh
scripts/scratch-daemon-provenance-test.sh
scripts/lint-allow-test.sh
scripts/lint-allow-ratchet-test.sh
scripts/scenario-pkgs-test.sh
scripts/lint-changed-test.sh
scripts/changed-func-coverage-test.sh
scripts/queue-daemon-count-test.sh
scripts/required-check-name-gate-test.sh
scripts/commit-msg-gate-test.sh
scripts/secret-scan-test.sh
scripts/pipefail-grepq-gate-test.sh
scripts/comment-only-commit-gate-test.sh
scripts/with-lane-gocache.sh scripts/reachability-gate-test.sh
# freeze-gates — the per-subsystem "do not move this back" greps. Cheap
# (sub-second each) and they only ever answer a structural question, so they
# belong in the inner loop.
.PHONY: freeze-gates
freeze-gates: ## Subsystem freeze / ratchet greps (structural, sub-second each)
scripts/transport-freeze-gate.sh
scripts/queuewiring-freeze-gate.sh
scripts/crewrun-freeze-gate.sh
scripts/harnesscodex-freeze-gate.sh
scripts/harnessclaude-freeze-gate.sh
scripts/harnesspi-freeze-gate.sh
scripts/runmerge-freeze-gate.sh
scripts/projectconfig-freeze-gate.sh
scripts/runlaunch-freeze-gate.sh
scripts/runloop-freeze-gate.sh
scripts/readywait-freeze-gate.sh
scripts/workersbootwire-freeze-gate.sh
scripts/runloop-emitter-gate.sh
scripts/workloop-scheduler-freeze-gate.sh
scripts/queue-status-writer-ratchet.sh
scripts/lint-allow-ratchet.sh
scripts/comment-only-commit-gate.sh
scripts/required-check-name-gate.sh
scripts/pipefail-grepq-gate.sh
# gate-static — everything fast and full share that runs no test.
#
# Ordered cheapest-first so the inner loop reports the cheap break first.
# Every Go step runs under a GOCACHE private to THIS checkout. The lanes used
# to share one, and a concurrent process invalidating cache facts mid-run gives
# "could not import ... no such file or directory". The cache is keyed on the
# checkout root and PERSISTS, so a lane stays as warm as a shared cache would.
# Do NOT swap in with-isolated-gocache.sh: it deletes the cache on exit, so
# every line here would build cold.
.PHONY: gate-static
gate-static: ## Shared static half of fast and full: script self-tests, format, build, vet, freeze greps, changed-line lint
$(MAKE) script-tests
$(MAKE) gate-static-product
# gate-static-product — the static half MINUS the script self-tests.
#
# WHY THIS SPLIT EXISTS. `script-tests` tests the GATE TOOLING — the shell
# scripts that implement the gates — not the product. It costs minutes and it
# runs before a single line of product code is checked. That is right for `fast`
# and `full`, which are developer and CI targets. It is wrong for the per-bead
# commit gate, whose only question is "can this run beads through the queue?".
# A bead's gate must not spend its first several minutes proving that
# lint-allow-test.sh still works.
#
# `make core` therefore calls THIS target, and `fast` / `full` keep the script
# self-tests through `gate-static` above.
#
# WHAT THE SPLIT COSTS, stated plainly, because an earlier version of this
# comment claimed it cost nothing. `make core` is the only gate the per-bead
# path runs on its own, and it now runs the gate scripts WITHOUT running the
# tests that prove those scripts fail closed. secret-scan.sh, commit-msg-gate.sh
# and lint-changed.sh all still execute here; what no longer executes on this
# path is the proof that they still refuse what they exist to refuse. That proof
# now lives only in `fast`, `full` and CI.
#
# Read that as a real reduction, not a technicality:
# scripts/gate-fails-closed-test.sh exists because secret-scan.sh had no caller
# for twenty days AND was admitting a key when a test was finally written for it.
# That test just moved off the continuously-run path onto the hand-run one.
#
# The trade is still worth making — a bead's gate must not spend its first