@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/CHEATSHEET.md +1 -1
- package/CLAUDE.md +14 -1
- package/docs/ETHOS.md +106 -0
- package/docs/OUTPUT_EVIDENCE.md +118 -0
- package/docs/WHY.md +42 -0
- package/knowledge/patterns/ensemble_union_detection_task_pattern.md +125 -0
- package/knowledge/shared/GLOSSARY.md +77 -0
- package/knowledge/shared/harness-core/harness_frontier_diagnosis_2026-06-02.md +1 -1
- package/knowledge/shared/harness-core/meta_harness_engineering_definition.md +1 -1
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +9 -0
- package/knowledge/shared/patterns/multi-persona-review.md +88 -0
- package/knowledge/shared/plugin-catalog/recommended_plugins.md +117 -0
- package/package.json +28 -1
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/.claude-plugin/plugin.json +2 -2
- package/plugins/fh-meta/CHANGELOG.md +617 -0
- package/scripts/below_floor_scan.sh +91 -0
- package/scripts/chamber_run.sh +184 -0
- package/scripts/degrade_direction_scan.sh +17 -2
- package/scripts/fh_env_delta_scan.sh +108 -0
- package/scripts/memory_link_check.py +237 -0
- package/scripts/memory_nearcheck.py +131 -0
- package/scripts/package_coverage_check.sh +140 -0
- package/scripts/selfcheck.sh +47 -0
- package/scripts/session_close_check.sh +31 -1
- package/scripts/sidecar_wait.sh +76 -0
- package/scripts/substrate_jump_detector.sh +60 -0
- package/scripts/test_card_drift_probe.sh +77 -0
- package/scripts/test_degrade_scan_shell_probes.sh +26 -0
- package/scripts/test_marker_floor_lanes.sh +45 -0
- package/scripts/test_memory_link_check.sh +134 -0
- package/scripts/test_session_close_lanes.sh +99 -0
- package/scripts/tier_census_grep.sh +54 -0
- package/templates/.claude/rules/session.md +153 -0
- package/templates/contrib_session.md +34 -0
- package/templates/degrade_direction_scan.sh +17 -2
- package/templates/goal-quench-hook-setup.md +152 -0
- package/templates/starter_profile.md +83 -0
- package/templates/temper_check.sh +46 -0
- package/plugins/fh-meta/skills/context-bridge-dispatch/SKILL.md +0 -32
- 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\`
|
|
155
|
-
done < <(grep -nE '\|[^|]
|
|
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())
|