mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-11 02:33:10 +08:00
Compare commits
12 Commits
fix/atomic
...
patch-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ecee70196 | ||
|
|
ba0c4d13a8 | ||
|
|
e989c0ef0e | ||
|
|
f93e8f6869 | ||
|
|
116e61d8cb | ||
|
|
d904edc615 | ||
|
|
5acb01a276 | ||
|
|
7c2f71315b | ||
|
|
28548f67ba | ||
|
|
33ed494adf | ||
|
|
b068069b9b | ||
|
|
e3483fda15 |
82
README.md
82
README.md
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
[](https://github.com/affaan-m/everything-claude-code/stargazers)
|
[](https://github.com/affaan-m/ECC/stargazers)
|
||||||
[](https://github.com/affaan-m/everything-claude-code/network/members)
|
[](https://github.com/affaan-m/ECC/network/members)
|
||||||
[](https://github.com/affaan-m/everything-claude-code/graphs/contributors)
|
[](https://github.com/affaan-m/ECC/graphs/contributors)
|
||||||
[](https://www.npmjs.com/package/ecc-universal)
|
[](https://www.npmjs.com/package/ecc-universal)
|
||||||
[](https://www.npmjs.com/package/ecc-agentshield)
|
[](https://www.npmjs.com/package/ecc-agentshield)
|
||||||
[](https://github.com/marketplace/ecc-tools)
|
[](https://github.com/marketplace/ecc-tools)
|
||||||
@@ -59,7 +59,7 @@ ECC v2.0.0-rc.1 adds the public Hermes operator story on top of that reusable la
|
|||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td width="25%" align="center">
|
<td width="25%" align="center">
|
||||||
<a href="https://github.com/affaan-m/everything-claude-code/discussions">
|
<a href="https://github.com/affaan-m/ECC/discussions">
|
||||||
<strong>Community</strong>
|
<strong>Community</strong>
|
||||||
<br />
|
<br />
|
||||||
<sub>Discussions · Q&A · Show & Tell</sub>
|
<sub>Discussions · Q&A · Show & Tell</sub>
|
||||||
@@ -172,7 +172,7 @@ This repo is the raw code only. The guides explain everything.
|
|||||||
|
|
||||||
### v1.4.1 — Bug Fix (Feb 2026)
|
### v1.4.1 — Bug Fix (Feb 2026)
|
||||||
|
|
||||||
- **Fixed instinct import content loss** — `parse_instinct_file()` was silently dropping all content after frontmatter (Action, Evidence, Examples sections) during `/instinct-import`. ([#148](https://github.com/affaan-m/everything-claude-code/issues/148), [#161](https://github.com/affaan-m/everything-claude-code/pull/161))
|
- **Fixed instinct import content loss** — `parse_instinct_file()` was silently dropping all content after frontmatter (Action, Evidence, Examples sections) during `/instinct-import`. ([#148](https://github.com/affaan-m/ECC/issues/148), [#161](https://github.com/affaan-m/ECC/pull/161))
|
||||||
|
|
||||||
### v1.4.0 — Multi-Language Rules, Installation Wizard & PM2 (Feb 2026)
|
### v1.4.0 — Multi-Language Rules, Installation Wizard & PM2 (Feb 2026)
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ This repo is the raw code only. The guides explain everything.
|
|||||||
- **Session management** — `/sessions` command for session history
|
- **Session management** — `/sessions` command for session history
|
||||||
- **Continuous learning v2** — Instinct-based learning with confidence scoring, import/export, evolution
|
- **Continuous learning v2** — Instinct-based learning with confidence scoring, import/export, evolution
|
||||||
|
|
||||||
See the full changelog in [Releases](https://github.com/affaan-m/everything-claude-code/releases).
|
See the full changelog in [Releases](https://github.com/affaan-m/ECC/releases).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -265,7 +265,7 @@ npx ecc install --profile minimal --target claude --with capability:machine-lear
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add marketplace
|
# Add marketplace
|
||||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
/plugin marketplace add https://github.com/affaan-m/ECC
|
||||||
|
|
||||||
# Install plugin
|
# Install plugin
|
||||||
/plugin install ecc@ecc
|
/plugin install ecc@ecc
|
||||||
@@ -275,7 +275,7 @@ npx ecc install --profile minimal --target claude --with capability:machine-lear
|
|||||||
|
|
||||||
ECC now has three public identifiers, and they are not interchangeable:
|
ECC now has three public identifiers, and they are not interchangeable:
|
||||||
|
|
||||||
- GitHub source repo: `affaan-m/everything-claude-code`
|
- GitHub source repo: `affaan-m/ECC`
|
||||||
- Claude marketplace/plugin identifier: `ecc@ecc`
|
- Claude marketplace/plugin identifier: `ecc@ecc`
|
||||||
- npm package: `ecc-universal`
|
- npm package: `ecc-universal`
|
||||||
|
|
||||||
@@ -295,8 +295,8 @@ This is intentional. Anthropic marketplace/plugin installs are keyed by a canoni
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repo first
|
# Clone the repo first
|
||||||
git clone https://github.com/affaan-m/everything-claude-code.git
|
git clone https://github.com/affaan-m/ECC.git
|
||||||
cd everything-claude-code
|
cd ECC
|
||||||
|
|
||||||
# Install dependencies (pick your package manager)
|
# Install dependencies (pick your package manager)
|
||||||
npm install # or: pnpm install | yarn install | bun install
|
npm install # or: pnpm install | yarn install | bun install
|
||||||
@@ -494,7 +494,7 @@ Windows PowerShell:
|
|||||||
This repo is a **Claude Code plugin** - install it directly or copy components manually.
|
This repo is a **Claude Code plugin** - install it directly or copy components manually.
|
||||||
|
|
||||||
```
|
```
|
||||||
everything-claude-code/
|
ECC/
|
||||||
|-- .claude-plugin/ # Plugin and marketplace manifests
|
|-- .claude-plugin/ # Plugin and marketplace manifests
|
||||||
| |-- plugin.json # Plugin metadata and component paths
|
| |-- plugin.json # Plugin metadata and component paths
|
||||||
| |-- marketplace.json # Marketplace catalog for /plugin marketplace add
|
| |-- marketplace.json # Marketplace catalog for /plugin marketplace add
|
||||||
@@ -812,7 +812,7 @@ Claude Code v2.1+ **automatically loads** `hooks/hooks.json` from any installed
|
|||||||
Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file
|
Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file
|
||||||
```
|
```
|
||||||
|
|
||||||
**History:** This has caused repeated fix/revert cycles in this repo ([#29](https://github.com/affaan-m/everything-claude-code/issues/29), [#52](https://github.com/affaan-m/everything-claude-code/issues/52), [#103](https://github.com/affaan-m/everything-claude-code/issues/103)). The behavior changed between Claude Code versions, leading to confusion. We now have a regression test to prevent this from being reintroduced.
|
**History:** This has caused repeated fix/revert cycles in this repo ([#29](https://github.com/affaan-m/ECC/issues/29), [#52](https://github.com/affaan-m/ECC/issues/52), [#103](https://github.com/affaan-m/ECC/issues/103)). The behavior changed between Claude Code versions, leading to confusion. We now have a regression test to prevent this from being reintroduced.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -824,7 +824,7 @@ The easiest way to use this repo - install as a Claude Code plugin:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add this repo as a marketplace
|
# Add this repo as a marketplace
|
||||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
/plugin marketplace add https://github.com/affaan-m/ECC
|
||||||
|
|
||||||
# Install the plugin
|
# Install the plugin
|
||||||
/plugin install ecc@ecc
|
/plugin install ecc@ecc
|
||||||
@@ -838,7 +838,7 @@ Or add directly to your `~/.claude/settings.json`:
|
|||||||
"ecc": {
|
"ecc": {
|
||||||
"source": {
|
"source": {
|
||||||
"source": "github",
|
"source": "github",
|
||||||
"repo": "affaan-m/everything-claude-code"
|
"repo": "affaan-m/ECC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -854,20 +854,21 @@ This gives you instant access to all commands, agents, skills, and hooks.
|
|||||||
>
|
>
|
||||||
> ```bash
|
> ```bash
|
||||||
> # Clone the repo first
|
> # Clone the repo first
|
||||||
> git clone https://github.com/affaan-m/everything-claude-code.git
|
> git clone https://github.com/affaan-m/ECC.git
|
||||||
|
> cd ECC
|
||||||
>
|
>
|
||||||
> # Option A: User-level rules (applies to all projects)
|
> # Option A: User-level rules (applies to all projects)
|
||||||
> mkdir -p ~/.claude/rules/ecc
|
> mkdir -p ~/.claude/rules/ecc
|
||||||
> cp -r everything-claude-code/rules/common ~/.claude/rules/ecc/
|
> cp -r rules/common ~/.claude/rules/ecc/
|
||||||
> cp -r everything-claude-code/rules/typescript ~/.claude/rules/ecc/ # pick your stack
|
> cp -r rules/typescript ~/.claude/rules/ecc/ # pick your stack
|
||||||
> cp -r everything-claude-code/rules/python ~/.claude/rules/ecc/
|
> cp -r rules/python ~/.claude/rules/ecc/
|
||||||
> cp -r everything-claude-code/rules/golang ~/.claude/rules/ecc/
|
> cp -r rules/golang ~/.claude/rules/ecc/
|
||||||
> cp -r everything-claude-code/rules/php ~/.claude/rules/ecc/
|
> cp -r rules/php ~/.claude/rules/ecc/
|
||||||
>
|
>
|
||||||
> # Option B: Project-level rules (applies to current project only)
|
> # Option B: Project-level rules (applies to current project only)
|
||||||
> mkdir -p .claude/rules/ecc
|
> mkdir -p .claude/rules/ecc
|
||||||
> cp -r everything-claude-code/rules/common .claude/rules/ecc/
|
> cp -r rules/common .claude/rules/ecc/
|
||||||
> cp -r everything-claude-code/rules/typescript .claude/rules/ecc/ # pick your stack
|
> cp -r rules/typescript .claude/rules/ecc/ # pick your stack
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -878,34 +879,35 @@ If you prefer manual control over what's installed:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repo
|
# Clone the repo
|
||||||
git clone https://github.com/affaan-m/everything-claude-code.git
|
git clone https://github.com/affaan-m/ECC.git
|
||||||
|
cd ECC
|
||||||
|
|
||||||
# Copy agents to your Claude config
|
# Copy agents to your Claude config
|
||||||
cp everything-claude-code/agents/*.md ~/.claude/agents/
|
cp agents/*.md ~/.claude/agents/
|
||||||
|
|
||||||
# Copy rules directories (common + language-specific)
|
# Copy rules directories (common + language-specific)
|
||||||
mkdir -p ~/.claude/rules/ecc
|
mkdir -p ~/.claude/rules/ecc
|
||||||
cp -r everything-claude-code/rules/common ~/.claude/rules/ecc/
|
cp -r rules/common ~/.claude/rules/ecc/
|
||||||
cp -r everything-claude-code/rules/typescript ~/.claude/rules/ecc/ # pick your stack
|
cp -r rules/typescript ~/.claude/rules/ecc/ # pick your stack
|
||||||
cp -r everything-claude-code/rules/python ~/.claude/rules/ecc/
|
cp -r rules/python ~/.claude/rules/ecc/
|
||||||
cp -r everything-claude-code/rules/golang ~/.claude/rules/ecc/
|
cp -r rules/golang ~/.claude/rules/ecc/
|
||||||
cp -r everything-claude-code/rules/php ~/.claude/rules/ecc/
|
cp -r rules/php ~/.claude/rules/ecc/
|
||||||
cp -r everything-claude-code/rules/arkts ~/.claude/rules/ecc/
|
cp -r rules/arkts ~/.claude/rules/ecc/
|
||||||
|
|
||||||
# Copy skills first (primary workflow surface)
|
# Copy skills first (primary workflow surface)
|
||||||
# Recommended (new users): core/general skills only
|
# Recommended (new users): core/general skills only
|
||||||
mkdir -p ~/.claude/skills/ecc
|
mkdir -p ~/.claude/skills/ecc
|
||||||
cp -r everything-claude-code/.agents/skills/* ~/.claude/skills/ecc/
|
cp -r .agents/skills/* ~/.claude/skills/ecc/
|
||||||
cp -r everything-claude-code/skills/search-first ~/.claude/skills/ecc/
|
cp -r skills/search-first ~/.claude/skills/ecc/
|
||||||
|
|
||||||
# Optional: add niche/framework-specific skills only when needed
|
# Optional: add niche/framework-specific skills only when needed
|
||||||
# for s in django-patterns django-tdd laravel-patterns springboot-patterns quarkus-patterns; do
|
# for s in django-patterns django-tdd laravel-patterns springboot-patterns quarkus-patterns; do
|
||||||
# cp -r everything-claude-code/skills/$s ~/.claude/skills/ecc/
|
# cp -r skills/$s ~/.claude/skills/ecc/
|
||||||
# done
|
# done
|
||||||
|
|
||||||
# Optional: keep maintained slash-command compatibility during migration
|
# Optional: keep maintained slash-command compatibility during migration
|
||||||
mkdir -p ~/.claude/commands
|
mkdir -p ~/.claude/commands
|
||||||
cp everything-claude-code/commands/*.md ~/.claude/commands/
|
cp commands/*.md ~/.claude/commands/
|
||||||
|
|
||||||
# Retired shims live in legacy-command-shims/commands/.
|
# Retired shims live in legacy-command-shims/commands/.
|
||||||
# Copy individual files from there only if you still need old names such as /tdd.
|
# Copy individual files from there only if you still need old names such as /tdd.
|
||||||
@@ -1083,7 +1085,7 @@ This shows all available agents, commands, and skills from the plugin.
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>My hooks aren't working / I see "Duplicate hooks file" errors</b></summary>
|
<summary><b>My hooks aren't working / I see "Duplicate hooks file" errors</b></summary>
|
||||||
|
|
||||||
This is the most common issue. **Do NOT add a `"hooks"` field to `.claude-plugin/plugin.json`.** Claude Code v2.1+ automatically loads `hooks/hooks.json` from installed plugins. Explicitly declaring it causes duplicate detection errors. See [#29](https://github.com/affaan-m/everything-claude-code/issues/29), [#52](https://github.com/affaan-m/everything-claude-code/issues/52), [#103](https://github.com/affaan-m/everything-claude-code/issues/103).
|
This is the most common issue. **Do NOT add a `"hooks"` field to `.claude-plugin/plugin.json`.** Claude Code v2.1+ automatically loads `hooks/hooks.json` from installed plugins. Explicitly declaring it causes duplicate detection errors. See [#29](https://github.com/affaan-m/ECC/issues/29), [#52](https://github.com/affaan-m/ECC/issues/52), [#103](https://github.com/affaan-m/ECC/issues/103).
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -1128,11 +1130,11 @@ Yes. Use Option 2 (manual installation) and copy only what you need:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Just agents
|
# Just agents
|
||||||
cp everything-claude-code/agents/*.md ~/.claude/agents/
|
cp agents/*.md ~/.claude/agents/
|
||||||
|
|
||||||
# Just rules
|
# Just rules
|
||||||
mkdir -p ~/.claude/rules/ecc/
|
mkdir -p ~/.claude/rules/ecc/
|
||||||
cp -r everything-claude-code/rules/common ~/.claude/rules/ecc/
|
cp -r rules/common ~/.claude/rules/ecc/
|
||||||
```
|
```
|
||||||
|
|
||||||
Each component is fully independent.
|
Each component is fully independent.
|
||||||
@@ -1145,7 +1147,7 @@ Yes. ECC is cross-platform:
|
|||||||
- **Cursor**: Pre-translated configs in `.cursor/`. See [Cursor IDE Support](#cursor-ide-support).
|
- **Cursor**: Pre-translated configs in `.cursor/`. See [Cursor IDE Support](#cursor-ide-support).
|
||||||
- **Gemini CLI**: Experimental project-local support via `.gemini/GEMINI.md` and shared installer plumbing.
|
- **Gemini CLI**: Experimental project-local support via `.gemini/GEMINI.md` and shared installer plumbing.
|
||||||
- **OpenCode**: Full plugin support in `.opencode/`. See [OpenCode Support](#opencode-support).
|
- **OpenCode**: Full plugin support in `.opencode/`. See [OpenCode Support](#opencode-support).
|
||||||
- **Codex**: First-class support for both macOS app and CLI, with adapter drift guards and SessionStart fallback. See PR [#257](https://github.com/affaan-m/everything-claude-code/pull/257).
|
- **Codex**: First-class support for both macOS app and CLI, with adapter drift guards and SessionStart fallback. See PR [#257](https://github.com/affaan-m/ECC/pull/257).
|
||||||
- **GitHub Copilot (VS Code)**: Instruction and prompt layer via `.github/copilot-instructions.md`, `.vscode/settings.json`, and `.github/prompts/`. See [GitHub Copilot Support](#github-copilot-support).
|
- **GitHub Copilot (VS Code)**: Instruction and prompt layer via `.github/copilot-instructions.md`, `.vscode/settings.json`, and `.github/prompts/`. See [GitHub Copilot Support](#github-copilot-support).
|
||||||
- **Antigravity**: Tightly integrated setup for workflows, skills, and flattened rules in `.agent/`. See [Antigravity Guide](docs/ANTIGRAVITY-GUIDE.md).
|
- **Antigravity**: Tightly integrated setup for workflows, skills, and flattened rules in `.agent/`. See [Antigravity Guide](docs/ANTIGRAVITY-GUIDE.md).
|
||||||
- **JoyCode / CodeBuddy**: Project-local selective install adapters for commands, agents, skills, and flattened rules. See [JoyCode Adapter Guide](docs/JOYCODE-GUIDE.md).
|
- **JoyCode / CodeBuddy**: Project-local selective install adapters for commands, agents, skills, and flattened rules. See [JoyCode Adapter Guide](docs/JOYCODE-GUIDE.md).
|
||||||
@@ -1488,7 +1490,7 @@ OpenCode's plugin system is MORE sophisticated than Claude Code with 20+ event t
|
|||||||
|
|
||||||
**Option 1: Use directly**
|
**Option 1: Use directly**
|
||||||
```bash
|
```bash
|
||||||
cd everything-claude-code
|
cd ECC
|
||||||
opencode
|
opencode
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1738,7 +1740,7 @@ This project is free and open source. Sponsors help keep it maintained and growi
|
|||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://star-history.com/#affaan-m/everything-claude-code&Date)
|
[](https://star-history.com/#affaan-m/ECC&Date)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,31 @@ function isDangerousInvisibleCodePoint(codePoint) {
|
|||||||
(codePoint >= 0x202A && codePoint <= 0x202E) ||
|
(codePoint >= 0x202A && codePoint <= 0x202E) ||
|
||||||
(codePoint >= 0x2066 && codePoint <= 0x2069) ||
|
(codePoint >= 0x2066 && codePoint <= 0x2069) ||
|
||||||
(codePoint >= 0xFE00 && codePoint <= 0xFE0F) ||
|
(codePoint >= 0xFE00 && codePoint <= 0xFE0F) ||
|
||||||
(codePoint >= 0xE0100 && codePoint <= 0xE01EF)
|
(codePoint >= 0xE0100 && codePoint <= 0xE01EF) ||
|
||||||
|
// Unicode Tag block (U+E0000–U+E007F). Tag characters were proposed
|
||||||
|
// for language tagging in Unicode 3.1 and have been deprecated since
|
||||||
|
// Unicode 5.1, so no legitimate text uses them. They are the canonical
|
||||||
|
// vector for "ASCII smuggling" / "Tag smuggling" prompt injection:
|
||||||
|
// an attacker hides instructions inside ASCII-looking strings (PR
|
||||||
|
// bodies, SKILL.md, frontmatter), the LLM consumes the tag bytes,
|
||||||
|
// and the human reviewer sees nothing.
|
||||||
|
(codePoint >= 0xE0000 && codePoint <= 0xE007F) ||
|
||||||
|
// U+180E MONGOLIAN VOWEL SEPARATOR — formerly classified as a space
|
||||||
|
// separator, reclassified as a format control in Unicode 6.3; renders
|
||||||
|
// as zero-width and routinely abused for homograph / smuggling.
|
||||||
|
codePoint === 0x180E ||
|
||||||
|
// U+115F / U+1160 HANGUL CHOSEONG/JUNGSEONG FILLER — zero-width fillers
|
||||||
|
// used in Korean text shaping; abused as invisible characters.
|
||||||
|
codePoint === 0x115F ||
|
||||||
|
codePoint === 0x1160 ||
|
||||||
|
// U+2061–U+2064 invisible math operators (FUNCTION APPLICATION,
|
||||||
|
// INVISIBLE TIMES, INVISIBLE SEPARATOR, INVISIBLE PLUS). Zero-width
|
||||||
|
// and not used outside math typesetting; legitimate Markdown / source
|
||||||
|
// does not contain them.
|
||||||
|
(codePoint >= 0x2061 && codePoint <= 0x2064) ||
|
||||||
|
// U+3164 HANGUL FILLER — zero-width filler reportedly used in Discord
|
||||||
|
// / Twitter smuggling attacks; not used in legitimate Korean text.
|
||||||
|
codePoint === 0x3164
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,11 @@
|
|||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { sanitizeSessionId, readBridge } = require('../lib/session-bridge');
|
const { sanitizeSessionId, readBridge, renameWithRetry } = require('../lib/session-bridge');
|
||||||
|
|
||||||
const CONTEXT_WARNING_PCT = 35;
|
const CONTEXT_WARNING_PCT = 35;
|
||||||
const CONTEXT_CRITICAL_PCT = 25;
|
const CONTEXT_CRITICAL_PCT = 25;
|
||||||
@@ -61,15 +62,30 @@ function readWarnState(sessionId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write debounce state.
|
* Write debounce state atomically (unique-suffix tmp then rename).
|
||||||
|
*
|
||||||
|
* The tmp path includes `process.pid` plus a random nonce so concurrent
|
||||||
|
* PostToolUse subprocesses writing to the same session's warn-state
|
||||||
|
* file do not clobber each other's tmp mid-write. Without the unique
|
||||||
|
* suffix, two writers race over a shared `${target}.tmp` and produce
|
||||||
|
* either a corrupted payload or an ENOENT throw on the second rename.
|
||||||
|
*
|
||||||
|
* Same pattern as `writeBridgeAtomic` in `scripts/lib/session-bridge.js`
|
||||||
|
* and `writeCostWarningIfChanged` in `scripts/hooks/ecc-metrics-bridge.js`.
|
||||||
|
*
|
||||||
* @param {string} sessionId
|
* @param {string} sessionId
|
||||||
* @param {object} state
|
* @param {object} state
|
||||||
*/
|
*/
|
||||||
function writeWarnState(sessionId, state) {
|
function writeWarnState(sessionId, state) {
|
||||||
const target = getWarnPath(sessionId);
|
const target = getWarnPath(sessionId);
|
||||||
const tmp = `${target}.tmp`;
|
const tmp = `${target}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
||||||
fs.writeFileSync(tmp, JSON.stringify(state), 'utf8');
|
fs.writeFileSync(tmp, JSON.stringify(state), 'utf8');
|
||||||
fs.renameSync(tmp, target);
|
try {
|
||||||
|
renameWithRetry(tmp, target);
|
||||||
|
} catch (err) {
|
||||||
|
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* without scanning large JSONL logs on every invocation.
|
* without scanning large JSONL logs on every invocation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@@ -51,15 +52,80 @@ function readBridge(sessionId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write bridge data atomically (write .tmp then rename).
|
* Write bridge data atomically (write unique-suffix tmp then rename).
|
||||||
|
*
|
||||||
|
* The tmp path includes `process.pid` plus a random nonce so concurrent
|
||||||
|
* writers (e.g. PostToolUse `ecc-metrics-bridge` and the background
|
||||||
|
* `ecc-statusline`, both writing to the same session bridge) do not
|
||||||
|
* clobber each other's tmp file mid-write. With a fixed `.tmp` suffix
|
||||||
|
* two writers could both call `writeFileSync` against the same path
|
||||||
|
* before either reaches `renameSync`, causing one writer's payload to
|
||||||
|
* silently overwrite the other and the second `renameSync` to throw
|
||||||
|
* ENOENT once the rename consumes the file.
|
||||||
|
*
|
||||||
|
* Same pattern already used by `writeCostWarningIfChanged` in
|
||||||
|
* `scripts/hooks/ecc-metrics-bridge.js` (commit 9b1d8918) for the
|
||||||
|
* cost-warning cache; this commit applies it to the session-bridge
|
||||||
|
* primitive too.
|
||||||
|
*
|
||||||
* @param {string} sessionId - Already-sanitized session ID
|
* @param {string} sessionId - Already-sanitized session ID
|
||||||
* @param {object} data
|
* @param {object} data
|
||||||
*/
|
*/
|
||||||
function writeBridgeAtomic(sessionId, data) {
|
function writeBridgeAtomic(sessionId, data) {
|
||||||
const target = getBridgePath(sessionId);
|
const target = getBridgePath(sessionId);
|
||||||
const tmp = `${target}.tmp`;
|
const tmp = `${target}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
||||||
fs.writeFileSync(tmp, JSON.stringify(data), 'utf8');
|
fs.writeFileSync(tmp, JSON.stringify(data), 'utf8');
|
||||||
fs.renameSync(tmp, target);
|
try {
|
||||||
|
renameWithRetry(tmp, target);
|
||||||
|
} catch (err) {
|
||||||
|
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a file via rename, retrying briefly on transient OS-level errors.
|
||||||
|
*
|
||||||
|
* POSIX `rename(2)` is atomic between source and destination, so concurrent
|
||||||
|
* writers each rename onto the same target without conflict. Windows
|
||||||
|
* `MoveFileExW` is different: it fails with EPERM/EACCES/EBUSY if the
|
||||||
|
* target is currently being renamed by *another* process — a short race
|
||||||
|
* window that fires reliably under our PostToolUse + statusline concurrency.
|
||||||
|
*
|
||||||
|
* To stay portable, retry up to 5 times with exponential backoff (20 ms,
|
||||||
|
* 40, 80, 160, 320) on the Windows-only transient codes. POSIX runs hit
|
||||||
|
* the first try and exit immediately. Other error codes (ENOENT, ENOSPC,
|
||||||
|
* EROFS, …) re-throw without retry — they are not transient.
|
||||||
|
*
|
||||||
|
* Sleep uses `Atomics.wait` on a throwaway SharedArrayBuffer so the
|
||||||
|
* retry path does not busy-spin the CPU. This works on the main thread
|
||||||
|
* in Node ≥ 17 (and on workers in earlier versions).
|
||||||
|
*
|
||||||
|
* @param {string} tmp
|
||||||
|
* @param {string} target
|
||||||
|
*/
|
||||||
|
function renameWithRetry(tmp, target) {
|
||||||
|
const RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
||||||
|
const MAX_ATTEMPTS = 5;
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
fs.renameSync(tmp, target);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (attempt + 1 >= MAX_ATTEMPTS || !RETRY_CODES.has(err.code)) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const delayMs = 20 << attempt;
|
||||||
|
try {
|
||||||
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs);
|
||||||
|
} catch {
|
||||||
|
// Atomics.wait throws on the main thread in some older runtimes;
|
||||||
|
// fall back to a brief busy-wait so the retry path still has a delay.
|
||||||
|
const until = Date.now() + delayMs;
|
||||||
|
while (Date.now() < until) { /* spin */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,6 +142,7 @@ module.exports = {
|
|||||||
getBridgePath,
|
getBridgePath,
|
||||||
readBridge,
|
readBridge,
|
||||||
writeBridgeAtomic,
|
writeBridgeAtomic,
|
||||||
|
renameWithRetry,
|
||||||
resolveSessionId,
|
resolveSessionId,
|
||||||
MAX_SESSION_ID_LENGTH
|
MAX_SESSION_ID_LENGTH
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -135,6 +135,130 @@ function runTests() {
|
|||||||
passed++;
|
passed++;
|
||||||
else failed++;
|
else failed++;
|
||||||
|
|
||||||
|
// Concurrency contract: two processes writing to the same session
|
||||||
|
// bridge must not throw ENOENT and must never leave a corrupt JSON
|
||||||
|
// file behind. The previous implementation used a fixed `${target}.tmp`
|
||||||
|
// suffix; with concurrent writers it raced over a shared tmp path,
|
||||||
|
// producing both ENOENT on rename and (occasionally) a half-written
|
||||||
|
// payload on the destination.
|
||||||
|
//
|
||||||
|
// This test exercises the atomic-rename primitive only — it does NOT
|
||||||
|
// attempt to defend against the read-modify-write race in callers,
|
||||||
|
// which is a separate concern. Each subprocess writes its own
|
||||||
|
// independent payload N times; we assert (a) every process exits 0
|
||||||
|
// (no ENOENT bubbled up) and (b) the final file is always parseable
|
||||||
|
// JSON whose contents match one of the two writers' last payloads.
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('concurrent writeBridgeAtomic does not throw ENOENT or corrupt the bridge file', () => {
|
||||||
|
// Spawn two child processes that BOTH stay alive at the same time
|
||||||
|
// and call writeBridgeAtomic in a tight loop. `spawnSync` would
|
||||||
|
// run them sequentially (blocking on each), which would never
|
||||||
|
// exercise the race the fix targets. Instead a sync runner script
|
||||||
|
// launches both as async `spawn` children inside its own process,
|
||||||
|
// waits for both to exit, and reports their statuses on stdout —
|
||||||
|
// and the test calls *that* runner via `spawnSync`. The runner is
|
||||||
|
// the only place that needs the event loop.
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
const testId = `test-bridge-race-${Date.now()}-${process.pid}`;
|
||||||
|
const writerPath = path.join(__dirname, '..', '__tmp_bridge_writer.js');
|
||||||
|
const runnerPath = path.join(__dirname, '..', '__tmp_bridge_race_runner.js');
|
||||||
|
const bridgeLib = path.join(__dirname, '..', '..', 'scripts', 'lib', 'session-bridge');
|
||||||
|
fs.writeFileSync(
|
||||||
|
writerPath,
|
||||||
|
[
|
||||||
|
"const { writeBridgeAtomic } = require(" + JSON.stringify(bridgeLib) + ");",
|
||||||
|
"const [, , sid, tag] = process.argv;",
|
||||||
|
"for (let i = 0; i < 200; i++) {",
|
||||||
|
" writeBridgeAtomic(sid, { writer: tag, i });",
|
||||||
|
"}",
|
||||||
|
].join('\n'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
runnerPath,
|
||||||
|
[
|
||||||
|
"'use strict';",
|
||||||
|
"const { spawn } = require('child_process');",
|
||||||
|
"const [, , writerPath, sid] = process.argv;",
|
||||||
|
"const c1 = spawn(process.execPath, [writerPath, sid, 'A'], { stdio: ['ignore','pipe','pipe'] });",
|
||||||
|
"const c2 = spawn(process.execPath, [writerPath, sid, 'B'], { stdio: ['ignore','pipe','pipe'] });",
|
||||||
|
"const exits = {};",
|
||||||
|
"const stderrs = { A: '', B: '' };",
|
||||||
|
"c1.stderr.on('data', chunk => { stderrs.A += chunk.toString(); });",
|
||||||
|
"c2.stderr.on('data', chunk => { stderrs.B += chunk.toString(); });",
|
||||||
|
"let done = 0;",
|
||||||
|
"function onExit(tag) { return function(code) { exits[tag] = code; if (++done === 2) finish(); }; }",
|
||||||
|
"c1.on('exit', onExit('A'));",
|
||||||
|
"c2.on('exit', onExit('B'));",
|
||||||
|
"function finish() {",
|
||||||
|
" process.stdout.write(JSON.stringify({ exits, stderrs }));",
|
||||||
|
" process.exit(0);",
|
||||||
|
"}",
|
||||||
|
].join('\n'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const result = spawnSync('node', [runnerPath, writerPath, testId], { encoding: 'utf8' });
|
||||||
|
assert.strictEqual(result.status, 0,
|
||||||
|
`race runner should exit 0, got ${result.status}: ${result.stderr}`);
|
||||||
|
const parsed = JSON.parse(result.stdout);
|
||||||
|
assert.strictEqual(parsed.exits.A, 0,
|
||||||
|
`writer A should exit 0 (no ENOENT), got ${parsed.exits.A}: ${parsed.stderrs.A}`);
|
||||||
|
assert.strictEqual(parsed.exits.B, 0,
|
||||||
|
`writer B should exit 0 (no ENOENT), got ${parsed.exits.B}: ${parsed.stderrs.B}`);
|
||||||
|
// Final file must be parseable JSON and belong to one of the writers.
|
||||||
|
const final = readBridge(testId);
|
||||||
|
assert.ok(final && typeof final === 'object',
|
||||||
|
`expected parseable JSON object, got: ${JSON.stringify(final)}`);
|
||||||
|
assert.ok(final.writer === 'A' || final.writer === 'B',
|
||||||
|
`expected last-writer-wins payload, got: ${JSON.stringify(final)}`);
|
||||||
|
} finally {
|
||||||
|
try { fs.unlinkSync(getBridgePath(testId)); } catch { /* ignore */ }
|
||||||
|
try { fs.unlinkSync(writerPath); } catch { /* ignore */ }
|
||||||
|
try { fs.unlinkSync(runnerPath); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('writeBridgeAtomic cleans up its tmp file on renameSync failure', () => {
|
||||||
|
// Trigger renameSync failure by passing a sessionId whose path is
|
||||||
|
// already a directory. The tmp file exists at this point; the fix
|
||||||
|
// must not leak it behind.
|
||||||
|
const path = require('path');
|
||||||
|
const testId = `test-bridge-cleanup-${Date.now()}-${process.pid}`;
|
||||||
|
const target = getBridgePath(testId);
|
||||||
|
const os = require('os');
|
||||||
|
const tmpDir = os.tmpdir();
|
||||||
|
// Plant a directory at the target path so renameSync (target.tmp → target) fails.
|
||||||
|
fs.mkdirSync(target);
|
||||||
|
try {
|
||||||
|
assert.throws(
|
||||||
|
() => writeBridgeAtomic(testId, { x: 1 }),
|
||||||
|
// renameSync of a regular file onto an existing directory throws
|
||||||
|
// EISDIR on Linux, EPERM on macOS, ENOTDIR on some BSDs. Accept
|
||||||
|
// any of those so the test stays portable across CI runners.
|
||||||
|
/EISDIR|EPERM|ENOTDIR|ENOENT/,
|
||||||
|
'expected rename failure to surface'
|
||||||
|
);
|
||||||
|
// Count any leaked tmp files. The pid+nonce suffix is unique per
|
||||||
|
// call, so we look for any matching pattern under os.tmpdir().
|
||||||
|
const prefix = path.basename(target) + '.' + process.pid + '.';
|
||||||
|
const leaked = fs.readdirSync(tmpDir).filter(f => f.startsWith(prefix) && f.endsWith('.tmp'));
|
||||||
|
assert.strictEqual(leaked.length, 0,
|
||||||
|
`expected no leaked tmp files after rename failure, found: ${leaked.join(', ')}`);
|
||||||
|
} finally {
|
||||||
|
try { fs.rmdirSync(target); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
// resolveSessionId tests
|
// resolveSessionId tests
|
||||||
console.log('\nresolveSessionId:');
|
console.log('\nresolveSessionId:');
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,74 @@ if (
|
|||||||
passed++;
|
passed++;
|
||||||
else failed++;
|
else failed++;
|
||||||
|
|
||||||
|
// Invisible code points newly covered by the denylist. These were missing
|
||||||
|
// from the previous denylist and silently passed through both detection and
|
||||||
|
// `--write` mode. Each is a documented LLM-prompt-injection vector
|
||||||
|
// (Tag block "ASCII smuggling"; the other invisibles are widely cited in
|
||||||
|
// homograph / Discord / Twitter smuggling references).
|
||||||
|
|
||||||
|
const NEWLY_COVERED_RANGES = [
|
||||||
|
{ codePoint: 0xE0041, label: 'Tag block U+E0041 (TAG LATIN CAPITAL LETTER A)' },
|
||||||
|
{ codePoint: 0xE007F, label: 'Tag block U+E007F (CANCEL TAG, range end)' },
|
||||||
|
{ codePoint: 0x180E, label: 'U+180E MONGOLIAN VOWEL SEPARATOR' },
|
||||||
|
{ codePoint: 0x115F, label: 'U+115F HANGUL CHOSEONG FILLER' },
|
||||||
|
{ codePoint: 0x1160, label: 'U+1160 HANGUL JUNGSEONG FILLER' },
|
||||||
|
{ codePoint: 0x2061, label: 'U+2061 FUNCTION APPLICATION' },
|
||||||
|
{ codePoint: 0x2064, label: 'U+2064 INVISIBLE PLUS (range end)' },
|
||||||
|
{ codePoint: 0x3164, label: 'U+3164 HANGUL FILLER' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { codePoint, label } of NEWLY_COVERED_RANGES) {
|
||||||
|
if (
|
||||||
|
test(`detects ${label}`, () => {
|
||||||
|
const root = makeTempRoot('ecc-unicode-newly-covered-');
|
||||||
|
fs.mkdirSync(path.join(root, 'docs'), { recursive: true });
|
||||||
|
const hex = codePoint.toString(16).toUpperCase().padStart(4, '0');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, 'docs', `probe-${hex}.md`),
|
||||||
|
`# Probe\n\nBenign${String.fromCodePoint(codePoint)}text\n`
|
||||||
|
);
|
||||||
|
const result = runCheck(root);
|
||||||
|
assert.notStrictEqual(result.status, 0,
|
||||||
|
`expected exit non-zero on U+${hex}, got ${result.status}: ${result.stderr}`);
|
||||||
|
assert.match(result.stderr, new RegExp(`dangerous-invisible U\\+${hex}`),
|
||||||
|
`expected violation message for U+${hex}, got: ${result.stderr}`);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
test('write mode strips newly-covered invisibles from markdown', () => {
|
||||||
|
const root = makeTempRoot('ecc-unicode-newly-covered-write-');
|
||||||
|
fs.mkdirSync(path.join(root, 'docs'), { recursive: true });
|
||||||
|
const tagHidden = [...Array(5)].map((_, i) => String.fromCodePoint(0xE0041 + i)).join('');
|
||||||
|
const mongolianHidden = String.fromCodePoint(0x180E);
|
||||||
|
const filePath = path.join(root, 'docs', 'mixed.md');
|
||||||
|
fs.writeFileSync(filePath, `# Title\n\nBenign${tagHidden}${mongolianHidden}text.\n`);
|
||||||
|
|
||||||
|
const writeResult = runCheck(root, ['--write']);
|
||||||
|
assert.strictEqual(writeResult.status, 0,
|
||||||
|
`expected --write to succeed, got ${writeResult.status}: ${writeResult.stderr}`);
|
||||||
|
|
||||||
|
const sanitized = fs.readFileSync(filePath, 'utf8');
|
||||||
|
assert.doesNotMatch(sanitized, /[\u{E0000}-\u{E007F}]/u,
|
||||||
|
'expected tag block characters stripped');
|
||||||
|
assert.doesNotMatch(sanitized, /\u{180E}/u,
|
||||||
|
'expected U+180E stripped');
|
||||||
|
assert.strictEqual(sanitized, '# Title\n\nBenigntext.\n',
|
||||||
|
'expected only the invisible characters removed, surrounding text preserved');
|
||||||
|
|
||||||
|
// Re-run without --write; should now pass cleanly.
|
||||||
|
const clean = runCheck(root);
|
||||||
|
assert.strictEqual(clean.status, 0,
|
||||||
|
`expected post-sanitize re-run to pass, got: ${clean.stderr}`);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
test('skips Python virtual environments', () => {
|
test('skips Python virtual environments', () => {
|
||||||
const root = makeTempRoot('ecc-unicode-venv-');
|
const root = makeTempRoot('ecc-unicode-venv-');
|
||||||
|
|||||||
Reference in New Issue
Block a user