@event4u/agent-config 14.6.0 → 14.8.0
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 +1 -1
- package/CHANGELOG.md +255 -145
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/inbox.md +264 -10
- package/dist/agent-src/commands/analyze.md +1 -1
- package/dist/agent-src/commands/evals/git-pr-merge.json +13 -0
- package/dist/agent-src/commands/evals/roadmap.json +17 -1
- package/dist/agent-src/commands/pr/create.md +14 -2
- package/dist/agent-src/commands/pr/merge.md +379 -0
- package/dist/agent-src/commands/roadmap/process-full.md +272 -32
- package/dist/agent-src/commands/roadmap.md +1 -1
- package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +46 -36
- package/dist/agent-src/contexts/execution/autonomy-mechanics.md +37 -2
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +95 -2
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +289 -106
- package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
- package/dist/agent-src/rules/context-hygiene.md +2 -0
- package/dist/agent-src/rules/decision-revisit-gate.md +67 -32
- package/dist/agent-src/rules/fix-what-you-see.md +108 -0
- package/dist/agent-src/rules/no-roadmap-references.md +0 -5
- package/dist/agent-src/rules/recurring-criticism.md +110 -0
- package/dist/agent-src/rules/roadmap-progress-sync.md +20 -1
- package/dist/agent-src/rules/rule-type-governance.md +0 -2
- package/dist/agent-src/rules/self-repair-loop.md +17 -0
- package/dist/agent-src/rules/skill-quality.md +0 -2
- package/dist/agent-src/rules/source-confidentiality.md +37 -7
- package/dist/agent-src/scripts/archival_sweep.ts +117 -0
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +26 -7
- package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
- package/dist/agent-src/scripts/gate_budget.ts +282 -0
- package/dist/agent-src/scripts/gate_execute.ts +72 -31
- package/dist/agent-src/scripts/roadmap_gates.ts +74 -3
- package/dist/agent-src/scripts/update_roadmap_progress.ts +296 -53
- package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
- package/dist/agent-src/skills/ai-council/SKILL.md +48 -807
- package/dist/agent-src/skills/ai-council/references/advanced-modes.md +320 -0
- package/dist/agent-src/skills/ai-council/references/cost-and-redaction.md +99 -0
- package/dist/agent-src/skills/ai-council/references/execution-modes.md +162 -0
- package/dist/agent-src/skills/ai-council/references/output-and-synthesis.md +125 -0
- package/dist/agent-src/skills/ai-council/references/procedure.md +143 -0
- package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
- package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
- package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
- package/dist/agent-src/skills/quality-tools/SKILL.md +34 -326
- package/dist/agent-src/skills/quality-tools/references/js-ts-tools.md +113 -0
- package/dist/agent-src/skills/quality-tools/references/php-tools.md +222 -0
- package/dist/agent-src/skills/roadmap-management/SKILL.md +15 -409
- package/dist/agent-src/skills/roadmap-management/references/archival.md +226 -0
- package/dist/agent-src/skills/roadmap-management/references/authoring.md +107 -0
- package/dist/agent-src/skills/roadmap-management/references/dashboard.md +47 -0
- package/dist/agent-src/skills/roadmap-management/references/lifecycle.md +58 -0
- package/dist/agent-src/skills/security/SKILL.md +8 -0
- package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
- package/dist/agent-src/skills/skill-writing/SKILL.md +34 -570
- package/dist/agent-src/skills/skill-writing/references/procedure.md +275 -0
- package/dist/agent-src/skills/skill-writing/references/read-only-scripts.md +18 -0
- package/dist/agent-src/skills/skill-writing/references/section-patterns.md +298 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +65 -4
- package/dist/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
- package/dist/agent-src/templates/scripts/telemetry/remote.ts +160 -7
- package/dist/agent-src/templates/scripts/telemetry/transport.ts +169 -0
- package/dist/cli/registry.js +2 -1
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-2GNWGMAL.js +172 -0
- package/dist/cli-delegate/{chunk-P6LQDIWK.js → chunk-7PRE6ZV2.js} +338 -0
- package/dist/cli-delegate/{chunk-BKO2AZ7Z.js → chunk-B2ERU2VA.js} +1 -1
- package/dist/cli-delegate/chunk-E4XIZDIB.js +402 -0
- package/dist/cli-delegate/{chunk-3UCJWMMP.js → chunk-EEJUMCDZ.js} +16 -16
- package/dist/cli-delegate/{chunk-NXFWFAYV.js → chunk-G23AT6GD.js} +1 -0
- package/dist/cli-delegate/{chunk-XURJ4AQC.js → chunk-H4VIVD4C.js} +385 -284
- package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
- package/dist/cli-delegate/{chunk-RLN73PR5.js → chunk-LPPWO5CE.js} +12 -8
- package/dist/cli-delegate/chunk-MWRD5YOH.js +100 -0
- package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-N7CDDGI3.js} +4 -23
- package/dist/cli-delegate/{chunk-63WSP6YZ.js → chunk-PMEKGDED.js} +74 -167
- package/dist/cli-delegate/{chunk-GRRPEPRN.js → chunk-T7L2QK3K.js} +24 -5
- package/dist/cli-delegate/{chunk-EVTY7ISR.js → chunk-YM4GZDJG.js} +9 -1
- package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
- package/dist/cli-delegate/cmd_conformance.js +466 -716
- package/dist/cli-delegate/cmd_converge.js +3 -3
- package/dist/cli-delegate/cmd_doctor.js +10 -10
- package/dist/cli-delegate/cmd_explain.js +1 -1
- package/dist/cli-delegate/cmd_export.js +10 -9
- package/dist/cli-delegate/cmd_handoff.js +10 -9
- package/dist/cli-delegate/cmd_memory_get.js +2 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +1 -1
- package/dist/cli-delegate/cmd_preflight.js +5 -5
- package/dist/cli-delegate/cmd_prune.js +11 -10
- package/dist/cli-delegate/cmd_route_audit.js +6 -5
- package/dist/cli-delegate/cmd_route_explain.js +3 -3
- package/dist/cli-delegate/cmd_session_recycle.js +840 -30
- package/dist/cli-delegate/cmd_settings_get.js +3 -3
- package/dist/cli-delegate/cmd_settings_migrate.js +10 -9
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +11 -10
- package/dist/cli-delegate/cmd_uninstall.js +13 -11
- package/dist/cli-delegate/cmd_update.js +4 -4
- package/dist/cli-delegate/cmd_upgrade.js +4 -4
- package/dist/cli-delegate/cmd_validate.js +11 -10
- package/dist/cli-delegate/cmd_versions.js +3 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +111 -37
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +5 -5
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +15 -12
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +11 -8
- package/dist/hooks/dispatch.js +3165 -2192
- package/dist/install/claudePathsPlan.js +187 -0
- package/dist/install/claudePathsPlan.js.map +1 -0
- package/dist/install/globalRuleLayers.js +169 -0
- package/dist/install/globalRuleLayers.js.map +1 -0
- package/dist/install/hostLayerFingerprint.js +136 -0
- package/dist/install/hostLayerFingerprint.js.map +1 -0
- package/dist/install/install.mjs +553 -425
- package/dist/install/partitionEligibility.js +432 -0
- package/dist/install/partitionEligibility.js.map +1 -0
- package/dist/install/ruleLayerPartition.js +157 -0
- package/dist/install/ruleLayerPartition.js.map +1 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1863 -1234
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_src.js +41 -0
- package/dist/scripts/_lib/agent_src.js.map +1 -1
- package/dist/scripts/_lib/install_layout.js +83 -0
- package/dist/scripts/_lib/install_layout.js.map +1 -0
- package/dist/scripts/_lib/installed_lock.js +444 -0
- package/dist/scripts/_lib/installed_lock.js.map +1 -0
- package/dist/scripts/_lib/prune_empty_dirs.js +85 -0
- package/dist/scripts/_lib/prune_empty_dirs.js.map +1 -0
- package/dist/scripts/condense.js +63 -251
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/schemas/settings.js +4 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +12 -6
- package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +8 -3
- package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
- package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +26 -0
- package/docs/guidelines/agent-infra/false-green.md +47 -1
- package/docs/guidelines/agent-infra/memory-access.md +17 -0
- package/docs/guidelines/agent-infra/recurring-criticism-mechanics.md +96 -0
- package/package.json +1 -1
- package/src/agent-src/scripts/archival_sweep.ts +117 -0
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +26 -7
- package/src/agent-src/scripts/dashboard_mode.ts +212 -0
- package/src/agent-src/scripts/gate_budget.ts +282 -0
- package/src/agent-src/scripts/gate_execute.ts +72 -31
- package/src/agent-src/scripts/roadmap_gates.ts +74 -3
- package/src/agent-src/scripts/update_roadmap_progress.ts +296 -53
- package/src/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
- package/src/agent-src/templates/scripts/telemetry/remote.ts +160 -7
- package/src/agent-src/templates/scripts/telemetry/transport.ts +169 -0
- package/src/config/agent-settings.template.yml +27 -2
- package/src/config/ci-local-parity.yml +39 -0
- package/src/config/estate-count-budget.json +612 -3
- package/src/config/evaluator-budgets.json +3 -3
- package/src/config/gate-coverage.yml +220 -12
- package/src/config/gate-violation-baselines.json +73 -63
- package/src/config/gitignore-block.txt +15 -2
- package/src/config/hook-latency-budget.json +5 -0
- package/src/config/rule-activation-census.json +14 -6
- package/src/config/rule-stub-ceilings.json +34 -7
- package/src/install/claudePathsPlan.ts +196 -0
- package/src/install/globalRuleLayers.ts +197 -0
- package/src/install/hostLayerFingerprint.ts +145 -0
- package/src/install/partitionEligibility.ts +502 -0
- package/src/install/ruleLayerPartition.ts +212 -0
- package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
- package/src/scripts/_cli/cmd_session_recycle.ts +9 -1
- package/src/scripts/_dispatch.bash +17 -1
- package/src/scripts/_lib/ac_heading.ts +30 -0
- package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
- package/src/scripts/_lib/adr_frontmatter.ts +459 -0
- package/src/scripts/_lib/agent_src.ts +42 -0
- package/src/scripts/_lib/bench_ab_pinned_repo.ts +162 -0
- package/src/scripts/_lib/bench_ab_workspace.ts +10 -1
- package/src/scripts/_lib/envelope_grounding.ts +49 -9
- package/src/scripts/_lib/installed_lock.ts +50 -5
- package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
- package/src/scripts/_lib/md_section.ts +129 -0
- package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
- package/src/scripts/_lib/scope_guard.sh +26 -3
- package/src/scripts/_lib/self_repair.ts +19 -0
- package/src/scripts/_lib/self_repair_class_b.ts +171 -0
- package/src/scripts/adr/evidence_census.ts +899 -0
- package/src/scripts/adr/regenerate_index.ts +12 -32
- package/src/scripts/adr_cite_check.ts +540 -39
- package/src/scripts/ai_council/cli_help.ts +55 -0
- package/src/scripts/audit_adr_coverage.ts +103 -17
- package/src/scripts/before_complete_hook.ts +153 -28
- package/src/scripts/bench_ab_v2_complexity.ts +27 -7
- package/src/scripts/bench_hook_latency.ts +204 -0
- package/src/scripts/build_archive_index.ts +34 -0
- package/src/scripts/check_adr_frontmatter.ts +574 -39
- package/src/scripts/check_hook_bundle_content.ts +219 -0
- package/src/scripts/check_new_adr_evidence.ts +529 -0
- package/src/scripts/check_references.ts +78 -0
- package/src/scripts/check_release_published.ts +32 -11
- package/src/scripts/check_rule_layer_partition.ts +531 -0
- package/src/scripts/check_single_delivery.ts +194 -17
- package/src/scripts/check_standing_rule_delivery.ts +8 -2
- package/src/scripts/ci_settle.ts +170 -0
- package/src/scripts/condense.ts +79 -259
- package/src/scripts/council_cli.ts +3 -3
- package/src/scripts/dispatch_r2_reviewer.ts +6 -14
- package/src/scripts/external_sources_denylist.json +4 -1
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +77 -14
- package/src/scripts/hooks/block_no_verify.ts +61 -10
- package/src/scripts/hooks/block_unauthorized_git.ts +21 -1
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/dispatch_hook.ts +154 -187
- package/src/scripts/hooks/dispatch_issues.ts +38 -19
- package/src/scripts/hooks/fallback_yaml.ts +66 -0
- package/src/scripts/hooks/host_semantics.ts +21 -0
- package/src/scripts/hooks/injection_budget.ts +13 -3
- package/src/scripts/hooks/py_json_dumps.ts +103 -0
- package/src/scripts/hooks/rtk_wrap_hook.ts +8 -3
- package/src/scripts/hooks/run_continuation_hook.ts +8 -5
- package/src/scripts/hooks/ship_diff_volume_hook.ts +81 -15
- package/src/scripts/hooks/source_first_gate_hook.ts +464 -0
- package/src/scripts/hooks/state_io.ts +870 -21
- package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
- package/src/scripts/hooks/subagent_ledger_hook.ts +44 -15
- package/src/scripts/hooks/telemetry_flush_hook.ts +139 -0
- package/src/scripts/hooks/telemetry_self_repair.ts +128 -0
- package/src/scripts/hooks/telemetry_usage_hook.ts +22 -4
- package/src/scripts/hooks/turn_end_gate_hook.ts +81 -15
- package/src/scripts/injection_scan_hook.ts +137 -5
- package/src/scripts/install.ts +16 -11
- package/src/scripts/language_mirror_hook.ts +561 -25
- package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
- package/src/scripts/lint_hook_manifest.ts +49 -0
- package/src/scripts/lint_plan_risk_register.ts +4 -3
- package/src/scripts/lint_provenance_vocabulary.ts +472 -4
- package/src/scripts/lint_skill_router_head.ts +4 -4
- package/src/scripts/mcp_server/tools.ts +2 -2
- package/src/scripts/new_roadmap.ts +136 -0
- package/src/scripts/pack_mcp_content.ts +20 -5
- package/src/scripts/release.ts +37 -38
- package/src/scripts/report_layer_overlap.ts +6 -0
- package/src/scripts/routing_doctor.ts +5 -1
- package/src/scripts/self_repair_hook.ts +37 -5
- package/src/scripts/session_register_hook.ts +11 -0
- package/src/scripts/skill_usage_collect.ts +11 -1
- package/src/scripts/skill_usage_report.ts +278 -8
- package/src/scripts/sync_pr_branch.ts +170 -20
- package/src/server/schemas/settings.ts +8 -0
- package/dist/cli-delegate/chunk-EJITJBJX.js +0 -172
- package/dist/cli-delegate/chunk-PZWRPRWP.js +0 -38
- package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
- package/dist/cli-delegate/{chunk-RYNADUFG.js → chunk-LWTYTX6F.js} +0 -0
- package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-MMSGVZKV.js} +3 -3
|
@@ -72,7 +72,16 @@ import fs from "node:fs";
|
|
|
72
72
|
import path from "node:path";
|
|
73
73
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
74
74
|
|
|
75
|
-
import {
|
|
75
|
+
import {
|
|
76
|
+
atomic_write_json,
|
|
77
|
+
atomic_write_text,
|
|
78
|
+
has_stable_session_id,
|
|
79
|
+
is_replay_mode,
|
|
80
|
+
owns_session_state,
|
|
81
|
+
prune_legacy_state_file,
|
|
82
|
+
prune_stale_session_states,
|
|
83
|
+
session_state_file,
|
|
84
|
+
} from "./hooks/state_io.js";
|
|
76
85
|
import { readHookStdin } from "./hooks/hook_stdin.js";
|
|
77
86
|
import { humanAuthoredLead, isSyntheticPrompt } from "./_lib/prompt_shape.js";
|
|
78
87
|
|
|
@@ -85,8 +94,97 @@ const EXIT_ALLOW = 0;
|
|
|
85
94
|
// by tracing the delivery rather than re-reading the unit tests.
|
|
86
95
|
const EXIT_WARN = 2;
|
|
87
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The pre-split single-file state. ONE file per project root — and in this
|
|
99
|
+
* repo's worktree workflow `CLAUDE_PROJECT_DIR` resolves to the parent
|
|
100
|
+
* checkout, so every concurrent session shared it. Measured consequence
|
|
101
|
+
* (2026-08-20, session 15b9ac52): a terse German "1" read a neighbouring
|
|
102
|
+
* English session's pin and injected `Reply language for this turn: English.`
|
|
103
|
+
* into a German conversation.
|
|
104
|
+
*
|
|
105
|
+
* Nothing reads it after the per-session split. It is kept exported because
|
|
106
|
+
* `_pruneLegacyState` needs to name the path it deletes, and because a reader
|
|
107
|
+
* that still resolves it should get a compile-visible symbol rather than a
|
|
108
|
+
* silently stale literal.
|
|
109
|
+
*
|
|
110
|
+
* It used to say this "names the path an older bundle may still be writing
|
|
111
|
+
* during an upgrade". That was written without checking and a cross-model review
|
|
112
|
+
* (2026-08-20) built a finding on it: every session under one project root runs
|
|
113
|
+
* ONE dispatcher bundle, resolved through `CLAUDE_PROJECT_DIR` — the parent
|
|
114
|
+
* checkout even in a worktree — so there is no steady mixed-version state. The
|
|
115
|
+
* real, narrow window and its bound are stated once, at
|
|
116
|
+
* `state_io.prune_legacy_state_file`.
|
|
117
|
+
*/
|
|
88
118
|
export const STATE_FILE = path.join("agents", "state", "language-mirror.json");
|
|
89
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Per-SESSION state, one file each — the same layout R2 finding 10 gave the
|
|
122
|
+
* pin-lost markers, and for the same reason.
|
|
123
|
+
*
|
|
124
|
+
* A map-in-one-file was the smaller diff and was rejected: two sessions writing
|
|
125
|
+
* concurrently through `atomic_write_json` (write + rename) drop one another's
|
|
126
|
+
* entry, so the cross-talk would return as a lost update instead of a wrong
|
|
127
|
+
* read.
|
|
128
|
+
*
|
|
129
|
+
* This comment used to end "separate files cannot collide by construction", and
|
|
130
|
+
* both council seats named that sentence as false: the first cut derived the
|
|
131
|
+
* filename with a character sanitiser, so `a/b` and `a_b` addressed one file and
|
|
132
|
+
* a substantive prompt from either destroyed the other's state. It is true now
|
|
133
|
+
* only because `statePathFor` keys on a digest of the full id — the property
|
|
134
|
+
* comes from the digest, not from the directory layout, and stating it the other
|
|
135
|
+
* way is how the defect survived a round of review.
|
|
136
|
+
*/
|
|
137
|
+
export const STATE_DIR = path.join("agents", "state", "language-mirror");
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Days after which an untouched session's state is pruned.
|
|
141
|
+
*
|
|
142
|
+
* One file per session grows without bound otherwise. 7 matches the retention
|
|
143
|
+
* window this repo already uses for council session artefacts — a convention
|
|
144
|
+
* match, not a measurement, and stated as such.
|
|
145
|
+
*/
|
|
146
|
+
export const STATE_RETENTION_DAYS = 7;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Is there a stable identity to key state on at all?
|
|
150
|
+
*
|
|
151
|
+
* BLOCKER 2, council round 2 (both seats): a sanitised empty id bucketed every
|
|
152
|
+
* id-less invocation into `unknown-session.json`, and `_ownsPin` at the time
|
|
153
|
+
* ACCEPTED an empty stored owner as its own — so id-less sessions shared one
|
|
154
|
+
* file with NO secondary defense. That is the original defect restored, in the
|
|
155
|
+
* one case that had no guard left. (Round 3 closed the second half too:
|
|
156
|
+
* `_ownsPin` now requires exact ownership, so an empty owner is foreign. Stated
|
|
157
|
+
* in the past tense on purpose — describing a fixed defect in the present is
|
|
158
|
+
* how a comment ends up contradicting its own code, which is a round-2 finding
|
|
159
|
+
* against an earlier version of this very file.) There is no sound local way to tell two id-less sessions apart
|
|
160
|
+
* (a fresh UUID per invocation would give one session a new file per hook call,
|
|
161
|
+
* which destroys continuity rather than providing it), so the honest answer is
|
|
162
|
+
* to run stateless: classify the prompt, pin from it, persist nothing.
|
|
163
|
+
*/
|
|
164
|
+
export function hasStableSessionId(session_id: string): boolean {
|
|
165
|
+
return has_stable_session_id(session_id);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Path for one session's pin, keyed by a digest of the FULL id.
|
|
170
|
+
*
|
|
171
|
+
* BLOCKER 1, council round 2: the previous sanitiser mapped `a/b` and `a_b` onto
|
|
172
|
+
* one filename, and `_ownsPin` catches that only AFTER the collision — it
|
|
173
|
+
* refuses to inherit, but it cannot stop a substantive prompt from replacing the
|
|
174
|
+
* other session's file, which loses that session's pin and counter permanently.
|
|
175
|
+
* The seat had already asked for a hashed identifier in round 1 and this code
|
|
176
|
+
* answered with the guard instead; the guard was the wrong answer.
|
|
177
|
+
*
|
|
178
|
+
* A digest also removes the filename-length failure mode for unusually long ids.
|
|
179
|
+
* `_ownsPin` stays as an in-file integrity check — cheap, and it guards against
|
|
180
|
+
* a stale or hand-copied file rather than against a hash collision. Round 3
|
|
181
|
+
* tightened it to exact equality for that role: an ownerless file at a hashed
|
|
182
|
+
* path can only be corruption, since every writer here stamps the owner.
|
|
183
|
+
*/
|
|
184
|
+
export function statePathFor(session_id: string): string {
|
|
185
|
+
return session_state_file(STATE_DIR, session_id);
|
|
186
|
+
}
|
|
187
|
+
|
|
90
188
|
/**
|
|
91
189
|
* Marker written at `pre_compact` and cleared by the single `post_tool_use`
|
|
92
190
|
* re-emit that follows it (round-5 § 6.1). Its existence — not a counter — is
|
|
@@ -337,6 +435,11 @@ export interface PinState extends JsonObject {
|
|
|
337
435
|
de_markers: number;
|
|
338
436
|
en_markers: number;
|
|
339
437
|
session_id: string;
|
|
438
|
+
/**
|
|
439
|
+
* Tool calls seen since the pin last reached the model. Optional because
|
|
440
|
+
* every state written before this field existed is still a valid pin.
|
|
441
|
+
*/
|
|
442
|
+
tool_calls_since_pin?: number;
|
|
340
443
|
}
|
|
341
444
|
|
|
342
445
|
function _loadState(target: string): Partial<PinState> {
|
|
@@ -349,6 +452,55 @@ function _loadState(target: string): Partial<PinState> {
|
|
|
349
452
|
}
|
|
350
453
|
}
|
|
351
454
|
|
|
455
|
+
/**
|
|
456
|
+
* Is a stored pin this session's to inherit? Exact ownership, nothing weaker.
|
|
457
|
+
*
|
|
458
|
+
* This used to accept an absent or empty owner as "legacy state, therefore
|
|
459
|
+
* mine". Council round 3 refuted that for the hashed layout, and the argument
|
|
460
|
+
* is the layout's own: the pre-split shared file is deleted rather than
|
|
461
|
+
* migrated, and every writer into a hashed path sets `session_id` — so a
|
|
462
|
+
* hashed file WITHOUT an owner is corruption or a hand-copy, i.e. exactly the
|
|
463
|
+
* condition this check claims to catch. The permissive branch was dead code
|
|
464
|
+
* that weakened the guarantee in its own doc comment.
|
|
465
|
+
*
|
|
466
|
+
* No compatibility window is owed: the digest path has never shipped, so no
|
|
467
|
+
* deployed version ever wrote an ownerless hashed file.
|
|
468
|
+
*
|
|
469
|
+
* Absent state (`{}`) returns false and that changes nothing at any call site
|
|
470
|
+
* — each one already returns early on a missing `language`, which absent state
|
|
471
|
+
* cannot carry.
|
|
472
|
+
*/
|
|
473
|
+
export function _ownsPin(previous: Partial<PinState>, session_id: string): boolean {
|
|
474
|
+
return owns_session_state(previous, session_id);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Tool calls after which `post_tool_use` re-states the pin once.
|
|
479
|
+
*
|
|
480
|
+
* MEASURED, not chosen (2026-08-20, 10 most recent transcripts, 447 assistant
|
|
481
|
+
* turns following a German prompt): 11 of the 12 English replies sat at
|
|
482
|
+
* 179–200 tool calls since the pin, while compliant turns had p90 = 122 and
|
|
483
|
+
* p99 = 184.
|
|
484
|
+
*
|
|
485
|
+
* 150 lies between compliant p90 (122) and the EARLIEST observed violation
|
|
486
|
+
* (179) — deliberately NOT between the two distributions, because they overlap:
|
|
487
|
+
* compliant p99 = 184 is past the violation floor, so some compliant traffic
|
|
488
|
+
* does cross 150 and will be reminded. That is the accepted cost of margin, and
|
|
489
|
+
* saying otherwise was an overclaim both council seats caught (2026-08-20): the
|
|
490
|
+
* earlier wording here read "never inside the band where compliance is already
|
|
491
|
+
* 100 %", which the p99 refutes. A block of 200 tool calls costs ONE re-emit.
|
|
492
|
+
*
|
|
493
|
+
* HONEST BASIS: n = 11, and they cluster in a single long autonomous session.
|
|
494
|
+
* The distance signal is sharp (0 violations below 179) but the corpus is one
|
|
495
|
+
* session wide, so this is "the number the observed failure sits behind", not
|
|
496
|
+
* a fitted optimum.
|
|
497
|
+
*
|
|
498
|
+
* Revisit-if: a violation is recorded BELOW this distance (the threshold is
|
|
499
|
+
* too high), or a session shows the re-emit firing without any violation
|
|
500
|
+
* having been plausible at that distance across ≥ 5 sessions (too low).
|
|
501
|
+
*/
|
|
502
|
+
export const REEMIT_AFTER_TOOL_CALLS = 150;
|
|
503
|
+
|
|
352
504
|
/**
|
|
353
505
|
* Decide the next state from the previous one and this turn's classification.
|
|
354
506
|
* Exported so the tests can assert the keep-previous-pin rule directly.
|
|
@@ -362,8 +514,32 @@ export function nextState(
|
|
|
362
514
|
locale: Verdict = "und",
|
|
363
515
|
): PinState | null {
|
|
364
516
|
if (classification.language === "und") {
|
|
365
|
-
// Terse continuation ("1", "ok", "weiter") — keep whatever was pinned
|
|
366
|
-
|
|
517
|
+
// Terse continuation ("1", "ok", "weiter") — keep whatever was pinned,
|
|
518
|
+
// but ONLY when the pin is this session's own.
|
|
519
|
+
//
|
|
520
|
+
// The state file is ONE file per project root, and in this repo's worktree
|
|
521
|
+
// workflow `CLAUDE_PROJECT_DIR` resolves to the parent checkout — so every
|
|
522
|
+
// concurrent session shares it. Without this check a terse German "3" read
|
|
523
|
+
// a neighbouring English session's pin and injected
|
|
524
|
+
// `Reply language for this turn: English.` into a German conversation
|
|
525
|
+
// (observed 2026-08-20, session 15b9ac52 line 2155). That is the rule
|
|
526
|
+
// inverted, not merely absent, and it is the same cross-session cross-talk
|
|
527
|
+
// R2 finding 10 closed for the pin-lost markers while leaving the language
|
|
528
|
+
// state itself open.
|
|
529
|
+
//
|
|
530
|
+
// An ABSENT `session_id` is FOREIGN, not owned. This comment said the
|
|
531
|
+
// opposite — "treated as owned … only a DIFFERENT id is" — describing the
|
|
532
|
+
// permissive `_ownsPin` that round 3 replaced, and a concurrent session
|
|
533
|
+
// caught it still standing after `owns_session_state` made exact ownership
|
|
534
|
+
// shared. The reasoning it gave (a pre-field state cannot be evidence of
|
|
535
|
+
// another session) no longer applies: every writer into a digest path stamps
|
|
536
|
+
// the owner and that layout never shipped without one, so an unowned file is
|
|
537
|
+
// corruption or a hand-copy, which is precisely what the check is for.
|
|
538
|
+
//
|
|
539
|
+
// Unreachable in practice, and that is why it survived: nothing in the
|
|
540
|
+
// digest layout produces an ownerless file. A comment asserting the inverse
|
|
541
|
+
// of its own code is still a defect — the next reader trusts the prose.
|
|
542
|
+
if (_ownsPin(previous, session_id) && (previous.language === "de" || previous.language === "en")) {
|
|
367
543
|
return null;
|
|
368
544
|
}
|
|
369
545
|
// FIRST turn and nothing to keep: the locale is better than no pin at all.
|
|
@@ -378,6 +554,7 @@ export function nextState(
|
|
|
378
554
|
de_markers: classification.de_markers,
|
|
379
555
|
en_markers: classification.en_markers,
|
|
380
556
|
session_id,
|
|
557
|
+
tool_calls_since_pin: 0,
|
|
381
558
|
};
|
|
382
559
|
}
|
|
383
560
|
return {
|
|
@@ -388,6 +565,7 @@ export function nextState(
|
|
|
388
565
|
de_markers: classification.de_markers,
|
|
389
566
|
en_markers: classification.en_markers,
|
|
390
567
|
session_id,
|
|
568
|
+
tool_calls_since_pin: 0,
|
|
391
569
|
};
|
|
392
570
|
}
|
|
393
571
|
|
|
@@ -475,32 +653,375 @@ function _clearPinLost(consumer_root: string, session_id: string): void {
|
|
|
475
653
|
* and only when a pin actually exists — an absent or undetermined pin is not
|
|
476
654
|
* an obligation, and inventing one here would be worse than the gap.
|
|
477
655
|
*/
|
|
478
|
-
export function _reEmitAfterCompaction(
|
|
656
|
+
export function _reEmitAfterCompaction(
|
|
657
|
+
consumer_root: string,
|
|
658
|
+
session_id: string,
|
|
659
|
+
write_json: StateWriter = atomic_write_json,
|
|
660
|
+
): number {
|
|
661
|
+
if (!hasStableSessionId(session_id)) {
|
|
662
|
+
return EXIT_ALLOW; // BLOCKER 2 — nothing was persisted, so nothing to restore.
|
|
663
|
+
}
|
|
479
664
|
if (!_pinLost(consumer_root, session_id)) {
|
|
480
665
|
return EXIT_ALLOW;
|
|
481
666
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
|
|
488
|
-
|
|
667
|
+
|
|
668
|
+
// Serialised against any peer tool write for this session, and silent when
|
|
669
|
+
// one holds the lock. Everything below reads,
|
|
670
|
+
// decides and writes inside the lock, so two overlapping post_tool_use calls
|
|
671
|
+
// cannot both advance past the same counter value nor both emit at the
|
|
672
|
+
// threshold.
|
|
673
|
+
return _withToolWriteLock(consumer_root, session_id, EXIT_ALLOW, () => { const previous = _loadState(path.join(consumer_root, statePathFor(session_id)));
|
|
674
|
+
const language = previous.language as Verdict | undefined;
|
|
675
|
+
const source: PinSource = previous.source === "system-locale" ? "system-locale" : "prompt";
|
|
676
|
+
// Clear FIRST: if the write below throws, the marker must not survive to
|
|
677
|
+
// re-fire on every subsequent tool call — that is the shape § 6.2 refuses.
|
|
678
|
+
_clearPinLost(consumer_root, session_id);
|
|
679
|
+
if (language !== "de" && language !== "en") {
|
|
680
|
+
return EXIT_ALLOW;
|
|
681
|
+
}
|
|
682
|
+
// A foreign session's pin is not this session's to restore — same reading as
|
|
683
|
+
// `nextState`, for the same shared-state reason.
|
|
684
|
+
if (!_ownsPin(previous, session_id)) {
|
|
685
|
+
return EXIT_ALLOW;
|
|
686
|
+
}
|
|
687
|
+
// The pin is about to be fresh in context again, so the distance counter
|
|
688
|
+
// starts over. Without this the two re-emit triggers would drift apart and
|
|
689
|
+
// a post-compaction session could re-state the pin twice in a row.
|
|
690
|
+
//
|
|
691
|
+
// BLOCKER 4, council round 2: this used to ignore the result and emit anyway.
|
|
692
|
+
// A reset that does not land leaves the counter at 149, so the next successful
|
|
693
|
+
// tool-hook write reaches 150 and emits a SECOND reminder — which contradicts
|
|
694
|
+
// the "compaction wins" invariant this file documents. Same fail-closed policy
|
|
695
|
+
// as the distance path: no durable reset, no emit.
|
|
696
|
+
if (!_resetDistance(consumer_root, previous, session_id, write_json)) {
|
|
697
|
+
return EXIT_ALLOW;
|
|
698
|
+
}
|
|
699
|
+
process.stdout.write(
|
|
700
|
+
`${JSON.stringify({
|
|
701
|
+
decision: "warn",
|
|
702
|
+
reason: noticeText(language, source),
|
|
703
|
+
additional_context: pinText(language, source),
|
|
704
|
+
})}\n`,
|
|
705
|
+
);
|
|
706
|
+
return EXIT_WARN;
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Remove the pre-split single file once this version owns the tree.
|
|
712
|
+
*
|
|
713
|
+
* Not a migration: a new session has no pin to inherit anyway, so there is
|
|
714
|
+
* nothing in the old file worth carrying across. Leaving it would leave dead
|
|
715
|
+
* state that looks live to anyone reading the directory.
|
|
716
|
+
*/
|
|
717
|
+
export function _pruneLegacyState(consumer_root: string): void {
|
|
718
|
+
prune_legacy_state_file(path.join(consumer_root, STATE_FILE));
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Drop session states untouched for longer than the retention window.
|
|
723
|
+
*
|
|
724
|
+
* One file per session is unbounded growth otherwise. Called on the prompt
|
|
725
|
+
* path only — once per turn, never per tool call.
|
|
726
|
+
*/
|
|
727
|
+
export function _pruneStaleSessions(
|
|
728
|
+
consumer_root: string,
|
|
729
|
+
now_ms: number,
|
|
730
|
+
/**
|
|
731
|
+
* How a file's age is read. Injectable so the claim-then-revalidate race in
|
|
732
|
+
* the shared pruner is reachable from a test — the branch only runs when the
|
|
733
|
+
* candidate check and the post-claim check DISAGREE, which no cutoff-only
|
|
734
|
+
* test can stage.
|
|
735
|
+
*/
|
|
736
|
+
mtimeOf: (target: string) => number = (target) => fs.statSync(target).mtimeMs,
|
|
737
|
+
): number {
|
|
738
|
+
return prune_stale_session_states(
|
|
739
|
+
path.join(consumer_root, STATE_DIR),
|
|
740
|
+
now_ms,
|
|
741
|
+
STATE_RETENTION_DAYS,
|
|
742
|
+
mtimeOf,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Persist a counter value. Returns whether it landed; never raises into the turn.
|
|
748
|
+
*
|
|
749
|
+
* The boolean is load-bearing, and the comment it replaces was a false
|
|
750
|
+
* guarantee (council review 2026-08-20, both seats): "a failed write costs at
|
|
751
|
+
* most one late re-emit" is true for a failed INCREMENT and false for a failed
|
|
752
|
+
* RESET. A reset that does not land leaves the counter at the threshold, so
|
|
753
|
+
* every subsequent tool call re-fires — unbounded repeated reminders, which is
|
|
754
|
+
* exactly the shape § 6.2 refuses. The caller must therefore know.
|
|
755
|
+
*/
|
|
756
|
+
/**
|
|
757
|
+
* How long a lock file may sit before it is treated as abandoned.
|
|
758
|
+
*
|
|
759
|
+
* A whole hook run is milliseconds, so anything this old belongs to a process
|
|
760
|
+
* that died holding it. Deliberately NOT retried after a break: a retry loop
|
|
761
|
+
* re-opens exactly the window the lock exists to close.
|
|
762
|
+
*/
|
|
763
|
+
export const LOCK_STALE_MS = 30_000;
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Serialise the TOOL-side state update for one session.
|
|
767
|
+
*
|
|
768
|
+
* OWN FINDING, not a council one — stated that way on purpose. Round 3 asked
|
|
769
|
+
* both seats about this file and NEITHER named it: the artefact covers the
|
|
770
|
+
* pruner TOCTOU, the crash-stranded tombstones, `_ownsPin`, the foreign-owner
|
|
771
|
+
* test gap and the `chmod` portability, and nothing else. It was first written
|
|
772
|
+
* here as "council round 3 (OpenAI seat)", a peer checked the artefact and
|
|
773
|
+
* refuted that, and the attribution is corrected rather than quietly dropped —
|
|
774
|
+
* a comment claiming a reviewer found something is exactly the kind of false
|
|
775
|
+
* line that let a real blocker survive round 2 of this same file.
|
|
776
|
+
*
|
|
777
|
+
* The defect is real regardless of who found it, and the counter-probe is the
|
|
778
|
+
* evidence: the counter path was a read-decide-write over a snapshot taken at
|
|
779
|
+
* the start of the hook run, with nothing serialising two of them. Two overlapping `post_tool_use` calls both
|
|
780
|
+
* read the same counter and both wrote the same increment (one tool call lost),
|
|
781
|
+
* and at the threshold both read 149, both reset to zero and both emitted —
|
|
782
|
+
* which breaks the one-re-emit-per-block invariant this mechanism is bounded to.
|
|
783
|
+
*
|
|
784
|
+
* The protocol is deliberately ASYMMETRIC, and that is the whole reason it can
|
|
785
|
+
* be this small:
|
|
786
|
+
*
|
|
787
|
+
* - the PROMPT write is authoritative. A new prompt pin is meant to replace
|
|
788
|
+
* the state, counter included, so it takes no lock and always wins.
|
|
789
|
+
* - the TOOL write is defensive. It never replaces a pin, only advances a
|
|
790
|
+
* counter, so it serialises against other tool writes here and re-reads
|
|
791
|
+
* the pin inside `_writeDistance` before touching anything.
|
|
792
|
+
*
|
|
793
|
+
* A lock that cannot be taken means a peer is mid-update: return the caller's
|
|
794
|
+
* silent value. A lost reminder is recoverable, a double reminder is the shape
|
|
795
|
+
* § 6.2 refuses.
|
|
796
|
+
*/
|
|
797
|
+
function _withToolWriteLock<T>(
|
|
798
|
+
consumer_root: string,
|
|
799
|
+
session_id: string,
|
|
800
|
+
when_busy: T,
|
|
801
|
+
body: () => T,
|
|
802
|
+
now_ms: number = Date.now(),
|
|
803
|
+
): T {
|
|
804
|
+
const lock = `${path.join(consumer_root, statePathFor(session_id))}.lock`;
|
|
805
|
+
let fd: number;
|
|
806
|
+
const acquire = (): number | null => {
|
|
807
|
+
try {
|
|
808
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
|
809
|
+
return fs.openSync(lock, "wx");
|
|
810
|
+
} catch (err) {
|
|
811
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") return null;
|
|
812
|
+
let age: number;
|
|
813
|
+
try {
|
|
814
|
+
age = now_ms - fs.statSync(lock).mtimeMs;
|
|
815
|
+
} catch {
|
|
816
|
+
return null; // vanished under us — a peer is actively working
|
|
817
|
+
}
|
|
818
|
+
if (age < LOCK_STALE_MS) return null;
|
|
819
|
+
try {
|
|
820
|
+
fs.rmSync(lock, { force: true });
|
|
821
|
+
return fs.openSync(lock, "wx");
|
|
822
|
+
} catch {
|
|
823
|
+
return null; // a peer broke it first and now holds it
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
const held = acquire();
|
|
828
|
+
if (held === null) return when_busy;
|
|
829
|
+
fd = held;
|
|
830
|
+
try {
|
|
831
|
+
return body();
|
|
832
|
+
} finally {
|
|
833
|
+
try {
|
|
834
|
+
fs.closeSync(fd);
|
|
835
|
+
} catch {
|
|
836
|
+
/* already closed */
|
|
837
|
+
}
|
|
838
|
+
try {
|
|
839
|
+
fs.rmSync(lock, { force: true });
|
|
840
|
+
} catch {
|
|
841
|
+
/* a peer broke a stale lock and owns the path now */
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
export function _writeDistance(
|
|
847
|
+
consumer_root: string,
|
|
848
|
+
/**
|
|
849
|
+
* The pin the CALLER decided against. Used only to detect that the decision
|
|
850
|
+
* has since been overtaken — never as the object that gets written. Passing a
|
|
851
|
+
* snapshot here and writing it back is the defect itself.
|
|
852
|
+
*/
|
|
853
|
+
expected: Partial<PinState>,
|
|
854
|
+
session_id: string,
|
|
855
|
+
value: number,
|
|
856
|
+
/**
|
|
857
|
+
* How the counter is persisted. Injectable for the same reason the pruner
|
|
858
|
+
* injects its age reader, and council round 3 asked for exactly this parity:
|
|
859
|
+
* the fail-closed policy is only observable when the write FAILS, and the
|
|
860
|
+
* `chmod 0o500` that used to stage that is silently ineffective for an
|
|
861
|
+
* elevated user — a root CI ran those tests green against any
|
|
862
|
+
* implementation. A thrown error from here reaches the same catch a real
|
|
863
|
+
* ENOSPC or EACCES would.
|
|
864
|
+
*/
|
|
865
|
+
write_json: StateWriter = atomic_write_json,
|
|
866
|
+
): boolean {
|
|
867
|
+
if (is_replay_mode()) return false;
|
|
868
|
+
// No stable identity → nothing may be persisted (BLOCKER 2). Reported as a
|
|
869
|
+
// failed write so every fail-closed caller stays silent rather than emitting
|
|
870
|
+
// against a counter that cannot be reset.
|
|
871
|
+
if (!hasStableSessionId(session_id)) return false;
|
|
872
|
+
const target = path.join(consumer_root, statePathFor(session_id));
|
|
873
|
+
// Re-read HERE, and build the write from the fresh object — never from the
|
|
874
|
+
// caller's snapshot.
|
|
875
|
+
//
|
|
876
|
+
// This used to spread `previous`, loaded at the top of the hook run. A substantive prompt landing in between
|
|
877
|
+
// writes a NEW pin, and the tool hook then replaced that whole object with
|
|
878
|
+
// the stale one — language, source, timestamp and all. The wrong language
|
|
879
|
+
// came back with no hash collision and no lost update involved, which is the
|
|
880
|
+
// very defect this file exists to close. `atomic_write_json` prevents a torn
|
|
881
|
+
// file; it cannot prevent a stale whole-object replacement.
|
|
882
|
+
const current = _loadState(target);
|
|
883
|
+
// Nothing to advance: no pin, or a pin this session does not own.
|
|
884
|
+
if (!_ownsPin(current, session_id)) return false;
|
|
885
|
+
// The decision was made against a pin that is no longer the live one, so the
|
|
886
|
+
// counter it computed does not describe this state. Report a failed write so
|
|
887
|
+
// every fail-closed caller stays silent; the next tool call re-decides
|
|
888
|
+
// against the new pin. `detected_at` alone would catch a replacement, and
|
|
889
|
+
// language/source are compared as well so a same-millisecond re-pin in a
|
|
890
|
+
// different language cannot slip through.
|
|
891
|
+
if (
|
|
892
|
+
current.detected_at !== expected.detected_at ||
|
|
893
|
+
current.language !== expected.language ||
|
|
894
|
+
current.source !== expected.source
|
|
895
|
+
) {
|
|
896
|
+
return false;
|
|
897
|
+
}
|
|
898
|
+
try {
|
|
899
|
+
write_json(target, {
|
|
900
|
+
...current,
|
|
901
|
+
session_id: typeof current.session_id === "string" && current.session_id !== ""
|
|
902
|
+
? current.session_id
|
|
903
|
+
: session_id,
|
|
904
|
+
tool_calls_since_pin: value,
|
|
905
|
+
} as PinState);
|
|
906
|
+
return true;
|
|
907
|
+
} catch {
|
|
908
|
+
return false;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
export type StateWriter = (target: string, state: PinState) => void;
|
|
913
|
+
|
|
914
|
+
function _resetDistance(
|
|
915
|
+
consumer_root: string,
|
|
916
|
+
expected: Partial<PinState>,
|
|
917
|
+
session_id: string,
|
|
918
|
+
write_json: StateWriter = atomic_write_json,
|
|
919
|
+
): boolean {
|
|
920
|
+
return _writeDistance(consumer_root, expected, session_id, 0, write_json);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* The distance trigger — the second of the two re-emit paths.
|
|
925
|
+
*
|
|
926
|
+
* WHY THIS IS NOT "the same failed mechanism running more often" (§ 6.2, and
|
|
927
|
+
* the 2026-07-06 reminder-injection null this repo settled at Δ=0): that pilot
|
|
928
|
+
* could not produce a red baseline — kernel-only complied 12/12 — and its own
|
|
929
|
+
* revisit clause reopens the question the moment one is found: *"a real red
|
|
930
|
+
* baseline — e.g. genuine >3K-token distance in a live multi-turn session, or
|
|
931
|
+
* telemetry showing tier-2 obligations missed in production"*. Production
|
|
932
|
+
* transcripts now supply exactly that: 11 English replies to a German user,
|
|
933
|
+
* every one of them 179+ tool calls past the pin, none below it. So this is a
|
|
934
|
+
* measured-gap fix in the regime the null explicitly excluded, not a re-run of
|
|
935
|
+
* the refused shape — which was a re-pin on EVERY tool call, unbounded. This
|
|
936
|
+
* one fires once per `REEMIT_AFTER_TOOL_CALLS` — once per ~200-call block. It
|
|
937
|
+
* does reach some compliant traffic (compliant p99 = 184 > 150); the claim is
|
|
938
|
+
* bounded frequency against a measured red baseline, never zero false fires.
|
|
939
|
+
*/
|
|
940
|
+
export function _reEmitAfterDistance(
|
|
941
|
+
consumer_root: string,
|
|
942
|
+
session_id: string,
|
|
943
|
+
write_json: StateWriter = atomic_write_json,
|
|
944
|
+
): number {
|
|
945
|
+
if (is_replay_mode()) {
|
|
946
|
+
return EXIT_ALLOW;
|
|
947
|
+
}
|
|
948
|
+
// Stateless without an id: no counter to advance, so no distance to measure
|
|
949
|
+
// (BLOCKER 2). The prompt path still pins from a marked prompt.
|
|
950
|
+
if (!hasStableSessionId(session_id)) {
|
|
489
951
|
return EXIT_ALLOW;
|
|
490
952
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
);
|
|
498
|
-
|
|
953
|
+
|
|
954
|
+
// Serialised against any peer tool write for this session, and silent when
|
|
955
|
+
// one holds the lock. Everything below reads,
|
|
956
|
+
// decides and writes inside the lock, so two overlapping post_tool_use calls
|
|
957
|
+
// cannot both advance past the same counter value nor both emit at the
|
|
958
|
+
// threshold.
|
|
959
|
+
return _withToolWriteLock(consumer_root, session_id, EXIT_ALLOW, () => { const previous = _loadState(path.join(consumer_root, statePathFor(session_id)));
|
|
960
|
+
const language = previous.language as Verdict | undefined;
|
|
961
|
+
if (language !== "de" && language !== "en") {
|
|
962
|
+
return EXIT_ALLOW;
|
|
963
|
+
}
|
|
964
|
+
if (!_ownsPin(previous, session_id)) {
|
|
965
|
+
return EXIT_ALLOW;
|
|
966
|
+
}
|
|
967
|
+
const seen =
|
|
968
|
+
typeof previous.tool_calls_since_pin === "number" && previous.tool_calls_since_pin >= 0
|
|
969
|
+
? previous.tool_calls_since_pin
|
|
970
|
+
: 0;
|
|
971
|
+
const advanced = seen + 1;
|
|
972
|
+
const due = advanced >= REEMIT_AFTER_TOOL_CALLS;
|
|
973
|
+
// Write BEFORE emitting, and reset on the same call that emits: if the
|
|
974
|
+
// stdout write throws, the counter must not stay at the threshold and fire
|
|
975
|
+
// on every subsequent tool call.
|
|
976
|
+
const written = _writeDistance(
|
|
977
|
+
consumer_root,
|
|
978
|
+
previous,
|
|
979
|
+
session_id,
|
|
980
|
+
due ? 0 : advanced,
|
|
981
|
+
write_json,
|
|
982
|
+
);
|
|
983
|
+
if (!due) {
|
|
984
|
+
return EXIT_ALLOW;
|
|
985
|
+
}
|
|
986
|
+
// FAIL-CLOSED, stated as a policy rather than left to the catch: if the reset
|
|
987
|
+
// did not land, staying silent loses at most one reminder, while emitting
|
|
988
|
+
// anyway re-fires on every later tool call because the counter is still at
|
|
989
|
+
// the threshold. A lost reminder is recoverable; an unbounded loop is the
|
|
990
|
+
// failure this whole mechanism is bounded to avoid.
|
|
991
|
+
if (!written) {
|
|
992
|
+
return EXIT_ALLOW;
|
|
993
|
+
}
|
|
994
|
+
const source: PinSource = previous.source === "system-locale" ? "system-locale" : "prompt";
|
|
995
|
+
process.stdout.write(
|
|
996
|
+
`${JSON.stringify({
|
|
997
|
+
decision: "warn",
|
|
998
|
+
reason: noticeText(language, source),
|
|
999
|
+
additional_context: pinText(language, source),
|
|
1000
|
+
})}\n`,
|
|
1001
|
+
);
|
|
1002
|
+
return EXIT_WARN;
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* `post_tool_use` carries both re-emit triggers. Compaction wins when both are
|
|
1008
|
+
* live: it already re-states the pin and resets the distance counter, so
|
|
1009
|
+
* running the distance path after it would emit twice for one event.
|
|
1010
|
+
*/
|
|
1011
|
+
export function _onToolUse(
|
|
1012
|
+
consumer_root: string,
|
|
1013
|
+
session_id: string,
|
|
1014
|
+
write_json: StateWriter = atomic_write_json,
|
|
1015
|
+
): number {
|
|
1016
|
+
if (_pinLost(consumer_root, session_id)) {
|
|
1017
|
+
return _reEmitAfterCompaction(consumer_root, session_id, write_json);
|
|
1018
|
+
}
|
|
1019
|
+
return _reEmitAfterDistance(consumer_root, session_id, write_json);
|
|
499
1020
|
}
|
|
500
1021
|
|
|
501
1022
|
export function run(
|
|
502
1023
|
stdin_text: string,
|
|
503
|
-
options: { consumer_root: string; env?: NodeJS.ProcessEnv },
|
|
1024
|
+
options: { consumer_root: string; env?: NodeJS.ProcessEnv; write_json?: StateWriter },
|
|
504
1025
|
): number {
|
|
505
1026
|
let envelope: JsonObject = {};
|
|
506
1027
|
if (stdin_text.trim()) {
|
|
@@ -536,7 +1057,7 @@ export function run(
|
|
|
536
1057
|
return EXIT_ALLOW;
|
|
537
1058
|
}
|
|
538
1059
|
if (event === "post_tool_use") {
|
|
539
|
-
return
|
|
1060
|
+
return _onToolUse(options.consumer_root, _sessionId(envelope), options.write_json);
|
|
540
1061
|
}
|
|
541
1062
|
|
|
542
1063
|
const prompt = _extractPrompt(payload);
|
|
@@ -551,8 +1072,13 @@ export function run(
|
|
|
551
1072
|
}
|
|
552
1073
|
|
|
553
1074
|
const session_id = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
|
|
554
|
-
const
|
|
555
|
-
|
|
1075
|
+
const stateful = hasStableSessionId(session_id);
|
|
1076
|
+
// BLOCKER 2: with no stable id there is no file to read or write. The pin for
|
|
1077
|
+
// THIS turn still comes from this turn's prompt — that needs no state — but
|
|
1078
|
+
// terse-continuation inheritance and the distance counter are both off, since
|
|
1079
|
+
// both would have to share one bucket with every other id-less invocation.
|
|
1080
|
+
const target = stateful ? path.join(options.consumer_root, statePathFor(session_id)) : "";
|
|
1081
|
+
const previous = stateful ? _loadState(target) : {};
|
|
556
1082
|
const classification = classify(prompt);
|
|
557
1083
|
const next = nextState(
|
|
558
1084
|
previous,
|
|
@@ -566,16 +1092,26 @@ export function run(
|
|
|
566
1092
|
systemLocaleVerdict(options.env ?? process.env),
|
|
567
1093
|
);
|
|
568
1094
|
|
|
569
|
-
|
|
1095
|
+
// A foreign session's pin is never inherited — see `nextState`. Without this,
|
|
1096
|
+
// the `null` "keep" return and a cross-session read are indistinguishable
|
|
1097
|
+
// here, which is the path by which an English pin reached a German turn.
|
|
1098
|
+
const inherited = _ownsPin(previous, session_id)
|
|
1099
|
+
? (previous.language as Verdict | undefined)
|
|
1100
|
+
: undefined;
|
|
1101
|
+
const effective = next?.language ?? inherited;
|
|
570
1102
|
// A state file written before `source` existed carries only what the old
|
|
571
1103
|
// version could produce — a prompt reading. Absent is therefore `prompt`,
|
|
572
1104
|
// never "unknown": treating it as unknown would reword the audited notice
|
|
573
1105
|
// for every pre-existing session.
|
|
574
1106
|
const effectiveSource: PinSource =
|
|
575
1107
|
next?.source ?? (previous.source === "system-locale" ? "system-locale" : "prompt");
|
|
576
|
-
if (next) {
|
|
1108
|
+
if (next && stateful) {
|
|
577
1109
|
try {
|
|
578
1110
|
atomic_write_json(target, next);
|
|
1111
|
+
// Housekeeping runs on the prompt path only — once per turn, not once
|
|
1112
|
+
// per tool call — and never before the write it is cleaning up after.
|
|
1113
|
+
_pruneLegacyState(options.consumer_root);
|
|
1114
|
+
_pruneStaleSessions(options.consumer_root, Date.now());
|
|
579
1115
|
} catch {
|
|
580
1116
|
// Observability only — a failed state write never blocks the turn.
|
|
581
1117
|
}
|