From 3076af359ae2ccfdbaf73a17ed02bbd60998679d Mon Sep 17 00:00:00 2001 From: LantisPrime Date: Tue, 16 Jun 2026 05:13:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(rfc-008):=20P3c=20=E2=80=94=20classifier?= =?UTF-8?q?=20runtime-sources=20taxonomy.json=20(R4/F4/F6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit command-classifier.sh now sources its label vocabulary from patterns/taxonomy.json at runtime via a zero-dep node helper and fails closed if the resolved set drifts from the _priority() case-arms (the single bash label authority CI already pins == taxonomy). Eliminates the hand-maintained label list "by construction" (F4/OQ-2). - _ensure_taxonomy_synced guard fronts BOTH public emitters (classify_command + classify_path); never overrides the non-overridable marker_write (deadlock escape) or unsafe_complex labels. - Resolution: $HOME/.episodic-memory/patterns/taxonomy.json (== install GLOBAL_DIR), then a CONDITIONAL in-repo climb gated on repo sentinels + a classifier realpath round-trip — the installed layout can never read an ambient parent taxonomy. - install.mjs co-deploys taxonomy.json to the same global root; WARNs (split pre-P3c-no-helper vs post-P3c) when a divergent classifier is kept. - validate-bp-contract.mjs assertion 7c: robust parser asserts the guard is defined + called from both entry points + emit-site label literals subset of taxonomy. extractPriorityArms allowlists `declare -f _priority` (inert read). - Tests: test-classifier-taxonomy-sync.sh (18/0) + 5 new 7c cases + 4 install T20 cases; wired command-classifier + taxonomy-sync suites into CI. Plan reviewed by negative-scenario-planner + 3 codex rounds (ACCEPT). Defers (per workplan v125): override interface + F3 alert -> P3b-2; TSV->NDJSON -> P4; em-recall purification -> P3d. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/plugin-validate.yml | 6 + install.mjs | 53 ++++++ .../hooks/lib/command-classifier.sh | 145 +++++++++++++++ scripts/validate-bp-contract.mjs | 111 ++++++++++- tests/test-classifier-taxonomy-sync.sh | 175 ++++++++++++++++++ tests/test-install-hooks.sh | 43 +++++ tests/test-validate-bp-contract.mjs | 31 ++++ 7 files changed, 563 insertions(+), 1 deletion(-) create mode 100644 tests/test-classifier-taxonomy-sync.sh diff --git a/.github/workflows/plugin-validate.yml b/.github/workflows/plugin-validate.yml index 616c70b..5472ee2 100644 --- a/.github/workflows/plugin-validate.yml +++ b/.github/workflows/plugin-validate.yml @@ -55,3 +55,9 @@ jobs: - name: Run validate-bp-contract self-tests (golden corpus + stable-ID E2E) run: node tests/test-validate-bp-contract.mjs + + - name: Run command-classifier regression suite (label-emit / _priority lock) + run: bash tests/test-command-classifier.sh + + - name: Run classifier taxonomy runtime-sourcing tests (RFC-008 P3c, R4/F4) + run: bash tests/test-classifier-taxonomy-sync.sh diff --git a/install.mjs b/install.mjs index 5a5370e..549a0da 100755 --- a/install.mjs +++ b/install.mjs @@ -295,6 +295,13 @@ if (fs.existsSync(repoPatternsIndex)) { console.log(`Installed patterns/_index.json to ${globalPatternsDir}`) } +// NOTE: patterns/taxonomy.json is a RUNTIME dependency of command-classifier.sh, +// NOT a global-validation artifact like _index.json — so it is co-deployed WITH +// the classifier inside the `if (installHooks)` block (§5a-tax below), never +// unconditionally here. Deploying it in the main body would advance runtime +// candidate-1 on a no-hooks install while leaving an already-installed classifier +// stale + unwarned (PR-level codex BLOCKER; R4/F4 root parity + sync coupling). + // --------------------------------------------------------------------------- // 2. Create local .episodic-memory in target project // --------------------------------------------------------------------------- @@ -1202,6 +1209,52 @@ if (installHooks) { ) } + // 5a-tax. RFC-008 P3c (R4/F4): co-deploy patterns/taxonomy.json to the SAME + // global root the classifier reads at runtime (candidate 1 = + // $HOME/.episodic-memory/patterns/taxonomy.json; GLOBAL_DIR = os.homedir()/ + // .episodic-memory, no EPISODIC_MEMORY_HOME indirection — codex R2-P2 root + // parity). This is INSIDE the installHooks block so taxonomy and classifier + // advance together: a no-hooks install touches neither (PR-level codex + // BLOCKER — taxonomy must not advance candidate-1 while the installed + // classifier stays stale + unwarned). + const repoTaxonomy = path.join(REPO_DIR, 'patterns', 'taxonomy.json') + if (fs.existsSync(repoTaxonomy)) { + fs.mkdirSync(globalPatternsDir, { recursive: true }) + fs.copyFileSync(repoTaxonomy, path.join(globalPatternsDir, 'taxonomy.json')) + console.log(`Installed patterns/taxonomy.json to ${globalPatternsDir}`) + } + + // RFC-008 P3c (R4/F4, codex R1-P1b): if the command classifier was KEPT as a + // divergent local edit while taxonomy.json was (re)deployed just above, the + // installed classifier and the global taxonomy may disagree. Two cases, + // distinguished by whether the kept file carries the runtime-sourcing helper: + // pre-P3c (no _ensure_taxonomy_synced): runs stale hardcoded labels and is + // NOT taxonomy-synced — the gate is silently unprotected by + // runtime-sourcing (no fail-closed at all). + // post-P3c (has the helper): will FAIL CLOSED loudly on any drift until + // re-forced. + if (libResults['command-classifier.sh'] === 'skipped-divergent') { + let keptClassifier = '' + try { + keptClassifier = fs.readFileSync( + path.join(userHooksLibDir, 'command-classifier.sh'), 'utf8') + } catch { /* unreadable → treat as pre-P3c (no helper) below */ } + if (keptClassifier.includes('_ensure_taxonomy_synced')) { + console.log( + 'WARNING: command-classifier.sh kept (divergent local edit) while ' + + 'taxonomy.json was redeployed — the kept classifier will FAIL CLOSED ' + + 'on any taxonomy drift. Re-run with --install-hooks-force to sync.' + ) + } else { + console.log( + 'WARNING: command-classifier.sh kept (divergent local edit) is pre-P3c ' + + '— it does NOT runtime-source taxonomy.json and is NOT taxonomy-synced ' + + '(runs stale hardcoded labels). Re-run with --install-hooks-force to ' + + 'install runtime label-sourcing.' + ) + } + } + // 5a. Hook specs imported from scripts/lib/install-manifest.mjs (single // source of truth shared with tools/migration-cutover.mjs). Closes // Codex round-2 implementation attention point: avoid a second diff --git a/plugins/claude-code/hooks/lib/command-classifier.sh b/plugins/claude-code/hooks/lib/command-classifier.sh index c4e5704..013eb9c 100755 --- a/plugins/claude-code/hooks/lib/command-classifier.sh +++ b/plugins/claude-code/hooks/lib/command-classifier.sh @@ -2400,6 +2400,129 @@ _resolve_marker_path() { # cwd or a worktree. Codex R1 P1 reproduced marker miss for that # divergence. # +# --------------------------------------------------------------------------- +# Runtime taxonomy sourcing (RFC-008 P3c, maps to R4 / F4 / F6) +# --------------------------------------------------------------------------- +# The label vocabulary is single-sourced from patterns/taxonomy.json at runtime +# and fail-closed if it drifts from this classifier's own label authority. Per +# F4 (OQ-2 closed) this eliminates a hand-maintained bash label list "by +# construction"; the bash label authority is the _priority() case-arms (already +# CI-validated == taxonomy.labels by validate-bp-contract.mjs assertion 7b), so +# the runtime check compares taxonomy.labels against _priority arms — NO second +# parallel list (adversarial GAP-1). +# +# Resolution (codex R2-P1 / R2-P2): +# candidate 1: $HOME/.episodic-memory/patterns/taxonomy.json — the SAME root +# install.mjs writes (GLOBAL_DIR, install.mjs:24) and the idiom +# the hooks already use (checkpoint-gate.sh:741). No +# EPISODIC_MEMORY_HOME indirection (os.homedir() wouldn't honor +# it); tests isolate via HOME. +# candidate 2: in-repo copy via the $BASH_SOURCE climb, used ONLY when the +# climbed root PROVES it is the repo copy (repo sentinels present +# AND the classifier path round-trips). The installed layout +# (~/.claude/hooks/lib) fails the predicate, so it can never read +# an ambient parent patterns/taxonomy.json (authority-root +# containment). +# No EM_TAXONOMY_PATH env override (codex R1-P1: command-local env taxonomy +# authority is a bypass vector, PR-271). +# +# Fail-closed surfaces via a blocking label (unsafe_complex) with a distinct +# reason so logs disambiguate misconfig from a dangerous command. marker_write +# is NEVER fail-closed (deadlock-class-1 escape hatch, taxonomy.json +# non_overridable_rationale): classify_command exempts marker_write and +# classify_path's marker cases return before the guard. + +_TAXONOMY_SYNC_DONE="" # plain, NON-exported per-process guard (adversarial +_TAXONOMY_SYNC_STATUS="" # axis-7: a child re-sources + re-validates rather +_TAXONOMY_SYNC_REASON="" # than inheriting a stale "passed" flag) + +# Physical (symlink-resolved) path of a file by resolving its DIRECTORY via +# `cd -P` (cross-platform; no GNU `readlink -f`). Resolves the /var→/private/var +# class that fail-opened P3b-1's isMain check. +_taxonomy_file_realpath() { + local f="$1" d b + d="$(dirname "$f")" || return 1 + b="$(basename "$f")" + d="$(cd -P "$d" 2>/dev/null && pwd)" || return 1 + printf '%s/%s' "$d" "$b" +} + +# The single bash label authority: the _priority() case-arm names, sorted and +# space-joined. declare -f is bash-native and formatting-stable for our regex +# (matches `