@chrono-meta/fh-gate 1.4.73 → 1.4.75

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 (42) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CHEATSHEET.md +1 -1
  3. package/CLAUDE.md +14 -1
  4. package/docs/ETHOS.md +106 -0
  5. package/docs/OUTPUT_EVIDENCE.md +118 -0
  6. package/docs/WHY.md +42 -0
  7. package/knowledge/patterns/ensemble_union_detection_task_pattern.md +125 -0
  8. package/knowledge/shared/GLOSSARY.md +77 -0
  9. package/knowledge/shared/harness-core/harness_frontier_diagnosis_2026-06-02.md +1 -1
  10. package/knowledge/shared/harness-core/meta_harness_engineering_definition.md +1 -1
  11. package/knowledge/shared/learnings/subagent_invocations_log.yaml +9 -0
  12. package/knowledge/shared/patterns/multi-persona-review.md +88 -0
  13. package/knowledge/shared/plugin-catalog/recommended_plugins.md +117 -0
  14. package/package.json +28 -1
  15. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  16. package/plugins/fh-meta/.claude-plugin/plugin.json +2 -2
  17. package/plugins/fh-meta/CHANGELOG.md +617 -0
  18. package/scripts/below_floor_scan.sh +91 -0
  19. package/scripts/chamber_run.sh +184 -0
  20. package/scripts/degrade_direction_scan.sh +17 -2
  21. package/scripts/fh_env_delta_scan.sh +108 -0
  22. package/scripts/memory_link_check.py +237 -0
  23. package/scripts/memory_nearcheck.py +131 -0
  24. package/scripts/package_coverage_check.sh +140 -0
  25. package/scripts/selfcheck.sh +47 -0
  26. package/scripts/session_close_check.sh +31 -1
  27. package/scripts/sidecar_wait.sh +76 -0
  28. package/scripts/substrate_jump_detector.sh +60 -0
  29. package/scripts/test_card_drift_probe.sh +77 -0
  30. package/scripts/test_degrade_scan_shell_probes.sh +26 -0
  31. package/scripts/test_marker_floor_lanes.sh +45 -0
  32. package/scripts/test_memory_link_check.sh +134 -0
  33. package/scripts/test_session_close_lanes.sh +99 -0
  34. package/scripts/tier_census_grep.sh +54 -0
  35. package/templates/.claude/rules/session.md +153 -0
  36. package/templates/contrib_session.md +34 -0
  37. package/templates/degrade_direction_scan.sh +17 -2
  38. package/templates/goal-quench-hook-setup.md +152 -0
  39. package/templates/starter_profile.md +83 -0
  40. package/templates/temper_check.sh +46 -0
  41. package/plugins/fh-meta/skills/context-bridge-dispatch/SKILL.md +0 -32
  42. package/plugins/fh-meta/skills/self-marketing-lint/SKILL.md +0 -30
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env bash
2
+ # below_floor_scan.sh — standing consumer for below-floor adversarial markers
3
+ #
4
+ # The pre-commit hook accepts a below-floor Axis-2 pass when an operator ack is
5
+ # present, on the promise that "the weekly audit re-queues below-floor markers
6
+ # for floor-tier re-run" (§Floor governance, multi_model_sidecar_strategy.md).
7
+ # This script IS that consumer: it enumerates below-floor markers and reports
8
+ # which ones still await floor-tier re-validation. Read-only, zero side effects.
9
+ #
10
+ # Resolution protocol (append to the marker after acting — machine-greppable):
11
+ # floor-rerun: <YYYY-MM-DD> <model> PASS|FAIL ← Axis 2 re-run at >= floor
12
+ # floor-writeoff: <YYYY-MM-DD> <one-line reason> ← operator writes the ack off
13
+ #
14
+ # Usage: bash scripts/below_floor_scan.sh [repo_root]
15
+ # Exit: 0 = no pending below-floor markers; 1 = pending re-runs found
16
+ # (manual weekly-audit step — Phase 1.5; exit 1 = raise the pending
17
+ # items as S-tier. No automated caller is wired yet.)
18
+
19
+ set -uo pipefail
20
+
21
+ REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
22
+ MARKER_DIR="$REPO_ROOT/tracks/_meta"
23
+ PENDING=0
24
+ TOTAL=0
25
+
26
+ echo "── below-floor marker scan: $MARKER_DIR ──"
27
+
28
+ if [ ! -d "$MARKER_DIR" ]; then
29
+ echo " (no tracks/_meta directory — nothing to scan)"
30
+ exit 0
31
+ fi
32
+
33
+ SONNET_PENDING=0
34
+ for m in "$MARKER_DIR"/.axes_23_passed_*.marker; do
35
+ [ -e "$m" ] || continue
36
+ # sonnet-floor lane (Sonnet-Floor Doctrine 2026-07-10): anchored Sonnet passes are
37
+ # first-class at commit time but provisional for judged depth — queued here as
38
+ # R-tier (advisory re-validation), distinct from below-floor's S-tier hard queue.
39
+ if grep -qE '^[[:space:]]*floor-status:[[:space:]]*sonnet-floor' "$m"; then
40
+ name=$(basename "$m")
41
+ rerun=$(grep -m1 -E '^[[:space:]]*floor-rerun:' "$m" \
42
+ | sed -E 's/^[[:space:]]*floor-rerun:[[:space:]]*//' || true)
43
+ writeoff=$(grep -m1 -E '^[[:space:]]*floor-writeoff:' "$m" \
44
+ | sed -E 's/^[[:space:]]*floor-writeoff:[[:space:]]*//' || true)
45
+ if [ -n "$rerun" ]; then
46
+ echo "✅ RESOLVED (re-run) $name — floor-rerun: $rerun"
47
+ elif [ -n "$writeoff" ]; then
48
+ echo "✅ RESOLVED (writeoff) $name — floor-writeoff: $writeoff"
49
+ else
50
+ SONNET_PENDING=$((SONNET_PENDING + 1))
51
+ anchor=$(grep -m1 -E '^[[:space:]]*axis2-anchor:' "$m" \
52
+ | sed -E 's/^[[:space:]]*axis2-anchor:[[:space:]]*//' || true)
53
+ echo "🟨 R-tier re-validate $name (sonnet-floor)"
54
+ echo " axis2-anchor: ${anchor:-<missing>}"
55
+ echo " action: re-run judged depth at >= opus or via sidecar dispatch when"
56
+ echo " available, append 'floor-rerun: ...' — or 'floor-writeoff: ...'"
57
+ fi
58
+ continue
59
+ fi
60
+ grep -qE '^[[:space:]]*floor-status:[[:space:]]*below-floor' "$m" || continue
61
+ TOTAL=$((TOTAL + 1))
62
+ name=$(basename "$m")
63
+ model=$(grep -m1 -E '^[[:space:]]*axis2-model:' "$m" \
64
+ | sed -E 's/^[[:space:]]*axis2-model:[[:space:]]*//' || true)
65
+ ack=$(grep -m1 -E '^[[:space:]]*below-floor-ack:' "$m" \
66
+ | sed -E 's/^[[:space:]]*below-floor-ack:[[:space:]]*//' || true)
67
+ rerun=$(grep -m1 -E '^[[:space:]]*floor-rerun:' "$m" \
68
+ | sed -E 's/^[[:space:]]*floor-rerun:[[:space:]]*//' || true)
69
+ writeoff=$(grep -m1 -E '^[[:space:]]*floor-writeoff:' "$m" \
70
+ | sed -E 's/^[[:space:]]*floor-writeoff:[[:space:]]*//' || true)
71
+
72
+ if [ -n "$rerun" ]; then
73
+ echo "✅ RESOLVED (re-run) $name — floor-rerun: $rerun"
74
+ elif [ -n "$writeoff" ]; then
75
+ echo "✅ RESOLVED (writeoff) $name — floor-writeoff: $writeoff"
76
+ else
77
+ PENDING=$((PENDING + 1))
78
+ echo "🟥 PENDING re-run $name"
79
+ echo " axis2-model: ${model:-<missing>} | ack: ${ack:-<missing>}"
80
+ echo " action: re-run Axis 2 at >= floor (opus), append 'floor-rerun: ...'"
81
+ echo " or operator writes off: append 'floor-writeoff: ...'"
82
+ fi
83
+ done
84
+
85
+ echo "── below-floor markers: $TOTAL total, $PENDING pending (S-tier) ──"
86
+ echo "── sonnet-floor markers pending re-validation: $SONNET_PENDING (R-tier, advisory) ──"
87
+ # Exit semantics unchanged: only below-floor (S-tier) hard-fails the scan.
88
+ # sonnet-floor pendings are advisory — reported, never exit-blocking (doctrine:
89
+ # Sonnet base is first-class; the queue exists so residuals terminate, not decorate).
90
+ [ "$PENDING" -gt 0 ] && exit 1
91
+ exit 0
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env bash
2
+ # chamber_run.sh — chamber run orchestrator (the runner the skeleton's gaps G1-forcing/G2/G4/STATUS all
3
+ # hung on). GLUE ONLY: it wires the pieces that already exist (workspace convention · budget gate notion ·
4
+ # the isolated persona agents · the Emission Gate · the G4 ledger) into one intent-driven, resumable flow
5
+ # so a chamber run can be *completed by intent* rather than hand-followed from the skeleton doc.
6
+ #
7
+ # What it MECHANIZES: workspace + INTENT/BUDGET templates · STATUS stamping (resumable — re-run to advance) ·
8
+ # the budget-entry gate (G2: blocks step 4 until an ESTIMATE is recorded — no uncapped run) · the step-4
9
+ # isolation gate (G1-forcing: blocks step 5 until SIM_NOTES has ≥3 blind persona sections) · the Emission
10
+ # Gate verdict capture · actual-cost record · and the G4 ledger auto-append (idempotent).
11
+ #
12
+ # What it CANNOT mechanize (honest muscle boundary, documented in CHAMBER_RUN_SKELETON.md): bash cannot
13
+ # spawn the isolated Agents itself. Step 4 PRINTS the exact dispatch and GATES on the ≥3 persona artifact —
14
+ # the human/Claude does the actual `fh-meta:{beginner,challenger,main-player}` dispatch. Isolation stays a
15
+ # salience+artifact gate, not a spawn. Budget/cost numbers calibrate only across real runs (muscle, not wiring).
16
+ #
17
+ # Usage: bash scripts/chamber_run.sh <candidate-slug> # create/advance the run (idempotent)
18
+ # bash scripts/chamber_run.sh <candidate-slug> status # show where the run is
19
+ # exit 0 = advanced or already complete · exit 1 = blocked on a missing artifact (message says which)
20
+ # exit 2 = harness error (FH root / bad slug)
21
+
22
+ set -uo pipefail
23
+
24
+ FH="$(cd "$(dirname "$0")/.." && pwd)"
25
+ if [ ! -d "$FH/tracks" ] || [ ! -d "$FH/plugins" ]; then
26
+ echo "❌ FH root not found at '$FH' — run from the FH repo." >&2; exit 2
27
+ fi
28
+
29
+ SLUG="${1:-}"
30
+ [ -z "$SLUG" ] && { echo "usage: chamber_run.sh <candidate-slug> [status]" >&2; exit 2; }
31
+ # slug charset: [A-Za-z0-9-] only, no leading dash. Rejecting regex metachars (`.` `+` `[` `*`) is
32
+ # load-bearing — $SLUG is interpolated raw into an ERE idempotency grep below; `a.b` would let `.` match
33
+ # any char (idempotency mismatch → duplicate ledger row), `a+b` would break the ERE (Axis-2 LOW-5).
34
+ case "$SLUG" in -*) echo "❌ bad slug '$SLUG' (no leading dash)" >&2; exit 2 ;; esac
35
+ case "$SLUG" in *[!A-Za-z0-9-]*) echo "❌ bad slug '$SLUG' (allowed: letters, digits, hyphen)" >&2; exit 2 ;; esac
36
+ CMD="${2:-advance}"
37
+
38
+ WS="$FH/tracks/_chamber/$SLUG"
39
+ LEDGER="$FH/tracks/_chamber/INDEX.md"
40
+ STATUS_F="$WS/STATUS"
41
+ TODAY="$(date +%Y-%m-%d)"
42
+
43
+ _status() { [ -f "$STATUS_F" ] && cat "$STATUS_F" || echo "step-0"; }
44
+ _stamp() { printf '%s\n' "$1" > "$STATUS_F"; }
45
+
46
+ if [ "$CMD" = "status" ]; then
47
+ echo "chamber run '$SLUG' → STATUS: $(_status)"
48
+ [ -d "$WS" ] && ls -1 "$WS" 2>/dev/null | sed 's/^/ /'
49
+ exit 0
50
+ fi
51
+
52
+ echo "── chamber run: $SLUG (STATUS: $(_status)) ──"
53
+
54
+ # STEP 1 — workspace
55
+ if [ ! -d "$WS" ]; then
56
+ mkdir -p "$WS" 2>/dev/null || { echo "❌ cannot create workspace $WS" >&2; exit 2; }
57
+ echo " ✓ step 1: workspace created ($WS)"
58
+ fi
59
+ _stamp "step-1-done"
60
+
61
+ # STEP 2 — INTENT.md (template if absent; block until it has real content)
62
+ if [ ! -f "$WS/INTENT.md" ]; then
63
+ cat > "$WS/INTENT.md" <<EOF
64
+ # INTENT — $SLUG (chamber run)
65
+
66
+ ## Candidate intent
67
+ <one line: the capability/project to incubate>
68
+
69
+ ## Success conditions (each with a check class: mandatory-pass / measured / judged)
70
+ 1.
71
+ 2.
72
+
73
+ ## Failure cost (blast radius AND reinvention risk)
74
+ -
75
+
76
+ ## Chamber metadata
77
+ - entry reason: <uncertain | exploratory | failure-expensive | high-reinvention-risk>
78
+ - date: $TODAY
79
+ EOF
80
+ echo " ⛔ step 2 BLOCKED: fill in $WS/INTENT.md (template written), then re-run."; exit 1
81
+ fi
82
+ if grep -q '<one line: the capability' "$WS/INTENT.md"; then
83
+ echo " ⛔ step 2 BLOCKED: $WS/INTENT.md still has the placeholder — fill it, then re-run."; exit 1
84
+ fi
85
+ # gate (a) is "artifact exists WITH real content", not just "placeholder removed" (Axis-2 LOW-6): require
86
+ # at least one non-empty numbered success condition so a gutted INTENT.md doesn't pass.
87
+ if ! awk '/^## Success conditions/{f=1;next} /^## /{f=0} f && /^[0-9]+\.[[:space:]]*[^[:space:]]/{print; exit}' "$WS/INTENT.md" | grep -q .; then
88
+ echo " ⛔ step 2 BLOCKED: $WS/INTENT.md has no filled success condition (need a numbered line with content), re-run."; exit 1
89
+ fi
90
+ _stamp "step-2-done"; echo " ✓ step 2: INTENT.md present"
91
+
92
+ # STEP 3 — budget-entry gate (G2). Cannot invoke goal-quench from bash; MECHANICALLY require a recorded
93
+ # estimate before any (expensive) simulation runs. No ESTIMATE = no run — that IS the entry cap.
94
+ if [ ! -f "$WS/BUDGET.md" ]; then
95
+ cat > "$WS/BUDGET.md" <<EOF
96
+ # BUDGET — $SLUG (chamber run)
97
+
98
+ # Route through goal-quench's budget gate for an expensive run, then record here.
99
+ # Demo-scale runs may self-cap — but an ESTIMATE line is mandatory (this is the entry cap).
100
+ ESTIMATE: <e.g. ~3 persona dispatches, demo-scale, self-capped | or a token budget>
101
+ ACTUAL: <filled at step 6>
102
+ EOF
103
+ echo " ⛔ step 3 BLOCKED: record an ESTIMATE in $WS/BUDGET.md (budget-entry gate G2), then re-run."; exit 1
104
+ fi
105
+ if grep -qE '^ESTIMATE:[[:space:]]*<' "$WS/BUDGET.md" || ! grep -qE '^ESTIMATE:[[:space:]]*\S' "$WS/BUDGET.md"; then
106
+ echo " ⛔ step 3 BLOCKED: $WS/BUDGET.md ESTIMATE is empty/placeholder (G2 entry cap), then re-run."; exit 1
107
+ fi
108
+ _stamp "step-3-done"; echo " ✓ step 3: budget ESTIMATE recorded (entry cap satisfied)"
109
+
110
+ # STEP 4 — persona simulation (G1-forcing gate). Dispatch is human/Claude-side (bash can't spawn Agents);
111
+ # gate on the ≥3 blind persona artifact. Isolation is the mechanism — the runner enforces the artifact, not the spawn.
112
+ if [ ! -f "$WS/SIM_NOTES.md" ]; then
113
+ echo " ⛔ step 4 BLOCKED: dispatch 3 BLIND ISOLATED Agents and record each in $WS/SIM_NOTES.md:"
114
+ echo " Agent fh-meta:beginner → first-contact friction"
115
+ echo " Agent fh-meta:main-player → daily-use / target-user value"
116
+ echo " Agent fh-meta:challenger → skeptic: emit value? failure cost? what's invisible?"
117
+ echo " Each as '## <persona> ...' section. (sim-conductor fills persona_container_schema's 6 slots.)"
118
+ exit 1
119
+ fi
120
+ # count DISTINCT personas (not raw lines — 3×"## beginner" must NOT satisfy the 3-blind-persona gate).
121
+ NPERS=0
122
+ for _p in beginner main-player challenger; do
123
+ grep -iqE "^##[[:space:]].*$_p" "$WS/SIM_NOTES.md" 2>/dev/null && NPERS=$((NPERS+1))
124
+ done
125
+ if [ "$NPERS" -lt 3 ]; then
126
+ echo " ⛔ step 4 BLOCKED: SIM_NOTES.md has $NPERS/3 DISTINCT blind persona sections (need all of beginner + main-player + challenger)."; exit 1
127
+ fi
128
+ _stamp "step-4-done"; echo " ✓ step 4: $NPERS blind persona sections present (isolation-gate satisfied)"
129
+
130
+ # STEP 5 — Emission Gate. Require a VERDICT: EMIT | PARTIAL-EMIT | KILL.
131
+ if [ ! -f "$WS/EMISSION_VERDICT.md" ]; then
132
+ cat > "$WS/EMISSION_VERDICT.md" <<EOF
133
+ # Emission Gate Verdict — $SLUG (chamber run)
134
+
135
+ VERDICT: <EMIT | PARTIAL-EMIT | KILL>
136
+
137
+ ## Judged: does the simulation hold? (+ mechanical anchor: overlap grep / gate verdicts / reproduced flows)
138
+
139
+ ## Carry-forward (what compounds into the next run)
140
+ -
141
+ EOF
142
+ echo " ⛔ step 5 BLOCKED: decide WITH the operator (HITL), record VERDICT in $WS/EMISSION_VERDICT.md, re-run."; exit 1
143
+ fi
144
+ # PARTIAL-EMIT listed FIRST in every alternation so it is never mis-extracted as its EMIT substring.
145
+ VERDICT=$(grep -ioE '^VERDICT:[[:space:]]*(PARTIAL-EMIT|EMIT|KILL)' "$WS/EMISSION_VERDICT.md" 2>/dev/null | head -1 | grep -ioE 'PARTIAL-EMIT|EMIT|KILL' | head -1 | tr 'a-z' 'A-Z')
146
+ # a bare "## Verdict:" prose line (run #3 style) also counts if it names KILL/EMIT
147
+ [ -z "$VERDICT" ] && VERDICT=$(grep -ioE 'VERDICT[: *]+\**(PARTIAL-EMIT|EMIT|KILL)' "$WS/EMISSION_VERDICT.md" 2>/dev/null | grep -ioE 'PARTIAL-EMIT|EMIT|KILL' | head -1 | tr 'a-z' 'A-Z')
148
+ if [ -z "$VERDICT" ]; then
149
+ echo " ⛔ step 5 BLOCKED: no VERDICT (EMIT|PARTIAL-EMIT|KILL) found in $WS/EMISSION_VERDICT.md, re-run."; exit 1
150
+ fi
151
+ _stamp "step-5-done"; echo " ✓ step 5: Emission Gate verdict = $VERDICT"
152
+
153
+ # STEP 6 — actual cost / carry-forward record.
154
+ if grep -qE '^ACTUAL:[[:space:]]*<' "$WS/BUDGET.md" || ! grep -qE '^ACTUAL:[[:space:]]*\S' "$WS/BUDGET.md"; then
155
+ echo " ⛔ step 6 BLOCKED: record ACTUAL cost in $WS/BUDGET.md (actual-vs-estimate calibration), re-run."; exit 1
156
+ fi
157
+ _stamp "step-6-done"; echo " ✓ step 6: actual cost recorded"
158
+
159
+ # STEP 7 — terminus + G4 ledger auto-append (idempotent).
160
+ if [ ! -f "$LEDGER" ]; then
161
+ echo " ⚠ step 7: no ledger at $LEDGER — skipping auto-append (fail-visible)."
162
+ elif grep -qE "^\|[^|]*\|[^|]*\|[^|]*\`$SLUG\`" "$LEDGER"; then
163
+ echo " ✓ step 7: ledger already has a row for '$SLUG' (idempotent — no duplicate append)."
164
+ else
165
+ NEXT=$(grep -oE '^\|[[:space:]]*#([0-9]+)' "$LEDGER" | grep -oE '[0-9]+' | sort -n | tail -1)
166
+ NEXT=$(( ${NEXT:-0} + 1 ))
167
+ CARRY=$(grep -A2 -iE '^##[[:space:]]*Carry-forward' "$WS/EMISSION_VERDICT.md" 2>/dev/null | grep -E '^-[[:space:]]*\S' | head -1 | sed 's/^-[[:space:]]*//; s/|/·/g')
168
+ [ -z "$CARRY" ] && CARRY="see $SLUG/EMISSION_VERDICT.md"
169
+ printf '| #%s | %s | `%s` | **%s** | %s | `%s/` |\n' "$NEXT" "$TODAY" "$SLUG" "$VERDICT" "$CARRY" "$SLUG" >> "$LEDGER"
170
+ echo " ✓ step 7: appended run #$NEXT ($VERDICT) to the G4 ledger."
171
+ fi
172
+ _stamp "step-7-done"
173
+
174
+ echo ""
175
+ case "$VERDICT" in
176
+ EMIT) echo "TERMINUS (EMIT): route by class — field harness → Full-Harness Mode (auto_project_mapping.md §6);"
177
+ echo " FH-internal utility → New-Skill Pre-Commit gate + asset-placement-gate." ;;
178
+ PARTIAL-EMIT) echo "TERMINUS (PARTIAL-EMIT): the standing candidate is killed; fold the surviving sliver into an"
179
+ echo " existing asset / the skeleton (no new asset). Workspace stays as evidence." ;;
180
+ KILL) echo "TERMINUS (KILL): first-class success — a cheap run prevented a speculative/reinvention build."
181
+ echo " No emit. Workspace stays as the evidence record; seen-filter will skip re-listing it." ;;
182
+ esac
183
+ echo "chamber run '$SLUG' COMPLETE (STATUS: step-7-done, verdict $VERDICT)."
184
+ exit 0
@@ -150,9 +150,24 @@ for f in "${FILES[@]}"; do
150
150
  # S5 — the pipefail-fallback disarm: `... | grep -c ... || echo 0` appends a SECOND line under
151
151
  # `set -o pipefail`, so the later `-gt` integer test becomes a bash error (= false) and the guard
152
152
  # passes silently, with the error going only to stderr. Measured class, 2026-07-26.
153
+ #
154
+ # NARROWED 2026-07-28 after hand-verifying all 9 hits this repo produced: 9/9 were false
155
+ # positives, i.e. the probe was pure noise for its own class, and 100% FP trains dismissal of
156
+ # the one hit that will matter. Two distinct causes, both mechanically reproduced:
157
+ # (a) `a || b || echo 0` was read as a pipeline — the old regex could anchor its `\|` on the
158
+ # SECOND bar of the first `||`. No pipe exists, so no second line can ever be produced.
159
+ # (Every `_mtime() { stat -c %Y … || stat -f %m … || echo 0; }` in the tree was flagged.)
160
+ # (b) a real pipeline whose failing stage emits NOTHING (`… | jq -r … || echo 0`) — the
161
+ # fallback then supplies the only line, which is exactly the intended behavior.
162
+ # The disarm needs BOTH a real pipe AND a final stage that emits regardless of upstream failure
163
+ # — a counter (`grep -c`, `wc`). That is the measured shape: `find … | grep -c . || echo 0`
164
+ # yields "9\n0" and the `-gt` guard goes silent. Verified as a known pair (both directions) in
165
+ # scripts/test_degrade_scan_shell_probes.sh; narrowing without that anchor would just trade a
166
+ # noisy probe for a blind one.
153
167
  while IFS= read -r m; do
154
- emit "$f" "${m%%:*}" "S5:pipefail-fallback(sh)" "\`|| echo 0\` fallback on a pipeline — under \`set -o pipefail\` this yields a multi-line value whose integer comparison errors out and silently passes the guard; split the pipeline and sanitize to an integer"
155
- done < <(grep -nE '\|[^|]+\|\|[[:space:]]*echo[[:space:]]+[\"'"'"']?0' "$f" 2>/dev/null \
168
+ emit "$f" "${m%%:*}" "S5:pipefail-fallback(sh)" "\`|| echo 0\` fallback on a pipeline ending in a counter (grep -c/wc) that stage emits even when an upstream stage fails, so under \`set -o pipefail\` the value gains a SECOND line, the integer comparison errors out, and the guard passes silently; split the pipeline and sanitize to an integer"
169
+ done < <(grep -nE '[^|]\|[[:space:]]*([a-z]+[[:space:]]+)*(grep[^|]*-c|wc)[^|]*\|\|[[:space:]]*echo[[:space:]]+[\"'"'"']?0' "$f" 2>/dev/null \
170
+ | grep -vE '^[0-9]+:[[:space:]]*#' \
156
171
  | grep -vE '#[[:space:]]*noqa[:[:space:]]*degrade')
157
172
  fi
158
173
 
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env bash
2
+ # fh_env_delta_scan.sh — Mode D SessionStart ENVIRONMENT-DELTA detector (mechanical).
3
+ #
4
+ # WHY: FH's "undeployed-asset discovery + auto-mapping" (CLAUDE.md claim ②) works ONLY when a skill
5
+ # is explicitly invoked. The AUTONOMOUS half — "the environment changed (a new sibling repo pulled,
6
+ # a task-first session opened in an unmapped project) → detect it and propose setup" — did NOT exist
7
+ # in code. It was suppressed by the onboarding guards (metadata-is-not-intent, task-first skip), which
8
+ # are correct (they stop FALSE onboarding from branch-name metadata) but also silence GENUINE
9
+ # env-change setup. (Measured miss 2026-07-06: pulling pmh in the company env was not self-detected.
10
+ # Cross-family confirmed 2026-07-06: Claude workflow + codex both rated ② PARTIAL/THEATER, biggest 허풍.)
11
+ #
12
+ # WHAT: this is the SIBLING of scripts/fh_session_load.sh. That hook closes the companion-store
13
+ # FRESHNESS gap ("did the store change?"); THIS hook closes the CAPABILITY-SURFACE gap ("did the
14
+ # environment change?"). It scans the projects root for git repos that are neither mapped
15
+ # (tracks/{name}/ — the is-mapped signal, feedback_tracks_dir_is_mapped_signal) nor wizard-done
16
+ # (~/.cc_sentinels/{name}_wizard_done), and emits a ONE-LINE proposal into turn-0 context. It fires
17
+ # BEFORE the first user turn regardless of what the user types — so it closes the task-first salience
18
+ # gap mechanically, exactly as fh_session_load.sh does for freshness.
19
+ #
20
+ # INVARIANTS:
21
+ # - PROPOSE, never auto-act. Detection is mechanical; mapping/install stays approval-gated (HITL).
22
+ # The hook emits a proposal line; the agent decides whether to surface/act. (metadata-is-not-intent:
23
+ # a mechanical fs delta is a proposal input, not an executed mapping.)
24
+ # - ONE-LINE proposal, NOT the onboarding menu. The guards suppress menus for a reason; this is a
25
+ # targeted delta, not a door skeleton.
26
+ # - Silent no-op when nothing is new (no repos, or all mapped/wizard-done). Majority path = quiet.
27
+ # - Offline-safe / fast: pure local filesystem, no network, maxdepth-1 scan.
28
+ # - Non-Mode-D public users: harmless — it only ever PROPOSES /install-wizard --dry-run, which is
29
+ # itself read-only. Still, gate on the hub being present so a bare plugin install stays silent.
30
+
31
+ set -u
32
+
33
+ # FH = the HUB, derived from THIS script's location ($FH/scripts/fh_env_delta_scan.sh → ../ = hub) —
34
+ # NOT from CLAUDE_PROJECT_DIR. (codex cross-family review 2026-07-06 [HIGH]: defaulting FH to
35
+ # CLAUDE_PROJECT_DIR made a hook run inside a new FIELD repo resolve FH to that field repo → the
36
+ # `tracks` guard failed → the hook silently did NOT fire in exactly the new-project scenario it exists
37
+ # for. Deriving from $0 makes FH correct wherever invoked.) HUB_DIR override still wins for tests.
38
+ FH="${HUB_DIR:-$(CDPATH= cd -- "$(dirname -- "$0")/.." 2>/dev/null && pwd)}"
39
+ FH="${FH:-$HOME/projects/forge-harness}"
40
+ ROOT="${FH_PROJECTS_ROOT:-$(dirname "$FH")}"
41
+ BE="${BE_DIR:-}" # companion store — never a mapping candidate
42
+ SENT="${CC_SENTINEL_DIR:-$HOME/.cc_sentinels}"
43
+
44
+ # Only operate where the hub actually exists (keeps bare-plugin installs silent).
45
+ [ -d "$FH/tracks" ] || exit 0
46
+ [ -d "$ROOT" ] || exit 0
47
+
48
+ _abspath() { CDPATH= cd -- "$1" 2>/dev/null && pwd; }
49
+ FH_ABS="$(_abspath "$FH")"
50
+ BE_ABS=""; [ -n "$BE" ] && BE_ABS="$(_abspath "$BE")"
51
+
52
+ # _candidate DIR → echoes basename if DIR is an unmapped mapping candidate, else nothing + returns 1.
53
+ # ONE predicate for BOTH sibling-scan and current-cwd (codex [MED]×2: cwd previously had weaker
54
+ # exclusions + ignored the skip sentinel → could propose the hub/companion/hidden repo and re-nag a
55
+ # skipped repo). [LOW]: `-e .git` (not `-d`) so git worktrees/submodules (`.git` is a file) count.
56
+ _candidate() {
57
+ local d abs name
58
+ abs="$(_abspath "$1")" || return 1
59
+ [ -n "$abs" ] || return 1
60
+ [ -e "$abs/.git" ] || return 1 # real repo (dir OR file .git = worktree)
61
+ name="$(basename "$abs")"
62
+ [ "$abs" = "$FH_ABS" ] && return 1 # not the hub
63
+ [ -n "$BE_ABS" ] && [ "$abs" = "$BE_ABS" ] && return 1 # not the companion store
64
+ case "$name" in _*|.*) return 1 ;; esac # not underscore/hidden
65
+ [ -d "$FH/tracks/$name" ] && return 1 # mapped (is-mapped signal)
66
+ [ -f "$SENT/${name}_wizard_done" ] && return 1 # wizard already run
67
+ [ -f "$SENT/${name}_mapping_skipped" ] && return 1 # operator skipped → mechanical no-re-nag
68
+ printf '%s' "$name"
69
+ }
70
+
71
+ CANDIDATES=""
72
+ COUNT=0
73
+ for d in "$ROOT"/*/; do
74
+ [ -d "$d" ] || continue
75
+ name="$(_candidate "$d")" || continue
76
+ [ -n "$name" ] || continue
77
+ COUNT=$((COUNT + 1))
78
+ [ "$COUNT" -le 6 ] && CANDIDATES="${CANDIDATES}${name}, "
79
+ done
80
+
81
+ # Current cwd itself an unmapped project? (the pmh-pull / task-first-in-new-project case) — SAME
82
+ # predicate, so identical exclusions + skip-sentinel apply.
83
+ CWD_UNMAPPED="$(_candidate "${CLAUDE_PROJECT_DIR:-$PWD}")" || CWD_UNMAPPED=""
84
+
85
+ # Nothing new → silent no-op (majority path).
86
+ [ "$COUNT" -eq 0 ] && [ -z "$CWD_UNMAPPED" ] && exit 0
87
+
88
+ # Emit the delta proposal (one block, imperative). Trim trailing ", ".
89
+ LIST="${CANDIDATES%, }"
90
+ # Emit: keep it LOW-NOISE (target-tier sim 2026-07-06 [noise finding]: naming irrelevant repos on a
91
+ # task-first entry reads as a nag). The current-cwd case is the high-signal one (the pmh-pull scenario
92
+ # = you are working IN an unmapped project right now); siblings collapse to a count + a single
93
+ # actionable (/install-wizard --dry-run), not a named list.
94
+ {
95
+ echo "⚙️ [FH env-delta] capability-surface change detected (mechanically, even on task-first entry):"
96
+ if [ -n "$CWD_UNMAPPED" ]; then
97
+ echo " • ★ current cwd '$CWD_UNMAPPED' is an UNMAPPED project — you are working in it now; propose mapping it (auto_project_mapping, HITL)."
98
+ fi
99
+ if [ "$COUNT" -gt 0 ]; then
100
+ echo " • $COUNT unmapped sibling repo(s) under $ROOT — run \`/install-wizard --dry-run\` to review + map (HITL); skip via touch \"$SENT/<name>_mapping_skipped\" (mechanical, no re-nag)."
101
+ fi
102
+ # Honest scope (target-tier sim [surfacing residual]): this hook mechanizes the CONTEXT-INJECTION
103
+ # half of claim ② — the env-change now reliably REACHES turn-0 context. It does NOT mechanize the
104
+ # SURFACING half (whether the model mentions it to the operator), which stays salience-dependent —
105
+ # a model cannot be mechanically forced to emit. Surface this in ONE line if the cwd is unmapped;
106
+ # for siblings-only, mention at most once and never derail an explicit task.
107
+ }
108
+ exit 0
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env python3
2
+ """memory_link_check.py — wikilink integrity over the memory graph.
3
+
4
+ WHY THIS EXISTS (measured 2026-07-28, 872 links across 210 notes)
5
+
6
+ `memory_intent_recall.md` makes the memory store a GRAPH: nodes are files, edges are `[[links]]`,
7
+ and recall walks one hop from an index hit. That doctrine is only as good as the edges. Nothing
8
+ checked them, and the first measurement found **50 edges pointing at a note that exists under a
9
+ different separator** (`[[feedback-pmh-issue-routing]]` while the file is
10
+ `feedback_pmh_issue_routing.md`) plus **22 pointing at nothing at all**. A 1-hop walk across a dead
11
+ edge returns nothing and looks exactly like "there is nothing related" — the failure is silent, and
12
+ it degrades the one mechanism that is supposed to surface a forgotten lesson.
13
+
14
+ Absorbed from obsidian-mind's `wikilinks.ts` (MIT, breferrari) — the concern, not the code.
15
+
16
+ TWO INSTRUMENT RULES LEARNED WHILE WRITING IT, both from wrong first numbers:
17
+
18
+ 1. RESOLVE ACROSS EVERY STORE THE AUTHOR CAN LINK INTO. A first pass scanned only the memory
19
+ directory and reported 39 "missing". 17 of those resolve in the hub repo or the companion
20
+ store — legitimate cross-store edges. Counting them as broken would have overstated the
21
+ defect by 44% and sent someone hunting for files that are exactly where they belong.
22
+ 2. EXCLUDE THE DOC TEMPLATE. `[[link]]` / `[[name]]` appear inside prose that DESCRIBES the
23
+ convention. Scoring the instructions as defects is the "probe flags its own remedy" class.
24
+
25
+ CLASSES (a link is exactly one)
26
+ ok resolves in the memory store as written
27
+ ambiguous two notes share a normalized name — NEVER auto-fixed, a human picks
28
+ separator resolves after -/_ normalization — mechanically repairable, and the only class
29
+ `--fix-separators` will touch
30
+ cross-store resolves in the hub repo or companion store — reported, never "fixed"
31
+ placeholder the convention's own example text
32
+ dangling resolves nowhere. A human decides: write the note, or drop the edge.
33
+
34
+ DEGRADE DIRECTION: advisory. This is a reversible surface (a memory edit is re-editable), so it
35
+ reports and never blocks. `--fix-separators` writes, and only for the one class where the target is
36
+ proven to exist.
37
+
38
+ Usage:
39
+ python3 scripts/memory_link_check.py [--memory DIR] [--fix-separators] [--quiet]
40
+ Exit: 0 = scanned (always, unless the extractor itself broke) · 2 = extractor found no notes
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import argparse
45
+ import re
46
+ import sys
47
+ from collections import Counter
48
+ from pathlib import Path
49
+
50
+ LINK_RE = re.compile(r"\[\[([^\]|#]+)")
51
+ # Fenced blocks are QUOTED CONTENT — a `[[wrong-form]]` inside one is usually an example of the
52
+ # convention, and "fixing" it destroys the documentation that teaches the rule. Skipped on the
53
+ # write path only; they are still COUNTED, because a reader deserves to know they exist.
54
+ #
55
+ # Inline backticks are deliberately NOT skipped. Measured on this corpus (2026-07-28): 25 of the
56
+ # 150 repaired links sat inside inline code and every one was a real link the author had merely
57
+ # styled with backticks — that is this store's citation convention. Fenced-block changes in the
58
+ # same run: 0. So the two spans are not the same thing here, and treating them alike would either
59
+ # damage examples (skip nothing) or leave a quarter of the dead edges dead (skip both).
60
+ FENCE_RE = re.compile(r"```.*?```", re.S)
61
+ PLACEHOLDERS = {"link", "name", "their-name"}
62
+ def default_memory() -> Path | None:
63
+ """Locate this project's Claude-Code memory dir WITHOUT hard-coding a username or path.
64
+
65
+ Claude Code stores per-project memory under ~/.claude/projects/<encoded-abs-path>/memory, and
66
+ the encoding maps path separators to '-', so it differs per user and per OS. Globbing the
67
+ encoded tail (parent + project folder) is portable AND keeps an operator's real home path out
68
+ of a public file — the confidentiality gate rejected the first draft of this line for exactly
69
+ that reason, which is the gate working.
70
+ """
71
+ root = Path(__file__).resolve().parents[1]
72
+ tail = f"{root.parent.name}-{root.name}"
73
+ base = Path.home() / ".claude/projects"
74
+ if not base.is_dir():
75
+ return None
76
+ for d in sorted(base.glob(f"*{tail}")):
77
+ if (d / "memory").is_dir():
78
+ return d / "memory"
79
+ return None
80
+
81
+
82
+ def extra_roots() -> list[Path]:
83
+ """Other stores an author may legitimately link into.
84
+
85
+ The hub itself, plus any sibling store named by MEMORY_LINK_EXTRA_ROOTS (colon-separated).
86
+ A private companion store is NOT named here: its name is operator configuration, not a
87
+ property of this tool, and embedding it would publish a private repo name.
88
+ """
89
+ import os
90
+ roots = [Path(__file__).resolve().parents[1]]
91
+ for raw in filter(None, os.environ.get("MEMORY_LINK_EXTRA_ROOTS", "").split(":")):
92
+ roots.append(Path(raw).expanduser())
93
+ return [r for r in roots if r.is_dir()]
94
+
95
+
96
+ def norm(s: str) -> str:
97
+ return s.replace("-", "_").lower()
98
+
99
+
100
+ # Normalized keys whose bucket holds MORE THAN ONE distinct memory note. Auto-fixing these would
101
+ # pick whichever file sorted first and silently reroute an edge to the wrong note — and the reroute
102
+ # is permanent, because the rewritten link then resolves exactly and no later run flags it.
103
+ # Cross-family review (gpt-5.5, 2026-07-29) supplied the reachable input: `alpha-beta.md` and
104
+ # `alpha_beta.md` both exist, a note links `[[Alpha_Beta]]` meaning the underscore one, and case
105
+ # drift alone is enough to send it to the hyphen one. Reported as ambiguous, never rewritten.
106
+ AMBIGUOUS: set[str] = set()
107
+
108
+
109
+ def build_index(memory: Path) -> dict[str, tuple[str, str]]:
110
+ idx: dict[str, tuple[str, str]] = {}
111
+ AMBIGUOUS.clear()
112
+ seen: dict[str, str] = {}
113
+ for p in sorted(memory.glob("*.md")):
114
+ k = norm(p.stem)
115
+ if k in seen and seen[k] != p.name:
116
+ AMBIGUOUS.add(k)
117
+ seen[k] = p.name
118
+ idx.setdefault(k, ("memory", p.name))
119
+ for root in extra_roots():
120
+ for p in root.rglob("*.md"):
121
+ if ".git" in p.parts:
122
+ continue
123
+ idx.setdefault(norm(p.stem), (root.name, str(p.relative_to(root))))
124
+ return idx
125
+
126
+
127
+ def classify(target: str, memory: Path, idx: dict) -> str:
128
+ t = target.strip()
129
+ if t in PLACEHOLDERS:
130
+ return "placeholder"
131
+ if (memory / f"{t}.md").exists():
132
+ return "ok"
133
+ k = norm(t)
134
+ hit = idx.get(k)
135
+ if hit is None:
136
+ return "dangling"
137
+ if hit[0] == "memory":
138
+ return "ambiguous" if k in AMBIGUOUS else "separator"
139
+ return "cross-store"
140
+
141
+
142
+ def main() -> int:
143
+ ap = argparse.ArgumentParser()
144
+ ap.add_argument("--memory", type=Path, default=None)
145
+ ap.add_argument("--fix-separators", action="store_true",
146
+ help="rewrite ONLY the separator class, whose target is proven to exist")
147
+ ap.add_argument("--quiet", action="store_true")
148
+ a = ap.parse_args()
149
+
150
+ memory = a.memory or default_memory()
151
+ if memory is None or not memory.is_dir():
152
+ print(f"memory-link-check: SKIP (no memory dir at {memory})")
153
+ return 0
154
+ notes = sorted(memory.glob("*.md"))
155
+ if not notes:
156
+ # Impossible-zero guard: a store with no notes means the path or glob broke. A scan that
157
+ # cannot see its subject must not report a clean graph.
158
+ print("memory-link-check: FAIL — 0 notes found; the scan broke, it did not pass", file=sys.stderr)
159
+ return 2
160
+
161
+ idx = build_index(memory)
162
+ counts: Counter[str] = Counter()
163
+ dangling: list[tuple[str, str]] = []
164
+ ambiguous: list[tuple[str, str]] = []
165
+ seps: list[tuple[Path, str, str]] = []
166
+
167
+ for p in notes:
168
+ text = p.read_text(encoding="utf-8", errors="ignore")
169
+ for raw in LINK_RE.findall(text):
170
+ t = raw.strip()
171
+ k = classify(t, memory, idx)
172
+ counts[k] += 1
173
+ if k == "dangling":
174
+ dangling.append((p.name, t))
175
+ elif k == "ambiguous":
176
+ ambiguous.append((p.name, t))
177
+ elif k == "separator":
178
+ seps.append((p, t, idx[norm(t)][1][:-3]))
179
+
180
+ total = sum(counts.values())
181
+ if not a.quiet:
182
+ print(f"memory-link-check: {len(notes)} notes · {total} links")
183
+ for k in ("ok", "separator", "ambiguous", "cross-store", "placeholder", "dangling"):
184
+ print(f" {k:12s} {counts[k]}")
185
+ if counts["ambiguous"]:
186
+ print("\n ambiguous — two notes share a normalized name; a human must pick, the fixer will not:")
187
+ for t, n in Counter(t for _, t in ambiguous).most_common():
188
+ print(f" {n}x [[{t}]]")
189
+ if dangling:
190
+ print("\n dangling (nothing on disk answers these — write the note, or drop the edge):")
191
+ for t, n in Counter(t for _, t in dangling).most_common():
192
+ print(f" {n}x [[{t}]]")
193
+
194
+ if a.fix_separators and seps:
195
+ touched, skipped, fixed = 0, 0, 0
196
+ for p in {s[0] for s in seps}:
197
+ text = p.read_text(encoding="utf-8")
198
+ # Split on fenced blocks and rewrite only the parts OUTSIDE them, then rejoin. Doing it
199
+ # by span keeps the fence contents byte-identical instead of relying on the replacement
200
+ # string being unique.
201
+ parts, last, out = [], 0, []
202
+ for m in FENCE_RE.finditer(text):
203
+ parts.append((text[last:m.start()], True))
204
+ parts.append((m.group(0), False))
205
+ last = m.end()
206
+ parts.append((text[last:], True))
207
+ for chunk, editable in parts:
208
+ if editable:
209
+ for _, wrong, right in [s for s in seps if s[0] == p]:
210
+ # Rewrite the TARGET only, leaving whatever follows it intact — an anchor
211
+ # (`#section`), an alias (`|shown as`), or nothing.
212
+ #
213
+ # An earlier version enumerated the closing forms by hand (`]]` and `|`)
214
+ # and therefore silently skipped `[[target#anchor]]`: the link was still
215
+ # COUNTED as repairable, so every later run flagged it again (idempotence
216
+ # broken) and the summary reported more fixes than it had made. Found by a
217
+ # cross-family reviewer and confirmed by execution before being accepted.
218
+ pat = re.compile(r"\[\[" + re.escape(wrong) + r"(?=[\]|#])")
219
+ chunk, n = pat.subn(f"[[{right}", chunk)
220
+ fixed += n
221
+ else:
222
+ skipped += sum(chunk.count(f"[[{s[1]}") for s in seps if s[0] == p)
223
+ out.append(chunk)
224
+ p.write_text("".join(out), encoding="utf-8")
225
+ touched += 1
226
+ # Report what was ACTUALLY rewritten, not what was eligible. The two diverged once and the
227
+ # summary over-reported; a fixer that miscounts its own writes cannot be checked by reading
228
+ # its output.
229
+ print(f"\n fixed {fixed} separator link(s) across {touched} file(s)"
230
+ + (f"; left {skipped} inside fenced blocks (quoted examples)" if skipped else ""))
231
+ elif seps and not a.quiet:
232
+ print(f"\n {len(seps)} separator link(s) are mechanically repairable → --fix-separators")
233
+ return 0
234
+
235
+
236
+ if __name__ == "__main__":
237
+ sys.exit(main())