@chrono-meta/fh-gate 1.4.74 → 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/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/package.json +7 -1
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/scripts/degrade_direction_scan.sh +17 -2
- package/scripts/memory_link_check.py +237 -0
- package/scripts/memory_nearcheck.py +131 -0
- package/scripts/package_coverage_check.sh +25 -4
- package/scripts/selfcheck.sh +35 -0
- package/scripts/session_close_check.sh +31 -1
- package/scripts/sidecar_wait.sh +76 -0
- package/scripts/test_card_drift_probe.sh +77 -0
- package/scripts/test_degrade_scan_shell_probes.sh +26 -0
- package/scripts/test_memory_link_check.sh +134 -0
- package/scripts/test_session_close_lanes.sh +99 -0
- package/templates/degrade_direction_scan.sh +17 -2
- package/plugins/fh-meta/skills/context-bridge-dispatch/SKILL.md +0 -32
- package/plugins/fh-meta/skills/self-marketing-lint/SKILL.md +0 -30
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "fh-meta",
|
|
14
|
-
"version": "1.4.
|
|
14
|
+
"version": "1.4.75",
|
|
15
15
|
"description": "Hub meta-operations toolkit — 35 skills + 7 agents. New in 1.4.53: `fh-codex-doctor` (npm bin) — Codex adapter drift scanner; reads the documented M1/M2/M3 skill tier map + skill/agent source and reports codex-native/adapter-required/claude-native/unclassified per unit, wired into `npm test`/`prepublishOnly` (fail-closed on unclassified Claude-native primitives). New in 1.4.49: steel-quench gains Step 0.6 Verdict-Invariance Probe (groundedness axis — a load-bearing judged gate's verdict must track behavior, not rubric phrasing; measured flip-count over cross-family paraphrases; arXiv:2605.06161 Policy Invariance anchor); multi_model_sidecar_strategy §Vendor-native harness (a model is strongest in its own vendor CLI — Claude/CC, GPT/codex, Gemini/Antigravity; a universal router degrades all of them, so it stays an autocomplete/QA sidecar, never orchestration); predelete_check.sh fail-closed rewrite; memory-hygiene A-TMA anchor. New in 1.4.48: phantom-quench + steel-quench gain external frontier anchors (arXiv:2607.02052 package-hallucination; arXiv:2607.02057 prompt-coverage-adequacy); README model-flat claim reframed from a per-release point-curve to structural invariants (operation flattens across tiers; depth tier-order fixed within a generation). New in 1.4.47: onboarding step ① surfaces the Mode D companion-store session-start load in the auto-read salience anchor (previously only in the local binding + rules, so a greeting could skip the load). New in 1.4.46: context-doctor command-output axis (route to rtk/proxy for verbose CLI stdout, complementing .claudeignore; risk-gated to token-scarce envs). New in 1.4.41: context-doctor 2026 trigger vocab (context engineering/rot/collapse) + phantom-citation hardening; hub measurement-integrity-checklist (cross-model measurement pre-flight: display-name pin/reps≥3/discriminating probe). New in 1.4.40: install-wizard queryable-wiki scaffold (INDEX + session-start read + R/W/C ingest). New in 1.4.39: auto-decorrelation (cross-family verifier sidecar recruitment) + video-ingest (capability-routed video ingestion). New in 1.4.x: verify-axis check-class taxonomy (mandatory-pass/measured/judged), no-reinvention Tier-0 inventory, 7-class failure taxonomy, Destructive-Op Gate, Wave-T (Temper), tier-floor governance, Mode D Model Notice, FC consent lane, default-Sonnet guidance. New in 1.3.0: public-surface-audit, field-harvest Mode B auto-trigger, 4-axis gate scope ext. Validated cross-CLI: Claude Code, Codex, Gemini.",
|
|
16
16
|
"source": "./plugins/fh-meta"
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
"name": "fh-commons",
|
|
20
|
-
"version": "1.4.
|
|
20
|
+
"version": "1.4.75",
|
|
21
21
|
"description": "Project-agnostic utility skills — 4 skills (convergence-loop · deliberation · mcp-circuit-breaker · token-budget-gate) + 1 agent (quench-challenger). Domain-independent utilities transplantable into any project.",
|
|
22
22
|
"source": "./plugins/fh-commons"
|
|
23
23
|
}
|
package/CHEATSHEET.md
CHANGED
|
@@ -497,7 +497,7 @@ Claude agents feature
|
|
|
497
497
|
| `asset-placement-gate` | Routes a new skill/agent/plugin to the right place (hub vs project), no role overlap | "where does this go", "should this be shared", "hub or project" |
|
|
498
498
|
| `marketplace-gate` | Scores a repo against 5 listing criteria (README, zero-config, maintenance, dup, safety) | "is this OK to publish", "ready for the marketplace", "pre-publish check" |
|
|
499
499
|
| `public-surface-audit` | Scans git-tracked files for operator-private tokens (real username, corp names, home paths) | "did I leak anything", "scan for private tokens", "is my split clean" |
|
|
500
|
-
| `
|
|
500
|
+
| `harness-doctor --lint` | Flags self-promotional / hype wording in descriptions (plain-text discipline) | "lint the marketing language", "is this description hyped" |
|
|
501
501
|
|
|
502
502
|
> **Going public?** Don't run these one at a time — say **"publish"** / **"make this repo public"** and Claude
|
|
503
503
|
> fires the **Pre-Publish Surface Gate** (CLAUDE.md), which runs `public-surface-audit` + `marketplace-gate`
|
package/CLAUDE.md
CHANGED
|
@@ -695,7 +695,10 @@ Closing phrase detected ("wrap up", "done", "good work", "end session", etc.)
|
|
|
695
695
|
**(b) flag the matching card carry item resolved for ⑤** — ⑤ owns the card write (card-last
|
|
696
696
|
guard), ④-c never edits the card. **First-run no-op** if no matching handoff/carry exists.
|
|
697
697
|
(Why-its-own-step origin + ownership split + salience/backstops → §detail below.)
|
|
698
|
-
→ ⑤
|
|
698
|
+
→ ⑤ **Log-close + card update, in that internal order** ← ABSOLUTE LAST: must capture ①–④-c
|
|
699
|
+
outcomes. ⑤ is ATOMIC and owns BOTH writes: (a) append any close-time finding to
|
|
700
|
+
`fh_completed_{date}.md` FIRST, (b) then write the card. Once ⑤ starts, `fh_completed`
|
|
701
|
+
is CLOSED — a later append re-opens the violation ⑤ exists to prevent.
|
|
699
702
|
→ ⑥ Commit card + push
|
|
700
703
|
```
|
|
701
704
|
|
|
@@ -709,6 +712,16 @@ Closing phrase detected ("wrap up", "done", "good work", "end session", etc.)
|
|
|
709
712
|
new findings, a carry item flipped to DONE) feeds INTO ⑤ — card is never written mid-sequence and
|
|
710
713
|
then left open for more work to accumulate after it.
|
|
711
714
|
|
|
715
|
+
**Why ⑤ became atomic (N=3, 2026-07-28 — three closes in one day)**: the miss was always the same
|
|
716
|
+
shape — a finding surfaced *during* the close and the reflex appended it to `fh_completed`, which is
|
|
717
|
+
correct under ④ and fatal after ⑤. The three prose repairs ("next time write it into the card first")
|
|
718
|
+
all failed, including one session that stated the vow and then broke it in the same close. So the
|
|
719
|
+
sequence is restructured rather than re-promised: `fh_completed` is not a step that runs alongside ⑤,
|
|
720
|
+
it is the **first half of** ⑤. A close-time finding has exactly one landing order — log, then card —
|
|
721
|
+
and there is no remaining moment where appending is the natural move. *Honest scope*: this removes
|
|
722
|
+
the ordering ambiguity, not the reflex; the pre-push gate stays the floor, and on a violation it now
|
|
723
|
+
**names the offending files and prints their last lines** so re-running ⑤ is a delta, not a re-read.
|
|
724
|
+
|
|
712
725
|
**Mid-session card writes are drafts**: If a task (e.g., a calibration run) internally updates
|
|
713
726
|
the card, that is a draft. The close chain always re-runs ⑤ to capture post-draft activities.
|
|
714
727
|
Never skip ⑤ because "the card was just updated" — check for delta first.
|
|
@@ -56,7 +56,7 @@ hierarchical-context remedy (L1 always-on / L2 session / L3 on-demand) + prompt
|
|
|
56
56
|
| Candidate | Frontier basis | FH hook |
|
|
57
57
|
|---|---|---|
|
|
58
58
|
| **Machine-readable `agent-card`-style capability registry** for the FH agents (capability / input-output contract, synced to actual file counts) | A2A Agent Card = the discovery standard | Closes the "count drift / no canonical registry" gap already flagged in `fh_ecosystem_positioning.md` |
|
|
59
|
-
| **Coordination-overhead budget** in `context-bridge-dispatch
|
|
59
|
+
| **Coordination-overhead budget** (then in `context-bridge-dispatch`, merged into `agent-composer` Step 3-a on 2026-06-02): parallel-fan-out cap (3–4) + capability-aware routing | Centralized = +285% tokens; team size caps at 3–4 | `plugins/fh-meta/skills/agent-composer` |
|
|
60
60
|
|
|
61
61
|
### ② Frontier → Org Propagation
|
|
62
62
|
|
|
@@ -55,7 +55,7 @@ Proposed three-layer harness taxonomy:
|
|
|
55
55
|
|---|---|---|
|
|
56
56
|
| Harness interface | Context injection layer | `CLAUDE.md` · `.claude/rules/*.md` · `MEMORY.md` |
|
|
57
57
|
| Harness mechanisms | Skill bus | `verify-bidirectional` · `steel-quench` · `phantom-quench` · `agent-composer` |
|
|
58
|
-
| Scale | Multi-agent dispatch | Agent View · `
|
|
58
|
+
| Scale | Multi-agent dispatch | Agent View · `agent-composer` parallel dispatch (Step 3-a — absorbed the former context-bridge-dispatch) |
|
|
59
59
|
| *(meta layer, not in taxonomy)* | Harness evolution | `harvest-loop` · `harness-doctor` · `frontier-digest` · `prompt-regression` |
|
|
60
60
|
|
|
61
61
|
The academic taxonomy covers the **static structure** of a harness. FH adds the **dynamic evolution layer** — the mechanism by which the harness improves itself over time.
|
|
@@ -552,3 +552,12 @@
|
|
|
552
552
|
finding: "Ran the adversarial pass INLINE instead. It did find a HIGH (S1 scope-exclusion swallowed `[ -f lib ] || exit 0` dependency guards — the fail-open class), closed with a regression anchor. But inline review is same-context by construction, so the isolation property the gate asks for was NOT obtained."
|
|
553
553
|
note: "Recorded as `sustained` (decided NOT to invoke) because the session carries a standing instruction: no Agent tool unless the user requests it. FIRST `sustained` entry in this log — and directly relevant to fh_signal_2026-07-28_decorrelation-log-uncalibrated, which measured 0 rejected / 0 sustained across 74 entries and argued the log is written selectively toward optimistic outcomes. Operator decision pending on dispatching before merge."
|
|
554
554
|
|
|
555
|
+
|
|
556
|
+
- date: 2026-07-28
|
|
557
|
+
agent: general-purpose Sonnet blind sim (NOT invoked)
|
|
558
|
+
model: n/a
|
|
559
|
+
purpose: "Target-tier sim gate (fh_4axis_gate.md) on the salience-dependent CLAUDE.md ⑤ atomicity change"
|
|
560
|
+
prompt_summary: "Would have been: cold-read the rewritten Session Wrap-up ⑤ at Sonnet and close a session with a finding arriving mid-close — does the atomic ordering actually fire, or does the fh_completed append still happen after the card?"
|
|
561
|
+
outcome: sustained
|
|
562
|
+
finding: "Not run. The four other worklist items are mechanical (scripts/hooks — tier-independent, exempt by the gate's own enforcement-column test) and were verified by known-pair + mutation + positive control. The CLAUDE.md ⑤ change is the one item the sim gate actually targets, and it is therefore UNVERIFIED at floor tier — recorded as a residual in the Axes 2-3 marker rather than absorbed silently."
|
|
563
|
+
note: "Second `sustained` in this log, same cause as the first: standing session instruction forbids the Agent tool unless the user requests it. The pattern is now n=2 — the gate's near-mandatory sim is structurally unreachable in sessions carrying that instruction, which is a governance question (whose exception is it?) rather than a per-session judgment call. Candidate for fh_signal."
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrono-meta/fh-gate",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.75",
|
|
4
4
|
"description": "FH runtime adapters — run FH governance, skills, and agents via Claude or Codex with machine-parseable gates.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -102,6 +102,12 @@
|
|
|
102
102
|
"scripts/prepush_guard_check.sh",
|
|
103
103
|
"scripts/psa_scan_lib.sh",
|
|
104
104
|
"scripts/session_close_check.sh",
|
|
105
|
+
"scripts/memory_link_check.py",
|
|
106
|
+
"scripts/test_memory_link_check.sh",
|
|
107
|
+
"scripts/memory_nearcheck.py",
|
|
108
|
+
"scripts/sidecar_wait.sh",
|
|
109
|
+
"scripts/test_session_close_lanes.sh",
|
|
110
|
+
"scripts/test_card_drift_probe.sh",
|
|
105
111
|
"scripts/universal_guard_check.sh",
|
|
106
112
|
"scripts/test_prepush_stdin_integrity.sh",
|
|
107
113
|
"scripts/public_surface_scan_files.sh",
|
|
@@ -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,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())
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""nearcheck.py — read-only near-duplicate probe over a markdown memory store.
|
|
3
|
+
|
|
4
|
+
Design absorbed from obsidian-mind's `memory-similarity.ts` (MIT, breferrari) — the PROPOSITIONS,
|
|
5
|
+
not the code:
|
|
6
|
+
* lexical, not semantic. An embedding call on the write hot path costs latency and money, and
|
|
7
|
+
the failure being prevented is the LITERAL re-record (a session restating a lesson in nearly
|
|
8
|
+
the same words), not two genuinely different phrasings. Semantics is what RECALL is for.
|
|
9
|
+
* facet-gated: two notes are only comparable when their declared reach overlaps.
|
|
10
|
+
* pure: no IO in the scoring core, so it can be calibrated on fixtures.
|
|
11
|
+
|
|
12
|
+
DEVIATION, and why: obsidian-mind tokenizes on word boundaries. This corpus is majority KOREAN,
|
|
13
|
+
where whitespace tokens are morphologically inflected and word-level overlap under-reports badly —
|
|
14
|
+
the same failure class this repo already measured (an ASCII-token scanner over a Korean corpus
|
|
15
|
+
produced ~96% false positives). So the shingles here are CHARACTER 3-grams over NFC-normalized,
|
|
16
|
+
markup-stripped text, which is script-agnostic.
|
|
17
|
+
|
|
18
|
+
READ-ONLY. Writes nothing. Exit 0 always — this is a measurement, not a gate.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
import re, sys, unicodedata
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from itertools import combinations
|
|
24
|
+
|
|
25
|
+
N = 3 # character shingle size
|
|
26
|
+
TOP = 40 # how many pairs to print
|
|
27
|
+
|
|
28
|
+
def strip_markup(t: str) -> str:
|
|
29
|
+
t = re.sub(r"```.*?```", " ", t, flags=re.S) # fenced code
|
|
30
|
+
t = re.sub(r"`[^`]*`", " ", t) # inline code
|
|
31
|
+
t = re.sub(r"\[\[([^\]]*)\]\]", r"\1", t) # wikilinks -> text
|
|
32
|
+
t = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", t) # md links -> text
|
|
33
|
+
t = re.sub(r"https?://\S+", " ", t)
|
|
34
|
+
t = re.sub(r"[#*_>|\-–—·:;,.!?()\[\]{}\"'`~^=+/\\]", " ", t)
|
|
35
|
+
return re.sub(r"\s+", " ", t).strip()
|
|
36
|
+
|
|
37
|
+
def parse(p: Path) -> dict:
|
|
38
|
+
raw = p.read_text(encoding="utf-8", errors="ignore")
|
|
39
|
+
fm, body = {}, raw
|
|
40
|
+
if raw.startswith("---"):
|
|
41
|
+
end = raw.find("\n---", 3)
|
|
42
|
+
if end > 0:
|
|
43
|
+
for line in raw[3:end].splitlines():
|
|
44
|
+
if ":" in line and not line.startswith(" "):
|
|
45
|
+
k, v = line.split(":", 1)
|
|
46
|
+
fm[k.strip()] = v.strip().strip('"')
|
|
47
|
+
body = raw[end + 4 :]
|
|
48
|
+
return {
|
|
49
|
+
"path": p,
|
|
50
|
+
"name": fm.get("name", p.stem),
|
|
51
|
+
"desc": fm.get("description", ""),
|
|
52
|
+
"type": fm.get("type", ""),
|
|
53
|
+
"body": unicodedata.normalize("NFC", strip_markup(body)),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
def shingles(t: str) -> set[str]:
|
|
57
|
+
t = t.replace(" ", "")
|
|
58
|
+
return {t[i : i + N] for i in range(max(0, len(t) - N + 1))}
|
|
59
|
+
|
|
60
|
+
def jaccard(a: set, b: set) -> float:
|
|
61
|
+
if not a or not b:
|
|
62
|
+
return 0.0
|
|
63
|
+
inter = len(a & b)
|
|
64
|
+
return inter / (len(a) + len(b) - inter)
|
|
65
|
+
|
|
66
|
+
def containment(a: set, b: set) -> float:
|
|
67
|
+
"""Asymmetric: how much of the SMALLER note is inside the larger.
|
|
68
|
+
|
|
69
|
+
Jaccard alone under-reports the case that matters most here — a short lesson later restated
|
|
70
|
+
inside a longer note.
|
|
71
|
+
|
|
72
|
+
SIZE-GATED (repaired 2026-07-28 after the probe's first run): without the gate this metric
|
|
73
|
+
SATURATES on size-asymmetric pairs. Korean character 3-grams have a very high base rate
|
|
74
|
+
(inflectional endings and particles recur everywhere), so a 22 KB note "contains" almost every
|
|
75
|
+
short note's shingles and the top of the ranking became one large file paired with everything.
|
|
76
|
+
Hand-check that killed it: user_role.md (658 B, "user is a QA engineer") scored 0.80 against a
|
|
77
|
+
22 KB provenance note. Not a near-duplicate by any reading — an instrument artifact.
|
|
78
|
+
A restatement worth flagging is of COMPARABLE size; a short note swallowed by a long one is
|
|
79
|
+
the containment metric measuring the alphabet, not the content.
|
|
80
|
+
"""
|
|
81
|
+
if not a or not b:
|
|
82
|
+
return 0.0
|
|
83
|
+
small, large = (a, b) if len(a) <= len(b) else (b, a)
|
|
84
|
+
if len(small) < 0.34 * len(large):
|
|
85
|
+
return 0.0
|
|
86
|
+
return len(small & large) / len(small)
|
|
87
|
+
|
|
88
|
+
def main(argv):
|
|
89
|
+
root = Path(argv[1])
|
|
90
|
+
docs = [parse(p) for p in sorted(root.glob("*.md"))
|
|
91
|
+
if p.name not in {"MEMORY.md", "MEMORY_archive.md"}]
|
|
92
|
+
if not docs:
|
|
93
|
+
print("EXTRACTOR_BROKE: 0 documents parsed — the probe did not run", file=sys.stderr)
|
|
94
|
+
return 2
|
|
95
|
+
# Corpus-frequency filter — the second half of the same repair. A shingle present in most
|
|
96
|
+
# notes carries no evidence of duplication; it is this corpus's alphabet. Dropping the common
|
|
97
|
+
# band is the lexical equivalent of a stopword list, derived rather than hand-listed so it
|
|
98
|
+
# transfers to a corpus in any script.
|
|
99
|
+
from collections import Counter
|
|
100
|
+
df = Counter()
|
|
101
|
+
for d in docs:
|
|
102
|
+
d["sh_raw"] = shingles(d["body"])
|
|
103
|
+
df.update(d["sh_raw"])
|
|
104
|
+
cutoff = max(2, int(0.15 * len(docs)))
|
|
105
|
+
common = {g for g, n in df.items() if n > cutoff}
|
|
106
|
+
for d in docs:
|
|
107
|
+
d["sh"] = d["sh_raw"] - common
|
|
108
|
+
d["shd"] = shingles(d["desc"]) - common
|
|
109
|
+
|
|
110
|
+
empty = [d["path"].name for d in docs if len(d["sh"]) < 20]
|
|
111
|
+
pairs = []
|
|
112
|
+
for a, b in combinations(docs, 2):
|
|
113
|
+
j = jaccard(a["sh"], b["sh"])
|
|
114
|
+
c = containment(a["sh"], b["sh"])
|
|
115
|
+
jd = jaccard(a["shd"], b["shd"])
|
|
116
|
+
if j >= 0.25 or c >= 0.55 or jd >= 0.45:
|
|
117
|
+
pairs.append((max(j, c), j, c, jd, a, b))
|
|
118
|
+
pairs.sort(key=lambda t: -t[0])
|
|
119
|
+
|
|
120
|
+
print(f"scanned: {len(docs)} notes (skipped index files)")
|
|
121
|
+
print(f"corpus-common shingles dropped: {len(common)} (present in >{cutoff} notes)")
|
|
122
|
+
print(f"too-short-to-score (<20 shingles): {len(empty)}" + (f" → {empty}" if empty else ""))
|
|
123
|
+
print(f"candidate pairs above threshold: {len(pairs)}\n")
|
|
124
|
+
for score, j, c, jd, a, b in pairs[:TOP]:
|
|
125
|
+
print(f"[{score:.2f}] jac={j:.2f} cont={c:.2f} desc={jd:.2f}")
|
|
126
|
+
print(f" A {a['path'].name}")
|
|
127
|
+
print(f" B {b['path'].name}")
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
sys.exit(main(sys.argv))
|
|
@@ -73,6 +73,7 @@ pat = re.compile(
|
|
|
73
73
|
)
|
|
74
74
|
|
|
75
75
|
phantom = {}
|
|
76
|
+
exercised = set() # accepted entries a shipped doc ACTUALLY still points at
|
|
76
77
|
for s in shipped:
|
|
77
78
|
try:
|
|
78
79
|
text = open(s, encoding='utf-8', errors='ignore').read()
|
|
@@ -82,8 +83,11 @@ for s in shipped:
|
|
|
82
83
|
# Only a path that REALLY EXISTS here but is left out of the tarball is this defect.
|
|
83
84
|
# A path that exists nowhere is the ordinary phantom-reference class the ref-path
|
|
84
85
|
# check above already owns; a path outside files[] that is also absent is nothing.
|
|
85
|
-
if os.path.exists(m) and not covered(m)
|
|
86
|
-
|
|
86
|
+
if os.path.exists(m) and not covered(m):
|
|
87
|
+
if m in accepted:
|
|
88
|
+
exercised.add(m)
|
|
89
|
+
else:
|
|
90
|
+
phantom.setdefault(m, set()).add(s)
|
|
87
91
|
|
|
88
92
|
# Impossible-zero guard: this repo always has shipped docs. Zero scanned means the extractor
|
|
89
93
|
# broke — report that as a failure rather than letting a dead check print a pass
|
|
@@ -94,6 +98,11 @@ if not shipped:
|
|
|
94
98
|
|
|
95
99
|
for p, srcs in sorted(phantom.items(), key=lambda kv: (-len(kv[1]), kv[0])):
|
|
96
100
|
print(f"{p}\t{len(srcs)}\t{sorted(srcs)[0]}")
|
|
101
|
+
|
|
102
|
+
# An exception nobody exercises is a SILENCER waiting for a future real case to land on it.
|
|
103
|
+
# Report the unexercised ones so the list stays a set of decisions, not accumulated residue.
|
|
104
|
+
for a in sorted(accepted - exercised):
|
|
105
|
+
print(f"STALE\t{a}")
|
|
97
106
|
raise SystemExit(1 if phantom else 0)
|
|
98
107
|
PY
|
|
99
108
|
)
|
|
@@ -104,9 +113,13 @@ if [ "$rc" -eq 2 ] || [ "$out" = "EXTRACTOR_BROKE" ]; then
|
|
|
104
113
|
exit 1
|
|
105
114
|
fi
|
|
106
115
|
|
|
116
|
+
STALE_LIST=$(printf '%s\n' "$out" | grep '^STALE ' | cut -f2- || true)
|
|
117
|
+
STALE_N=$(printf '%s' "$STALE_LIST" | grep -c . || true)
|
|
118
|
+
EXERCISED_N=$(( ${#ACCEPTED_ABSENT[@]} - ${STALE_N:-0} ))
|
|
119
|
+
|
|
107
120
|
if [ "$rc" -ne 0 ]; then
|
|
108
121
|
echo "FAIL package-coverage: shipped document(s) point at file(s) the package omits:"
|
|
109
|
-
printf '%s\n' "$out" | while IFS=$'\t' read -r path n src; do
|
|
122
|
+
printf '%s\n' "$out" | grep -v '^STALE ' | while IFS=$'\t' read -r path n src; do
|
|
110
123
|
[ -z "$path" ] && continue
|
|
111
124
|
printf ' %s (named by %s shipped doc(s), e.g. %s)\n' "$path" "$n" "$src"
|
|
112
125
|
done
|
|
@@ -115,5 +128,13 @@ if [ "$rc" -ne 0 ]; then
|
|
|
115
128
|
exit 1
|
|
116
129
|
fi
|
|
117
130
|
|
|
118
|
-
|
|
131
|
+
# Advisory only — a stale exception is hygiene, not a shipped-doc defect, so it must not
|
|
132
|
+
# convert a clean package into a red gate (that trains the runner to ignore the check).
|
|
133
|
+
if [ "${STALE_N:-0}" -gt 0 ]; then
|
|
134
|
+
echo "⚠️ package-coverage: ${STALE_N} ACCEPTED_ABSENT entry(ies) no longer exercised — remove, or a"
|
|
135
|
+
echo " future real omission can land on the stale exception and be silenced:"
|
|
136
|
+
printf '%s\n' "$STALE_LIST" | sed 's/^/ /'
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
echo "PASS package-coverage: every referenced path is shipped or explicitly accepted (${#ACCEPTED_ABSENT[@]} accepted, ${EXERCISED_N} exercised)"
|
|
119
140
|
exit 0
|
package/scripts/selfcheck.sh
CHANGED
|
@@ -111,6 +111,41 @@ if [ -f scripts/package_coverage_check.sh ]; then
|
|
|
111
111
|
fi
|
|
112
112
|
fi
|
|
113
113
|
|
|
114
|
+
# memory-link-check — the memory store is a GRAPH (memory_intent_recall.md: nodes=files,
|
|
115
|
+
# edges=[[links]], recall walks one hop). Measured 2026-07-28: 50 of 872 edges pointed at a note
|
|
116
|
+
# that existed under a different separator and 22 at nothing — a dead edge returns nothing and is
|
|
117
|
+
# indistinguishable from "nothing is related", so the doctrine degraded silently. The checker's
|
|
118
|
+
# --fix-separators path WRITES, so its anchor runs here rather than being invoked by hand.
|
|
119
|
+
# Package/other-machine mode: the checker self-SKIPs when no memory dir exists.
|
|
120
|
+
if [ -f scripts/test_memory_link_check.sh ] && [ -f scripts/memory_link_check.py ]; then
|
|
121
|
+
if ! bash scripts/test_memory_link_check.sh; then
|
|
122
|
+
fail=1
|
|
123
|
+
fi
|
|
124
|
+
elif [ -f scripts/memory_link_check.py ]; then
|
|
125
|
+
echo "FAIL memory-link-check: checker present but scripts/test_memory_link_check.sh missing"
|
|
126
|
+
fail=1
|
|
127
|
+
fi
|
|
128
|
+
|
|
129
|
+
# session-close gate lanes (② harvest-loop discharge + ⑤ card-last) and the ⑤-b card-drift probe.
|
|
130
|
+
# Both anchors calibrate scripts/session_close_check.sh, which the pre-push hook runs on every push
|
|
131
|
+
# — an uncalibrated instrument there produces exactly the false verdicts the close chain exists to
|
|
132
|
+
# prevent. test_card_drift_probe.sh had shipped with ZERO callers since it was written; wiring it
|
|
133
|
+
# here closes that, and the anchors are added to files[] in the same change so package mode runs
|
|
134
|
+
# them too rather than reporting a deleted anchor.
|
|
135
|
+
for _anchor in scripts/test_session_close_lanes.sh scripts/test_card_drift_probe.sh; do
|
|
136
|
+
if [ ! -f scripts/session_close_check.sh ]; then
|
|
137
|
+
echo "SKIP ${_anchor##*/} (subject scripts/session_close_check.sh absent)"
|
|
138
|
+
elif [ -f "$_anchor" ]; then
|
|
139
|
+
if ! bash "$_anchor"; then
|
|
140
|
+
fail=1
|
|
141
|
+
fi
|
|
142
|
+
else
|
|
143
|
+
# subject present, anchor gone => the calibration was deleted. Real failure, not a skip.
|
|
144
|
+
echo "FAIL ${_anchor##*/}: session_close_check.sh present but its anchor is missing"
|
|
145
|
+
fail=1
|
|
146
|
+
fi
|
|
147
|
+
done
|
|
148
|
+
|
|
114
149
|
# Referenced-path existence is a source-tree check. The npm package intentionally
|
|
115
150
|
# ships a narrower runtime surface, so package-mode selfcheck skips this section.
|
|
116
151
|
if [ -d ".claude/rules" ]; then
|
|
@@ -42,10 +42,28 @@ fi
|
|
|
42
42
|
# masking a real match as pipeline failure so the warning NEVER fired on true positives
|
|
43
43
|
# (caught by a Sonnet blind probe 2026-07-10, 5/5 deterministic repro). `grep -c` reads the
|
|
44
44
|
# whole stream; `|| true` guards its exit-1-on-zero.
|
|
45
|
+
#
|
|
46
|
+
# SATISFIABLE (2026-07-28): this warning used to fire on EVERY close that touched an FH asset, with
|
|
47
|
+
# no way for the session to discharge it — the script had no means of observing whether harvest-loop
|
|
48
|
+
# ran. A permanently-firing line is noise, and noise trains the runner to skim past the ❌ lines that
|
|
49
|
+
# do matter (the same objection raised against pre-push:484 on 2026-07-28 — one standard, both places).
|
|
50
|
+
# So the obligation now has a mechanical discharge: a `harvest-loop` mention in TODAY's fh_completed
|
|
51
|
+
# file. HONEST SCOPE: this tests ACKNOWLEDGMENT, not execution — the script cannot see a skill run.
|
|
52
|
+
# Recording "harvest-loop: skipped, <reason>" discharges it exactly as recording a run does, which is
|
|
53
|
+
# correct: CLAUDE.md ② accepts "harvest-loop (or an explicit skip note)". What it now catches is the
|
|
54
|
+
# real miss class — closing with FH assets changed and *no decision recorded either way*.
|
|
45
55
|
FH_CHANGED=$(git -C "$FH" log --since="today 00:00" --name-only --pretty=format: 2>/dev/null \
|
|
46
56
|
| grep -cE '^(plugins/.*SKILL\.md|\.claude/rules/|templates/|CLAUDE\.md|knowledge/)' || true)
|
|
47
57
|
if [ "${FH_CHANGED:-0}" -gt 0 ]; then
|
|
48
|
-
|
|
58
|
+
HL_NOTED=0
|
|
59
|
+
[ -f "$FH/tracks/_meta/fh_completed_${TODAY}.md" ] \
|
|
60
|
+
&& HL_NOTED=$(grep -ciE 'harvest[-_]loop' "$FH/tracks/_meta/fh_completed_${TODAY}.md" || true)
|
|
61
|
+
if [ "${HL_NOTED:-0}" -gt 0 ]; then
|
|
62
|
+
echo "✅ ② FH assets changed today ($FH_CHANGED path-touch(es)) — harvest-loop decision recorded in fh_completed_${TODAY}.md"
|
|
63
|
+
else
|
|
64
|
+
echo "⚠️ ② FH assets changed today ($FH_CHANGED path-touch(es)) and fh_completed_${TODAY}.md records no"
|
|
65
|
+
echo " harvest-loop decision — run it, or write one line stating the skip and why (either discharges this)"
|
|
66
|
+
fi
|
|
49
67
|
fi
|
|
50
68
|
|
|
51
69
|
# ④ real-time completion log — required whenever any commit landed today
|
|
@@ -100,6 +118,18 @@ if [ -f "$CARD" ]; then
|
|
|
100
118
|
NEWER=$(find "$FH/tracks/_meta" -maxdepth 1 -type f \( -name "fh_completed_*.md" -o -name "fh_signal_*.md" \) -newer "$CARD" 2>/dev/null | wc -l | tr -d ' ')
|
|
101
119
|
if [ "$NEWER" -gt 0 ]; then
|
|
102
120
|
echo "❌ ⑤ card-last violated — $NEWER close artifact(s) newer than the session card; re-run ⑤ (delta update)"
|
|
121
|
+
# NAME the offenders. Recurrence N=3 (2026-07-28, three closes in one day): every repair so far
|
|
122
|
+
# was a prose vow ("next time I'll write the finding into the card first") and every one failed,
|
|
123
|
+
# because the reflex fires mid-close. What is mechanizable is not the reflex but the COST of the
|
|
124
|
+
# miss — a bare count makes ⑤ a re-read of the whole session, while naming the files and showing
|
|
125
|
+
# what landed after the card makes the delta update a minute's work, which is what actually gets
|
|
126
|
+
# done rather than deferred. Diagnosis, not prevention: this line does not claim to stop the miss.
|
|
127
|
+
find "$FH/tracks/_meta" -maxdepth 1 -type f \( -name "fh_completed_*.md" -o -name "fh_signal_*.md" \) -newer "$CARD" 2>/dev/null \
|
|
128
|
+
| while IFS= read -r f; do
|
|
129
|
+
echo " ↳ ${f#"$FH"/}"
|
|
130
|
+
tail -n 3 "$f" 2>/dev/null | sed 's/^/ │ /'
|
|
131
|
+
done
|
|
132
|
+
echo " → fold the above into the card, save the card LAST, then re-push."
|
|
103
133
|
FAIL=1
|
|
104
134
|
else
|
|
105
135
|
echo "✅ ⑤ card is the newest close artifact (card-last holds)"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# sidecar_wait.sh — run a sidecar to COMPLETION and report a typed verdict about it.
|
|
3
|
+
#
|
|
4
|
+
# WHY THIS EXISTS (measured twice, 2026-07-28 and 2026-07-29)
|
|
5
|
+
#
|
|
6
|
+
# A session dispatched `codex exec` and `agy -p` into the background and read their output files
|
|
7
|
+
# one second and thirty seconds later. Both were empty at that moment, so the session recorded
|
|
8
|
+
# "both sidecars returned 0-output", wrote that into a gate marker, a PR body, a session card and
|
|
9
|
+
# a memory file, and did the adversarial work itself instead.
|
|
10
|
+
#
|
|
11
|
+
# Both sidecars had in fact answered. codex produced 48 KB containing three findings — one HIGH
|
|
12
|
+
# (a normalization collision that silently reroutes a link and then looks clean forever) and one
|
|
13
|
+
# MED that showed the change was OVER-APPLIED. agy produced a HIGH of its own (anchor-form links
|
|
14
|
+
# were counted as fixable but never rewritten, so the fixer broke idempotence and over-reported
|
|
15
|
+
# its own writes). Every one was real; all were confirmed by execution and fixed.
|
|
16
|
+
#
|
|
17
|
+
# So the failure was never the sidecars. It was reading a still-running process and calling the
|
|
18
|
+
# silence a result. That mis-reading then propagated as a claim about ANOTHER system — the worst
|
|
19
|
+
# shape a measurement error can take, because it retires a working mechanism.
|
|
20
|
+
#
|
|
21
|
+
# The reflex fires mid-work and prose does not stop it (three consecutive card-last violations the
|
|
22
|
+
# day before are the same lesson). So the wait becomes mechanical: this script will not emit a
|
|
23
|
+
# verdict while the process is alive, and "no output" is only sayable after the process exits.
|
|
24
|
+
#
|
|
25
|
+
# VERDICTS (typed — grep these, never the prose)
|
|
26
|
+
# SIDECAR_VERDICT=COMPLETE exit=<n> bytes=<n> process exited on its own
|
|
27
|
+
# SIDECAR_VERDICT=TIMEOUT waited=<n>s bytes=<n> still alive when the budget ran out; NOT a result
|
|
28
|
+
# SIDECAR_VERDICT=EMPTY exit=<n> exited cleanly having written nothing — the only
|
|
29
|
+
# state in which "the sidecar said nothing" is true
|
|
30
|
+
#
|
|
31
|
+
# Usage:
|
|
32
|
+
# bash scripts/sidecar_wait.sh <outfile> <timeout_seconds> -- <command> [args...]
|
|
33
|
+
# printf '%s' "$prompt" | bash scripts/sidecar_wait.sh out.txt 600 -- codex exec -m gpt-5.5 -
|
|
34
|
+
#
|
|
35
|
+
# Exit: 0 = COMPLETE (with or without output) · 1 = TIMEOUT (verdict withheld, not a failure claim)
|
|
36
|
+
set -uo pipefail
|
|
37
|
+
|
|
38
|
+
OUT="${1:?usage: sidecar_wait.sh <outfile> <timeout_s> -- <cmd...>}"
|
|
39
|
+
BUDGET="${2:?missing timeout seconds}"
|
|
40
|
+
shift 2
|
|
41
|
+
[ "${1:-}" = "--" ] && shift
|
|
42
|
+
[ $# -gt 0 ] || { echo "sidecar_wait: no command given" >&2; exit 2; }
|
|
43
|
+
|
|
44
|
+
: > "$OUT"
|
|
45
|
+
"$@" > "$OUT" 2>&1 &
|
|
46
|
+
PID=$!
|
|
47
|
+
|
|
48
|
+
waited=0
|
|
49
|
+
last_size=0
|
|
50
|
+
# Poll rather than `wait`, so a live-but-quiet process is distinguishable from a dead one and the
|
|
51
|
+
# caller can SEE progress. A silent minute on a reasoning model is normal; the earlier misreading
|
|
52
|
+
# happened precisely because silence was treated as termination.
|
|
53
|
+
while kill -0 "$PID" 2>/dev/null; do
|
|
54
|
+
if [ "$waited" -ge "$BUDGET" ]; then
|
|
55
|
+
size=$(wc -c < "$OUT" 2>/dev/null | tr -d ' ')
|
|
56
|
+
echo "SIDECAR_VERDICT=TIMEOUT waited=${BUDGET}s bytes=${size:-0} pid=$PID"
|
|
57
|
+
echo " the process is STILL RUNNING — this is not 'no output'. Raise the budget, or kill $PID" >&2
|
|
58
|
+
exit 1
|
|
59
|
+
fi
|
|
60
|
+
sleep 5
|
|
61
|
+
waited=$((waited + 5))
|
|
62
|
+
size=$(wc -c < "$OUT" 2>/dev/null | tr -d ' ')
|
|
63
|
+
if [ "${size:-0}" -ne "$last_size" ]; then
|
|
64
|
+
echo " … ${waited}s elapsed, ${size} bytes so far (alive)" >&2
|
|
65
|
+
last_size=${size:-0}
|
|
66
|
+
fi
|
|
67
|
+
done
|
|
68
|
+
|
|
69
|
+
wait "$PID"; rc=$?
|
|
70
|
+
size=$(wc -c < "$OUT" 2>/dev/null | tr -d ' ')
|
|
71
|
+
if [ "${size:-0}" -eq 0 ]; then
|
|
72
|
+
echo "SIDECAR_VERDICT=EMPTY exit=$rc"
|
|
73
|
+
else
|
|
74
|
+
echo "SIDECAR_VERDICT=COMPLETE exit=$rc bytes=$size"
|
|
75
|
+
fi
|
|
76
|
+
exit 0
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# test_card_drift_probe.sh — session_close_check.sh ⑤-b 카드-드리프트 프로브의 known-pair 픽스처.
|
|
3
|
+
#
|
|
4
|
+
# WHY: 프로브 자체가 계기다 — CLAUDE.md §Instrument Calibration 이 요구하는 known-pair
|
|
5
|
+
# (양성 1 + 음성 1)를 통과하지 못하는 프로브는 배선하면 안 된다(다음 오판정의 원천이 된다).
|
|
6
|
+
# 이 파일이 그 캘리브레이션의 회귀 앵커: 프로브 정규식/토큰추출을 고칠 때마다 재실행.
|
|
7
|
+
#
|
|
8
|
+
# 픽스처 3종:
|
|
9
|
+
# P (known-positive): 카드가 🔴 "foo-digest 미가동 — 산출물 0" 주장 + 실물
|
|
10
|
+
# tracks/_meta/foo_digest_2026_07_22.md 존재 → ⑤-b 경고가 떠야 한다
|
|
11
|
+
# N1 (known-negative): 카드가 부재 주장하는 bar-report 는 진짜 없음 → 경고 0
|
|
12
|
+
# N2 (과발화 가드): 같은 실물이 있어도 부재-주장이 아닌 🟢 줄 → 경고 0
|
|
13
|
+
# (건강한 날 뜨는 경고는 무시를 학습시킨다 — 과발화도 결함)
|
|
14
|
+
#
|
|
15
|
+
# Exit 0 = 3/3 캘리브레이션 통과 · exit 1 = 프로브 계기 불량 (배선 금지)
|
|
16
|
+
|
|
17
|
+
set -uo pipefail
|
|
18
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
|
+
CHECK="$SCRIPT_DIR/session_close_check.sh"
|
|
20
|
+
FAILED=0
|
|
21
|
+
|
|
22
|
+
run_fixture() { # $1=name $2=card-content $3=make-artifact(0/1) $4=expect-warn(0/1)
|
|
23
|
+
local name="$1" card="$2" mkart="$3" expect="$4"
|
|
24
|
+
local T; T=$(mktemp -d)
|
|
25
|
+
mkdir -p "$T/tracks/_meta/logs"
|
|
26
|
+
printf '%s\n' "$card" > "$T/tracks/_meta/reference_next_session_starter.md"
|
|
27
|
+
[ "$mkart" = 1 ] && touch "$T/tracks/_meta/foo_digest_2026_07_22.md"
|
|
28
|
+
# git 없는 디렉토리라도 다른 스텝은 조용히 지나간다(모두 2>/dev/null 가드)
|
|
29
|
+
local out; out=$(bash "$CHECK" "$T" 2>/dev/null)
|
|
30
|
+
local warned=0
|
|
31
|
+
printf '%s\n' "$out" | grep -q "⑤-b card-drift" && warned=1
|
|
32
|
+
rm -rf "$T"
|
|
33
|
+
if [ "$warned" = "$expect" ]; then
|
|
34
|
+
echo "✅ $name (warn=$warned, expected=$expect)"
|
|
35
|
+
else
|
|
36
|
+
echo "❌ $name — warn=$warned, expected=$expect"
|
|
37
|
+
FAILED=1
|
|
38
|
+
fi
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
run_fixture "P known-positive: 부재주장+실물존재 → 경고" \
|
|
42
|
+
"- 🔴 **foo-digest 잡 미가동(07-22)** — 로그도 산출물도 0(launchd 확인 필요)." 1 1
|
|
43
|
+
|
|
44
|
+
run_fixture "N1 known-negative: 부재주장+진짜부재 → 무경고" \
|
|
45
|
+
"- 🔴 **bar-report 잡 미가동** — 산출물 0, 미착수." 0 0
|
|
46
|
+
|
|
47
|
+
run_fixture "N2 과발화가드: 실물존재+부재주장아님 → 무경고" \
|
|
48
|
+
"- 🟢 **foo-digest 가동 중** — 오늘자 산출 확인." 1 0
|
|
49
|
+
|
|
50
|
+
run_fixture "N3 정정문맥가드: 부재주장 인용+오판정 명시 → 무경고" \
|
|
51
|
+
"- 🔴 **foo-digest 산출 누락 수리 미완**. 기존 카드의 \"미가동\"은 오판정이었음." 1 0
|
|
52
|
+
|
|
53
|
+
run_fixture "N4 날짜토큰가드: 부재주장이나 토큰이 날짜뿐 → 무경고" \
|
|
54
|
+
"- 🔴 잡 미가동 2026-07-22 산출물 0" 1 0
|
|
55
|
+
|
|
56
|
+
# challenger A-1 반례 (2026-07-23): 살아있는 주장 + 무관한 debunk 어휘 = 경고가 떠야 한다.
|
|
57
|
+
# debunk-단독 가드는 이 두 줄을 무음 삼켰다(FN) — 인용부 조건이 판별자.
|
|
58
|
+
run_fixture "P2 살아있는 주장+무관한 '정정 필요' → 경고 (A-1 FN 앵커)" \
|
|
59
|
+
"- 🔴 **foo-digest 미가동 지속** — 산출물 0. 지난 카드의 실행횟수 수치는 정정 필요." 1 1
|
|
60
|
+
|
|
61
|
+
run_fixture "P3 살아있는 주장+무관한 '거짓' → 경고 (A-1 FN 앵커)" \
|
|
62
|
+
"- 🔴 **foo-digest 미가동** — 산출물 0, 로그는 거짓 성공만 찍힘" 1 1
|
|
63
|
+
|
|
64
|
+
run_fixture "N5 위치-언급 디렉토리 → 무경고 (A-2 FP 앵커)" \
|
|
65
|
+
"- 🟡 bar-report 미생성 — tracks/_meta/ 산출물 0" 0 0
|
|
66
|
+
|
|
67
|
+
run_fixture "N6 카드 자신 매치 제외 (A-3 FP 앵커)" \
|
|
68
|
+
"- 🔴 next_session_starter 갱신 부재 — 0건" 0 0
|
|
69
|
+
|
|
70
|
+
run_fixture "P4 영어 부재주장 (A-4 앵커)" \
|
|
71
|
+
"- 🔴 **foo-digest job not running** — zero outputs" 1 1
|
|
72
|
+
|
|
73
|
+
run_fixture "N7 인용된 부재키워드+정정 → 무경고 (실카드 07-22 클래스)" \
|
|
74
|
+
"- 🔴 foo-digest 산출 누락. 기존 카드의 \"미가동\" 주장은 오판정이었음" 1 0
|
|
75
|
+
|
|
76
|
+
echo "── card-drift probe calibration: $([ "$FAILED" -eq 0 ] && echo "PASS (전 픽스처) — 배선 가능" || echo "FAIL — 계기 불량, 배선 금지") ──"
|
|
77
|
+
exit "$FAILED"
|
|
@@ -155,6 +155,32 @@ n=$(s_hits "$TMP/dependency_guards.sh")
|
|
|
155
155
|
[ "$n" -eq 2 ] && ok "dependency guards (\`[ -f lib ] || exit 0\`) still detected — scope exclusion did not swallow them" \
|
|
156
156
|
|| bad "dependency guards: expected 2 S-hits, got $n — 'guard library missing → allow' is hidden again"
|
|
157
157
|
|
|
158
|
+
# S5 known pair — added 2026-07-28 when the probe shipped with NO fixture of its own and every one
|
|
159
|
+
# of the 9 hits it produced in this repo turned out to be a false positive. Both directions are
|
|
160
|
+
# pinned because narrowing an all-FP probe is one edit away from a blind one.
|
|
161
|
+
cat > "$TMP/s5_positive.sh" <<'EOF'
|
|
162
|
+
#!/usr/bin/env bash
|
|
163
|
+
set -uo pipefail
|
|
164
|
+
N=$(find /nope . -maxdepth 1 2>/dev/null | grep -c . || echo 0)
|
|
165
|
+
M=$(git log --oneline 2>/dev/null | wc -l || echo 0)
|
|
166
|
+
EOF
|
|
167
|
+
cat > "$TMP/s5_negative.sh" <<'EOF'
|
|
168
|
+
#!/usr/bin/env bash
|
|
169
|
+
set -uo pipefail
|
|
170
|
+
# `a || b || echo 0` is NOT a pipeline — no stage can emit a second line.
|
|
171
|
+
_mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0; }
|
|
172
|
+
# A pipeline whose failing stage emits nothing: the fallback supplies the only line, as intended.
|
|
173
|
+
J=$(printf '%s' "$x" | jq -r '.a // 0' 2>/dev/null || echo 0)
|
|
174
|
+
# A comment describing the defect must not be scored as the defect: cmd | grep -c . || echo 0
|
|
175
|
+
EOF
|
|
176
|
+
n=$(s_hits "$TMP/s5_positive.sh")
|
|
177
|
+
[ "$n" -eq 2 ] && ok "S5 known-positive: counter-stage fallbacks (grep -c, wc) detected 2/2" \
|
|
178
|
+
|| bad "S5 known-positive: expected 2 S-hits, got $n — the pipefail disarm is invisible"
|
|
179
|
+
|
|
180
|
+
n=$(s_hits "$TMP/s5_negative.sh")
|
|
181
|
+
[ "$n" -eq 0 ] && ok "S5 known-negative: \`||\` chains, empty-on-failure pipelines and comments stay silent" \
|
|
182
|
+
|| bad "S5 known-negative: $n hit(s) — S5 is noise again (9/9 FP was its measured state)"
|
|
183
|
+
|
|
158
184
|
n=$(p_hits "$TMP/kp.py")
|
|
159
185
|
[ "$n" -ge 1 ] && ok "python known-positive: pre-existing probes still fire" \
|
|
160
186
|
|| bad "python known-positive: no hits — the Python probes regressed"
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# test_memory_link_check.sh — known-pair anchor for scripts/memory_link_check.py.
|
|
3
|
+
#
|
|
4
|
+
# WHY: the checker's --fix-separators path WRITES to personal knowledge files. Everything it gets
|
|
5
|
+
# wrong, it gets wrong silently and in bulk. Two of its rules were found only by attacking it:
|
|
6
|
+
# * a first draft rewrote links inside FENCED blocks — i.e. it "corrected" the examples that
|
|
7
|
+
# document the convention, which is the probe-damages-the-remedy class;
|
|
8
|
+
# * a first measurement counted cross-store links as broken, overstating the defect by 44%.
|
|
9
|
+
# Both are pinned below, alongside the classes.
|
|
10
|
+
#
|
|
11
|
+
# Lanes
|
|
12
|
+
# C1 the five classes separate on a fixture (ok / separator / cross-store-absent / placeholder / dangling)
|
|
13
|
+
# F1 a link inside a fenced block is COUNTED but never REWRITTEN
|
|
14
|
+
# F2 a link inside inline backticks IS rewritten (this corpus styles real links that way —
|
|
15
|
+
# measured 2026-07-28: 25 such links repaired, 0 fenced changes in the same run)
|
|
16
|
+
# F3 aliased links [[target|alias]] keep their alias
|
|
17
|
+
# F4 re-running the fixer changes nothing (idempotent)
|
|
18
|
+
# G1 an empty store reports an extractor failure, never a clean graph
|
|
19
|
+
#
|
|
20
|
+
# Exit 0 = 6/6.
|
|
21
|
+
set -uo pipefail
|
|
22
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
23
|
+
CHK="$ROOT/scripts/memory_link_check.py"
|
|
24
|
+
[ -f "$CHK" ] || { echo "FAIL: $CHK missing"; exit 1; }
|
|
25
|
+
|
|
26
|
+
pass=0; fail=0
|
|
27
|
+
ok() { printf ' ✅ %s\n' "$1"; pass=$((pass+1)); }
|
|
28
|
+
bad() { printf ' ❌ %s\n' "$1"; fail=$((fail+1)); }
|
|
29
|
+
|
|
30
|
+
TD="$(mktemp -d)"; trap 'rm -rf "$TD"' EXIT
|
|
31
|
+
mkdir -p "$TD/store"
|
|
32
|
+
printf -- '---\nname: real_target\n---\nbody\n' > "$TD/store/real_target.md"
|
|
33
|
+
cat > "$TD/store/src.md" <<'EOF'
|
|
34
|
+
---
|
|
35
|
+
name: src
|
|
36
|
+
---
|
|
37
|
+
plain ok: [[real_target]]
|
|
38
|
+
plain separator: [[real-target]]
|
|
39
|
+
alias: [[real-target|shown as this]]
|
|
40
|
+
inline: `[[real-target]]`
|
|
41
|
+
dangling: [[nothing_here]]
|
|
42
|
+
placeholder: [[link]]
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
fenced example — must NOT be rewritten: [[real-target]]
|
|
46
|
+
```
|
|
47
|
+
EOF
|
|
48
|
+
|
|
49
|
+
_run() { python3 "$CHK" --memory "$TD/store" "$@" 2>/dev/null; }
|
|
50
|
+
|
|
51
|
+
out=$(_run)
|
|
52
|
+
# FIRST match only. `dangling` appears twice in the report — once as a count row and once as a
|
|
53
|
+
# section header ("dangling (nothing on disk...)") — so an unbounded match returned two lines and
|
|
54
|
+
# the numeric comparison silently failed against a correct tool. Instrument fault, fixed here.
|
|
55
|
+
_n() { printf '%s\n' "$out" | awk -v k="$1" '$1==k && $2 ~ /^[0-9]+$/ {print $2; exit}'; }
|
|
56
|
+
if [ "$(_n ok)" = "1" ] && [ "$(_n placeholder)" = "1" ] && [ "$(_n dangling)" = "1" ] && [ "$(_n separator)" -ge 4 ]; then
|
|
57
|
+
ok "C1 classes separate (ok=$(_n ok) separator=$(_n separator) placeholder=$(_n placeholder) dangling=$(_n dangling))"
|
|
58
|
+
else
|
|
59
|
+
bad "C1 class counts wrong"; printf '%s\n' "$out" | sed 's/^/ /'
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
_run --fix-separators --quiet >/dev/null
|
|
63
|
+
body=$(cat "$TD/store/src.md")
|
|
64
|
+
|
|
65
|
+
if printf '%s' "$body" | sed -n '/```/,/```/p' | grep -q '\[\[real-target\]\]'; then
|
|
66
|
+
ok "F1 fenced example left untouched (the documentation of the rule survives the fixer)"
|
|
67
|
+
else
|
|
68
|
+
bad "F1 the fixer rewrote a link inside a fenced block — it corrected its own example"
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
if printf '%s' "$body" | grep -q 'inline: `\[\[real_target\]\]`'; then
|
|
72
|
+
ok "F2 inline-backticked link repaired (this store's citation style is a real link)"
|
|
73
|
+
else
|
|
74
|
+
bad "F2 inline-backticked link was not repaired"
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
if printf '%s' "$body" | grep -q '\[\[real_target|shown as this\]\]'; then
|
|
78
|
+
ok "F3 alias preserved through the rewrite"
|
|
79
|
+
else
|
|
80
|
+
bad "F3 alias lost or target not rewritten"
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
before=$(cat "$TD/store/src.md")
|
|
84
|
+
_run --fix-separators --quiet >/dev/null
|
|
85
|
+
if [ "$before" = "$(cat "$TD/store/src.md")" ]; then
|
|
86
|
+
ok "F4 idempotent on re-run"
|
|
87
|
+
else
|
|
88
|
+
bad "F4 a second run changed the file again"
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
mkdir -p "$TD/empty"
|
|
92
|
+
if python3 "$CHK" --memory "$TD/empty" >/dev/null 2>&1; then
|
|
93
|
+
bad "G1 an empty store exited 0 — a scan that cannot see its subject reported a clean graph"
|
|
94
|
+
else
|
|
95
|
+
ok "G1 empty store fails as an extractor error, not a pass"
|
|
96
|
+
fi
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# H1/A1 — cross-family findings, both confirmed by execution before acceptance.
|
|
100
|
+
# H1 (agy): an anchor-form link `[[target#section]]` was COUNTED as repairable but the rewrite
|
|
101
|
+
# enumerated closing forms by hand and never matched it — so it was flagged on every
|
|
102
|
+
# run (idempotence broken) and the summary reported more fixes than it made.
|
|
103
|
+
# A1 (gpt-5.5): two notes sharing a normalized name let the fixer reroute an edge to whichever
|
|
104
|
+
# sorted first, and the wrong link then resolves exactly, so no later run flags it.
|
|
105
|
+
printf -- '---\nname: my_topic\n---\nbody\n' > "$TD/store/my_topic.md"
|
|
106
|
+
printf -- '---\nname: anchored\n---\nanchor: [[my-topic#section-1]]\nplain: [[my-topic]]\n' > "$TD/store/anchored.md"
|
|
107
|
+
_run --fix-separators --quiet >/dev/null
|
|
108
|
+
if grep -q '\[\[my_topic#section-1\]\]' "$TD/store/anchored.md"; then
|
|
109
|
+
ok "H1 anchor-form link rewritten (target only, #section preserved)"
|
|
110
|
+
else
|
|
111
|
+
bad "H1 [[target#anchor]] left unrewritten — flagged forever, and the fix count over-reports"
|
|
112
|
+
fi
|
|
113
|
+
# Idempotence here is a STABLE count, not zero: the fenced example is counted every run and
|
|
114
|
+
# deliberately never rewritten, so zero is unreachable by design. Asserting zero was an instrument
|
|
115
|
+
# error in this anchor's first draft — it scored a correct tool as failing.
|
|
116
|
+
before_n=$(out=$(_run); printf '%s\n' "$out" | awk '$1=="separator" && $2 ~ /^[0-9]+$/ {print $2; exit}')
|
|
117
|
+
_run --fix-separators --quiet >/dev/null
|
|
118
|
+
after_n=$(out=$(_run); printf '%s\n' "$out" | awk '$1=="separator" && $2 ~ /^[0-9]+$/ {print $2; exit}')
|
|
119
|
+
if [ "$before_n" = "$after_n" ]; then
|
|
120
|
+
ok "H1b idempotent with anchor forms present (count stable at $after_n — the fenced example)"
|
|
121
|
+
else
|
|
122
|
+
bad "H1b count moved $before_n → $after_n across a second fix pass"
|
|
123
|
+
fi
|
|
124
|
+
printf -- 'A\n' > "$TD/store/collide-x.md"; printf -- 'B\n' > "$TD/store/collide_x.md"
|
|
125
|
+
out=$(_run)
|
|
126
|
+
if printf '%s\n' "$out" | grep -q 'ambiguous'; then
|
|
127
|
+
ok "A1 colliding normalized names surface as a reported class"
|
|
128
|
+
else
|
|
129
|
+
bad "A1 no ambiguous class — a colliding pair can still be auto-rerouted"
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
echo "----"
|
|
133
|
+
echo "memory-link-check anchor: $pass passed, $fail failed"
|
|
134
|
+
[ "$fail" -eq 0 ] || exit 1
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# test_session_close_lanes.sh — known-pair anchor for session_close_check.sh ② (harvest-loop
|
|
3
|
+
# obligation) and ⑤ (card-last invariant).
|
|
4
|
+
#
|
|
5
|
+
# WHY (2026-07-28): ② used to be an UNSATISFIABLE warning — it fired whenever an FH asset was
|
|
6
|
+
# touched today and the script had no way to observe whether harvest-loop ran, so no session could
|
|
7
|
+
# ever discharge it. A line that fires on every healthy close is noise, and noise trains the runner
|
|
8
|
+
# to skim past the ❌ lines that matter. The repair gave it a mechanical discharge (a harvest-loop
|
|
9
|
+
# decision recorded in TODAY's fh_completed file). This file is that repair's regression anchor:
|
|
10
|
+
# the fix is only real if the ⚠️ still fires when NOTHING is recorded, and stops firing when it is.
|
|
11
|
+
#
|
|
12
|
+
# Lanes (each is a decision the gate must get right, not a smoke test):
|
|
13
|
+
# ②-N FH asset touched today, no harvest-loop line anywhere → ⚠️ MUST fire
|
|
14
|
+
# ②-P1 same, plus "harvest-loop 실행 완료" → ✅ must NOT fire
|
|
15
|
+
# ②-P2 same, plus an explicit SKIP note → ✅ must NOT fire
|
|
16
|
+
# (CLAUDE.md ② accepts "harvest-loop (or an explicit skip note)" — a recorded skip is
|
|
17
|
+
# a discharged obligation, not an evaded one)
|
|
18
|
+
# ②-C no FH asset touched today → neither line appears at all
|
|
19
|
+
# (over-firing is a defect in its own right — the whole reason this repair exists)
|
|
20
|
+
# ⑤-N a close artifact newer than the card → ❌ card-last MUST fire (exit 1)
|
|
21
|
+
# ⑤-P card is the newest artifact → ✅ card-last holds
|
|
22
|
+
#
|
|
23
|
+
# Exit 0 = 7/7 lanes calibrated · exit 1 = the gate's instrument is wrong (do not trust its verdict)
|
|
24
|
+
|
|
25
|
+
set -uo pipefail
|
|
26
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
27
|
+
CHECK="$SCRIPT_DIR/session_close_check.sh"
|
|
28
|
+
TODAY=$(date +%Y-%m-%d)
|
|
29
|
+
FAILED=0
|
|
30
|
+
|
|
31
|
+
if [ ! -f "$CHECK" ]; then
|
|
32
|
+
echo "FAIL session-close lanes: subject $CHECK missing"
|
|
33
|
+
exit 1
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# Builds a throwaway repo whose HEAD commit touches (or does not touch) an FH asset path.
|
|
37
|
+
_fixture() { # $1=touch_fh_asset(0/1) $2=completed-file body (empty = no file)
|
|
38
|
+
local touch_fh="$1" body="$2" T
|
|
39
|
+
T=$(mktemp -d)
|
|
40
|
+
( cd "$T" \
|
|
41
|
+
&& git init -q . \
|
|
42
|
+
&& git config user.email anchor@local && git config user.name anchor \
|
|
43
|
+
&& if [ "$touch_fh" = 1 ]; then echo x > CLAUDE.md; else echo x > unrelated.txt; fi \
|
|
44
|
+
&& git add -A && git commit -qm "fixture" ) >/dev/null 2>&1
|
|
45
|
+
mkdir -p "$T/tracks/_meta"
|
|
46
|
+
[ -n "$body" ] && printf '%s\n' "$body" > "$T/tracks/_meta/fh_completed_${TODAY}.md"
|
|
47
|
+
printf '# card\n' > "$T/tracks/_meta/reference_next_session_starter.md"
|
|
48
|
+
printf '%s' "$T"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_lane() { # $1=name $2=grep-pattern $3=expect(0/1) $4=fixture dir
|
|
52
|
+
local name="$1" pat="$2" expect="$3" T="$4" out hit
|
|
53
|
+
out=$(bash "$CHECK" "$T" 2>/dev/null)
|
|
54
|
+
hit=0
|
|
55
|
+
printf '%s\n' "$out" | grep -q "$pat" && hit=1
|
|
56
|
+
rm -rf "$T"
|
|
57
|
+
if [ "$hit" = "$expect" ]; then
|
|
58
|
+
echo "✅ $name (hit=$hit, expected=$expect)"
|
|
59
|
+
else
|
|
60
|
+
echo "❌ $name — hit=$hit, expected=$expect"
|
|
61
|
+
printf '%s\n' "$out" | sed 's/^/ /'
|
|
62
|
+
FAILED=1
|
|
63
|
+
fi
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
WARN2='⚠️ ② FH assets changed today'
|
|
67
|
+
OK2='✅ ② FH assets changed today'
|
|
68
|
+
|
|
69
|
+
_lane "②-N no harvest-loop decision recorded → warns" "$WARN2" 1 "$(_fixture 1 '- 항목 하나')"
|
|
70
|
+
_lane "②-P1 harvest-loop run recorded → silent" "$WARN2" 0 "$(_fixture 1 '- harvest-loop 실행 완료')"
|
|
71
|
+
_lane "②-P2 explicit skip note → silent" "$WARN2" 0 "$(_fixture 1 '- harvest-loop: skipped — 3-repo session, deferred')"
|
|
72
|
+
_lane "②-P1 run recorded → prints the ✅ form" "$OK2" 1 "$(_fixture 1 '- harvest-loop 실행 완료')"
|
|
73
|
+
_lane "②-C no FH asset touched → no ② line at all" ' ② ' 0 "$(_fixture 0 '- 항목 하나')"
|
|
74
|
+
|
|
75
|
+
# ⑤ card-last: an artifact newer than the card is the bug class the invariant exists to catch.
|
|
76
|
+
T=$(_fixture 1 '- harvest-loop 실행 완료')
|
|
77
|
+
touch "$T/tracks/_meta/fh_completed_${TODAY}.md" # make it strictly newer than the card
|
|
78
|
+
if bash "$CHECK" "$T" >/dev/null 2>&1; then
|
|
79
|
+
echo "❌ ⑤-N artifact newer than card → expected exit 1, got 0 (card-last not enforced)"
|
|
80
|
+
FAILED=1
|
|
81
|
+
else
|
|
82
|
+
echo "✅ ⑤-N artifact newer than card → exit 1 (card-last enforced)"
|
|
83
|
+
fi
|
|
84
|
+
touch "$T/tracks/_meta/reference_next_session_starter.md" # card becomes newest
|
|
85
|
+
if bash "$CHECK" "$T" >/dev/null 2>&1; then
|
|
86
|
+
echo "✅ ⑤-P card newest → exit 0"
|
|
87
|
+
else
|
|
88
|
+
echo "❌ ⑤-P card newest → expected exit 0, got 1 (over-blocking: trains --no-verify)"
|
|
89
|
+
bash "$CHECK" "$T" 2>&1 | sed 's/^/ /'
|
|
90
|
+
FAILED=1
|
|
91
|
+
fi
|
|
92
|
+
rm -rf "$T"
|
|
93
|
+
|
|
94
|
+
if [ "$FAILED" -ne 0 ]; then
|
|
95
|
+
echo "SESSION-CLOSE LANES: FAIL — the gate's instrument is miscalibrated"
|
|
96
|
+
exit 1
|
|
97
|
+
fi
|
|
98
|
+
echo "SESSION-CLOSE LANES: PASS (7/7)"
|
|
99
|
+
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
|
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: context-bridge-dispatch
|
|
3
|
-
description: >-
|
|
4
|
-
DEPRECATED — merged into agent-composer Step 3-a (2026-06-02).
|
|
5
|
-
Context Card injection (N≤2 standard / N≥3 Registry mode) + coordination-overhead budget + Focus Mode
|
|
6
|
-
are now part of agent-composer Step 3. Invoke /agent-composer for parallel dispatch.
|
|
7
|
-
user-invocable: false
|
|
8
|
-
allowed-tools: []
|
|
9
|
-
model: sonnet
|
|
10
|
-
deprecated: true
|
|
11
|
-
deprecated_reason: absorbed into agent-composer Step 3-a
|
|
12
|
-
deprecated_date: 2026-06-02
|
|
13
|
-
successor: agent-composer
|
|
14
|
-
---
|
|
15
|
-
|
|
16
|
-
# context-bridge-dispatch — DEPRECATED
|
|
17
|
-
|
|
18
|
-
> **Merged into `agent-composer` Step 3-a (2026-06-02).**
|
|
19
|
-
> Context Card injection, Registry mode, coordination-overhead budget, and Focus Mode are now part of agent-composer Step 3.
|
|
20
|
-
> Use `/agent-composer` for all parallel dispatch with context injection.
|
|
21
|
-
|
|
22
|
-
## Content preserved at
|
|
23
|
-
|
|
24
|
-
`plugins/fh-meta/skills/agent-composer/SKILL.md §Step 3-a` — the live successor.
|
|
25
|
-
|
|
26
|
-
> The original pre-merge content is preserved in git history (this stub's earlier revisions); the
|
|
27
|
-
> shipped `SKILL_detail.md` archive was removed in the curator shrink (2026-06-16) as redundant with
|
|
28
|
-
> git history. No live content lost — the functional content lives in agent-composer Step 3-a.
|
|
29
|
-
|
|
30
|
-
## Done When
|
|
31
|
-
|
|
32
|
-
Deprecated — no active execution path. Done When: skill is never directly invoked; all dispatch routes through `/agent-composer` Step 3-a (the successor). This entry satisfies the harness-doctor L2 M-tier requirement for missing Done When (CLAUDE.md §New Skill Creation Pre-Commit Gate).
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: self-marketing-lint
|
|
3
|
-
description: >-
|
|
4
|
-
DEPRECATED — merged into harness-doctor --lint mode (2026-06-02).
|
|
5
|
-
Language pattern detection (self-marketing, cushion words, version labels) is now Step 3-L of harness-doctor.
|
|
6
|
-
Use /harness-doctor --lint instead.
|
|
7
|
-
user-invocable: false
|
|
8
|
-
allowed-tools: []
|
|
9
|
-
model: sonnet
|
|
10
|
-
deprecated: true
|
|
11
|
-
deprecated_reason: absorbed into harness-doctor Step 3-L (--lint mode)
|
|
12
|
-
deprecated_date: 2026-06-02
|
|
13
|
-
successor: harness-doctor
|
|
14
|
-
---
|
|
15
|
-
|
|
16
|
-
# self-marketing-lint — DEPRECATED
|
|
17
|
-
|
|
18
|
-
This skill was absorbed into **harness-doctor** on 2026-06-02. Its self-marketing /
|
|
19
|
-
cushion-word / version-brag detection patterns now live in **harness-doctor Step 3-L
|
|
20
|
-
(`--lint` mode)**, which carries the canonical baseline.
|
|
21
|
-
|
|
22
|
-
**Use instead:**
|
|
23
|
-
|
|
24
|
-
```
|
|
25
|
-
/harness-doctor --lint
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
Triggers that previously reached this skill (`check FH files`, `remove marketing
|
|
29
|
-
language`, `description diet`) now route to harness-doctor Step 3-L. This stub remains
|
|
30
|
-
only so old references resolve.
|