@chrono-meta/fh-gate 1.4.97 → 1.4.99

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 (44) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CATALOG.md +19 -0
  3. package/CHEATSHEET.md +9 -1
  4. package/CLAUDE.md +28 -2
  5. package/README.ja.md +229 -42
  6. package/README.ko.md +241 -45
  7. package/README.md +168 -31
  8. package/README.zh.md +219 -40
  9. package/docs/OUTPUT_EVIDENCE.md +21 -12
  10. package/docs/pillars.svg +3 -7
  11. package/knowledge/shared/harness-core/fh_ecosystem_positioning.md +2 -0
  12. package/knowledge/shared/harness-core/fh_global_positioning_and_distribution_roadmap.md +136 -0
  13. package/knowledge/shared/harness-core/fh_three_layer_canon.md +20 -0
  14. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +215 -2
  15. package/knowledge/shared/harness-core/ship_readiness_gate.md +112 -0
  16. package/knowledge/shared/learnings/subagent_invocations_log.yaml +65 -0
  17. package/package.json +5 -1
  18. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  19. package/plugins/fh-commons/skills/ko-tech-writer/SKILL.md +63 -12
  20. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  21. package/plugins/fh-meta/CHANGELOG.md +166 -0
  22. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +30 -0
  23. package/scripts/consent_registry_check.sh +124 -1
  24. package/scripts/degrade_direction_scan.sh +10 -1
  25. package/scripts/digest_landing_check.sh +20 -4
  26. package/scripts/fh_node_check.sh +128 -1
  27. package/scripts/fh_session_load.sh +22 -2
  28. package/scripts/frontier_digest_autopilot.sh +229 -0
  29. package/scripts/lane_runner_check.sh +294 -26
  30. package/scripts/package_coverage_check.sh +17 -0
  31. package/scripts/postinstall_notice.js +34 -0
  32. package/scripts/selfcheck.sh +183 -5
  33. package/scripts/test_consent_registry.sh +99 -0
  34. package/scripts/test_degrade_scan_shell_probes.sh +75 -0
  35. package/scripts/test_field_canon_lanes.sh +29 -5
  36. package/scripts/test_lane_runner_lanes.sh +295 -0
  37. package/scripts/test_node_check_lanes.sh +217 -0
  38. package/scripts/test_selfcheck_state_lanes.sh +61 -0
  39. package/scripts/test_stale_clone_guard_lanes.sh +21 -7
  40. package/scripts/test_version_lockstep_lanes.sh +62 -0
  41. package/scripts/version_lockstep_check.sh +143 -1
  42. package/templates/.git-hooks/pre-commit +22 -1
  43. package/templates/consent_classes.yaml.example +30 -0
  44. package/templates/degrade_direction_scan.sh +10 -1
@@ -151,6 +151,132 @@ IDENTITY=""
151
151
  if [ -z "$PREV_ID" ]; then IDENTITY="first session for this clone"
152
152
  elif [ "$PREV_ID" != "$NODE_ID" ]; then IDENTITY="machine changed ($PREV_ID → $NODE_ID)"; fi
153
153
 
154
+ # ── git-remote freshness (origin) — distinct axis from the infra-delta check above ─
155
+ # INFRA (above) compares against PREV_HEAD, the commit THIS CLONE last saw — it cannot detect
156
+ # "origin has commits I never pulled" because it never talks to the remote. Measured 2026-08-15:
157
+ # a machine idle for two weeks sat 145 commits behind origin/main with zero signal from this hook
158
+ # (companion-store sync covers only the gitignored half; the FH repo itself is a separate git
159
+ # transport with no freshness check of its own). Condition, not event — reported every session
160
+ # while true, same as MISS above.
161
+ #
162
+ # WHY THE TARGET REF IS THE LOCAL main BRANCH, NOT `git symbolic-ref --short HEAD`: an earlier
163
+ # revision compared `HEAD..origin/<current-branch>`. Two bugs, one catch (fh-meta:challenger
164
+ # 2026-08-15 [HIGH]): (a) on a detached HEAD, `symbolic-ref` fails and silently fell back to the
165
+ # literal string "main" — not empty — so the "not measurable, stay silent" case never triggered
166
+ # and it happily compared against the wrong ref; (b) on a feature branch — this repo's own normal
167
+ # workflow (CLAUDE.md §PR Direction: never commit main directly) — it compared against
168
+ # origin/<feature-branch>, never origin/main, so the exact incident this check exists to catch
169
+ # (idle main sitting behind) would NOT have been caught while checked out on a feature branch.
170
+ # Fix: always measure the LOCAL main branch REF directly (refs/heads/<default>), independent of
171
+ # what is currently checked out. Default branch name is read from origin/HEAD, falling back to
172
+ # "main" only if that symref is absent (e.g. never fetched before).
173
+ GIT_BEHIND_NOTE=""
174
+ if git -C "$FH" rev-parse --git-dir >/dev/null 2>&1 && git -C "$FH" remote get-url origin >/dev/null 2>&1; then
175
+ export GIT_TERMINAL_PROMPT=0
176
+ # GIT_SSH_COMMAND: fh_session_load.sh's companion-store fetch sets this (BatchMode + accept-new
177
+ # host keys) so an SSH remote with an unrecognized host key fails fast instead of hanging on an
178
+ # interactive prompt that GIT_TERMINAL_PROMPT=0 alone does not suppress (ssh, not git, owns that
179
+ # prompt). This block had copied the timeout half of that pattern but not the SSH half — same
180
+ # challenger round caught the drift live, not hypothetically.
181
+ export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new}"
182
+ # Same bounded-fetch pattern as fh_session_load.sh's companion-store pull: a SessionStart hook
183
+ # must never hang the first turn on a slow/stalled network step. perl-alarm is the portable
184
+ # watchdog; no perl → run unbounded rather than silently never fetch (same tradeoff as there).
185
+ # Deliberately a DISTINCT env var from that script's FH_FETCH_DEADLINE (this fetch targets a
186
+ # different remote — the FH repo's own origin, not the companion store's) — sharing the name
187
+ # would let tuning one fetch's timeout silently retune the other (challenger 2026-08-15).
188
+ if command -v perl >/dev/null 2>&1; then
189
+ _fh_gitcheck_deadline() { perl -e 'alarm shift @ARGV; exec @ARGV' "$@"; }
190
+ else
191
+ _fh_gitcheck_deadline() { shift; "$@"; }
192
+ fi
193
+ if _fh_gitcheck_deadline "${FH_NODE_GIT_FETCH_DEADLINE:-8}" git -C "$FH" fetch --quiet origin >/dev/null 2>&1; then
194
+ _DEFAULT_BRANCH="$(git -C "$FH" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')"
195
+ [ -n "$_DEFAULT_BRANCH" ] || _DEFAULT_BRANCH=main
196
+ if git -C "$FH" show-ref --verify --quiet "refs/heads/$_DEFAULT_BRANCH" \
197
+ && git -C "$FH" show-ref --verify --quiet "refs/remotes/origin/$_DEFAULT_BRANCH"; then
198
+ _BEHIND="$(git -C "$FH" rev-list --count "refs/heads/$_DEFAULT_BRANCH..refs/remotes/origin/$_DEFAULT_BRANCH" 2>/dev/null || echo "")"
199
+ case "$_BEHIND" in
200
+ ''|*[!0-9]*) : ;; # not measurable — silent, not a claim
201
+ 0) : ;;
202
+ *)
203
+ # ── auto-apply, consent-gated, inside a deliberately narrow envelope ───────────────
204
+ # Operator request 2026-08-15, verbatim: "사람이 일일이 수동으로 깃풀해서 최신화해야하는지를
205
+ # 판단하지않고 … 세션 시작 시 레포체크를 클로드가 알아서 하고 최신화 제안하는 기능이 있으면
206
+ # 좋을것같아. 그리고 앞으로도 자동으로 이렇게 동기화할지 물어보는 것도."
207
+ #
208
+ # 🟥 IT NEVER SWITCHES BRANCHES, and that is the whole safety envelope — not a nicety.
209
+ # The recommendation this line used to print told the reader to `git checkout
210
+ # $_DEFAULT_BRANCH && git pull`. In a SHARED CHECKOUT a checkout yanks the ground out from
211
+ # under a peer session: measured on this repo 2026-08-09 (two sessions, one worktree, one
212
+ # committed onto the other's branch), which is why scripts/branch_claim.sh exists at all.
213
+ # As prose advice a human weighed that; automated, nobody would. So the apply arm fires
214
+ # ONLY when the default branch is ALREADY checked out, and `--ff-only` means it can
215
+ # neither rewrite history nor absorb a divergence — it refuses instead.
216
+ #
217
+ # Consent is a LEASE, joined mechanically, never inferred: the class must be registered
218
+ # promotion_eligible in tracks/_meta/consent_classes.yaml AND granted unexpired in the
219
+ # UAP frontmatter. scripts/consent_registry_check.sh is the single decider (exit 0 = a
220
+ # real grant was joined; 3 = nothing granted; 1 = broken). Absent, expired, unreadable, or
221
+ # unknown all take the same branch as "no": surface, do not apply. absent ≠ granted.
222
+ # 🟥 The two conditions this replaced did NOT ask whether THIS class was granted, and a
223
+ # security pass before the 1.4.99 publish caught it with a live control (2026-08-15).
224
+ # · a bare run of consent_registry_check.sh returns 0 for a FILE-WIDE property — "the
225
+ # registry and the grants are well-formed and the floor join holds". One validly
226
+ # granted UNRELATED class produces that 0.
227
+ # · the second condition was `grep -q '^\s*repo-freshness-autopull:'` over the WHOLE UAP,
228
+ # which does not distinguish `granted` from `revoked` and does not care whether the
229
+ # hit is inside the machine-read frontmatter or in a prose paragraph.
230
+ # Reproduced: one unrelated class granted + the line ` repo-freshness-autopull: 안 쓰기로
231
+ # 했다` in prose → both conditions passed, the merge ran, and the banner told the operator
232
+ # it was acting on a standing consent that had never existed. The revoke path was the one
233
+ # that broke, which is the exact floor `absent ≠ granted` exists to hold.
234
+ # `--require-class` joins the ONE class: 0 only if it is an active, registered, unexpired
235
+ # grant; 3 otherwise. Same known pair now separates 0 from 3.
236
+ _AUTOPULL=""
237
+ if [ -x "$FH/scripts/consent_registry_check.sh" ] \
238
+ && bash "$FH/scripts/consent_registry_check.sh" --require-class repo-freshness-autopull >/dev/null 2>&1; then
239
+ _AUTOPULL=1
240
+ fi
241
+ _ON_DEFAULT=""
242
+ [ "$(git -C "$FH" symbolic-ref --short -q HEAD 2>/dev/null)" = "$_DEFAULT_BRANCH" ] && _ON_DEFAULT=1
243
+ # 🟥 NO DEADLINE HERE, AND THAT IS A DECISION — read before adding one back.
244
+ # A deadline was added here and then REMOVED the same session, because a cross-family
245
+ # review measured that the watchdog does not do what its name says: wrapping
246
+ # `git merge --ff-only` in `perl -e 'alarm N; exec @ARGV'` with N=2, against an upstream
247
+ # adding 20 files behind a slow smudge filter, took ~7.9s and returned 0. The alarm did
248
+ # not bound git. Shipping it would have added the appearance of a bound with none of the
249
+ # behaviour — the same false-green shape this release exists to fix.
250
+ # ⚠️ The consequence reaches further than this line: `_fh_gitcheck_deadline` guards the
251
+ # FETCH above too, and that guard predates this change. Whether it actually bounds a
252
+ # stalled fetch is now UNVERIFIED rather than assumed — a network stall may differ from a
253
+ # CPU-bound checkout, and neither was measured. Recorded as a residual instead of being
254
+ # quietly relied on.
255
+ # The exposure that motivated the attempt is real but unmeasured: the fetch may spend its
256
+ # full 8s inside a SessionStart hook budgeted at 10s, leaving ~2s for a merge whose true
257
+ # duration nobody has timed. Fixing it properly means measuring that duration and then
258
+ # bounding with something that actually bounds — not re-adding this line.
259
+ if [ -n "$_AUTOPULL" ] && [ -n "$_ON_DEFAULT" ] \
260
+ && git -C "$FH" merge --ff-only "refs/remotes/origin/$_DEFAULT_BRANCH" >/dev/null 2>&1; then
261
+ # Announce every unprompted run — §Operational Adaptation Loop requires it of a standing
262
+ # grant, and a sync the reader never saw is indistinguishable from one that never ran.
263
+ GIT_BEHIND_NOTE="local $_DEFAULT_BRANCH was ${_BEHIND} commit(s) behind — fast-forwarded automatically (standing consent: repo-freshness-autopull). Nothing else was touched; your gitignored state is out of git's reach by construction."
264
+ else
265
+ # Every not-applied path lands here and says the same thing: what to run. It does NOT
266
+ # say why it did not apply, on purpose — "no grant" and "wrong branch" and "ff refused"
267
+ # would each need their own true sentence, and a wrong reason printed confidently is
268
+ # worse than none (this file's own §absent-subject rule).
269
+ GIT_BEHIND_NOTE="local $_DEFAULT_BRANCH is ${_BEHIND} commit(s) behind origin/$_DEFAULT_BRANCH — while ON that branch run: git merge --ff-only origin/$_DEFAULT_BRANCH"
270
+ fi ;;
271
+ esac
272
+ fi
273
+ # no local branch named $_DEFAULT_BRANCH at all (e.g. a fork never checked it out) → silent,
274
+ # not measurable by this method.
275
+ fi
276
+ # fetch failure (offline / deadline hit) → silent. Detector-not-gate: absence of signal here
277
+ # must never be misread as "up to date" by a caller, so it prints nothing rather than a claim.
278
+ fi
279
+
154
280
  # ── state write — unconditional, and a failure is reported, never swallowed ────
155
281
  # Writing only when the check speaks would make the recorded timestamp mean "last time it spoke",
156
282
  # and a write failure would make this banner repeat forever with no explanation.
@@ -161,7 +287,7 @@ if ! { mkdir -p "$(dirname "$STATE")" 2>/dev/null \
161
287
  fi
162
288
 
163
289
  # ── emit: condition (every session) OR event (once) ───────────────────────────
164
- [ -n "$MISS$COMPANION_NOTE$INFRA$INFRA_NOTE$IDENTITY$STATE_WARN" ] || exit 0
290
+ [ -n "$MISS$COMPANION_NOTE$INFRA$INFRA_NOTE$IDENTITY$STATE_WARN$GIT_BEHIND_NOTE" ] || exit 0
165
291
 
166
292
  if [ -n "$MISS" ]; then
167
293
  echo "🖥️ [node] Missing mechanical floor on this machine (node: $NODE_ID): ${MISS% · }"
@@ -171,6 +297,7 @@ elif [ -n "$IDENTITY" ]; then
171
297
  echo "🖥️ [node] $IDENTITY (node: $NODE_ID) — floors present."
172
298
  fi
173
299
  [ -n "$STATE_WARN" ] && echo " ⚠️ $STATE_WARN"
300
+ [ -n "$GIT_BEHIND_NOTE" ] && echo "🔽 [git] $GIT_BEHIND_NOTE"
174
301
  [ -n "$COMPANION_NOTE" ] && echo " ℹ️ $COMPANION_NOTE"
175
302
  if [ -n "$INFRA_NOTE" ]; then
176
303
  echo " ⚠️ $INFRA_NOTE"
@@ -41,6 +41,20 @@ BE="${BE_DIR:-}" # companion-store path — supplied by the gitignored hook re
41
41
  # Resolved HERE (not at the Mode-D block below) because the frontier-digest check
42
42
  # needs it: on a multi-node setup the digest producer may be a DIFFERENT machine.
43
43
 
44
+ # TM = this hub's tracks-meta namespace under $BE (pmh-dev#68 PR #368 review): this reader used to
45
+ # hardcode "tracks-meta" unconditionally, so a sibling hub reading it after sync-to-be.sh's
46
+ # namespace fix would load FH's own session card/freshness data as if it were its own. Resolution
47
+ # is shared with sync-to-be.sh/sync-from-be.sh via fh_hub_identity.sh. This hook's own contract is
48
+ # "never block the first turn" (see header), so an unresolved identity degrades to the historical
49
+ # unsuffixed "tracks-meta" rather than erroring — that is the pre-fix behavior, not a new failure
50
+ # mode, and it only matters for a hub this file cannot even identify in the first place.
51
+ if [ -n "$BE" ]; then
52
+ _FH_IDLIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fh_hub_identity.sh"
53
+ # shellcheck source=scripts/fh_hub_identity.sh
54
+ [ -f "$_FH_IDLIB" ] && . "$_FH_IDLIB" && fh_resolve_hub_identity 2>/dev/null
55
+ fi
56
+ TM="${TM:-tracks-meta}"
57
+
44
58
  # ── node re-entry floor check ────────────────────────────────────────────────
45
59
  # 이 검사는 scripts/fh_node_check.sh 로 분리했다. 이유: 이 파일(fh_session_load.sh)은 gitignored
46
60
  # settings.local.json 에 등록되므로 새 클론/새 기계에선 애초에 안 돈다 — 검사가 존재 이유가 되는
@@ -112,7 +126,7 @@ _fd_hit() { find "$1" -maxdepth 1 -name "frontier_digest_$(date +%Y_%m_%d)*.md"
112
126
  # (2026-07-30 실측: 프로가 07-24~30 매일 정상 생산 중인데 에어는 7일 연속 FAILED 를 띄웠다.
113
127
  # 계기의 스코프가 대상보다 좁았던 케이스 — 대상은 '오늘 digest 가 있나'지 '이 디스크에 있나'가 아니다.)
114
128
  # 술어는 로컬과 **동일**(glob + -size +1k) — divergent-leniency 를 만들지 않는다.
115
- _fd_ready() { _fd_hit "$FH/tracks/_meta" || { [ -n "$BE" ] && _fd_hit "$BE/tracks-meta"; }; }
129
+ _fd_ready() { _fd_hit "$FH/tracks/_meta" || { [ -n "$BE" ] && _fd_hit "$BE/$TM"; }; }
116
130
  # THE SECOND HALF OF THE SAME SCOPE BUG (2026-07-31). The comment above got the principle right —
117
131
  # "대상은 '오늘 digest 가 있나'지 '이 디스크에 있나'가 아니다" — and then widened the predicate by
118
132
  # exactly ONE surface (the companion store), leaving it file-only. There are TWO live producers:
@@ -226,8 +240,11 @@ CARD_EPOCH=0
226
240
  # 3) Companion files NEWER than the card, in the surfaces that carry landed results/handoffs.
227
241
  # (paper-signals = completed experiments; handoff = cross-session/cross-machine; tracks-meta
228
242
  # = synced session meta.) These are exactly what a stale card fails to point at.
243
+ # ★ "$TM" (not the literal "tracks-meta") — paper-signals/handoff/digests are FH-exclusive areas
244
+ # sync-to-be.sh never namespaces (it does not write them for any hub), only tracks-meta needs the
245
+ # suffix here (pmh-dev#68 PR #368 review).
229
246
  NEWER=""
230
- for sub in paper-signals handoff tracks-meta digests; do
247
+ for sub in paper-signals handoff "$TM" digests; do
231
248
  d="$BE/$sub"
232
249
  [ -d "$d" ] || continue
233
250
  while IFS= read -r f; do
@@ -271,6 +288,9 @@ EOF
271
288
  done
272
289
 
273
290
  # 4) INDEX.md live pointers (the operator's wiki TOC — read-first per CLAUDE.local.md).
291
+ # Deliberately UNSUFFIXED: this is a single shared companion-store index, not a per-hub write
292
+ # target — sync-to-be.sh never writes a $TM-namespaced copy of it, so there is nothing to namespace
293
+ # here either (unlike tracks-meta above, which pmh-dev#68 PR #368 review flagged for exactly this).
274
294
  INDEX_HEAD=""
275
295
  if [ -f "$BE/INDEX.md" ]; then
276
296
  INDEX_HEAD="$(grep -iE 'live pointer|Live pointers' -A 8 "$BE/INDEX.md" 2>/dev/null | head -10)"
@@ -0,0 +1,229 @@
1
+ #!/bin/bash
2
+ # frontier_digest_autopilot.sh — daily digest + autonomous improvement pipeline, PR-only.
3
+ #
4
+ # WHY: frontier-digest itself has run unattended (launchd) since 2026-06 — collecting signal
5
+ # needs no approval. Turning a signal into an actual FH change was, until now, always a session
6
+ # the operator had to start by hand. Operator decision (2026-08-15): push automation one stage
7
+ # further — digest → persona-innovator → harvest-loop → 4-axis gate → PR, entirely unattended —
8
+ # but stop at the PR. Merge stays a human decision, per CLAUDE.md §AI Contribution Model ("AI does
9
+ # not commit directly to shared repositories") and §PR-Only Policy on main. This script is the
10
+ # proposal-only half of that split, not an exception to it.
11
+ #
12
+ # THIS IS STAGE 2 OF THE EXISTING DAILY JOB, NOT A SEPARATE CRON:
13
+ # Stage 1 (unchanged) = frontier_digest_daily.sh — collects the digest, hardened with its own
14
+ # retry/watchdog/lock (see that file's header). This script calls it first, then only proceeds
15
+ # to Stage 2 if Stage 1 actually produced today's digest.
16
+ # Stage 2 (new) = a single headless `claude -p` invocation that reads the digest and, ONLY IF a
17
+ # concrete change survives harvest-loop's own 4-axis gate, opens a PR. No gate-passing candidate
18
+ # → no PR, no forced busywork (operator's explicit threshold choice: "harvest-loop이 실제 게이트를
19
+ # 통과한 때만"). This mirrors the digest's own honesty rule (absence ≠ zero, but also: a thin
20
+ # signal is not manufactured into a change just to have shipped something today).
21
+ #
22
+ # BACK-END SHIPPING DOCTRINE (operator, 2026-08-15): "개발의 앞단-영혼심기, 중간단-탈상관 가속화,
23
+ # 뒷단 출하전-4단검증 및 하네스오너 리뷰" — the back end of shipping is 4-axis verification AND a
24
+ # standpoint-axis review, not 4-axis alone. CORRECTED same session (an earlier draft of this
25
+ # header named fh-meta:hub-cc-pr-reviewer here — wrong skill, caught by the operator: "내가 말한건
26
+ # 하네스오너(너) 아니라 그 하네스에 에이전트가 들어가서 그 입장에서 리뷰한다는거야"). "하네스오너
27
+ # 리뷰" is NOT the human operator (that gate is merge, unconditionally human, unchanged) NOR
28
+ # hub-cc-pr-reviewer (which checks FH's diff against FH's OWN conventions — same-repo
29
+ # self-consistency, a different question). It is the STANDPOINT AXIS
30
+ # (`knowledge/shared/harness-core/field_verdict_crossfamily_gate.md §7`, the mechanism behind the
31
+ # if(kakao)26 keynote's p15 "(c) 탈상관의 확장" slide — "계열을 늘려도 못 잡는 결함이 있습니다,
32
+ # 입장을 바꾸면 보입니다"): an agent actually running the diff's effect from ANOTHER harness's own
33
+ # repo/standpoint, which is orthogonal to family diversity and catches a documented, distinct class
34
+ # of defect family diversity alone does not. Wired into the Stage-2 prompt as two back-end
35
+ # checkpoints beyond the ordinary 4-axis gate: (1) an irreversible/load-bearing-surface diff holds —
36
+ # no PR, branch+signal only, operator decides whether a PR should even exist; (2) a diff that meets
37
+ # §7's own BEHAVIORAL trigger (alters another harness's actual behavior/gate-outcome/interaction
38
+ # contract — not merely a file-class match) gets a standpoint-axis pass, recorded on the closed
39
+ # tier1/tier2/tier2b/tier3/not-applicable/DEGRADED_* enum, before a PR opens (or folded into the
40
+ # held signal, if both checkpoints fire). Most ordinary digest-driven self-improvement will
41
+ # correctly land on `not-applicable` — that is the expected common case, not a shortfall.
42
+ # SAFETY RAILS (why each exists) — hardened by fh-meta:challenger 2026-08-15 [S×2 fixed]:
43
+ # ① git status must be clean before Stage 2 starts. A shared checkout may have another session's
44
+ # in-progress uncommitted work — Stage 2 must never commit, stash, or otherwise touch state
45
+ # it did not create. Dirty tree → skip, log why, leave everything untouched.
46
+ # ② skip if a LIVE PEER SESSION CLAIM exists at all (branch_claim.sh show), not just if the tree
47
+ # is dirty. FIXED 2026-08-15: an earlier revision's header claimed branch_claim.sh protection
48
+ # but never actually called it — a human could have a clean-but-mid-thought tree (branch
49
+ # switched, nothing edited yet) and this script would have force-checked-out their branch out
50
+ # from under them in the EXIT trap with zero warning (challenger [S], confirmed real: the
51
+ # 45-min Stage 2 window has no lock held the whole time — this check narrows, does not close,
52
+ # that TOCTOU; a peer session starting mid-run is a named residual, not fully closed). Any
53
+ # live peer at entry → skip Stage 2 entirely, log it, touch nothing.
54
+ # ③ skip if an autopilot PR is already open (branch prefix `auto/digest-improve-`) — one at a
55
+ # time, so a quiet week of "nothing concrete" backlog does not pile into an open-PR queue the
56
+ # operator has to triage. FIXED 2026-08-15: an earlier revision used
57
+ # `gh pr list --search 'head:auto/digest-improve-'` — GitHub's `head:` search qualifier is an
58
+ # EXACT branch-name match, not a prefix match, against branches that are actually named
59
+ # `auto/digest-improve-<full-date>` — the check would almost always find nothing and silently
60
+ # let PRs pile up unbounded (challenger [S], confirmed: verified via a live gh pr list --json
61
+ # call against this repo, see verify_next in edit_manifest.yaml). Fixed to filter headRefName
62
+ # client-side with jq's startswith(), which cannot have this exact-vs-prefix mismatch.
63
+ # ④ working tree is restored to the pre-run branch on exit (trap) ONLY IF still no live peer
64
+ # claim differs from where we started — re-checked at exit, not just at entry, because rail ②
65
+ # only guards the START of the window. If a peer claim appeared during the run, the trap logs
66
+ # a warning and leaves the tree where Stage 2 left it rather than yanking a now-live session's
67
+ # ground (mirrors branch_claim.sh's own printed guidance: "먼저 한 줄 알려라" — an unattended
68
+ # script cannot tell them first, so the fail-safe direction is: don't act, not: act anyway).
69
+ # ⑤ Stage 2 runs with --permission-mode bypassPermissions (no human present to answer a Bash
70
+ # approval prompt — acceptEdits, used by Stage 1, only covers Write/Edit, not git/gh). The
71
+ # actual floor is NOT this permission mode — it is the git hooks (pre-commit 4-axis gate,
72
+ # pre-push main-block, pre-push force-push block), which fire regardless of CC's own
73
+ # permission layer, plus merge being structurally impossible (this script and its prompt never
74
+ # invoke `gh pr merge`). NAMED RESIDUAL (challenger [S], not mitigated in v1 — accepted, not
75
+ # silently assumed covered): bypassPermissions removes CC's own approval layer for
76
+ # EVERYTHING, not just git in this repo — nothing here technically stops the subprocess from
77
+ # touching files elsewhere under this OS user (~/.ssh, other repos, arbitrary rm) or making
78
+ # outbound network calls beyond the git hooks' scope. The prompt instructs it not to; that is
79
+ # an instruction, not an enforcement boundary. Accepted for v1 because Stage 2 only fires after
80
+ # rails ①–③ pass and the blast radius is bounded by what a headless single-purpose prompt
81
+ # would plausibly do, not by a technical sandbox — revisit if this is ever wired to a more
82
+ # capability-rich prompt.
83
+ # ⑥ NAMED RESIDUAL — prompt injection via digest content (challenger [S], unmitigated): the
84
+ # digest ingests HN/arXiv content verbatim, and Stage 2 reads that digest under
85
+ # bypassPermissions with git-push/PR-create ability. Adversarial text embedded in fetched
86
+ # content could in principle steer the unattended session toward a misleading PR. No
87
+ # sanitization pass exists. Mitigant in practice: the PR is proposal-only (never merges
88
+ # itself) and still has to pass a real 4-axis gate with real challenger evidence — but that
89
+ # gate is text-based judgment, not a hard technical filter against this class.
90
+ # ⑦ NAMED RESIDUAL — log/secret exposure (challenger [A]): stdout+stderr of the Stage 2 claude
91
+ # process is captured verbatim into tracks/_meta/logs/, which a Stop hook may sync to a private
92
+ # companion store unattended, if one is configured. Any secret the agent happens to touch
93
+ # during the run propagates into a second store with no redaction pass. Accepted for v1 (same
94
+ # log-capture pattern as
95
+ # Stage 1's frontier_digest_daily.sh, already running this way since 2026-06) — revisit if
96
+ # Stage 2 is ever given credentials Stage 1 never touched.
97
+ #
98
+ # WHAT THIS DOES NOT DO: pick a "topic of the day" to force a contribution, retry on a NOT-CONVERGED
99
+ # outcome, touch anything if Stage 1 (digest) itself failed today, or act while any live peer
100
+ # session is claimed in this checkout.
101
+
102
+ FH_DIR="${FD_FH_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
103
+ CLAUDE_BIN="${FD_CLAUDE_BIN:-$(command -v claude || echo "${HOME}/.local/bin/claude")}"
104
+ TODAY=$(date +%Y_%m_%d)
105
+ HUMAN_DATE=$(date +%Y-%m-%d)
106
+ LOG_DIR="${FH_DIR}/tracks/_meta/logs"
107
+ LOG_FILE="${LOG_DIR}/frontier_digest_autopilot_${TODAY}.log"
108
+ mkdir -p "$LOG_DIR"
109
+ _log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"; }
110
+
111
+ # ── Stage 1: unchanged digest run ───────────────────────────────────────────
112
+ bash "${FH_DIR}/scripts/frontier_digest_daily.sh"
113
+ DIGEST_EXIT=$?
114
+ if [ "$DIGEST_EXIT" -ne 0 ]; then
115
+ _log "Stage 1 (digest) failed (exit ${DIGEST_EXIT}) — Stage 2 skipped, nothing to act on."
116
+ exit "$DIGEST_EXIT"
117
+ fi
118
+ _log "Stage 1 (digest) OK for ${HUMAN_DATE}."
119
+
120
+ cd "$FH_DIR" || { _log "cannot cd to \$FH_DIR ($FH_DIR)"; exit 1; }
121
+
122
+ # ── Stage 2 preconditions (rails ① ② ③) ─────────────────────────────────────
123
+ if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
124
+ _log "Stage 2 SKIPPED: working tree is dirty (another session's uncommitted work may be live) — never touching it unattended."
125
+ exit 0
126
+ fi
127
+
128
+ # rail ②: any LIVE peer claim at all → skip. branch_claim.sh's own "show" lists each recorded
129
+ # claim with a live/dead tag (dead = the recording session's PID no longer exists). We do not try
130
+ # to be clever about "does the peer's branch differ from ours" here — any live peer means a human
131
+ # is plausibly mid-thought in this exact checkout right now, full stop.
132
+ if [ -x "${FH_DIR}/scripts/branch_claim.sh" ]; then
133
+ if bash "${FH_DIR}/scripts/branch_claim.sh" show 2>/dev/null | grep -q '(live)'; then
134
+ _log "Stage 2 SKIPPED: a live peer session claim exists in this checkout — not touching HEAD/committing while a human may be mid-thought here. Will try again on the next scheduled run."
135
+ exit 0
136
+ fi
137
+ else
138
+ _log "Stage 2 SKIPPED: scripts/branch_claim.sh not found or not executable — cannot verify no live peer session, so refusing to touch the shared checkout unattended."
139
+ exit 0
140
+ fi
141
+
142
+ # rail ③: fixed 2026-08-15 — `gh pr list --search 'head:...'` is an EXACT-match qualifier, not a
143
+ # prefix match, so it silently never matched our date-suffixed branch names (challenger [S]).
144
+ # Filter client-side instead: list open PRs' head branch names, test the prefix ourselves.
145
+ if ! command -v gh >/dev/null 2>&1; then
146
+ _log "Stage 2 SKIPPED: gh CLI not found — cannot check for an existing open autopilot PR, and cannot open one."
147
+ exit 0
148
+ fi
149
+ OPEN_AUTO_COUNT="$(gh pr list --state open --json headRefName \
150
+ -q '[.[] | select(.headRefName | startswith("auto/digest-improve-"))] | length' 2>/dev/null)"
151
+ case "$OPEN_AUTO_COUNT" in
152
+ ''|*[!0-9]*)
153
+ _log "Stage 2 SKIPPED: could not measure open-autopilot-PR count (gh call failed/unparseable) — not measurable is not the same as zero, refusing to risk piling up a PR unseen."
154
+ exit 0
155
+ ;;
156
+ 0) ;; # clear to proceed
157
+ *)
158
+ _log "Stage 2 SKIPPED: ${OPEN_AUTO_COUNT} autopilot PR(s) already open (branch prefix auto/digest-improve-) — one at a time, operator has not triaged it yet."
159
+ exit 0
160
+ ;;
161
+ esac
162
+
163
+ ORIG_BRANCH="$(git symbolic-ref --short HEAD 2>/dev/null || echo main)"
164
+ # rail ④: re-check for a live peer at exit time too — a peer starting mid-run must not get its
165
+ # ground yanked by our restorative checkout. This narrows, not closes, the TOCTOU window (see
166
+ # header rail ② note) — a peer appearing in the few seconds between this check and the actual
167
+ # `git checkout` call below is a residual, not claimed as fully closed.
168
+ _fh_autopilot_exit_trap() {
169
+ if [ -x "${FH_DIR}/scripts/branch_claim.sh" ] \
170
+ && bash "${FH_DIR}/scripts/branch_claim.sh" show 2>/dev/null | grep -q '(live)'; then
171
+ _log "EXIT: a live peer session claim appeared during this run — leaving the tree as Stage 2 left it, NOT force-checking-out ${ORIG_BRANCH} (would yank a now-live session's ground)."
172
+ else
173
+ _log "EXIT: restoring working tree to ${ORIG_BRANCH}"
174
+ git checkout --quiet "$ORIG_BRANCH" 2>/dev/null
175
+ fi
176
+ }
177
+ trap _fh_autopilot_exit_trap EXIT
178
+
179
+ ATTEMPT_TIMEOUT_SECS="${FD_AUTOPILOT_TIMEOUT_SECS:-2700}"
180
+
181
+ # bash 3.2 (macOS default, confirmed this machine) has a known parser bug: a heredoc directly
182
+ # inside a `$(...)` command substitution mis-tracks quote balance once the heredoc BODY contains an
183
+ # unescaped apostrophe (e.g. "Today's") — `bash -n` fails with "unexpected EOF while looking for
184
+ # matching `'". Writing the heredoc to a plain file first, then capturing that file's content via
185
+ # `$(cat ...)` (a command substitution with NO heredoc inside it), sidesteps the bug entirely.
186
+ PROMPT_FILE="${LOG_DIR}/.autopilot_prompt_${TODAY}.txt"
187
+ cat > "$PROMPT_FILE" <<PROMPT_EOF
188
+ [automated-run: launchd, unattended — no human present to answer prompts] Today's frontier digest just landed at tracks/_meta/frontier_digest_${TODAY}.md (or the companion-store mirror if this node is not the digest runner). Run the digest -> persona-innovator (Mode F, full: internal gap scan + external frontier) -> harvest-loop pipeline against it, exactly as documented in CLAUDE.md and the relevant SKILL.md files - do not shortcut the 4-axis gate (edit-manifest entry, a real fh-meta:challenger adversarial pass with actual evidence, not a fabricated marker).
189
+
190
+ Threshold (operator's explicit choice, 2026-08-15): only act if a CONCRETE, well-scoped candidate survives harvest-loop and actually passes the 4-axis gate as a real committable diff. If nothing concrete surfaces today, do nothing further - do not manufacture a change to have shipped something. A quiet day is a correct outcome, not a failure.
191
+
192
+ BACK-END CHECKPOINTS (operator instruction, 2026-08-15 - required steps on this pipeline's shipping stage, not suggestions; this is the "뒷단 출하전 = 4축검증 + 하네스오너 리뷰" doctrine, applied to an unattended run):
193
+
194
+ 1. IRREVERSIBILITY CHECK: does the gate-passing diff touch an IRREVERSIBLE surface per CLAUDE.md's Irreversibility Gates section (a publish/delete/history-rewrite path) OR a load-bearing surface per the Field-Harness Load-Bearing Change Gate (a verdict/gate enum or exit code, an irreversible-op path, or a safety invariant such as a floor, verdict-binding, or a pre-push/pre-commit hook)? This includes changes to the git hooks themselves (templates/.git-hooks/*), scripts a hook calls, or anything in the Irreversibility Gates / Destructive-Op Gate / Pre-Publish Gate sections of CLAUDE.md.
195
+ - If YES: do NOT run gh pr create at all. Create the branch, commit, push it (so the work is not lost), write a signal file at tracks/_meta/fh_signal_${HUMAN_DATE}_autopilot-irreversible-hold.md with frontmatter 'status: NEEDS-OWNER-REVIEW' naming the branch, exactly which surface it touches and why, and what the change does - then stop. No PR exists yet; the operator decides whether one should even be opened.
196
+
197
+ 2. STANDPOINT AXIS (knowledge/shared/harness-core/field_verdict_crossfamily_gate.md §7 - read it before applying this step, this summary is not the full spec): does the diff alter ANOTHER harness's actual behavior, gate outcome, or interaction contract (not merely touch a path that happens to be synced elsewhere - the trigger is behavioral, not file-class)? Most ordinary FH self-improvement from a digest signal will correctly land on standpoint: not-applicable - that is a correct, expected answer, not a shortfall to fix. This is a genuinely different axis from "harness-owner reviewing FH's own conventions" - it means an agent actually running the diff's effect FROM the standpoint of the OTHER harness's own repo (family diversity alone does not catch this: the mechanism behind the if(kakao)26 keynote's p15 slide "(c) 탈상관의 확장" - "계열을 늘려도 못 잡는 결함이 있습니다, 입장을 바꾸면 보입니다" - §7 is built on three field incidents where full cross-family review missed a defect that only one execution-from-the-target's-own-repo caught).
198
+ - If NOT applicable (the common case): record standpoint: not-applicable in the marker/signal, state briefly what was checked (per the spec's own discipline - asserting non-applicability without naming what was checked is indistinguishable from not having looked at all), and move on.
199
+ - If applicable: check whether a local clone of the target harness exists on this machine (e.g. under the parent of ${FH_DIR} - sibling directories like pmh-dev, qasp-dev, or similar). If one exists, run the change against THAT repo's own content/rules from its own standpoint (tier2) - this must happen BEFORE any push or gh pr create for THIS diff, same non-negotiable pre-push timing as the irreversibility check above and for the identical reason (PR #370, 2026-08-14: a post-PR standpoint review still caught 2 residency leaks that had already sat in public view before the fix - public exposure is effectively irreversible, so this cannot run after the diff is visible). If it finds anything, fix it in the local diff and re-run until clean - only then push and open the PR (or, if step 1 already routed to hold, fold the finding into that signal file instead). If no local clone of the target harness is reachable, record standpoint: DEGRADED_NO_TARGET_ACCESS (could not, not did not) and proceed - do not block indefinitely on a target you structurally cannot reach, but do not silently claim not-applicable either when it actually is applicable and merely unreachable.
200
+ Do NOT conflate this with fh-meta:challenger (family/adversarial-correctness axis, already required by the 4-axis gate above) or with fh-meta:hub-cc-pr-reviewer (checks FH's own diff against FH's OWN baseline conventions - a same-repo self-consistency check, not a standpoint-axis review at all). All three are different lenses; running one is not a substitute for another.
201
+
202
+ 3. If the diff is neither irreversible/load-bearing (step 1: NO) nor standpoint-applicable (step 2: NO or DEGRADED) - the common case, ordinary small reversible self-improvement: skip straight to branch/commit/push/PR below.
203
+
204
+ In every case that reaches a PR: create branch auto/digest-improve-${HUMAN_DATE}, commit there, push it, open with gh pr create --fill, noting in the PR body which digest item motivated it. Never commit to main (the pre-push hook enforces this regardless). Do NOT run gh pr merge under any circumstances - merge is always the operator's decision. Do NOT push to main directly. Do NOT force-push. Do NOT touch any file outside what this specific candidate change requires.
205
+
206
+ If you are blocked (git status was unexpectedly dirty, gh auth failed, the gate genuinely cannot converge in one pass), stop and log why - do not retry, do not fall back to a weaker gate, do not touch main.
207
+ PROMPT_EOF
208
+
209
+ PROMPT="$(cat "$PROMPT_FILE")"
210
+ rm -f "$PROMPT_FILE"
211
+
212
+ _log "Stage 2 starting (timeout ${ATTEMPT_TIMEOUT_SECS}s)"
213
+ "$CLAUDE_BIN" -p --permission-mode bypassPermissions "$PROMPT" >> "$LOG_FILE" 2>&1 &
214
+ CLAUDE_PID=$!
215
+ DEADLINE=$((SECONDS + ATTEMPT_TIMEOUT_SECS))
216
+ while kill -0 "$CLAUDE_PID" 2>/dev/null && [ "$SECONDS" -lt "$DEADLINE" ]; do
217
+ sleep 30 & wait $!
218
+ done
219
+ if kill -0 "$CLAUDE_PID" 2>/dev/null; then
220
+ pkill -P "$CLAUDE_PID" 2>/dev/null
221
+ kill "$CLAUDE_PID" 2>/dev/null
222
+ wait "$CLAUDE_PID" 2>/dev/null
223
+ _log "Stage 2 killed by watchdog (${ATTEMPT_TIMEOUT_SECS}s) — no retry (this is not the digest's hard-realtime path)."
224
+ else
225
+ wait "$CLAUDE_PID"
226
+ _log "Stage 2 finished (exit $?)."
227
+ fi
228
+
229
+ exit 0