Compare commits

...

35 Commits

Author SHA1 Message Date
Affaan Mustafa 1ea51ded9a docs: sync Russian README catalog counts 2026-05-11 04:33:03 -04:00
Nikita 03c4e51a16 docs: update README language label 2026-05-11 04:29:39 -04:00
Nikita 1380ce7df5 docs: add Russian README translation 2026-05-11 04:29:39 -04:00
Affaan Mustafa 6fd20ffc72 feat: port Swift language agents (#1721) 2026-05-11 04:27:59 -04:00
Affaan Mustafa 7fa1e5b6db fix: port LLM provider config and tool schemas 2026-05-11 04:12:35 -04:00
Affaan Mustafa f442bac8c9 fix: port Windows hook safety fixes (#1719) 2026-05-11 03:56:51 -04:00
Affaan Mustafa 12e1bc424d fix: port continuous-learning observer fixes
Ports continuous-learning observer signal, storage, remote normalization, and v1 deprecation fixes onto current main.
2026-05-11 03:35:42 -04:00
Affaan Mustafa e674a7dbd7 fix: harden CI validators
Ports personal-path validator hardening and quoted checkout detection onto current main.
2026-05-11 03:08:43 -04:00
Affaan Mustafa 1abc3fb381 fix: port hook session and dashboard safety fixes
Ports suggest-compact session_id isolation and dashboard terminal/document launch safety onto current main.
2026-05-11 02:53:28 -04:00
Affaan Mustafa 27508842b1 fix: sync skill frontmatter and catalog counts
Adds missing skill frontmatter, normalizes strict YAML metadata, syncs README catalog counts, and extends catalog validation for README/plugin/marketplace count drift.
2026-05-11 02:33:29 -04:00
Affaan Mustafa 8a57679222 fix: restore short Claude plugin slug and skill installs (#1712) 2026-05-11 02:10:36 -04:00
Affaan Mustafa 7b964402ee fix: bypass GateGuard file gates in subagents (#1710) 2026-05-11 01:51:24 -04:00
Bill LeVine f8a0c4f884 feat(skills): add flox-environments skill (#1317)
* feat(skills): add flox-environments skill

Add a skill for creating reproducible, cross-platform development
environments with Flox. Covers manifest structure, package installation
patterns, language-specific recipes (Python, Node, Rust, Go, C/C++),
hooks/profile configuration, anti-patterns, environment sharing, and
AI-assisted/vibe coding workflows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(skills): address review feedback on flox-environments

- Add initdb guard to full-stack example so PostgreSQL works on first run
- Replace hardcoded /tmp path with mktemp in agent workflow snippet

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(skills): use variable for mktemp path in agent workflow

$_ resolves to the previous command's last argument (-c), not the
mktemp path. Use an explicit variable instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Update skills/flox-environments/SKILL.md

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-11 01:21:43 -04:00
Quang Tran 754bdbf440 feat: add ios-icon-gen skill (#1356)
* feat: add ios-icon-gen skill for Xcode asset catalog icon generation

Add a skill that generates PNG icon imagesets (1x, 2x, 3x) for Xcode
asset catalogs from two sources:

- Iconify API: 275k+ open source icons from 200+ collections
  (Material Design, Phosphor, Tabler, Lucide, etc.)
- SF Symbols: 5k+ Apple-native symbols (macOS only)

Includes search, preview, and generation scripts with customizable
size, color, weight, and direct output to asset catalogs.

* fix: address PR review feedback for ios-icon-gen skill

Security:
- Fix shell injection in iconify_gen.sh by passing query via sys.argv
  instead of interpolating into Python string literal

Robustness:
- Replace all try!/force-unwrap with do/try/catch and guard let in
  generate_icons.swift for graceful error handling
- Add option value validation (require_value/requireOptionValue) in
  both scripts to prevent crashes on missing flag values
- Add curl timeouts (--connect-timeout 10, --max-time 30) to all
  network calls
- Add sips conversion failure warnings instead of silent suppression
- Add error handling for curl in list_collections

Documentation:
- Rename SKILL.md sections to "When to Use", "How It Works", "Examples"
  to match repo conventions

* fix: restore canonical SKILL.md headers and validate color/weight CLI inputs

- Revert SKILL.md section headers back to "When to Activate" and
  "Core Principles" per CONTRIBUTING.md and SKILL-DEVELOPMENT-GUIDE.md
  (the prior rename to "When to Use"/"How It Works" was incorrect)
- Validate --color as a 6-digit hex code at parse time instead of
  silently falling back to the default gray
- Validate --weight against the known set of font weights instead of
  silently falling back to thin

---------

Co-authored-by: Quang Tran <16215255+trmquang93@users.noreply.github.com>
2026-05-11 01:19:47 -04:00
James M. ZHOU f01929c31a feat: add tinystruct-patterns skill and bootstrapping guidance (#1336)
* feat: add tinystruct-patterns skill and bootstrapping guidance

* docs(skills): restructuralize tinystruct-patterns to standard skill format

- Reorganize SKILL.md and all reference documents into "When to Use", "How It Works", and "Examples" sections to conform to project standards.
- Refine data-handling.md example to return Builder objects directly, leveraging framework auto-serialization.
- Simplify @Action examples in routing.md for better readability.
- Clarify framework mechanics including CLI bootstrapping via ApplicationManager.init(), event-driven architecture, and regex-based routing.

* Update skills/tinystruct-patterns/references/testing.md

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update skills/tinystruct-patterns/SKILL.md

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update skills/tinystruct-patterns/references/routing.md

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update skills/tinystruct-patterns/references/testing.md

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update skills/tinystruct-patterns/references/testing.md

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-05-11 01:19:44 -04:00
Gaurav Dubey e196f8a4cb fix(ci): flag SKILL.md frontmatter defects in validate-skills (#1669)
* fix(ci): flag SKILL.md frontmatter defects in validate-skills

Issue #1663 reported two SKILL.md frontmatter defects (missing `name:`
on skill-stocktake; literal block-scalar `description: |-` on
openclaw-persona-forge) that PR #1664 addresses at the data level.

This change is complementary: it extends `scripts/ci/validate-skills.js`
to catch the same class of defect statically going forward, so the
frontmatter-vs-renderer problems do not silently reappear as new skills
land.

## Checks added
- Frontmatter must declare a `name:` field.
- Frontmatter `description:` must not use a literal block scalar
  (`|` / `|-` / `|+`) — these preserve internal newlines and break
  flat-table renderers keyed off `description`. Folded (`>`) and inline
  strings are accepted.

## Behavior
- Frontmatter findings default to WARN (exit 0) so this PR does not
  break CI while the two known offenders are still on main. Pass
  `--strict` or set `CI_STRICT_SKILLS=1` to promote them to ERROR
  (exit 1). Structural findings (missing / empty SKILL.md) remain
  errors as before.
- Today against main, the validator reports exactly two warnings —
  the same two files called out in #1663 — and exits 0. When #1664
  lands, the validator reports zero warnings, at which point strict
  mode can be enabled in CI.

## Parser notes
- Bespoke frontmatter parser mirrors the style of `validate-agents.js`
  (tolerant of UTF-8 BOM and CRLF; no new npm dependency).
- Block-scalar continuation lines are skipped so keys inside a block
  scalar are not mistaken for top-level keys.
- Hidden directories (`.something/`) under skills/ are now skipped.

## Tests
Adds five focused tests to `tests/ci/validators.test.js`:
- warns when frontmatter is missing `name` (default mode)
- errors when frontmatter is missing `name` (--strict mode)
- warns on literal block-scalar description (|-)
- accepts folded (>) and inline descriptions under --strict
- skips hidden directories under skills/

## Docs
Adds two bullets to the `Skill Checklist` in CONTRIBUTING.md covering
the two rules now surfaced by the validator.

Refs #1663. Complements (does not compete with) #1664.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): harden SKILL.md frontmatter checks after bot review

Address findings from CodeRabbit, Greptile, and cubic on #1669:

- Guard empty or whitespace-only `name:` values. Previously
  `name:    ` silently passed because the presence check only
  tested key-set membership; now inspectFrontmatter captures
  trimmed values and validate flags an explicit 'name is empty'
  WARN/ERROR.
- Broaden block-scalar detection to cover YAML 1.2 indent
  indicators (`|2`, `|-2`, `>2-`) and trailing comments
  (`|-  # note`). The old regex required a bare `|`/`>` with
  optional `+`/`-`, which let valid-but-disallowed forms slip
  through.
- Update CONTRIBUTING.md checklist to list `|+` alongside `|`
  and `|-` for parity with the validator.
- Extend runSkillsValidator to accept env overrides and add four
  regression tests: empty name, |+ description, |-2 + comment, and
  CI_STRICT_SKILLS=1.

* fix(ci): address round-2 review on validate-skills frontmatter

- Tighten extractFrontmatter closing delimiter to require a newline or
  end-of-file after the closing `---`, so body lines beginning with
  `---text` are not parsed as frontmatter (CodeRabbit).
- Strip both trailing and comment-only values in inspectFrontmatter, so
  `name: # todo` is surfaced as empty rather than silently passing
  (cubic P2).
- Extract validateSkillDir helper so the per-directory validation
  block moves out of validateSkills, keeping both functions under the
  50-line guideline (CodeRabbit nit).
- Hoist runSkillsValidator to module scope in the test harness and
  share the spawnSync import with execFileSync so the helper stops
  re-requiring child_process on every invocation (CodeRabbit nit).
- Add regression tests: comment-only `name:` values must fail strict
  mode; `---trailing` body lines must not be parsed as frontmatter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Update tests/ci/validators.test.js

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-05-11 01:14:38 -04:00
Michael 600072ebd8 fix(hooks): resolve MCP health-check spawn ENOENT on Windows (#1456)
* fix(hooks): resolve MCP health-check spawn ENOENT on Windows

On Windows, commands like 'npx' are batch files (npx.cmd) that require
shell expansion to resolve via PATH. Without shell: true, Node.js
spawn() fails with ENOENT.

However, absolute paths (e.g. C:\Program Files\nodejs\node.exe) must
NOT use shell mode because cmd.exe misparses paths containing spaces.

Fix: enable shell mode only for non-absolute commands on Windows, using
path.isAbsolute() to distinguish. This matches how attemptReconnect()
already handles the shell option.

Fixes #1455

* fix(hooks): harden Windows shell spawn — validate command for metacharacters

Addresses bot review feedback on PR #1456:

- Add UNSAFE_SHELL_CHARS regex to guard against shell injection when
  needsShell=true: cmd.exe operators (&, |, <, >, ^, %, !, (), ;,
  whitespace) are rejected before shell mode is enabled
- Add typeof command === 'string' check so path.isAbsolute() cannot
  throw on malformed non-string command values
- Rename test to 'via PATH resolution' (not Windows-only; runs all platforms)
- Fix misleading test comment: 'node' resolves via PATH like npx.cmd but
  does not itself use .cmd; comment now accurately reflects the intent

* fix(hooks): kill full process tree on Windows when shell mode is used

When needsShell=true, the spawned child is cmd.exe. Calling child.kill()
only terminates the shell, leaving the real server process orphaned.

Use taskkill /PID <pid> /T /F on Windows+shell to kill the entire
process tree rooted at cmd.exe. Fall back to SIGTERM+SIGKILL on all
other platforms or when shell mode is not active.

* fix(hooks): fall back to child.kill() when taskkill fails

Windows taskkill can fail if it's not on PATH, the process already
exited, or permissions are denied. Previously the failure was silently
ignored and no kill signal reached the child.

Now: capture the spawnSync result and fall back to child.kill('SIGKILL')
on any taskkill error or non-zero status. This still may leak a
detached server process but at least guarantees the cmd.exe shell is
signaled.
2026-05-11 01:13:37 -04:00
Gaurav Dubey 2bb88cff47 docs(strategic-compact): fix hook command path in zh-CN/zh-TW/ja-JP SKILL.md (#1701)
Extends the hook command path correction from PR #1682 (English source) to
the zh-CN, zh-TW, and ja-JP translated mirrors so the PreToolUse hook
example matches the actual script location at
~/.claude/scripts/hooks/suggest-compact.js.

Changes per locale:

- docs/zh-CN/skills/strategic-compact/SKILL.md: update both command strings
  from ~/.claude/skills/strategic-compact/suggest-compact.js to
  ~/.claude/scripts/hooks/suggest-compact.js.

- docs/zh-TW/skills/strategic-compact/SKILL.md: replace the outdated
  suggest-compact.sh reference (the .sh variant was removed in merged PR
  #41) with the current node-invoked suggest-compact.js, and align the
  matcher block structure with the English canonical SKILL.md post-#1682.

- docs/ja-JP/skills/strategic-compact/SKILL.md: same .sh -> .js migration
  and matcher alignment as zh-TW.

The ko-KR mirror already uses the correct CLAUDE_PLUGIN_ROOT-based hook
path and needs no change.

Refs #1675
2026-05-11 01:13:12 -04:00
Gaurav Dubey 105b524c8f docs(strategic-compact): fix hook command path in SKILL.md (#1682)
The Hook Setup example pointed to
`~/.claude/skills/strategic-compact/suggest-compact.js`, which does not
exist in the current repo layout. The cross-platform Node.js hook ships
at `scripts/hooks/suggest-compact.js` and is installed to
`~/.claude/scripts/hooks/suggest-compact.js`.

Anyone copy-pasting the documented config hit a broken hook command.

Closes #1675

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:13:08 -04:00
dependabot[bot] 61a30a1f15 build(deps): bump the minor-and-patch group across 1 directory with 3 updates (#1582)
Bumps the minor-and-patch group with 3 updates in the / directory: [ajv](https://github.com/ajv-validator/ajv), @opencode-ai/plugin and [globals](https://github.com/sindresorhus/globals).


Updates `ajv` from 8.18.0 to 8.20.0
- [Release notes](https://github.com/ajv-validator/ajv/releases)
- [Commits](https://github.com/ajv-validator/ajv/compare/v8.18.0...v8.20.0)

Updates `@opencode-ai/plugin` from 1.3.15 to 1.14.33

Updates `globals` from 17.4.0 to 17.6.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.4.0...v17.6.0)

---
updated-dependencies:
- dependency-name: "@opencode-ai/plugin"
  dependency-version: 1.14.25
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: ajv
  dependency-version: 8.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: globals
  dependency-version: 17.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 01:12:35 -04:00
dependabot[bot] c013479019 build(deps): bump pnpm/action-setup from 6.0.0 to 6.0.6 (#1708)
Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 6.0.0 to 6.0.6.
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/08c4be7e2e672a47d11bd04269e27e5f3e8529cb...91ab88e2619ed1f46221f0ba42d1492c02baf788)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 01:12:31 -04:00
dependabot[bot] baba4ec1ab build(deps): bump fast-uri from 3.1.0 to 3.1.2 (#1703)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 01:11:54 -04:00
dependabot[bot] 01b171947c chore(deps): bump actions/cache from 5.0.4 to 5.0.5 (#1497)
Bumps [actions/cache](https://github.com/actions/cache) from 5.0.4 to 5.0.5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/668228422ae6a00e4ad889ee87cd7109ec5666a7...27d5ce7f107fe9357f9df03efb73ab90386fccae)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: 5.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 01:11:50 -04:00
Affaan Mustafa 841beea45c fix: handle dotted reserved snapshot names 2026-04-30 12:25:14 -04:00
Affaan Mustafa 61992f7f5e fix: harden loop-status snapshot writes 2026-04-30 12:25:14 -04:00
Affaan Mustafa 2715315438 fix: avoid loop-status index snapshot collision 2026-04-30 12:25:14 -04:00
Affaan Mustafa 7627926216 fix: preserve loop-status output on snapshot errors 2026-04-30 12:25:14 -04:00
Affaan Mustafa 20154ddb22 feat: write loop-status snapshots 2026-04-30 12:25:14 -04:00
Affaan Mustafa bb40978e31 fix: show correct gateguard hook recovery id 2026-04-30 11:26:15 -04:00
Affaan Mustafa 7c5452f4fa fix: keep gateguard destructive gate strict 2026-04-30 11:26:15 -04:00
Affaan Mustafa cfe770a735 fix: add gateguard recovery escape hatch 2026-04-30 11:26:15 -04:00
Affaan Mustafa 4c8499d509 docs: clarify loop-status exit-code watch constraint 2026-04-30 10:33:17 -04:00
Affaan Mustafa 85dfb5e5fc test: isolate loop-status missing transcript fixture 2026-04-30 10:33:17 -04:00
Affaan Mustafa 7b03a60503 fix: require bounded loop-status exit-code watch 2026-04-30 10:33:17 -04:00
Affaan Mustafa fbd441b448 feat: add loop-status exit-code mode 2026-04-30 10:33:17 -04:00
98 changed files with 6993 additions and 393 deletions
+1
View File
@@ -136,6 +136,7 @@ The test `plugin.json does NOT have explicit hooks declaration` in `tests/hooks/
ECC keeps `.mcp.json` at the repository root for Codex plugin installs and manual MCP setup.
Claude Code also auto-discovers plugin-root `.mcp.json` files by convention, which would bundle the same MCP servers into Claude plugin installs.
The Claude plugin slug is intentionally short (`ecc`), but this opt-out is still required because legacy installs and strict provider gateways have failed on generated names from longer plugin identifiers.
Keep this field in `.claude-plugin/plugin.json`:
+3 -3
View File
@@ -1,5 +1,5 @@
{
"name": "everything-claude-code",
"name": "ecc",
"owner": {
"name": "Affaan Mustafa",
"email": "me@affaanmustafa.com"
@@ -9,9 +9,9 @@
},
"plugins": [
{
"name": "everything-claude-code",
"name": "ecc",
"source": "./",
"description": "The most comprehensive Claude Code plugin — 48 agents, 182 skills, 68 legacy command shims, selective install profiles, and production-ready hooks for TDD, security scanning, code review, and continuous learning",
"description": "The most comprehensive Claude Code plugin — 50 agents, 185 skills, 68 legacy command shims, selective install profiles, and production-ready hooks for TDD, security scanning, code review, and continuous learning",
"version": "2.0.0-rc.1",
"author": {
"name": "Affaan Mustafa",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "everything-claude-code",
"name": "ecc",
"version": "2.0.0-rc.1",
"description": "Battle-tested Claude Code plugin for engineering teams — 48 agents, 182 skills, 68 legacy command shims, production-ready hooks, and selective install workflows evolved through continuous real-world use",
"description": "Battle-tested Claude Code plugin for engineering teams — 50 agents, 185 skills, 68 legacy command shims, production-ready hooks, and selective install workflows evolved through continuous real-world use",
"author": {
"name": "Affaan Mustafa",
"url": "https://x.com/affaanmustafa"
+1 -1
View File
@@ -12,7 +12,7 @@ This directory contains the **Codex plugin manifest** for Everything Claude Code
## What This Provides
- **182 skills** from `./skills/` — reusable Codex workflows for TDD, security,
- **185 skills** from `./skills/` — reusable Codex workflows for TDD, security,
code review, architecture, and more
- **6 MCP servers** — GitHub, Context7, Exa, Memory, Playwright, Sequential Thinking
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "ecc",
"version": "2.0.0-rc.1",
"description": "Battle-tested Codex workflows — 182 shared ECC skills, production-ready MCP configs, and selective-install-aligned conventions for TDD, security scanning, code review, and autonomous development.",
"description": "Battle-tested Codex workflows — 185 shared ECC skills, production-ready MCP configs, and selective-install-aligned conventions for TDD, security scanning, code review, and autonomous development.",
"author": {
"name": "Affaan Mustafa",
"email": "me@affaanmustafa.com",
@@ -15,7 +15,7 @@
"mcpServers": "./.mcp.json",
"interface": {
"displayName": "Everything Claude Code",
"shortDescription": "182 battle-tested ECC skills plus MCP configs for TDD, security, code review, and autonomous development.",
"shortDescription": "185 battle-tested ECC skills plus MCP configs for TDD, security, code review, and autonomous development.",
"longDescription": "Everything Claude Code (ECC) is a community-maintained collection of Codex-ready skills and MCP configs evolved over 10+ months of intensive daily use. It covers TDD workflows, security scanning, code review, architecture decisions, operator workflows, and more — all in one installable plugin.",
"developerName": "Affaan Mustafa",
"category": "Productivity",
+9 -5
View File
@@ -45,7 +45,7 @@ jobs:
# Package manager setup
- name: Setup pnpm
if: matrix.pm == 'pnpm' && matrix.node != '18.x'
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb # v6.0.0
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6.0.6
with:
# Keep an explicit pnpm major because this repo's packageManager is Yarn.
version: 10
@@ -77,7 +77,7 @@ jobs:
- name: Cache npm
if: matrix.pm == 'npm'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node-${{ matrix.node }}-npm-${{ hashFiles('**/package-lock.json') }}
@@ -94,7 +94,7 @@ jobs:
- name: Cache pnpm
if: matrix.pm == 'pnpm'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.pnpm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node-${{ matrix.node }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -115,7 +115,7 @@ jobs:
- name: Cache yarn
if: matrix.pm == 'yarn'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.yarn-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node-${{ matrix.node }}-yarn-${{ hashFiles('**/yarn.lock') }}
@@ -124,7 +124,7 @@ jobs:
- name: Cache bun
if: matrix.pm == 'bun'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
@@ -220,6 +220,10 @@ jobs:
run: node scripts/ci/check-unicode-safety.js
continue-on-error: false
- name: Validate no personal paths
run: node scripts/ci/validate-no-personal-paths.js
continue-on-error: false
security:
name: Security Scan
runs-on: ubuntu-latest
+5 -5
View File
@@ -36,7 +36,7 @@ jobs:
- name: Setup pnpm
if: inputs.package-manager == 'pnpm' && inputs.node-version != '18.x'
uses: pnpm/action-setup@08c4be7e2e672a47d11bd04269e27e5f3e8529cb # v6.0.0
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6.0.6
with:
# Keep an explicit pnpm major because this repo's packageManager is Yarn.
version: 10
@@ -67,7 +67,7 @@ jobs:
- name: Cache npm
if: inputs.package-manager == 'npm'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node-${{ inputs.node-version }}-npm-${{ hashFiles('**/package-lock.json') }}
@@ -84,7 +84,7 @@ jobs:
- name: Cache pnpm
if: inputs.package-manager == 'pnpm'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.pnpm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node-${{ inputs.node-version }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -105,7 +105,7 @@ jobs:
- name: Cache yarn
if: inputs.package-manager == 'yarn'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.yarn-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node-${{ inputs.node-version }}-yarn-${{ hashFiles('**/yarn.lock') }}
@@ -114,7 +114,7 @@ jobs:
- name: Cache bun
if: inputs.package-manager == 'bun'
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
+3
View File
@@ -50,3 +50,6 @@ jobs:
- name: Check unicode safety
run: node scripts/ci/check-unicode-safety.js
- name: Validate no personal paths
run: node scripts/ci/validate-no-personal-paths.js
+3 -3
View File
@@ -1,6 +1,6 @@
# Everything Claude Code (ECC) — Agent Instructions
This is a **production-ready AI coding plugin** providing 48 specialized agents, 182 skills, 68 commands, and automated hook workflows for software development.
This is a **production-ready AI coding plugin** providing 50 specialized agents, 185 skills, 68 commands, and automated hook workflows for software development.
**Version:** 2.0.0-rc.1
@@ -145,8 +145,8 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat
## Project Structure
```
agents/ — 48 specialized subagents
skills/ — 182 workflow skills and domain knowledge
agents/ — 50 specialized subagents
skills/ — 185 workflow skills and domain knowledge
commands/ — 68 slash commands
hooks/ — Trigger-based automations
rules/ — Always-follow guidelines (common + per-language)
+2
View File
@@ -167,6 +167,8 @@ Short version:
- [ ] Tested with Claude Code
- [ ] Links to related skills
- [ ] No sensitive data (API keys, tokens, paths)
- [ ] Frontmatter declares `name:` matching the directory name
- [ ] Frontmatter `description:` is an inline string or folded (`>`) scalar — not a literal block (`|`, `|-`, or `|+`), which preserves internal newlines and breaks flat-table renderers
### Example Skills
+22 -22
View File
@@ -1,4 +1,4 @@
**Language:** English | [Português (Brasil)](docs/pt-BR/README.md) | [简体中文](README.zh-CN.md) | [繁體中文](docs/zh-TW/README.md) | [日本語](docs/ja-JP/README.md) | [한국어](docs/ko-KR/README.md) | [Türkçe](docs/tr/README.md)
**Language:** English | [Português (Brasil)](docs/pt-BR/README.md) | [简体中文](README.zh-CN.md) | [繁體中文](docs/zh-TW/README.md) | [日本語](docs/ja-JP/README.md) | [한국어](docs/ko-KR/README.md) | [Türkçe](docs/tr/README.md) | [Русский](docs/ru/README.md)
# Everything Claude Code
@@ -25,10 +25,10 @@
<div align="center">
**Language / 语言 / 語言 / Dil**
**Language / 语言 / 語言 / Dil / Язык**
[**English**](README.md) | [Português (Brasil)](docs/pt-BR/README.md) | [简体中文](README.zh-CN.md) | [繁體中文](docs/zh-TW/README.md) | [日本語](docs/ja-JP/README.md) | [한국어](docs/ko-KR/README.md)
| [Türkçe](docs/tr/README.md)
| [Türkçe](docs/tr/README.md) | [Русский](docs/ru/README.md)
</div>
@@ -89,7 +89,7 @@ This repo is the raw code only. The guides explain everything.
### v2.0.0-rc.1 — Surface Refresh, Operator Workflows, and ECC 2.0 Alpha (Apr 2026)
- **Dashboard GUI** — New Tkinter-based desktop application (`ecc_dashboard.py` or `npm run dashboard`) with dark/light theme toggle, font customization, and project logo in header and taskbar.
- **Public surface synced to the live repo** — metadata, catalog counts, plugin manifests, and install-facing docs now match the actual OSS surface: 48 agents, 182 skills, and 68 legacy command shims.
- **Public surface synced to the live repo** — metadata, catalog counts, plugin manifests, and install-facing docs now match the actual OSS surface: 48 agents, 185 skills, and 68 legacy command shims.
- **Operator and outbound workflow expansion** — `brand-voice`, `social-graph-ranker`, `connections-optimizer`, `customer-billing-ops`, `ecc-tools-cost-audit`, `google-workspace-ops`, `project-flow-ops`, and `workspace-surface-audit` round out the operator lane.
- **Media and launch tooling** — `manim-video`, `remotion-video-creation`, and upgraded social publishing surfaces make technical explainers and launch content part of the same system.
- **Framework and product surface growth** — `nestjs-patterns`, richer Codex/OpenCode install surfaces, and expanded cross-harness packaging keep the repo usable beyond Claude Code alone.
@@ -226,7 +226,7 @@ It returns matching components, related profiles, and preview/install commands.
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
# Install plugin
/plugin install everything-claude-code@everything-claude-code
/plugin install ecc@ecc
```
### Naming + Migration Note
@@ -234,12 +234,12 @@ It returns matching components, related profiles, and preview/install commands.
ECC now has three public identifiers, and they are not interchangeable:
- GitHub source repo: `affaan-m/everything-claude-code`
- Claude marketplace/plugin identifier: `everything-claude-code@everything-claude-code`
- Claude marketplace/plugin identifier: `ecc@ecc`
- npm package: `ecc-universal`
This is intentional. Anthropic marketplace/plugin installs are keyed by a canonical plugin identifier, so ECC standardized on `everything-claude-code@everything-claude-code` to keep the listing name, `/plugin install`, `/plugin list`, and repo docs aligned to one public install surface. Older posts may still show the old short-form nickname; that shorthand is deprecated. Separately, the npm package stayed on `ecc-universal`, so npm installs and marketplace installs intentionally use different names.
This is intentional. Anthropic marketplace/plugin installs are keyed by a canonical plugin identifier, so ECC uses `ecc@ecc` to keep tool names and slash-command namespaces short enough for strict Desktop/API validators. Older posts may still show the former long marketplace identifier; treat that as a legacy alias only. Separately, the npm package stayed on `ecc-universal`, so npm installs and marketplace installs intentionally use different names.
### Step 2: Install Rules (Required)
### Step 2: Install Rules Only If You Need Them
> WARNING: **Important:** Claude Code plugins cannot distribute `rules` automatically.
>
@@ -341,16 +341,16 @@ If you stacked methods, clean up in this order:
# Existing slash-style command names still work while ECC migrates off commands/.
# Plugin install uses the canonical namespaced form
/everything-claude-code:plan "Add user authentication"
/ecc:plan "Add user authentication"
# Manual install keeps the shorter slash form:
# /plan "Add user authentication"
# Check available commands
/plugin list everything-claude-code@everything-claude-code
/plugin list ecc@ecc
```
**That's it!** You now have access to 48 agents, 182 skills, and 68 legacy command shims.
**That's it!** You now have access to 50 agents, 185 skills, and 68 legacy command shims.
### Dashboard GUI
@@ -448,7 +448,7 @@ everything-claude-code/
| |-- plugin.json # Plugin metadata and component paths
| |-- marketplace.json # Marketplace catalog for /plugin marketplace add
|
|-- agents/ # 36 specialized subagents for delegation
|-- agents/ # 50 specialized subagents for delegation
| |-- planner.md # Feature implementation planning
| |-- architect.md # System design decisions
| |-- tdd-guide.md # Test-driven development
@@ -767,7 +767,7 @@ The easiest way to use this repo - install as a Claude Code plugin:
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
# Install the plugin
/plugin install everything-claude-code@everything-claude-code
/plugin install ecc@ecc
```
Or add directly to your `~/.claude/settings.json`:
@@ -783,7 +783,7 @@ Or add directly to your `~/.claude/settings.json`:
}
},
"enabledPlugins": {
"everything-claude-code@everything-claude-code": true
"ecc@ecc": true
}
}
```
@@ -961,8 +961,8 @@ Not sure where to start? Use this quick reference. Skills are the canonical work
| I want to... | Use this surface | Agent used |
|--------------|-----------------|------------|
| Plan a new feature | `/everything-claude-code:plan "Add auth"` | planner |
| Design system architecture | `/everything-claude-code:plan` + architect agent | architect |
| Plan a new feature | `/ecc:plan "Add auth"` | planner |
| Design system architecture | `/ecc:plan` + architect agent | architect |
| Write code with tests first | `tdd-workflow` skill | tdd-guide |
| Review code I just wrote | `/code-review` | code-reviewer |
| Fix a failing build | `/build-fix` | build-error-resolver |
@@ -981,7 +981,7 @@ Slash forms below are shown where they remain part of the maintained command sur
**Starting a new feature:**
```
/everything-claude-code:plan "Add user authentication with OAuth"
/ecc:plan "Add user authentication with OAuth"
→ planner creates implementation blueprint
tdd-workflow skill → tdd-guide enforces write-tests-first
/code-review → code-reviewer checks your work
@@ -1009,7 +1009,7 @@ e2e-testing skill → e2e-runner: critical user flow
<summary><b>How do I check which agents/commands are installed?</b></summary>
```bash
/plugin list everything-claude-code@everything-claude-code
/plugin list ecc@ecc
```
This shows all available agents, commands, and skills from the plugin.
@@ -1336,9 +1336,9 @@ The configuration is automatically detected from `.opencode/opencode.json`.
| Feature | Claude Code | OpenCode | Status |
|---------|-------------|----------|--------|
| Agents | PASS: 48 agents | PASS: 12 agents | **Claude Code leads** |
| Agents | PASS: 50 agents | PASS: 12 agents | **Claude Code leads** |
| Commands | PASS: 68 commands | PASS: 31 commands | **Claude Code leads** |
| Skills | PASS: 182 skills | PASS: 37 skills | **Claude Code leads** |
| Skills | PASS: 185 skills | PASS: 37 skills | **Claude Code leads** |
| Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** |
| Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** |
| MCP Servers | PASS: 14 servers | PASS: Full | **Full parity** |
@@ -1441,9 +1441,9 @@ ECC is the **first plugin to maximize every major AI coding tool**. Here's how e
| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode |
|---------|------------|------------|-----------|----------|
| **Agents** | 48 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |
| **Agents** | 50 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |
| **Commands** | 68 | Shared | Instruction-based | 31 |
| **Skills** | 182 | Shared | 10 (native format) | 37 |
| **Skills** | 185 | Shared | 10 (native format) | 37 |
| **Hook Events** | 8 types | 15 types | None yet | 11 types |
| **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks |
| **Rules** | 34 (common + lang) | 34 (YAML frontmatter) | Instruction-based | 13 instructions |
+8 -8
View File
@@ -102,12 +102,12 @@
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
# 安装插件
/plugin install everything-claude-code@everything-claude-code
/plugin install ecc@ecc
```
> 安装名称说明:较早的帖子里可能还会出现旧的短别名。那个旧缩写现在已经废弃。Anthropic 的 marketplace/plugin 安装是按规范化插件标识符寻址的,因此 ECC 统一为 `everything-claude-code@everything-claude-code`,这样市场条目、安装命令、`/plugin list` 输出和仓库文档都使用同一个公开名称,不再出现两个名字指向同一插件的混乱
> 安装名称说明:较早的帖子里可能还会出现较长的旧标识符。Anthropic 的 marketplace/plugin 安装是按规范化插件标识符寻址的,因此 ECC 现在统一为 `ecc@ecc`,让工具名和 slash command 命名空间保持简短
### 第二步:安装规则(必需)
### 第二步:仅在需要时安装规则
> WARNING: **重要提示:** Claude Code 插件无法自动分发 `rules`。
>
@@ -151,16 +151,16 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
```bash
# 尝试一个命令(插件安装使用命名空间形式)
/everything-claude-code:plan "添加用户认证"
/ecc:plan "添加用户认证"
# 手动安装(选项2)使用简短形式:
# /plan "添加用户认证"
# 查看可用命令
/plugin list everything-claude-code@everything-claude-code
/plugin list ecc@ecc
```
**完成!** 你现在可以使用 48 个代理、182 个技能和 68 个命令。
**完成!** 你现在可以使用 50 个代理、185 个技能和 68 个命令。
### multi-* 命令需要额外配置
@@ -546,7 +546,7 @@ Claude Code v2.1+ 会**按照约定自动加载**已安装插件中的 `hooks/ho
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
# 安装插件
/plugin install everything-claude-code@everything-claude-code
/plugin install ecc@ecc
```
或直接添加到你的 `~/.claude/settings.json`
@@ -562,7 +562,7 @@ Claude Code v2.1+ 会**按照约定自动加载**已安装插件中的 `hooks/ho
}
},
"enabledPlugins": {
"everything-claude-code@everything-claude-code": true
"ecc@ecc": true
}
}
```
+161
View File
@@ -0,0 +1,161 @@
---
name: swift-build-resolver
description: Swift/Xcode build, compilation, and dependency error resolution specialist. Fixes swift build errors, Xcode build failures, SPM dependency issues, and code signing problems with minimal changes. Use when Swift builds fail.
tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"]
model: sonnet
---
# Swift Build Error Resolver
You are an expert Swift build error resolution specialist. Your mission is to fix Swift compilation errors, Xcode build failures, and dependency problems with **minimal, surgical changes**.
## Core Responsibilities
1. Diagnose `swift build` / `xcodebuild` errors
2. Fix type checker and protocol conformance errors
3. Resolve Swift Concurrency and `Sendable` issues
4. Handle SPM dependency and version resolution failures
5. Fix Xcode project configuration and code signing issues
## Diagnostic Commands
Run these in order:
```bash
swift build 2>&1
if command -v swiftlint >/dev/null 2>&1; then swiftlint lint --quiet 2>&1; else echo "[info] swiftlint not installed - skipping lint"; fi
swift package resolve 2>&1
swift package show-dependencies 2>&1
swift test 2>&1
```
For Xcode projects:
```bash
xcodebuild -list 2>&1
xcrun simctl list devices available 2>&1 | head -20 # find an available simulator
xcodebuild -scheme <Scheme> -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -50
xcodebuild -showBuildSettings 2>&1 | grep -E 'SWIFT_VERSION|CODE_SIGN|PRODUCT_BUNDLE_IDENTIFIER'
```
## Resolution Workflow
```text
1. swift build -> Parse error message and error code
2. Read affected file -> Understand type and protocol context
3. Apply minimal fix -> Only what's needed
4. swift build -> Verify fix
5. swiftlint lint -> Check for warnings (if swiftlint is installed)
6. swift test -> Ensure nothing broke
```
## Common Fix Patterns
| Error | Cause | Fix |
|-------|-------|-----|
| `cannot find type 'X' in scope` | Missing import or typo | Add `import Module` or fix name |
| `value of type 'X' has no member 'Y'` | Wrong type or missing extension | Fix type or add missing method |
| `cannot convert value of type 'X' to expected type 'Y'` | Type mismatch | Add conversion, cast, or fix type annotation |
| `type 'X' does not conform to protocol 'Y'` | Missing required members | Implement missing protocol requirements |
| `missing return in closure expected to return 'X'` | Incomplete closure body | Add explicit return statement |
| `expression is 'async' but is not marked with 'await'` | Missing `await` | Add `await` keyword |
| `non-sendable type 'X' passed in implicitly asynchronous call` | Sendable violation | Add `Sendable` conformance or restructure |
| `actor-isolated property cannot be referenced from non-isolated context` | Actor isolation mismatch | Add `await`, mark caller as `async`, or use `nonisolated` |
| `reference to captured var 'X' in concurrently-executing code` | Captured mutable state | Use `let` copy before closure or actor |
| `ambiguous use of 'X'` | Multiple matching declarations | Use fully qualified name or explicit type annotation |
| `circular reference` | Recursive type or protocol | Break cycle with indirect enum or protocol |
| `cannot assign to property: 'X' is a 'let' constant` | Mutating immutable value | Change `let` to `var` or restructure |
| `initializer requires that 'X' conform to 'Decodable'` | Missing Codable conformance | Add `Codable` conformance or custom init |
| `@MainActor function cannot be called from non-isolated context` | Main actor isolation | Add `await` and make caller `async`, or use `MainActor.run {}` |
## SPM Troubleshooting
```bash
# Check resolved dependency versions
cat Package.resolved | head -40
# Clear package caches
swift package reset
swift package resolve
# Show full dependency tree
swift package show-dependencies --format json
# Update a specific dependency
swift package update <PackageName>
# Check for version conflicts
swift package resolve 2>&1 | grep -i "conflict\\|error"
# Verify Package.swift syntax
swift package dump-package
```
## Xcode Build Troubleshooting
```bash
# Clean build folder
xcodebuild clean -scheme <Scheme>
# List available schemes and destinations
xcodebuild -list
xcrun simctl list devices available
# Check Swift version
xcrun --find swift
swift --version
grep 'swift-tools-version' Package.swift
# Code signing issues
security find-identity -v -p codesigning
xcodebuild -showBuildSettings | grep CODE_SIGN
# Module map / framework issues
xcodebuild -scheme <Scheme> build 2>&1 | grep -E 'module|framework|import'
```
## Swift Version and Toolchain Issues
```bash
# Check active toolchain
xcrun --find swift
swift --version
# Check swift-tools-version in Package.swift
head -1 Package.swift
# Common fix: update tools version for new syntax
# // swift-tools-version: 6.0 (requires Xcode 16+)
```
## Key Principles
- **Surgical fixes only** - don't refactor, just fix the error
- **Never** add `// swiftlint:disable` without explicit approval
- **Never** use force unwrap (`!`) to silence optionals - handle properly with `guard let` or `if let`
- **Never** use `@unchecked Sendable` to silence concurrency errors without verifying thread safety
- **Always** run `swift build` after every fix attempt
- Fix root cause over suppressing symptoms
- Prefer the simplest fix that preserves the original intent
## Stop Conditions
Stop and report if:
- Same error persists after 3 fix attempts
- Fix introduces more errors than it resolves
- Error requires architectural changes beyond scope
- Concurrency error requires redesigning actor isolation model
- Build failure is caused by missing provisioning profile or certificate (user action required)
## Output Format
```text
[FIXED] Sources/App/Services/UserService.swift:42
Error: type 'UserService' does not conform to protocol 'Sendable'
Fix: Converted mutable properties to let constants and added Sendable conformance
Remaining errors: 3
```
Final: `Build Status: SUCCESS/FAILED | Errors Fixed: N | Files Modified: list`
For detailed Swift patterns and rules, see rules: `swift/coding-style`, `swift/patterns`, `swift/security`. See also skill: `swift-concurrency-6-2`, `swift-actor-persistence`.
+107
View File
@@ -0,0 +1,107 @@
---
name: swift-reviewer
description: Expert Swift code reviewer specializing in protocol-oriented design, value semantics, ARC memory management, Swift Concurrency, and idiomatic patterns. Use for all Swift code changes. MUST BE USED for Swift projects.
tools: ["Read", "Grep", "Glob", "Bash"]
model: sonnet
---
You are a senior Swift code reviewer ensuring high standards of safety, idiomatic patterns, and performance.
When invoked:
1. Run `swift build`, `swiftlint lint --quiet` (if available), and `swift test` - if any fail, stop and report
2. Run `git diff HEAD~1 -- '*.swift'` (or `git diff main...HEAD -- '*.swift'` for PR review) to see recent Swift file changes
3. Focus on modified `.swift` files
4. If the project has CI or merge requirements, note that review assumes a green CI and resolved merge conflicts where applicable; call out if the diff suggests otherwise.
5. Begin review
## Review Priorities
### CRITICAL - Safety
- **Force unwrapping**: `value!` in production code paths - use `guard let`, `if let`, or `??`
- **Force try**: `try!` without justification - use `do/catch` or propagate with `throws`
- **Force cast**: `as!` without a preceding type check - use `as?` with conditional binding
- **Hardcoded secrets**: API keys, passwords, tokens in source - use Keychain or environment variables
- **UserDefaults for secrets**: Sensitive data in `UserDefaults` - use Keychain Services
- **ATS disabled**: App Transport Security exceptions without justification
- **SQL/command injection**: String interpolation in queries or shell commands - use parameterized queries
- **Path traversal**: User-controlled paths without validation and prefix check
- **Insecure deserialization**: Decoding untrusted data without validation or size limits
### CRITICAL - Error Handling
- **Silenced errors**: Empty `catch {}` blocks or `try?` discarding meaningful errors
- **Missing error context**: Rethrowing without wrapping in a domain-specific error
- **`fatalError()` for recoverable conditions**: Use `throw` for errors that callers can handle
- **`assert` for required invariants**: `assert` is stripped in release builds (debug-only) - use `precondition` when the check must hold in release, or `throw` for public API boundaries
- **`precondition` / `fatalError` in library code**: `precondition` crashes in both debug and release; `fatalError` crashes unconditionally in all builds - use `throw` for recoverable errors at public API boundaries
### HIGH - Concurrency
- **Data races**: Mutable shared state without actor isolation or synchronization
- **`@Sendable` violations**: Non-`Sendable` types crossing isolation boundaries
- **Blocking the main actor**: Synchronous I/O or `Thread.sleep` on `@MainActor` - use `Task.sleep` and async I/O
- **Unstructured `Task {}` without cancellation**: Fire-and-forget tasks leaking - use structured concurrency (`async let`, `TaskGroup`)
- **Actor reentrancy issues**: Assumptions about state consistency across `await` suspension points
- **Missing `@MainActor`**: UI updates performed off the main actor
### HIGH - Memory Management
- **Strong reference cycles**: Closures capturing `self` strongly in long-lived contexts - use `[weak self]` or `[unowned self]`
- **Delegates as strong references**: Delegate properties without `weak` - causes retain cycles
- **Closure capture lists missing**: Escaping closures without explicit capture semantics
- **Large value type copies**: Oversized structs copied on every assignment - consider `class` or `Cow`-like patterns
### HIGH - Code Quality
- **Large functions**: Over 50 lines
- **Deep nesting**: More than 4 levels
- **Wildcard switch on evolving enums**: `default:` hiding new cases - use `@unknown default`
- **Dead code**: Unused functions, imports, or variables
- **Non-exhaustive matching**: Catch-all where explicit handling is needed
### HIGH - Protocol-Oriented Design
- **Class inheritance where protocols suffice**: Prefer protocol conformance with default extensions
- **`Any` / `AnyObject` abuse**: Use constrained generics or `any Protocol` / `some Protocol`
- **Missing protocol conformance**: Types that should conform to `Equatable`, `Hashable`, `Codable`, or `Sendable`
- **Existential over generic**: `any Protocol` parameter when `some Protocol` or generic constraint is more efficient
### MEDIUM - Performance
- **Unnecessary allocation in hot paths**: Creating objects inside tight loops
- **Missing `reserveCapacity`**: Growing arrays when final size is known
- **String interpolation in loops**: Repeated `String` allocation - use `append` or preallocate
- **Unnecessary `@objc` bridging**: Swift-to-Objective-C overhead where pure Swift suffices
- **N+1 queries**: Database or network calls inside loops - batch operations
### MEDIUM - Best Practices
- **`var` when `let` suffices**: Prefer immutable bindings
- **`class` when `struct` suffices**: Prefer value types for data models
- **`print()` in production code**: Use `os.Logger` or structured logging
- **Missing access control**: Types and members defaulting to `internal` when `private` or `fileprivate` is appropriate
- **SwiftLint warnings unaddressed**: Suppressed with `// swiftlint:disable` without justification
- **Public API without documentation**: `public` items missing `///` doc comments
- **Magic numbers/strings**: Use named constants or enums
- **Stringly-typed APIs**: Use enums or dedicated types instead of raw strings
## Diagnostic Commands
```bash
swift build
if command -v swiftlint >/dev/null 2>&1; then swiftlint lint --quiet; else echo "[info] swiftlint not installed - skipping lint (install via 'brew install swiftlint')"; fi
swift test
swift package resolve
if command -v swift-format >/dev/null 2>&1; then swift-format lint -r . 2>&1 | head -30; else echo "[info] swift-format not installed - skipping format check"; fi
```
## Approval Criteria
- **Approve**: No CRITICAL or HIGH issues
- **Warning**: MEDIUM issues only
- **Block**: CRITICAL or HIGH issues found
For detailed Swift patterns and rules, see rules: `swift/coding-style`, `swift/patterns`, `swift/security`, `swift/testing`. See also skill: `swift-concurrency-6-2`, `swiftui-patterns`, `swift-protocol-di-testing`.
Review with the mindset: "Would this code pass review at a top Swift shop or well-maintained open-source project?"
+21
View File
@@ -40,9 +40,18 @@ tool calls that have no matching `tool_result`.
directly.
- `ecc loop-status --bash-timeout-seconds 1800` adjusts the stale Bash
threshold.
- `ecc loop-status --exit-code` exits `2` when stale loop or tool signals are
found, or `1` when transcripts cannot be scanned.
- `--exit-code` with `--watch` requires `--watch-count` so watchdog scripts do
not wait forever for a process exit.
- `ecc loop-status --watch` refreshes status until interrupted.
- `ecc loop-status --watch --watch-count 3 --exit-code` refreshes a bounded
number of times, then exits with the highest status seen.
- `ecc loop-status --watch --watch-count 3` emits a bounded watch stream for
scripts and handoffs.
- `ecc loop-status --watch --write-dir ~/.claude/loops` maintains
`index.json` and per-session JSON snapshots for sibling terminals or
watchdog scripts.
## Watch Mode
@@ -50,6 +59,18 @@ When `--watch` is present, refresh status periodically. With `--json`, each
refresh is emitted as one JSON object per line so another terminal or script can
consume the stream.
## Snapshot Files
Use `--write-dir <dir>` when a separate process needs to inspect loop state
without waiting for the current Claude session to dequeue `/loop-status`. The
CLI writes:
- `index.json` with one row per inspected session.
- `<session-id>.json` with the full status payload for that session.
These files are snapshots of local transcript analysis. They do not control or
timeout Claude Code runtime tool calls.
## Arguments
$ARGUMENTS:
+5 -5
View File
@@ -110,7 +110,7 @@
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
# プラグインをインストール
/plugin install everything-claude-code
/plugin install ecc@ecc
```
### ステップ2:ルールをインストール(必須)
@@ -134,13 +134,13 @@ cp -r everything-claude-code/rules/golang/* ~/.claude/rules/
```bash
# コマンドを試す(プラグインはネームスペース形式)
/everything-claude-code:plan "ユーザー認証を追加"
/ecc:plan "ユーザー認証を追加"
# 手動インストール(オプション2)は短縮形式:
# /plan "ユーザー認証を追加"
# 利用可能なコマンドを確認
/plugin list everything-claude-code@everything-claude-code
/plugin list ecc@ecc
```
**完了です!** これで13のエージェント、43のスキル、31のコマンドにアクセスできます。
@@ -427,7 +427,7 @@ Duplicate hook file detected: ./hooks/hooks.json is already resolved to a loaded
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
# プラグインをインストール
/plugin install everything-claude-code
/plugin install ecc@ecc
```
または、`~/.claude/settings.json` に直接追加:
@@ -443,7 +443,7 @@ Duplicate hook file detected: ./hooks/hooks.json is already resolved to a loaded
}
},
"enabledPlugins": {
"everything-claude-code@everything-claude-code": true
"ecc@ecc": true
}
}
```
+1 -1
View File
@@ -17,7 +17,7 @@ Everything Claude Code プロジェクトのインタラクティブなステッ
## 前提条件
このスキルは起動前に Claude Code からアクセス可能である必要があります。ブートストラップには2つの方法があります:
1. **プラグイン経由**: `/plugin install everything-claude-code@everything-claude-code` — プラグインがこのスキルを自動的にロードします
1. **プラグイン経由**: `/plugin install ecc@ecc` — プラグインがこのスキルを自動的にロードします
2. **手動**: このスキルのみを `~/.claude/skills/configure-ecc/SKILL.md` にコピーし、"configure ecc" と言って起動します
---
+11 -8
View File
@@ -21,7 +21,7 @@ description: 任意の自動コンパクションではなく、タスクフェ
## 仕組み
`suggest-compact.sh`スクリプトはPreToolUseEdit/Write)で実行され:
`suggest-compact.js`スクリプトはPreToolUseEdit/Write)で実行され:
1. **ツール呼び出しを追跡** - セッション内のツール呼び出しをカウント
2. **閾値検出** - 設定可能な閾値で提案(デフォルト:50回)
@@ -34,13 +34,16 @@ description: 任意の自動コンパクションではなく、タスクフェ
```json
{
"hooks": {
"PreToolUse": [{
"matcher": "tool == \"Edit\" || tool == \"Write\"",
"hooks": [{
"type": "command",
"command": "~/.claude/skills/strategic-compact/suggest-compact.sh"
}]
}]
"PreToolUse": [
{
"matcher": "Edit",
"hooks": [{ "type": "command", "command": "node ~/.claude/scripts/hooks/suggest-compact.js" }]
},
{
"matcher": "Write",
"hooks": [{ "type": "command", "command": "node ~/.claude/scripts/hooks/suggest-compact.js" }]
}
]
}
}
```
+9 -9
View File
@@ -115,7 +115,7 @@
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
# 플러그인 설치
/plugin install everything-claude-code
/plugin install ecc@ecc
```
### 2단계: 룰 설치 (필수)
@@ -141,13 +141,13 @@ cd everything-claude-code
```bash
# 커맨드 실행 (플러그인 설치 시 네임스페이스 형태 사용)
/everything-claude-code:plan "사용자 인증 추가"
/ecc:plan "사용자 인증 추가"
# 수동 설치(옵션 2) 시에는 짧은 형태를 사용:
# /plan "사용자 인증 추가"
# 사용 가능한 커맨드 확인
/plugin list everything-claude-code@everything-claude-code
/plugin list ecc@ecc
```
**끝!** 이제 16개 에이전트, 65개 스킬, 40개 커맨드를 사용할 수 있습니다.
@@ -359,7 +359,7 @@ Claude Code v2.1+는 설치된 플러그인의 `hooks/hooks.json`을 **자동으
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
# 플러그인 설치
/plugin install everything-claude-code
/plugin install ecc@ecc
```
또는 `~/.claude/settings.json`에 직접 추가:
@@ -375,7 +375,7 @@ Claude Code v2.1+는 설치된 플러그인의 `hooks/hooks.json`을 **자동으
}
},
"enabledPlugins": {
"everything-claude-code@everything-claude-code": true
"ecc@ecc": true
}
}
```
@@ -489,8 +489,8 @@ rules/
| 하고 싶은 것 | 사용할 커맨드 | 사용되는 에이전트 |
|-------------|-------------|-----------------|
| 새 기능 계획하기 | `/everything-claude-code:plan "인증 추가"` | planner |
| 시스템 아키텍처 설계 | `/everything-claude-code:plan` + architect 에이전트 | architect |
| 새 기능 계획하기 | `/ecc:plan "인증 추가"` | planner |
| 시스템 아키텍처 설계 | `/ecc:plan` + architect 에이전트 | architect |
| 테스트를 먼저 작성하며 코딩 | `/tdd` | tdd-guide |
| 방금 작성한 코드 리뷰 | `/code-review` | code-reviewer |
| 빌드 실패 수정 | `/build-fix` | build-error-resolver |
@@ -507,7 +507,7 @@ rules/
**새로운 기능 시작:**
```
/everything-claude-code:plan "OAuth를 사용한 사용자 인증 추가"
/ecc:plan "OAuth를 사용한 사용자 인증 추가"
→ planner가 구현 청사진 작성
/tdd → tdd-guide가 테스트 먼저 작성 강제
/code-review → code-reviewer가 코드 검토
@@ -535,7 +535,7 @@ rules/
<summary><b>설치된 에이전트/커맨드 확인은 어떻게 하나요?</b></summary>
```bash
/plugin list everything-claude-code@everything-claude-code
/plugin list ecc@ecc
```
플러그인에서 사용할 수 있는 모든 에이전트, 커맨드, 스킬을 보여줍니다.
+9 -9
View File
@@ -124,7 +124,7 @@ Comece em menos de 2 minutos:
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
# Instalar plugin
/plugin install everything-claude-code@everything-claude-code
/plugin install ecc@ecc
```
### Passo 2: Instalar as Regras (Obrigatório)
@@ -161,13 +161,13 @@ npx ecc-install typescript
```bash
# Experimente um comando (a instalação do plugin usa forma com namespace)
/everything-claude-code:plan "Adicionar autenticação de usuário"
/ecc:plan "Adicionar autenticação de usuário"
# Instalação manual (Opção 2) usa a forma mais curta:
# /plan "Adicionar autenticação de usuário"
# Verificar comandos disponíveis
/plugin list everything-claude-code@everything-claude-code
/plugin list ecc@ecc
```
**Pronto!** Você agora tem acesso a 28 agentes, 116 skills e 59 comandos.
@@ -313,7 +313,7 @@ claude --version
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
# Instalar o plugin
/plugin install everything-claude-code@everything-claude-code
/plugin install ecc@ecc
```
Ou adicione diretamente ao seu `~/.claude/settings.json`:
@@ -329,7 +329,7 @@ Ou adicione diretamente ao seu `~/.claude/settings.json`:
}
},
"enabledPlugins": {
"everything-claude-code@everything-claude-code": true
"ecc@ecc": true
}
}
```
@@ -408,8 +408,8 @@ Regras são diretrizes sempre seguidas, organizadas em `common/` (agnóstico à
| Quero... | Use este comando | Agente usado |
|----------|-----------------|--------------|
| Planejar um novo recurso | `/everything-claude-code:plan "Adicionar auth"` | planner |
| Projetar arquitetura de sistema | `/everything-claude-code:plan` + agente architect | architect |
| Planejar um novo recurso | `/ecc:plan "Adicionar auth"` | planner |
| Projetar arquitetura de sistema | `/ecc:plan` + agente architect | architect |
| Escrever código com testes primeiro | `/tdd` | tdd-guide |
| Revisar código que acabei de escrever | `/code-review` | code-reviewer |
| Corrigir build com falha | `/build-fix` | build-error-resolver |
@@ -424,7 +424,7 @@ Regras são diretrizes sempre seguidas, organizadas em `common/` (agnóstico à
**Começando um novo recurso:**
```
/everything-claude-code:plan "Adicionar autenticação de usuário com OAuth"
/ecc:plan "Adicionar autenticação de usuário com OAuth"
→ planner cria blueprint de implementação
/tdd → tdd-guide aplica escrita de testes primeiro
/code-review → code-reviewer verifica seu trabalho
@@ -452,7 +452,7 @@ Regras são diretrizes sempre seguidas, organizadas em `common/` (agnóstico à
<summary><b>Como verificar quais agentes/comandos estão instalados?</b></summary>
```bash
/plugin list everything-claude-code@everything-claude-code
/plugin list ecc@ecc
```
</details>
+1613
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -125,7 +125,7 @@ Bu repository yalnızca ham kodu içerir. Rehberler her şeyi açıklıyor.
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
# Plugin'i kur
/plugin install everything-claude-code
/plugin install ecc@ecc
```
### Adım 2: Rule'ları Kurun (Gerekli)
@@ -164,13 +164,13 @@ Manuel kurulum talimatları için `rules/` klasöründeki README'ye bakın.
```bash
# Bir command deneyin (plugin kurulumu namespace'li form kullanır)
/everything-claude-code:plan "Kullanıcı kimlik doğrulaması ekle"
/ecc:plan "Kullanıcı kimlik doğrulaması ekle"
# Manuel kurulum (Seçenek 2) daha kısa formu kullanır:
# /plan "Kullanıcı kimlik doğrulaması ekle"
# Mevcut command'ları kontrol edin
/plugin list everything-claude-code@everything-claude-code
/plugin list ecc@ecc
```
**Bu kadar!** Artık 28 agent, 116 skill ve 59 command'a erişiminiz var.
@@ -308,8 +308,8 @@ Nereden başlayacağınızdan emin değil misiniz? Bu hızlı referansı kullan
| Yapmak istediğim... | Bu command'ı kullan | Kullanılan agent |
|---------------------|---------------------|------------------|
| Yeni bir feature planla | `/everything-claude-code:plan "Auth ekle"` | planner |
| Sistem mimarisi tasarla | `/everything-claude-code:plan` + architect agent | architect |
| Yeni bir feature planla | `/ecc:plan "Auth ekle"` | planner |
| Sistem mimarisi tasarla | `/ecc:plan` + architect agent | architect |
| Önce testlerle kod yaz | `/tdd` | tdd-guide |
| Yazdığım kodu incele | `/code-review` | code-reviewer |
| Başarısız bir build'i düzelt | `/build-fix` | build-error-resolver |
@@ -324,7 +324,7 @@ Nereden başlayacağınızdan emin değil misiniz? Bu hızlı referansı kullan
**Yeni bir feature başlatma:**
```
/everything-claude-code:plan "OAuth ile kullanıcı kimlik doğrulaması ekle"
/ecc:plan "OAuth ile kullanıcı kimlik doğrulaması ekle"
→ planner implementasyon planı oluşturur
/tdd → tdd-guide önce-test-yaz'ı zorunlu kılar
/code-review → code-reviewer çalışmanızı kontrol eder
@@ -352,7 +352,7 @@ Nereden başlayacağınızdan emin değil misiniz? Bu hızlı referansı kullan
<summary><b>Hangi agent/command'ların kurulu olduğunu nasıl kontrol ederim?</b></summary>
```bash
/plugin list everything-claude-code@everything-claude-code
/plugin list ecc@ecc
```
Bu, plugin'den mevcut tüm agent'ları, command'ları ve skill'leri gösterir.
+3 -3
View File
@@ -1,6 +1,6 @@
# Everything Claude Code (ECC) — 智能体指令
这是一个**生产就绪的 AI 编码插件**,提供 48 个专业代理、182 项技能、68 条命令以及自动化钩子工作流,用于软件开发。
这是一个**生产就绪的 AI 编码插件**,提供 50 个专业代理、185 项技能、68 条命令以及自动化钩子工作流,用于软件开发。
**版本:** 2.0.0-rc.1
@@ -146,8 +146,8 @@
## 项目结构
```
agents/ — 48 个专业子代理
skills/ — 182 个工作流技能和领域知识
agents/ — 50 个专业子代理
skills/ — 185 个工作流技能和领域知识
commands/ — 68 个斜杠命令
hooks/ — 基于触发的自动化
rules/ — 始终遵循的指导方针(通用 + 每种语言)
+14 -14
View File
@@ -170,7 +170,7 @@
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
# Install plugin
/plugin install everything-claude-code@everything-claude-code
/plugin install ecc@ecc
```
### 步骤 2:安装规则(必需)
@@ -215,16 +215,16 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
```bash
# Try a command (plugin install uses namespaced form)
/everything-claude-code:plan "Add user authentication"
/ecc:plan "Add user authentication"
# Manual install (Option 2) uses the shorter form:
# /plan "Add user authentication"
# Check available commands
/plugin list everything-claude-code@everything-claude-code
/plugin list ecc@ecc
```
**搞定!** 你现在可以使用 48 个智能体、182 项技能和 68 个命令了。
**搞定!** 你现在可以使用 50 个智能体、185 项技能和 68 个命令了。
***
@@ -602,7 +602,7 @@ Claude Code v2.1+ **会自动加载** 任何已安装插件中的 `hooks/hooks.j
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
# Install the plugin
/plugin install everything-claude-code
/plugin install ecc@ecc
```
或者直接添加到您的 `~/.claude/settings.json`
@@ -618,7 +618,7 @@ Claude Code v2.1+ **会自动加载** 任何已安装插件中的 `hooks/hooks.j
}
},
"enabledPlugins": {
"everything-claude-code@everything-claude-code": true
"ecc@ecc": true
}
}
```
@@ -764,8 +764,8 @@ rules/
| 我想要... | 使用此表面 | 使用的智能体 |
|--------------|-----------------|------------|
| 规划新功能 | `/everything-claude-code:plan "Add auth"` | planner |
| 设计系统架构 | `/everything-claude-code:plan` + architect agent | architect |
| 规划新功能 | `/ecc:plan "Add auth"` | planner |
| 设计系统架构 | `/ecc:plan` + architect agent | architect |
| 先写测试再写代码 | `tdd-workflow` 技能 | tdd-guide |
| 评审我刚写的代码 | `/code-review` | code-reviewer |
| 修复失败的构建 | `/build-fix` | build-error-resolver |
@@ -783,7 +783,7 @@ rules/
**开始新功能:**
```
/everything-claude-code:plan "使用 OAuth 添加用户身份验证"
/ecc:plan "使用 OAuth 添加用户身份验证"
→ 规划器创建实现蓝图
tdd-workflow 技能 → tdd-guide 强制执行先写测试
/code-review → 代码审查员检查你的工作
@@ -813,7 +813,7 @@ e2e-testing 技能 → e2e-runner: 关键用户流
<summary><b>如何检查已安装的代理/命令?</b></summary>
```bash
/plugin list everything-claude-code@everything-claude-code
/plugin list ecc@ecc
```
这会显示插件中所有可用的代理、命令和技能。
@@ -1132,9 +1132,9 @@ opencode
| 功能特性 | Claude Code | OpenCode | 状态 |
|---------|-------------|----------|--------|
| 智能体 | PASS: 48 个 | PASS: 12 个 | **Claude Code 领先** |
| 智能体 | PASS: 50 个 | PASS: 12 个 | **Claude Code 领先** |
| 命令 | PASS: 68 个 | PASS: 31 个 | **Claude Code 领先** |
| 技能 | PASS: 182 项 | PASS: 37 项 | **Claude Code 领先** |
| 技能 | PASS: 185 项 | PASS: 37 项 | **Claude Code 领先** |
| 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** |
| 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
| MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** |
@@ -1240,9 +1240,9 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以
| 功能特性 | Claude Code | Cursor IDE | Codex CLI | OpenCode |
|---------|------------|------------|-----------|----------|
| **智能体** | 48 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
| **智能体** | 50 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
| **命令** | 68 | 共享 | 基于指令 | 31 |
| **技能** | 182 | 共享 | 10 (原生格式) | 37 |
| **技能** | 185 | 共享 | 10 (原生格式) | 37 |
| **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 |
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 |
| **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 |
+1 -1
View File
@@ -19,7 +19,7 @@ origin: ECC
此技能必须在激活前对 Claude Code 可访问。有两种引导方式:
1. **通过插件**: `/plugin install everything-claude-code@everything-claude-code` — 插件会自动加载此技能
1. **通过插件**: `/plugin install ecc@ecc` — 插件会自动加载此技能
2. **手动**: 仅将此技能复制到 `~/.claude/skills/configure-ecc/SKILL.md`,然后通过说 "configure ecc" 激活
***
@@ -1,4 +1,5 @@
---
name: skill-stocktake
description: "用于审计Claude技能和命令的质量。支持快速扫描(仅变更技能)和全面盘点模式,采用顺序子代理批量评估。"
origin: ECC
---
+2 -2
View File
@@ -48,11 +48,11 @@ origin: ECC
"PreToolUse": [
{
"matcher": "Edit",
"hooks": [{ "type": "command", "command": "node ~/.claude/skills/strategic-compact/suggest-compact.js" }]
"hooks": [{ "type": "command", "command": "node ~/.claude/scripts/hooks/suggest-compact.js" }]
},
{
"matcher": "Write",
"hooks": [{ "type": "command", "command": "node ~/.claude/skills/strategic-compact/suggest-compact.js" }]
"hooks": [{ "type": "command", "command": "node ~/.claude/scripts/hooks/suggest-compact.js" }]
}
]
}
+5 -5
View File
@@ -70,7 +70,7 @@
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
# 安裝外掛程式
/plugin install everything-claude-code
/plugin install ecc@ecc
```
### 第二步:安裝規則(必需)
@@ -89,13 +89,13 @@ cp -r everything-claude-code/rules/* ~/.claude/rules/
```bash
# 嘗試一個指令(外掛安裝使用命名空間形式)
/everything-claude-code:plan "新增使用者認證"
/ecc:plan "新增使用者認證"
# 手動安裝(選項2)使用簡短形式:
# /plan "新增使用者認證"
# 查看可用指令
/plugin list everything-claude-code@everything-claude-code
/plugin list ecc@ecc
```
**完成!** 您現在使用 15+ 代理程式、30+ 技能和 20+ 指令。
@@ -270,7 +270,7 @@ everything-claude-code/
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
# 安裝外掛程式
/plugin install everything-claude-code
/plugin install ecc@ecc
```
或直接新增到您的 `~/.claude/settings.json`
@@ -286,7 +286,7 @@ everything-claude-code/
}
},
"enabledPlugins": {
"everything-claude-code@everything-claude-code": true
"ecc@ecc": true
}
}
```
+11 -8
View File
@@ -21,7 +21,7 @@ description: Suggests manual context compaction at logical intervals to preserve
## 運作方式
`suggest-compact.sh` 腳本在 PreToolUseEdit/Write)執行並:
`suggest-compact.js` 腳本在 PreToolUseEdit/Write)執行並:
1. **追蹤工具呼叫** - 計算工作階段中的工具呼叫次數
2. **門檻偵測** - 在可設定門檻建議(預設:50 次呼叫)
@@ -34,13 +34,16 @@ description: Suggests manual context compaction at logical intervals to preserve
```json
{
"hooks": {
"PreToolUse": [{
"matcher": "tool == \"Edit\" || tool == \"Write\"",
"hooks": [{
"type": "command",
"command": "~/.claude/skills/strategic-compact/suggest-compact.sh"
}]
}]
"PreToolUse": [
{
"matcher": "Edit",
"hooks": [{ "type": "command", "command": "node ~/.claude/scripts/hooks/suggest-compact.js" }]
},
{
"matcher": "Write",
"hooks": [{ "type": "command", "command": "node ~/.claude/scripts/hooks/suggest-compact.js" }]
}
]
}
}
```
+23 -18
View File
@@ -8,10 +8,11 @@ import tkinter as tk
from tkinter import ttk, scrolledtext, messagebox
import os
import json
import subprocess
from pathlib import Path
from typing import Dict, List, Optional
import webbrowser
from scripts.lib.ecc_dashboard_runtime import build_terminal_launch, maximize_window
from scripts.lib.ecc_dashboard_runtime import launch_terminal, maximize_window
# ============================================================================
# DATA LOADERS - Load ECC data from the project
@@ -793,27 +794,31 @@ Project: github.com/affaan-m/everything-claude-code"""
def open_terminal(self):
"""Open terminal at project path"""
path = self.path_entry.get()
argv, kwargs = build_terminal_launch(path)
subprocess.Popen(argv, **kwargs)
path = os.path.realpath(self.path_entry.get())
try:
launch_terminal(path)
except Exception as exc:
messagebox.showerror("Error", f"Could not open terminal: {exc}")
def _open_project_doc(self, filename: str) -> None:
"""Open a project document safely, constrained to the project directory."""
base = os.path.realpath(self.path_entry.get())
target = os.path.realpath(os.path.join(base, filename))
if os.path.commonpath([base, target]) != base:
messagebox.showerror("Error", "Access denied: path is outside the project directory")
return
if os.path.exists(target):
webbrowser.open(Path(target).as_uri())
else:
messagebox.showerror("Error", f"{filename} not found")
def open_readme(self):
"""Open README in default browser/reader"""
import subprocess
path = os.path.join(self.path_entry.get(), 'README.md')
if os.path.exists(path):
subprocess.Popen(['xdg-open' if os.name != 'nt' else 'start', path])
else:
messagebox.showerror("Error", "README.md not found")
self._open_project_doc('README.md')
def open_agents(self):
"""Open AGENTS.md"""
import subprocess
path = os.path.join(self.path_entry.get(), 'AGENTS.md')
if os.path.exists(path):
subprocess.Popen(['xdg-open' if os.name != 'nt' else 'start', path])
else:
messagebox.showerror("Error", "AGENTS.md not found")
self._open_project_doc('AGENTS.md')
def refresh_data(self):
"""Refresh all data"""
+3
View File
@@ -99,6 +99,9 @@ export ECC_HOOK_PROFILE=standard
# Disable specific hook IDs (comma-separated)
export ECC_DISABLED_HOOKS="pre:bash:tmux-reminder,post:edit:typecheck"
# Disable only GateGuard during setup or recovery
export ECC_GATEGUARD=off
# Cap SessionStart additional context (default: 8000 chars)
export ECC_SESSION_START_MAX_CHARS=4000
+106
View File
@@ -21,6 +21,8 @@ const AGENTS_PATH = path.join(ROOT, 'AGENTS.md');
const README_ZH_CN_PATH = path.join(ROOT, 'README.zh-CN.md');
const DOCS_ZH_CN_README_PATH = path.join(ROOT, 'docs', 'zh-CN', 'README.md');
const DOCS_ZH_CN_AGENTS_PATH = path.join(ROOT, 'docs', 'zh-CN', 'AGENTS.md');
const PLUGIN_JSON_PATH = path.join(ROOT, '.claude-plugin', 'plugin.json');
const MARKETPLACE_JSON_PATH = path.join(ROOT, '.claude-plugin', 'marketplace.json');
const WRITE_MODE = process.argv.includes('--write');
const OUTPUT_MODE = process.argv.includes('--md')
@@ -99,6 +101,18 @@ function parseReadmeExpectations(readmeContent) {
{ category: 'commands', mode: 'exact', expected: Number(quickStartMatch[3]), source: 'README.md quick-start summary' }
);
const projectTreeAgentsMatch = readmeContent.match(/^\|\s*--\s*agents\/\s*#\s*(\d+)\s+specialized subagents for delegation\s*$/im);
if (!projectTreeAgentsMatch) {
throw new Error('README.md project tree is missing the agents count');
}
expectations.push({
category: 'agents',
mode: 'exact',
expected: Number(projectTreeAgentsMatch[1]),
source: 'README.md project tree (agents)'
});
const tablePatterns = [
{ category: 'agents', regex: /\|\s*(?:\*\*)?Agents(?:\*\*)?\s*\|\s*(?:(?:PASS:|\u2705)\s*)?(\d+)\s+agents\s*\|/i, source: 'README.md comparison table' },
{ category: 'commands', regex: /\|\s*(?:\*\*)?Commands(?:\*\*)?\s*\|\s*(?:(?:PASS:|\u2705)\s*)?(\d+)\s+commands\s*\|/i, source: 'README.md comparison table' },
@@ -346,6 +360,31 @@ function parseZhAgentsDocExpectations(agentsContent) {
return expectations;
}
function parseCatalogDescriptionExpectations(content, source, getDescription) {
let parsed;
try {
parsed = JSON.parse(content);
} catch (error) {
throw new Error(`${source} is not valid JSON: ${error.message}`);
}
const description = getDescription(parsed);
if (typeof description !== 'string') {
throw new Error(`${source} is missing the catalog count description`);
}
const match = description.match(/(\d+)\s+agents,\s+(\d+)\s+skills,\s+(\d+)\s+legacy command shims?/i);
if (!match) {
throw new Error(`${source} is missing the catalog count description`);
}
return [
{ category: 'agents', mode: 'exact', expected: Number(match[1]), source },
{ category: 'skills', mode: 'exact', expected: Number(match[2]), source },
{ category: 'commands', mode: 'exact', expected: Number(match[3]), source },
];
}
function evaluateExpectations(catalog, expectations) {
return expectations.map(expectation => {
const actual = catalog[expectation.category].count;
@@ -376,6 +415,12 @@ function syncEnglishReadme(content, catalog) {
`${prefix}${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsSuffix}${catalog.commands.count} legacy command shims`,
'README.md quick-start summary'
);
nextContent = replaceOrThrow(
nextContent,
/^(\|\s*--\s*agents\/\s*#\s*)(\d+)(\s+specialized subagents for delegation\s*)$/im,
(_, prefix, __, suffix) => `${prefix}${catalog.agents.count}${suffix}`,
'README.md project tree (agents)'
);
nextContent = replaceOrThrow(
nextContent,
/(\|\s*(?:\*\*)?Agents(?:\*\*)?\s*\|\s*(?:(?:PASS:|\u2705)\s*)?)(\d+)(\s+agents\s*\|)/i,
@@ -540,6 +585,31 @@ function syncZhAgents(content, catalog) {
return nextContent;
}
function syncCatalogDescription(content, catalog, source, getDescription, setDescription) {
let parsed;
try {
parsed = JSON.parse(content);
} catch (error) {
throw new Error(`${source} is not valid JSON: ${error.message}`);
}
const description = getDescription(parsed);
if (typeof description !== 'string') {
throw new Error(`${source} is missing the catalog count description`);
}
const nextDescription = replaceOrThrow(
description,
/(\d+)(\s+agents,\s+)(\d+)(\s+skills,\s+)(\d+)(\s+legacy command shims?)/i,
(_, __, agentsSuffix, ___, skillsSuffix, ____, commandsSuffix) =>
`${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsSuffix}${catalog.commands.count}${commandsSuffix}`,
source
);
setDescription(parsed, nextDescription);
return `${JSON.stringify(parsed, null, 2)}\n`;
}
function createDocumentSpecs(paths = {}) {
const {
readmePath = README_PATH,
@@ -547,6 +617,8 @@ function createDocumentSpecs(paths = {}) {
zhRootReadmePath = README_ZH_CN_PATH,
zhDocsReadmePath = DOCS_ZH_CN_README_PATH,
zhDocsAgentsPath = DOCS_ZH_CN_AGENTS_PATH,
pluginJsonPath = PLUGIN_JSON_PATH,
marketplaceJsonPath = MARKETPLACE_JSON_PATH,
} = paths;
return [
@@ -575,6 +647,36 @@ function createDocumentSpecs(paths = {}) {
parseExpectations: parseZhAgentsDocExpectations,
syncContent: syncZhAgents,
},
{
filePath: pluginJsonPath,
parseExpectations: content => parseCatalogDescriptionExpectations(
content,
'.claude-plugin/plugin.json description',
parsed => parsed.description
),
syncContent: (content, catalog) => syncCatalogDescription(
content,
catalog,
'.claude-plugin/plugin.json description',
parsed => parsed.description,
(parsed, description) => { parsed.description = description; }
),
},
{
filePath: marketplaceJsonPath,
parseExpectations: content => parseCatalogDescriptionExpectations(
content,
'.claude-plugin/marketplace.json plugin description',
parsed => parsed.plugins?.[0]?.description
),
syncContent: (content, catalog) => syncCatalogDescription(
content,
catalog,
'.claude-plugin/marketplace.json plugin description',
parsed => parsed.plugins?.[0]?.description,
(parsed, description) => { parsed.plugins[0].description = description; }
),
},
];
}
@@ -585,6 +687,8 @@ function createDocumentSpecsForRoot(root) {
zhRootReadmePath: path.join(root, 'README.zh-CN.md'),
zhDocsReadmePath: path.join(root, 'docs', 'zh-CN', 'README.md'),
zhDocsAgentsPath: path.join(root, 'docs', 'zh-CN', 'AGENTS.md'),
pluginJsonPath: path.join(root, '.claude-plugin', 'plugin.json'),
marketplaceJsonPath: path.join(root, '.claude-plugin', 'marketplace.json'),
});
}
@@ -689,11 +793,13 @@ module.exports = {
formatExpectation,
main,
parseAgentsDocExpectations,
parseCatalogDescriptionExpectations,
parseReadmeExpectations,
parseZhAgentsDocExpectations,
parseZhDocsReadmeExpectations,
parseZhRootReadmeExpectations,
runCatalogCheck,
syncCatalogDescription,
syncEnglishAgents,
syncEnglishReadme,
syncZhAgents,
+54 -10
View File
@@ -1,6 +1,11 @@
#!/usr/bin/env node
/**
* Prevent shipping user-specific absolute paths in public docs/skills/commands.
*
* Catches generic `/Users/<name>` (macOS) and `C:\Users\<name>` (Windows) paths,
* while allowing obvious placeholder usernames used in templates/examples.
* Forensic incident reports under `docs/fixes/` are exempt because they may
* legitimately document a reporter's local machine path.
*/
'use strict';
@@ -18,11 +23,50 @@ const TARGETS = [
'.opencode/commands',
];
const BLOCK_PATTERNS = [
/\/Users\/affoon\b/g,
/C:\\Users\\affoon\b/gi,
const EXEMPT_PREFIXES = [
'docs/fixes/',
];
const PLACEHOLDER_USERNAMES = new Set([
'example',
'me',
'user',
'username',
'you',
'yourname',
'yourusername',
'your-username',
]);
const POSIX_USER_RE = /\/Users\/([a-zA-Z][a-zA-Z0-9._-]*)/g;
const WIN_USER_RE = /C:\\Users\\([a-zA-Z][a-zA-Z0-9._-]*)/gi;
function repoRelative(file) {
return path.relative(ROOT, file).split(path.sep).join('/');
}
function isExempt(file) {
const rel = repoRelative(file);
return EXEMPT_PREFIXES.some(prefix => rel.startsWith(prefix));
}
function findLeaks(content) {
const leaks = [];
for (const pattern of [POSIX_USER_RE, WIN_USER_RE]) {
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(content)) !== null) {
if (!PLACEHOLDER_USERNAMES.has(match[1].toLowerCase())) {
leaks.push(match[0]);
}
}
}
return leaks;
}
function collectFiles(targetPath, out) {
if (!fs.existsSync(targetPath)) return;
const stat = fs.statSync(targetPath);
@@ -45,14 +89,14 @@ for (const target of TARGETS) {
let failures = 0;
for (const file of files) {
if (!/\.(md|json|js|ts|sh|toml|yml|yaml)$/i.test(file)) continue;
if (isExempt(file)) continue;
const content = fs.readFileSync(file, 'utf8');
for (const pattern of BLOCK_PATTERNS) {
const match = content.match(pattern);
if (match) {
console.error(`ERROR: personal path detected in ${path.relative(ROOT, file)}`);
failures += match.length;
break;
}
const leaks = findLeaks(content);
for (const leak of leaks) {
console.error(`ERROR: personal path "${leak}" detected in ${repoRelative(file)}`);
failures += 1;
}
}
+174 -20
View File
@@ -1,6 +1,22 @@
#!/usr/bin/env node
/**
* Validate curated skill directories (skills/ in repo).
*
* Checks:
* 1. Each sub-directory of skills/ contains a SKILL.md file.
* 2. SKILL.md is non-empty.
* 3. SKILL.md frontmatter (if present) declares a `name:` field.
* 4. SKILL.md frontmatter `description:` uses an inline scalar not a
* literal block scalar (`|` / `|-` / `|+`), which preserves internal
* newlines and breaks flat-table renderers keyed off `description`.
*
* Frontmatter findings default to WARN so CI does not break while
* pre-existing data defects are being cleaned up out of band (see #1663).
* Pass `--strict` or set `CI_STRICT_SKILLS=1` to promote frontmatter
* findings to errors (exit 1).
*
* Structural findings (missing/empty SKILL.md) are always errors.
*
* Scope: curated only. Learned/imported/evolved roots are out of scope.
* If skills/ does not exist, exit 0 (no curated skills to validate).
*/
@@ -10,6 +26,144 @@ const path = require('path');
const SKILLS_DIR = path.join(__dirname, '../../skills');
const STRICT = process.argv.includes('--strict') || process.env.CI_STRICT_SKILLS === '1';
/**
* Parse the leading YAML frontmatter of a markdown document.
*
* Returns `{ present, lines }` so callers can inspect raw lines
* (needed to detect block-scalar `description:` values).
*
* Tolerant of UTF-8 BOM and CRLF line endings, matching the other
* validators in this directory.
*
* @param {string} content
* @returns {{present: boolean, lines: string[]}}
*/
function extractFrontmatter(content) {
// Strip BOM if present (UTF-8 BOM: U+FEFF).
const clean = content.replace(/^\uFEFF/, '');
const match = clean.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
if (!match) return { present: false, lines: [] };
return {
present: true,
lines: match[1].split(/\r?\n/)
};
}
/**
* Extract top-level keys (with trimmed values) and flag block-scalar
* `description:` values.
*
* Lines that continue a block scalar (`|` or `>`) are skipped we only
* care about the top-level key set and the raw indicator on the
* `description:` line. Block-scalar indicators accept YAML chomp and
* indent modifiers and trailing comments, e.g. `|`, `|-`, `|+`, `|2`,
* `|-2`, `>- # note`.
*
* @param {string[]} lines
* @returns {{values: Record<string,string>, descriptionIndicator: string|null}}
*/
function inspectFrontmatter(lines) {
const values = Object.create(null);
let descriptionIndicator = null;
let inBlockScalar = false;
let blockScalarIndent = -1;
for (const rawLine of lines) {
if (inBlockScalar) {
// Stay inside the block until a line with indent <= the opener's
// indent (or an empty continuation).
const leadingSpaces = rawLine.match(/^(\s*)/)[1].length;
if (rawLine.trim() === '' || leadingSpaces > blockScalarIndent) {
continue;
}
inBlockScalar = false;
blockScalarIndent = -1;
}
const match = rawLine.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (!match) continue;
const key = match[1];
const rawValue = match[2];
// Strip unquoted comments for value/indicator inspection. Handles both
// trailing comments (`foo: bar # note`) and comment-only values
// (`foo: # todo`) so the latter is treated as empty.
const valueNoComment = rawValue
.replace(/^\s*#.*$/, '')
.replace(/\s+#.*$/, '')
.trim();
values[key] = valueNoComment;
// Detect literal / folded block-scalar indicators. Accept chomp
// modifiers (`-` / `+`) and optional indent-indicator digits in
// either order, per YAML 1.2.
if (/^[|>](?:[+-]?\d+|\d+[+-]?|[+-])?$/.test(valueNoComment)) {
if (key === 'description') {
descriptionIndicator = valueNoComment;
}
inBlockScalar = true;
blockScalarIndent = rawLine.match(/^(\s*)/)[1].length;
}
}
return { values, descriptionIndicator };
}
/**
* Validate a single skill directory.
*
* Returns `{ fatal }` where `fatal` indicates a structural error that
* should be surfaced via `console.error` and abort CI (missing/empty
* SKILL.md). Frontmatter findings are routed through
* `reportFrontmatterFinding`, which owns the WARN/ERROR decision based
* on strict mode.
*
* @param {string} dir
* @param {string} skillsDir
* @param {(msg: string) => void} reportFrontmatterFinding
* @returns {{fatal: boolean}}
*/
function validateSkillDir(dir, skillsDir, reportFrontmatterFinding) {
const skillMd = path.join(skillsDir, dir, 'SKILL.md');
if (!fs.existsSync(skillMd)) {
console.error(`ERROR: ${dir}/ - Missing SKILL.md`);
return { fatal: true };
}
let content;
try {
content = fs.readFileSync(skillMd, 'utf-8');
} catch (err) {
console.error(`ERROR: ${dir}/SKILL.md - ${err.message}`);
return { fatal: true };
}
if (content.trim().length === 0) {
console.error(`ERROR: ${dir}/SKILL.md - Empty file`);
return { fatal: true };
}
const fm = extractFrontmatter(content);
if (fm.present) {
const { values, descriptionIndicator } = inspectFrontmatter(fm.lines);
if (!Object.prototype.hasOwnProperty.call(values, 'name')) {
reportFrontmatterFinding(`${dir}/SKILL.md - frontmatter missing required field: name`);
} else if (values.name === '') {
reportFrontmatterFinding(`${dir}/SKILL.md - frontmatter 'name' is empty`);
}
if (descriptionIndicator && descriptionIndicator.startsWith('|')) {
reportFrontmatterFinding(
`${dir}/SKILL.md - frontmatter description uses literal block scalar ` + `'${descriptionIndicator}' which preserves internal newlines; ` + `use an inline string or folded '>' scalar instead`
);
}
}
return { fatal: false };
}
function validateSkills() {
if (!fs.existsSync(SKILLS_DIR)) {
console.log('No curated skills directory (skills/), skipping');
@@ -17,32 +171,28 @@ function validateSkills() {
}
const entries = fs.readdirSync(SKILLS_DIR, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
const dirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => e.name);
let hasErrors = false;
let warnCount = 0;
let validCount = 0;
const reportFrontmatterFinding = msg => {
if (STRICT) {
console.error(`ERROR: ${msg}`);
hasErrors = true;
} else {
console.warn(`WARN: ${msg}`);
warnCount++;
}
};
for (const dir of dirs) {
const skillMd = path.join(SKILLS_DIR, dir, 'SKILL.md');
if (!fs.existsSync(skillMd)) {
console.error(`ERROR: ${dir}/ - Missing SKILL.md`);
const { fatal } = validateSkillDir(dir, SKILLS_DIR, reportFrontmatterFinding);
if (fatal) {
hasErrors = true;
continue;
}
let content;
try {
content = fs.readFileSync(skillMd, 'utf-8');
} catch (err) {
console.error(`ERROR: ${dir}/SKILL.md - ${err.message}`);
hasErrors = true;
continue;
}
if (content.trim().length === 0) {
console.error(`ERROR: ${dir}/SKILL.md - Empty file`);
hasErrors = true;
continue;
}
validCount++;
}
@@ -50,7 +200,11 @@ function validateSkills() {
process.exit(1);
}
console.log(`Validated ${validCount} skill directories`);
let msg = `Validated ${validCount} skill directories`;
if (warnCount > 0) {
msg += ` (${warnCount} warning${warnCount === 1 ? '' : 's'})`;
}
console.log(msg);
}
validateSkills();
+1 -1
View File
@@ -75,7 +75,7 @@ function extractCheckoutSteps(source) {
startLine: block.startLine,
text: block.lines.join('\n'),
}))
.filter(block => /uses:\s*actions\/checkout@/m.test(block.text));
.filter(block => /uses:\s*['"]?actions\/checkout@/m.test(block.text));
}
function findViolations(filePath, source) {
+59 -4
View File
@@ -38,11 +38,26 @@ const READ_HEARTBEAT_MS = 60 * 1000;
const MAX_CHECKED_ENTRIES = 500;
const MAX_SESSION_KEYS = 50;
const ROUTINE_BASH_SESSION_KEY = '__bash_session__';
const EDIT_WRITE_HOOK_ID = 'pre:edit-write:gateguard-fact-force';
const BASH_HOOK_ID = 'pre:bash:gateguard-fact-force';
const ECC_DISABLE_VALUES = new Set(['0', 'false', 'off', 'disabled', 'disable']);
const DESTRUCTIVE_BASH = /\b(rm\s+-rf|git\s+reset\s+--hard|git\s+checkout\s+--|git\s+clean\s+-f|drop\s+table|delete\s+from|truncate|git\s+push\s+--force(?!-with-lease)|git\s+commit\s+--amend|dd\s+if=)\b/i;
// --- State management (per-session, atomic writes, bounded) ---
function normalizeEnvValue(value) {
return String(value || '').trim().toLowerCase();
}
function isGateGuardDisabled() {
if (normalizeEnvValue(process.env.GATEGUARD_DISABLED) === '1') {
return true;
}
return ECC_DISABLE_VALUES.has(normalizeEnvValue(process.env.ECC_GATEGUARD));
}
function sanitizeSessionKey(value) {
const raw = String(value || '').trim();
if (!raw) {
@@ -352,15 +367,41 @@ function routineBashMsg() {
].join('\n');
}
function withRecoveryHint(message, hookIds = [EDIT_WRITE_HOOK_ID]) {
const disableTargets = hookIds.map(hookId => `\`${hookId}\``).join(' or ');
return [
message,
'',
`Recovery: if GateGuard is blocking setup or repair work, run this session with \`ECC_GATEGUARD=off\` or add ${disableTargets} to \`ECC_DISABLED_HOOKS\`.`
].join('\n');
}
function isSubagentInvocation(data) {
if (!data || typeof data !== 'object') {
return false;
}
const candidates = [
data.agent_id,
data.agentId,
data.parent_tool_use_id,
data.parentToolUseId
];
return candidates.some(candidate => typeof candidate === 'string' && candidate.trim());
}
// --- Deny helper ---
function denyResult(reason) {
function denyResult(reason, options = {}) {
const includeRecoveryHint = options.includeRecoveryHint !== false;
const hookIds = Array.isArray(options.hookIds) && options.hookIds.length > 0 ? options.hookIds : [EDIT_WRITE_HOOK_ID];
return {
stdout: JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: reason
permissionDecisionReason: includeRecoveryHint ? withRecoveryHint(reason, hookIds) : reason
}
}),
exitCode: 0
@@ -383,6 +424,11 @@ function run(rawInput) {
} catch (_) {
return rawInput; // allow on parse error
}
if (isGateGuardDisabled()) {
return rawInput;
}
activeStateFile = null;
getStateFile(data);
@@ -391,6 +437,7 @@ function run(rawInput) {
// Normalize: case-insensitive matching via lookup map
const TOOL_MAP = { edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', bash: 'Bash' };
const toolName = TOOL_MAP[rawToolName.toLowerCase()] || rawToolName;
const inSubagent = isSubagentInvocation(data);
if (toolName === 'Edit' || toolName === 'Write') {
const filePath = toolInput.file_path || '';
@@ -398,6 +445,10 @@ function run(rawInput) {
return rawInput; // allow
}
if (inSubagent) {
return rawInput; // parent session already passed the first-touch file gate
}
if (!isChecked(filePath)) {
if (!markChecked(filePath)) {
return allowWithStateWarning();
@@ -409,6 +460,10 @@ function run(rawInput) {
}
if (toolName === 'MultiEdit') {
if (inSubagent) {
return rawInput; // parent session already passed the first-touch file gate
}
const edits = toolInput.edits || [];
for (const edit of edits) {
const filePath = edit.file_path || '';
@@ -435,7 +490,7 @@ function run(rawInput) {
if (!markChecked(key)) {
return allowWithStateWarning();
}
return denyResult(destructiveBashMsg());
return denyResult(destructiveBashMsg(), { includeRecoveryHint: false });
}
return rawInput; // allow retry after facts presented
}
@@ -444,7 +499,7 @@ function run(rawInput) {
if (!markChecked(ROUTINE_BASH_SESSION_KEY)) {
return allowWithStateWarning();
}
return denyResult(routineBashMsg());
return denyResult(routineBashMsg(), { hookIds: [BASH_HOOK_ID] });
}
return rawInput; // allow
+143 -64
View File
@@ -306,96 +306,175 @@ function probeCommandServer(serverName, config) {
...(config.env && typeof config.env === 'object' && !Array.isArray(config.env) ? config.env : {})
};
let stderr = '';
let done = false;
let timer = null;
function finish(result) {
if (done) return;
done = true;
if (timer) {
clearTimeout(timer);
timer = null;
}
resolve(result);
}
let child;
try {
child = spawn(command, args, {
env: mergedEnv,
cwd: process.cwd(),
stdio: ['pipe', 'ignore', 'pipe']
});
} catch (error) {
finish({
ok: false,
statusCode: null,
reason: error.message
});
return;
}
// On Windows, commands like 'npx' are commonly exposed as npx.cmd.
// Probe bare PATH commands through platform-extension fallbacks, but keep
// absolute/relative path commands as a single candidate so their existing
// ENOENT failure semantics stay intact.
const commandIsString = typeof command === 'string' && command.length > 0;
const isPathLike = commandIsString && (
path.isAbsolute(command)
|| command.includes('/')
|| command.includes('\\')
);
const candidates = process.platform === 'win32'
&& commandIsString
&& !path.extname(command)
&& !isPathLike
? [command, `${command}.cmd`, `${command}.exe`, `${command}.bat`]
: [command];
child.stderr.on('data', chunk => {
if (stderr.length < 4000) {
const remaining = 4000 - stderr.length;
stderr += String(chunk).slice(0, remaining);
// cmd.exe treats these as operators, grouping syntax, expansion markers,
// separators, or argument boundaries. Do not route such command strings
// through shell mode.
const UNSAFE_SHELL_CHARS = /[&|<>^%!()\s;]/;
function attempt(idx) {
const tryCommand = candidates[idx];
const isLast = idx + 1 >= candidates.length;
let stderr = '';
let attemptDone = false;
let timer = null;
function retryNext() {
if (attemptDone) return;
attemptDone = true;
if (timer) {
clearTimeout(timer);
timer = null;
}
attempt(idx + 1);
}
});
child.on('error', error => {
finish({
ok: false,
statusCode: null,
reason: error.message
});
});
function attemptFinish(result) {
if (attemptDone) return;
attemptDone = true;
if (timer) {
clearTimeout(timer);
timer = null;
}
finish(result);
}
child.on('exit', (code, signal) => {
finish({
ok: false,
statusCode: code,
reason: stderr.trim() || `process exited before handshake (${signal || code || 'unknown'})`
});
});
// Node 18.20+/20.12+ refuse to spawn .cmd/.bat directly on Windows
// after the CVE-2024-27980 mitigation. Only those extension candidates
// go through cmd.exe, after the command string is shell-character clean.
const useShell = process.platform === 'win32'
&& typeof tryCommand === 'string'
&& /\.(cmd|bat)$/i.test(tryCommand)
&& !UNSAFE_SHELL_CHARS.test(tryCommand);
timer = setTimeout(() => {
// A fast-crashing stdio server can finish before the timer callback runs
// on a loaded machine. Check the process state again before classifying it
// as healthy on timeout.
if (child.exitCode !== null || child.signalCode !== null) {
finish({
let child;
try {
child = spawn(tryCommand, args, {
env: mergedEnv,
cwd: process.cwd(),
stdio: ['pipe', 'ignore', 'pipe'],
shell: useShell
});
} catch (error) {
if ((error.code === 'ENOENT' || error.code === 'EINVAL') && !isLast) {
retryNext();
return;
}
attemptFinish({
ok: false,
statusCode: child.exitCode,
reason: stderr.trim() || `process exited before handshake (${child.signalCode || child.exitCode || 'unknown'})`
statusCode: null,
reason: error.message
});
return;
}
try {
child.kill('SIGTERM');
} catch {
// ignore
}
child.stderr.on('data', chunk => {
if (stderr.length < 4000) {
const remaining = 4000 - stderr.length;
stderr += String(chunk).slice(0, remaining);
}
});
child.on('error', error => {
if ((error.code === 'ENOENT' || error.code === 'EINVAL') && !isLast) {
retryNext();
return;
}
attemptFinish({
ok: false,
statusCode: null,
reason: error.message
});
});
child.on('exit', (code, signal) => {
attemptFinish({
ok: false,
statusCode: code,
reason: stderr.trim() || `process exited before handshake (${signal || code || 'unknown'})`
});
});
timer = setTimeout(() => {
// A fast-crashing stdio server can finish before the timer callback runs
// on a loaded machine. Check the process state again before classifying it
// as healthy on timeout.
if (child.exitCode !== null || child.signalCode !== null) {
attemptFinish({
ok: false,
statusCode: child.exitCode,
reason: stderr.trim() || `process exited before handshake (${child.signalCode || child.exitCode || 'unknown'})`
});
return;
}
setTimeout(() => {
try {
child.kill('SIGKILL');
if (useShell && child.pid && process.platform === 'win32') {
// When spawned via shell on Windows, child is cmd.exe. kill() only
// terminates the shell and leaves the real server process orphaned.
// taskkill /T kills the entire process tree rooted at cmd.exe.
const killResult = spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
stdio: 'ignore',
windowsHide: true
});
if (killResult.error || (typeof killResult.status === 'number' && killResult.status !== 0)) {
// taskkill not on PATH, permission denied, or already exited.
// Best-effort fallback: signal the cmd.exe shell directly. The
// child tree may still leak if it already detached, but this at
// least kills the shell we spawned.
try { child.kill('SIGKILL'); } catch { /* ignore */ }
}
} else {
child.kill('SIGTERM');
setTimeout(() => {
try {
child.kill('SIGKILL');
} catch {
// ignore
}
}, 200).unref?.();
}
} catch {
// ignore
}
}, 200).unref?.();
finish({
ok: true,
statusCode: null,
reason: `${serverName} accepted a new stdio process`
});
}, timeoutMs);
attemptFinish({
ok: true,
statusCode: null,
reason: `${serverName} accepted a new stdio process`
});
}, timeoutMs);
if (typeof timer.unref === 'function') {
timer.unref();
if (typeof timer.unref === 'function') {
timer.unref();
}
}
attempt(0);
});
}
+1 -1
View File
@@ -21,7 +21,7 @@ const { execFileSync, spawnSync } = require('child_process');
const path = require('path');
// Shell metacharacters that cmd.exe interprets as command separators/operators
const UNSAFE_PATH_CHARS = /[&|<>^%!]/;
const UNSAFE_PATH_CHARS = /[&|<>^%!;`()$]/;
const { findProjectRoot, detectFormatter, resolveFormatterBin } = require('../lib/resolve-formatter');
+2 -3
View File
@@ -10,7 +10,6 @@
*/
const {
getClaudeDir,
getSessionsDir,
getSessionSearchDirs,
getLearnedSkillsDir,
@@ -21,7 +20,7 @@ const {
stripAnsi,
log
} = require('../lib/utils');
const { resolveProjectContext, writeSessionLease, resolveSessionId } = require('../lib/observer-sessions');
const { resolveProjectContext, writeSessionLease, resolveSessionId, getHomunculusDir } = require('../lib/observer-sessions');
const { getPackageManager, getSelectionPrompt } = require('../lib/package-manager');
const { listAliases } = require('../lib/session-aliases');
const { detectProjectType } = require('../lib/project-detect');
@@ -325,7 +324,7 @@ function extractInstinctAction(content) {
}
function summarizeActiveInstincts(observerContext) {
const homunculusDir = path.join(getClaudeDir(), 'homunculus');
const homunculusDir = getHomunculusDir();
const globalDirs = [
{ dir: path.join(homunculusDir, 'instincts', 'personal'), scope: 'global' },
{ dir: path.join(homunculusDir, 'instincts', 'inherited'), scope: 'global' },
+19 -3
View File
@@ -18,14 +18,30 @@ const path = require('path');
const {
getTempDir,
writeFile,
readStdinJson,
log
} = require('../lib/utils');
async function resolveSessionId() {
// Claude Code passes hook input via stdin JSON; session_id is the
// canonical field. Fall back to the legacy env var, then 'default'.
try {
const input = await readStdinJson({ timeoutMs: 1000 });
if (input && typeof input.session_id === 'string' && input.session_id) {
return input.session_id;
}
} catch {
/* fall through to env */
}
return process.env.CLAUDE_SESSION_ID || 'default';
}
async function main() {
// Track tool call count (increment in a temp file)
// Use a session-specific counter file based on session ID from environment
// or parent PID as fallback
const sessionId = (process.env.CLAUDE_SESSION_ID || 'default').replace(/[^a-zA-Z0-9_-]/g, '') || 'default';
// Use a session-specific counter file based on session ID from stdin JSON,
// legacy env var, or 'default' as fallback.
const rawSessionId = await resolveSessionId();
const sessionId = rawSessionId.replace(/[^a-zA-Z0-9_-]/g, '') || 'default';
const counterFile = path.join(getTempDir(), `claude-tool-count-${sessionId}`);
const rawThreshold = parseInt(process.env.COMPACT_THRESHOLD || '50', 10);
const threshold = Number.isFinite(rawThreshold) && rawThreshold > 0 && rawThreshold <= 10000
+2
View File
@@ -24,6 +24,7 @@ function getHelpText() {
Usage: install.sh [--target <${LEGACY_INSTALL_TARGETS.join('|')}>] [--dry-run] [--json] <language> [<language> ...]
install.sh [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--dry-run] [--json] --profile <name> [--with <component>]... [--without <component>]...
install.sh [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--dry-run] [--json] --modules <id,id,...> [--with <component>]... [--without <component>]...
install.sh [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--dry-run] [--json] --skills <skill-id[,skill-id...]>
install.sh [--dry-run] [--json] --config <path>
Targets:
@@ -35,6 +36,7 @@ Options:
--profile <name> Resolve and install a manifest profile
--modules <ids> Resolve and install explicit module IDs
--with <component> Include a user-facing install component
--skills <ids> Install one or more skill directories by ID, e.g. continuous-learning-v2
--without <component>
Exclude a user-facing install component
--config <path> Load install intent from ecc-install.json
+10
View File
@@ -25,6 +25,7 @@ Usage:
node scripts/install-plan.js --list-components [--family <family>] [--target <target>] [--json]
node scripts/install-plan.js --profile <name> [--with <component>]... [--without <component>]... [--target <target>] [--json]
node scripts/install-plan.js --modules <id,id,...> [--with <component>]... [--without <component>]... [--target <target>] [--json]
node scripts/install-plan.js --skills <skill-id[,skill-id...]> [--target <target>] [--json]
node scripts/install-plan.js --config <path> [--json]
Options:
@@ -35,6 +36,7 @@ Options:
--profile <name> Resolve an install profile
--modules <ids> Resolve explicit module IDs (comma-separated)
--with <component> Include a user-facing install component
--skills <ids> Include one or more skill components by directory ID
--without <component>
Exclude a user-facing install component
--config <path> Load install intent from ecc-install.json
@@ -61,6 +63,11 @@ function parseArgs(argv) {
listComponents: false,
};
function normalizeSkillComponentIds(rawValue) {
return [...new Set(String(rawValue || '').split(',').map(value => value.trim()).filter(Boolean))]
.map(value => (value.startsWith('skill:') ? value : `skill:${value}`));
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--help' || arg === '-h') {
@@ -89,6 +96,9 @@ function parseArgs(argv) {
parsed.includeComponentIds.push(componentId.trim());
}
index += 1;
} else if (arg === '--skill' || arg === '--skills') {
parsed.includeComponentIds.push(...normalizeSkillComponentIds(args[index + 1] || ''));
index += 1;
} else if (arg === '--without') {
const componentId = args[index + 1] || '';
if (componentId.trim()) {
+10 -1
View File
@@ -45,7 +45,7 @@ def build_terminal_launch(
if resolved_os_name == 'nt':
creationflags = getattr(subprocess, 'CREATE_NEW_CONSOLE', 0)
return (
['cmd.exe', '/k', 'cd', '/d', path],
['cmd.exe'],
{
'cwd': path,
'creationflags': creationflags,
@@ -59,3 +59,12 @@ def build_terminal_launch(
['x-terminal-emulator', '-e', 'bash', '-lc', 'cd -- "$1"; exec bash', 'bash', path],
{},
)
def launch_terminal(path: str) -> None:
"""Open a terminal at the given path after validating the target directory."""
canonical = os.path.realpath(path)
if not os.path.isdir(canonical):
raise ValueError(f"Path is not a valid directory: {canonical!r}")
argv, kwargs = build_terminal_launch(canonical)
subprocess.Popen(argv, **kwargs) # noqa: S603 - list argv, no shell=True, path validated above
+54 -2
View File
@@ -78,6 +78,56 @@ function dedupeStrings(values) {
return [...new Set((Array.isArray(values) ? values : []).map(value => String(value).trim()).filter(Boolean))];
}
function listSkillDirectoryIds(repoRoot) {
const skillsRoot = path.join(repoRoot, 'skills');
if (!fs.existsSync(skillsRoot) || !fs.statSync(skillsRoot).isDirectory()) {
return [];
}
return fs.readdirSync(skillsRoot, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
.sort();
}
function addSyntheticSkillComponents({ repoRoot, modules, components }) {
const moduleIds = new Set(modules.map(module => module.id));
const componentIds = new Set(components.map(component => component.id));
for (const skillId of listSkillDirectoryIds(repoRoot)) {
const componentId = `skill:${skillId}`;
if (componentIds.has(componentId)) {
continue;
}
const moduleId = `skill-${skillId}`;
if (!moduleIds.has(moduleId)) {
modules.push({
id: moduleId,
kind: 'skills',
description: `Single-skill install surface for ${skillId}.`,
paths: [`skills/${skillId}`],
targets: SUPPORTED_INSTALL_TARGETS.slice(),
dependencies: [],
defaultInstall: false,
cost: 'light',
stability: 'stable',
synthetic: true,
});
moduleIds.add(moduleId);
}
components.push({
id: componentId,
family: 'skill',
description: `Install only the ${skillId} skill directory.`,
modules: [moduleId],
synthetic: true,
});
componentIds.add(componentId);
}
}
function readOptionalStringOption(options, key) {
if (
!Object.prototype.hasOwnProperty.call(options, key)
@@ -164,11 +214,13 @@ function loadInstallManifests(options = {}) {
const componentsData = fs.existsSync(componentsPath)
? readJson(componentsPath, 'install-components.json')
: { version: null, components: [] };
const modules = Array.isArray(modulesData.modules) ? modulesData.modules : [];
const modules = Array.isArray(modulesData.modules) ? modulesData.modules.slice() : [];
const profiles = profilesData && typeof profilesData.profiles === 'object'
? profilesData.profiles
: {};
const components = Array.isArray(componentsData.components) ? componentsData.components : [];
const components = Array.isArray(componentsData.components) ? componentsData.components.slice() : [];
addSyntheticSkillComponents({ repoRoot, modules, components });
for (const module of modules) {
readModuleTargetsOrThrow(module);
+9
View File
@@ -8,6 +8,12 @@ function dedupeStrings(values) {
return [...new Set((Array.isArray(values) ? values : []).map(value => String(value).trim()).filter(Boolean))];
}
function normalizeSkillComponentIds(rawValue) {
return dedupeStrings(String(rawValue || '').split(',')).map(value => (
value.startsWith('skill:') ? value : `skill:${value}`
));
}
function parseInstallArgs(argv) {
const args = argv.slice(2);
const parsed = {
@@ -45,6 +51,9 @@ function parseInstallArgs(argv) {
parsed.includeComponentIds.push(componentId.trim());
}
index += 1;
} else if (arg === '--skill' || arg === '--skills') {
parsed.includeComponentIds.push(...normalizeSkillComponentIds(args[index + 1] || ''));
index += 1;
} else if (arg === '--without') {
const componentId = args[index + 1] || '';
if (componentId.trim()) {
+40 -3
View File
@@ -1,11 +1,28 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const { spawnSync } = require('child_process');
const { getClaudeDir, ensureDir, sanitizeSessionId } = require('./utils');
const { ensureDir, sanitizeSessionId } = require('./utils');
function getHomunculusDir() {
return path.join(getClaudeDir(), 'homunculus');
const override = process.env.CLV2_HOMUNCULUS_DIR;
if (override) {
if (path.isAbsolute(override)) {
return override;
}
process.stderr.write(`[ecc] CLV2_HOMUNCULUS_DIR=${override} is not absolute; ignoring\n`);
}
const xdgDataHome = process.env.XDG_DATA_HOME;
if (xdgDataHome) {
if (path.isAbsolute(xdgDataHome)) {
return path.join(xdgDataHome, 'ecc-homunculus');
}
process.stderr.write(`[ecc] XDG_DATA_HOME=${xdgDataHome} is not absolute; ignoring\n`);
}
return path.join(os.homedir(), '.local', 'share', 'ecc-homunculus');
}
function getProjectsDir() {
@@ -39,6 +56,23 @@ function stripRemoteCredentials(remoteUrl) {
return String(remoteUrl).replace(/:\/\/[^@]+@/, '://');
}
function normalizeRemoteUrl(remoteUrl) {
if (!remoteUrl) return '';
const raw = String(remoteUrl);
const isNetwork = !raw.startsWith('file://') && (raw.includes('://') || /^[^@/:]+@[^:/]+:/.test(raw));
let normalized = stripRemoteCredentials(raw)
.replace(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//, '')
.replace(/^[^@/:]+@([^:/]+):/, '$1/')
.replace(/\.git\/?$/, '')
.replace(/\/+$/, '');
if (isNetwork) {
normalized = normalized.toLowerCase();
}
return normalized;
}
function resolveProjectRoot(cwd = process.cwd()) {
const envRoot = process.env.CLAUDE_PROJECT_DIR;
if (envRoot && fs.existsSync(envRoot)) {
@@ -53,7 +87,8 @@ function resolveProjectRoot(cwd = process.cwd()) {
function computeProjectId(projectRoot) {
const remoteUrl = stripRemoteCredentials(runGit(['remote', 'get-url', 'origin'], projectRoot));
return crypto.createHash('sha256').update(remoteUrl || projectRoot).digest('hex').slice(0, 12);
const hashInput = normalizeRemoteUrl(remoteUrl) || remoteUrl || projectRoot;
return crypto.createHash('sha256').update(hashInput).digest('hex').slice(0, 12);
}
function resolveProjectContext(cwd = process.cwd()) {
@@ -163,6 +198,8 @@ function stopObserverForContext(context) {
}
module.exports = {
getHomunculusDir,
normalizeRemoteUrl,
resolveProjectContext,
getObserverActivityFile,
getObserverPidFile,
+166 -3
View File
@@ -4,6 +4,7 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const DEFAULT_BASH_TIMEOUT_SECONDS = 30 * 60;
const DEFAULT_LIMIT = 10;
@@ -24,9 +25,11 @@ function usage() {
' --bash-timeout-seconds <n> Age before a pending Bash call is stale (default: 1800)',
' --wake-grace-multiplier <n> ScheduleWakeup grace multiplier (default: 2)',
' --now <time> Override current time (ISO, epoch ms, or "now")',
' --exit-code Exit 2 on attention signals, 1 on scan errors',
' --watch Refresh status until interrupted',
' --watch-count <n> Stop after n watch refreshes',
' --watch-interval-seconds <n> Seconds between watch refreshes (default: 5)',
' --write-dir <dir> Write index.json and per-session status snapshots',
'',
'Examples:',
' node scripts/loop-status.js --json',
@@ -62,6 +65,7 @@ function parseArgs(argv) {
const args = argv.slice(2);
const options = {
bashTimeoutSeconds: DEFAULT_BASH_TIMEOUT_SECONDS,
exitCode: false,
home: null,
json: false,
limit: DEFAULT_LIMIT,
@@ -72,6 +76,7 @@ function parseArgs(argv) {
watchCount: null,
wakeGraceMultiplier: DEFAULT_WAKE_GRACE_MULTIPLIER,
watchIntervalSeconds: DEFAULT_WATCH_INTERVAL_SECONDS,
writeDir: null,
};
for (let index = 0; index < args.length; index += 1) {
@@ -99,6 +104,8 @@ function parseArgs(argv) {
} else if (arg === '--now') {
options.now = readValue(args, index, arg);
index += 1;
} else if (arg === '--exit-code') {
options.exitCode = true;
} else if (arg === '--watch') {
options.watch = true;
} else if (arg === '--watch-count') {
@@ -107,11 +114,18 @@ function parseArgs(argv) {
} else if (arg === '--watch-interval-seconds') {
options.watchIntervalSeconds = readPositiveNumber(readValue(args, index, arg), arg);
index += 1;
} else if (arg === '--write-dir') {
options.writeDir = readValue(args, index, arg);
index += 1;
} else {
throw new Error(`Unknown option: ${arg}`);
}
}
if (options.exitCode && options.watch && options.watchCount === null) {
throw new Error('--exit-code with --watch requires --watch-count so the process can exit');
}
return options;
}
@@ -119,12 +133,14 @@ function normalizeOptions(options = {}) {
return {
...options,
bashTimeoutSeconds: options.bashTimeoutSeconds ?? DEFAULT_BASH_TIMEOUT_SECONDS,
exitCode: Boolean(options.exitCode),
limit: options.limit ?? DEFAULT_LIMIT,
transcriptPaths: options.transcriptPaths || [],
watch: Boolean(options.watch),
watchCount: options.watchCount ?? null,
wakeGraceMultiplier: options.wakeGraceMultiplier ?? DEFAULT_WAKE_GRACE_MULTIPLIER,
watchIntervalSeconds: options.watchIntervalSeconds ?? DEFAULT_WATCH_INTERVAL_SECONDS,
writeDir: options.writeDir || null,
};
}
@@ -594,6 +610,126 @@ function formatText(payload) {
return lines.join('\n');
}
function hashString(value) {
return crypto.createHash('sha256').update(String(value)).digest('hex');
}
function isWindowsReservedBasename(value) {
const basename = String(value).split('.')[0];
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(basename);
}
function sanitizeSnapshotName(value, fallback = 'session') {
const raw = String(value || '').trim() || fallback;
const sanitized = raw.replace(/[^a-zA-Z0-9._-]/g, '_').replace(/^_+|_+$/g, '');
if (sanitized && sanitized.length <= 96 && !isWindowsReservedBasename(sanitized)) {
return sanitized;
}
if (sanitized && isWindowsReservedBasename(sanitized)) {
const firstDotIndex = sanitized.indexOf('.');
const hashSuffix = hashString(raw).slice(0, 8);
if (firstDotIndex === -1) {
return `${sanitized}-${hashSuffix}`;
}
return `${sanitized.slice(0, firstDotIndex)}-${hashSuffix}${sanitized.slice(firstDotIndex)}`;
}
const prefix = sanitized ? sanitized.slice(0, 48).replace(/[._-]+$/g, '') : fallback;
return `${prefix || fallback}-${hashString(raw).slice(0, 12)}`;
}
function atomicWriteJson(filePath, payload) {
const data = JSON.stringify(payload, null, 2) + '\n';
const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
fs.writeFileSync(tempPath, data, 'utf8');
try {
fs.renameSync(tempPath, filePath);
} catch (error) {
try {
fs.unlinkSync(tempPath);
} catch (cleanupError) {
if (cleanupError.code !== 'ENOENT') {
console.error(`[loop-status] WARNING: could not remove temporary snapshot file ${tempPath}: ${cleanupError.message}`);
}
}
throw error;
}
}
function getSnapshotPath(outputDir, session, usedNames) {
const baseName = sanitizeSnapshotName(session.sessionId);
const hashSuffix = hashString(session.transcriptPath || session.sessionId).slice(0, 8);
let attempt = 0;
while (attempt < 1000) {
const suffix = attempt === 0 ? '' : `-${hashSuffix}${attempt === 1 ? '' : `-${attempt}`}`;
const fileName = `${baseName}${suffix}.json`;
if (!usedNames.has(fileName)) {
usedNames.add(fileName);
return path.join(outputDir, fileName);
}
attempt += 1;
}
throw new Error(`Could not allocate a snapshot filename for session ${session.sessionId}`);
}
function writeStatusSnapshots(payload, writeDir) {
if (!writeDir) {
return null;
}
const outputDir = path.resolve(writeDir);
fs.mkdirSync(outputDir, { recursive: true });
const usedNames = new Set(['index.json']);
const sessions = payload.sessions.map(session => {
const snapshotPath = getSnapshotPath(outputDir, session, usedNames);
atomicWriteJson(snapshotPath, {
generatedAt: payload.generatedAt,
schemaVersion: 'ecc.loop-status.session.v1',
session,
});
return {
lastEventAt: session.lastEventAt,
sessionId: session.sessionId,
signalTypes: session.signals.map(signal => signal.type),
snapshotPath,
state: session.state,
transcriptPath: session.transcriptPath,
};
});
const indexPath = path.join(outputDir, 'index.json');
atomicWriteJson(indexPath, {
errors: payload.errors,
generatedAt: payload.generatedAt,
schemaVersion: 'ecc.loop-status.index.v1',
sessionCount: payload.sessions.length,
sessions,
source: payload.source,
});
return {
indexPath,
sessionCount: payload.sessions.length,
};
}
function tryWriteStatusSnapshots(payload, options) {
if (!options.writeDir) {
return null;
}
try {
return writeStatusSnapshots(payload, options.writeDir);
} catch (error) {
console.error(`[loop-status] WARNING: could not write status snapshots: ${error.message}`);
return null;
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
@@ -606,15 +742,29 @@ function writeStatus(payload, options) {
}
}
function getStatusExitCode(payload) {
if (payload.sessions.some(session => session.state === 'attention')) {
return 2;
}
if (payload.errors.length > 0) {
return 1;
}
return 0;
}
async function runWatch(options) {
const normalizedOptions = normalizeOptions(options);
let iteration = 0;
let exitCode = 0;
while (normalizedOptions.watchCount === null || iteration < normalizedOptions.watchCount) {
if (iteration > 0 && !normalizedOptions.json) {
console.log('');
}
writeStatus(buildStatus(normalizedOptions), normalizedOptions);
const payload = buildStatus(normalizedOptions);
tryWriteStatusSnapshots(payload, normalizedOptions);
writeStatus(payload, normalizedOptions);
exitCode = Math.max(exitCode, getStatusExitCode(payload));
iteration += 1;
if (normalizedOptions.watchCount !== null && iteration >= normalizedOptions.watchCount) {
@@ -623,6 +773,8 @@ async function runWatch(options) {
await sleep(normalizedOptions.watchIntervalSeconds * 1000);
}
return exitCode;
}
async function main() {
@@ -633,11 +785,19 @@ async function main() {
}
if (options.watch) {
await runWatch(options);
const exitCode = await runWatch(options);
if (options.exitCode) {
process.exitCode = exitCode;
}
return;
}
writeStatus(buildStatus(options), options);
const payload = buildStatus(options);
tryWriteStatusSnapshots(payload, options);
writeStatus(payload, options);
if (options.exitCode) {
process.exitCode = getStatusExitCode(payload);
}
}
if (require.main === module) {
@@ -652,6 +812,9 @@ module.exports = {
buildStatus,
extractToolResultIds,
extractToolUses,
getStatusExitCode,
parseArgs,
runWatch,
tryWriteStatusSnapshots,
writeStatusSnapshots,
};
+1 -1
View File
@@ -18,7 +18,7 @@ An interactive, step-by-step installation wizard for the Everything Claude Code
## Prerequisites
This skill must be accessible to Claude Code before activation. Two ways to bootstrap:
1. **Via Plugin**: `/plugin install everything-claude-code` — the plugin loads this skill automatically
1. **Via Plugin**: `/plugin install ecc@ecc` — the plugin loads this skill automatically
2. **Manual**: Copy only this skill to `~/.claude/skills/configure-ecc/SKILL.md`, then activate by saying "configure ecc"
---
+19 -5
View File
@@ -26,7 +26,7 @@ An advanced learning system that turns your Claude Code sessions into reusable k
| Feature | v2.0 | v2.1 |
|---------|------|------|
| Storage | Global (~/.claude/homunculus/) | Project-scoped (projects/<hash>/) |
| Storage | Global (`~/.claude/homunculus/`) | Project-scoped (`${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/projects/<hash>/`) |
| Scope | All instincts apply everywhere | Project-scoped + global |
| Detection | None | git remote URL / repo path |
| Promotion | N/A | Project → global when seen in 2+ projects |
@@ -132,7 +132,21 @@ The system automatically detects your current project:
3. **`git rev-parse --show-toplevel`** -- fallback using repo path (machine-specific)
4. **Global fallback** -- if no project is detected, instincts go to global scope
Each project gets a 12-character hash ID (e.g., `a1b2c3d4e5f6`). A registry file at `~/.claude/homunculus/projects.json` maps IDs to human-readable names.
Each project gets a 12-character hash ID (e.g., `a1b2c3d4e5f6`). A registry file at `${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/projects.json` maps IDs to human-readable names.
### Data Directory
Continuous-learning-v2 stores observer data outside `~/.claude` so Claude Code's sensitive-path guard does not block background instinct writes:
1. `CLV2_HOMUNCULUS_DIR` when set to an absolute path
2. `$XDG_DATA_HOME/ecc-homunculus`
3. `$HOME/.local/share/ecc-homunculus`
Existing users with data at `~/.claude/homunculus` can migrate once:
```bash
bash skills/continuous-learning-v2/scripts/migrate-homunculus.sh
```
## Quick Start
@@ -173,7 +187,7 @@ The system creates directories automatically on first use, but you can also crea
```bash
# Global directories
mkdir -p ~/.claude/homunculus/{instincts/{personal,inherited},evolved/{agents,skills,commands},projects}
mkdir -p "${XDG_DATA_HOME:-$HOME/.local/share}/ecc-homunculus"/{instincts/{personal,inherited},evolved/{agents,skills,commands},projects}
# Project directories are auto-created when the hook first runs in a git repo
```
@@ -226,7 +240,7 @@ Other behavior (observation capture, instinct thresholds, project scoping, promo
## File Structure
```
~/.claude/homunculus/
${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/
+-- identity.json # Your profile, technical level
+-- projects.json # Registry: project hash -> name/path/remote
+-- observations.jsonl # Global observations (fallback)
@@ -322,7 +336,7 @@ Hooks fire **100% of the time**, deterministically. This means:
## Backward Compatibility
v2.1 is fully compatible with v2.0 and v1:
- Existing global instincts in `~/.claude/homunculus/instincts/` still work as global instincts
- Existing global instincts can be migrated from `~/.claude/homunculus/instincts/` with `scripts/migrate-homunculus.sh`
- Existing `~/.claude/skills/learned/` skills from v1 still work
- Stop hook still runs (but now also feeds into v2)
- Gradual migration: run both in parallel
@@ -10,6 +10,7 @@ unset CLAUDECODE
SLEEP_PID=""
USR1_FIRED=0
PENDING_ANALYSIS=0
ANALYZING=0
LAST_ANALYSIS_EPOCH=0
# Minimum seconds between analyses (prevents rapid re-triggering)
@@ -258,14 +259,17 @@ PROMPT
on_usr1() {
[ -n "$SLEEP_PID" ] && kill "$SLEEP_PID" 2>/dev/null
SLEEP_PID=""
USR1_FIRED=1
# Re-entrancy guard: skip if analysis is already running (#521)
# Re-entrancy guard: defer the nudge so the main loop runs a follow-up
# analysis immediately after the current analysis finishes.
if [ "$ANALYZING" -eq 1 ]; then
echo "[$(date)] Analysis already in progress, skipping signal" >> "$LOG_FILE"
PENDING_ANALYSIS=1
echo "[$(date)] Analysis already in progress, deferring signal" >> "$LOG_FILE"
return
fi
USR1_FIRED=1
# Cooldown: skip if last analysis was too recent (#521)
now_epoch=$(date +%s)
elapsed=$(( now_epoch - LAST_ANALYSIS_EPOCH ))
@@ -290,6 +294,17 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
while true; do
exit_if_idle_without_sessions
if [ "$PENDING_ANALYSIS" -eq 1 ]; then
PENDING_ANALYSIS=0
USR1_FIRED=0
ANALYZING=1
analyze_observations
LAST_ANALYSIS_EPOCH=$(date +%s)
ANALYZING=0
continue
fi
sleep "$OBSERVER_INTERVAL_SECONDS" &
SLEEP_PID=$!
wait "$SLEEP_PID" 2>/dev/null
@@ -299,6 +314,9 @@ while true; do
if [ "$USR1_FIRED" -eq 1 ]; then
USR1_FIRED=0
else
ANALYZING=1
analyze_observations
LAST_ANALYSIS_EPOCH=$(date +%s)
ANALYZING=0
fi
done
@@ -17,8 +17,8 @@ A background agent that analyzes observations from Claude Code sessions to detec
## Input
Reads observations from the **project-scoped** observations file:
- Project: `~/.claude/homunculus/projects/<project-hash>/observations.jsonl`
- Global fallback: `~/.claude/homunculus/observations.jsonl`
- Project: `${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/projects/<project-hash>/observations.jsonl`
- Global fallback: `${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/observations.jsonl`
```jsonl
{"timestamp":"2025-01-22T10:30:00Z","event":"tool_start","session":"abc123","tool":"Edit","input":"...","project_id":"a1b2c3d4e5f6","project_name":"my-react-app"}
@@ -66,8 +66,8 @@ When certain tools are consistently preferred:
## Output
Creates/updates instincts in the **project-scoped** instincts directory:
- Project: `~/.claude/homunculus/projects/<project-hash>/instincts/personal/`
- Global: `~/.claude/homunculus/instincts/personal/` (for universal patterns)
- Project: `${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/projects/<project-hash>/instincts/personal/`
- Global: `${XDG_DATA_HOME:-~/.local/share}/ecc-homunculus/instincts/personal/` (for universal patterns)
### Project-Scoped Instinct (default)
@@ -35,9 +35,13 @@ PYTHON_CMD="${CLV2_PYTHON_CMD:-}"
# Configuration
# ─────────────────────────────────────────────
CONFIG_DIR="${HOME}/.claude/homunculus"
# shellcheck disable=SC1091
. "${SKILL_ROOT}/scripts/lib/homunculus-dir.sh"
CONFIG_DIR="$(_ecc_resolve_homunculus_dir)"
if [ -n "${CLV2_CONFIG:-}" ]; then
CONFIG_FILE="$CLV2_CONFIG"
elif [ -f "${CONFIG_DIR}/config.json" ]; then
CONFIG_FILE="${CONFIG_DIR}/config.json"
else
CONFIG_FILE="${SKILL_ROOT}/config.json"
fi
@@ -115,7 +115,9 @@ fi
# Sourcing detect-project.sh creates project-scoped directories and updates
# projects.json, so automated sessions must return before that point.
CONFIG_DIR="${HOME}/.claude/homunculus"
# shellcheck disable=SC1091
. "$(dirname "$0")/../scripts/lib/homunculus-dir.sh"
CONFIG_DIR="$(_ecc_resolve_homunculus_dir)"
# Skip if disabled (check both default and CLV2_CONFIG-derived locations)
if [ -f "$CONFIG_DIR/disabled" ]; then
@@ -344,10 +346,12 @@ if [ -f "${CONFIG_DIR}/disabled" ]; then
OBSERVER_ENABLED=false
else
OBSERVER_ENABLED=false
CONFIG_FILE="${SKILL_ROOT}/config.json"
# Allow CLV2_CONFIG override
if [ -n "${CLV2_CONFIG:-}" ]; then
CONFIG_FILE="$CLV2_CONFIG"
elif [ -f "${CONFIG_DIR}/config.json" ]; then
CONFIG_FILE="${CONFIG_DIR}/config.json"
else
CONFIG_FILE="${SKILL_ROOT}/config.json"
fi
# Use effective config path for both existence check and reading
EFFECTIVE_CONFIG="$CONFIG_FILE"
@@ -19,7 +19,9 @@
# 3. git repo root path (fallback, machine-specific)
# 4. "global" (no project context detected)
_CLV2_HOMUNCULUS_DIR="${HOME}/.claude/homunculus"
# shellcheck disable=SC1091
. "$(dirname "${BASH_SOURCE[0]}")/lib/homunculus-dir.sh"
_CLV2_HOMUNCULUS_DIR="$(_ecc_resolve_homunculus_dir)"
_CLV2_PROJECTS_DIR="${_CLV2_HOMUNCULUS_DIR}/projects"
_CLV2_REGISTRY_FILE="${_CLV2_HOMUNCULUS_DIR}/projects.json"
@@ -49,6 +51,30 @@ export CLV2_PYTHON_CMD
CLV2_OBSERVER_PROMPT_PATTERN='Can you confirm|requires permission|Awaiting (user confirmation|confirmation|approval|permission)|confirm I should proceed|once granted access|grant.*access'
export CLV2_OBSERVER_PROMPT_PATTERN
_clv2_normalize_remote_url() {
local url="$1"
[ -z "$url" ] && return 0
local is_network=0
case "$url" in
file://*) is_network=0 ;;
*://*) is_network=1 ;;
*@*:*) is_network=1 ;;
*) is_network=0 ;;
esac
url=$(printf '%s' "$url" | sed -E 's|://[^@]+@|://|')
url=$(printf '%s' "$url" | sed -E 's|^[A-Za-z][A-Za-z0-9+.-]*://||')
url=$(printf '%s' "$url" | sed -E 's|^[^@/:]+@([^:/]+):|\1/|')
url=$(printf '%s' "$url" | sed -E 's|\.git/?$||; s|/+$||')
if [ "$is_network" = "1" ]; then
printf '%s' "$url" | tr '[:upper:]' '[:lower:]'
else
printf '%s' "$url"
fi
}
_clv2_detect_project() {
local project_root=""
local project_name=""
@@ -94,15 +120,20 @@ _clv2_detect_project() {
fi
fi
# Compute hash from the original remote URL (legacy, for backward compatibility)
local legacy_hash_input="${remote_url:-$project_root}"
local raw_remote_url="$remote_url"
# Strip embedded credentials from remote URL (e.g., https://ghp_xxxx@github.com/...)
if [ -n "$remote_url" ]; then
remote_url=$(printf '%s' "$remote_url" | sed -E 's|://[^@]+@|://|')
fi
local hash_input="${remote_url:-$project_root}"
local legacy_hash_input="${remote_url:-$project_root}"
local normalized_remote=""
if [ -n "$remote_url" ]; then
normalized_remote=$(_clv2_normalize_remote_url "$remote_url")
fi
local hash_input="${normalized_remote:-${remote_url:-$project_root}}"
# Prefer Python for consistent SHA256 behavior across shells/platforms.
# Pass the value via env var and encode as UTF-8 inside Python so the hash
# is locale-independent (shells vary between UTF-8 / CP932 / CP1252, which
@@ -122,19 +153,33 @@ print(hashlib.sha256(s.encode("utf-8")).hexdigest()[:12])
echo "fallback")
fi
# Backward compatibility: if credentials were stripped and the hash changed,
# check if a project dir exists under the legacy hash and reuse it
if [ "$legacy_hash_input" != "$hash_input" ] && [ -n "$_CLV2_PYTHON_CMD" ]; then
local legacy_id=""
legacy_id=$(_CLV2_HASH_INPUT="$legacy_hash_input" "$_CLV2_PYTHON_CMD" -c '
# Backward compatibility: migrate a single legacy project directory from
# credential-stripped or raw remote hashes to the normalized remote hash.
if [ -n "$_CLV2_PYTHON_CMD" ] && [ ! -d "${_CLV2_PROJECTS_DIR}/${project_id}" ]; then
local legacy_inputs=()
[ -n "$legacy_hash_input" ] && [ "$legacy_hash_input" != "$hash_input" ] \
&& legacy_inputs+=("$legacy_hash_input")
[ -n "$raw_remote_url" ] && [ "$raw_remote_url" != "$hash_input" ] \
&& [ "$raw_remote_url" != "$legacy_hash_input" ] \
&& legacy_inputs+=("$raw_remote_url")
local legacy_input legacy_id
for legacy_input in "${legacy_inputs[@]}"; do
legacy_id=$(_CLV2_HASH_INPUT="$legacy_input" "$_CLV2_PYTHON_CMD" -c '
import os, hashlib
s = os.environ["_CLV2_HASH_INPUT"]
print(hashlib.sha256(s.encode("utf-8")).hexdigest()[:12])
' 2>/dev/null)
if [ -n "$legacy_id" ] && [ -d "${_CLV2_PROJECTS_DIR}/${legacy_id}" ] && [ ! -d "${_CLV2_PROJECTS_DIR}/${project_id}" ]; then
# Migrate legacy directory to new hash
mv "${_CLV2_PROJECTS_DIR}/${legacy_id}" "${_CLV2_PROJECTS_DIR}/${project_id}" 2>/dev/null || project_id="$legacy_id"
fi
if [ -n "$legacy_id" ] && [ "$legacy_id" != "$project_id" ] \
&& [ -d "${_CLV2_PROJECTS_DIR}/${legacy_id}" ]; then
if mv "${_CLV2_PROJECTS_DIR}/${legacy_id}" "${_CLV2_PROJECTS_DIR}/${project_id}" 2>/dev/null; then
break
else
project_id="$legacy_id"
break
fi
fi
done
fi
# Export results
@@ -38,7 +38,48 @@ except ImportError:
# Configuration
# ─────────────────────────────────────────────
HOMUNCULUS_DIR = Path.home() / ".claude" / "homunculus"
def _resolve_homunculus_dir() -> Path:
override = os.environ.get("CLV2_HOMUNCULUS_DIR")
if override:
if Path(override).is_absolute():
return Path(override)
print(f"[ecc] CLV2_HOMUNCULUS_DIR={override!r} is not absolute; ignoring", file=sys.stderr)
xdg = os.environ.get("XDG_DATA_HOME")
if xdg:
if Path(xdg).is_absolute():
return Path(xdg) / "ecc-homunculus"
print(f"[ecc] XDG_DATA_HOME={xdg!r} is not absolute; ignoring", file=sys.stderr)
return Path.home() / ".local" / "share" / "ecc-homunculus"
def _strip_remote_credentials(remote_url: str) -> str:
return re.sub(r"://[^@]+@", "://", remote_url or "")
def _normalize_remote_url(remote_url: str) -> str:
if not remote_url:
return ""
is_network = (
not remote_url.startswith("file://")
and ("://" in remote_url or re.match(r"^[^@/:]+@[^:/]+:", remote_url) is not None)
)
normalized = _strip_remote_credentials(remote_url)
normalized = re.sub(r"^[A-Za-z][A-Za-z0-9+.-]*://", "", normalized)
normalized = re.sub(r"^[^@/:]+@([^:/]+):", r"\1/", normalized)
normalized = re.sub(r"\.git/?$", "", normalized)
normalized = re.sub(r"/+$", "", normalized)
return normalized.lower() if is_network else normalized
def _project_hash(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:12]
HOMUNCULUS_DIR = _resolve_homunculus_dir()
PROJECTS_DIR = HOMUNCULUS_DIR / "projects"
REGISTRY_FILE = HOMUNCULUS_DIR / "projects.json"
@@ -177,11 +218,35 @@ def detect_project() -> dict:
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
hash_source = remote_url if remote_url else project_root
project_id = hashlib.sha256(hash_source.encode()).hexdigest()[:12]
raw_remote_url = remote_url
if remote_url:
remote_url = _strip_remote_credentials(remote_url)
legacy_hash_source = remote_url if remote_url else project_root
normalized_remote = _normalize_remote_url(remote_url) if remote_url else ""
hash_source = normalized_remote if normalized_remote else legacy_hash_source
project_id = _project_hash(hash_source)
project_dir = PROJECTS_DIR / project_id
if not project_dir.exists():
legacy_sources = []
if legacy_hash_source and legacy_hash_source != hash_source:
legacy_sources.append(legacy_hash_source)
if raw_remote_url and raw_remote_url not in {hash_source, legacy_hash_source}:
legacy_sources.append(raw_remote_url)
for legacy_source in legacy_sources:
legacy_id = _project_hash(legacy_source)
legacy_dir = PROJECTS_DIR / legacy_id
if legacy_id != project_id and legacy_dir.exists():
try:
legacy_dir.rename(project_dir)
except OSError:
project_id = legacy_id
project_dir = legacy_dir
break
# Ensure project directory structure
for d in [
project_dir / "instincts" / "personal",
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Shared continuous-learning-v2 data-directory resolver.
#
# Resolution precedence:
# 1. CLV2_HOMUNCULUS_DIR, when absolute
# 2. XDG_DATA_HOME/ecc-homunculus, when XDG_DATA_HOME is absolute
# 3. HOME/.local/share/ecc-homunculus
_ecc_resolve_homunculus_dir() {
if [ -n "${CLV2_HOMUNCULUS_DIR:-}" ]; then
case "$CLV2_HOMUNCULUS_DIR" in
/*) printf '%s\n' "$CLV2_HOMUNCULUS_DIR"; return 0 ;;
*) printf '[ecc] CLV2_HOMUNCULUS_DIR=%s is not absolute; ignoring\n' "$CLV2_HOMUNCULUS_DIR" >&2 ;;
esac
fi
if [ -n "${XDG_DATA_HOME:-}" ]; then
case "$XDG_DATA_HOME" in
/*) printf '%s/ecc-homunculus\n' "$XDG_DATA_HOME"; return 0 ;;
*) printf '[ecc] XDG_DATA_HOME=%s is not absolute; ignoring\n' "$XDG_DATA_HOME" >&2 ;;
esac
fi
case "${HOME:-}" in
/*) printf '%s/.local/share/ecc-homunculus\n' "$HOME" ;;
*)
printf '[ecc] HOME=%s is not absolute; cannot resolve homunculus dir\n' "${HOME:-}" >&2
return 1
;;
esac
}
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# One-shot migration from the legacy Claude config tree into the
# continuous-learning-v2 data directory.
set -euo pipefail
OLD="${HOME}/.claude/homunculus"
# shellcheck disable=SC1091
. "$(dirname "$0")/lib/homunculus-dir.sh"
NEW="$(_ecc_resolve_homunculus_dir)"
if [ "$NEW" = "$OLD" ]; then
echo "Resolved destination equals source ($OLD); nothing to migrate."
exit 0
fi
if [ ! -d "$OLD" ]; then
echo "Nothing to migrate (no $OLD)."
exit 0
fi
if command -v pgrep >/dev/null 2>&1; then
if pgrep -f "${HOME}.*observer-loop\\.sh" >/dev/null 2>&1; then
echo "Refusing to migrate: observer-loop.sh is running." >&2
echo "Exit all Claude Code sessions, then re-run." >&2
exit 1
fi
else
echo "Warning: pgrep not available; skipping running-observer check." >&2
fi
mkdir -p "$(dirname "$NEW")"
if [ ! -d "$NEW" ]; then
mv "$OLD" "$NEW"
echo "Moved $OLD -> $NEW"
elif [ -z "$(ls -A "$NEW" 2>/dev/null || true)" ]; then
rmdir "$NEW"
mv "$OLD" "$NEW"
echo "Moved $OLD -> $NEW (replaced empty destination)"
else
old_count="$(find "$OLD" -type f 2>/dev/null | wc -l | tr -d ' ')"
new_count="$(find "$NEW" -type f 2>/dev/null | wc -l | tr -d ' ')"
echo "Refusing to migrate: both paths exist with content." >&2
echo " Old: $OLD ($old_count files)" >&2
echo " New: $NEW ($new_count files)" >&2
echo "Resolve manually, then re-run." >&2
exit 1
fi
settings="${HOME}/.claude/settings.json"
if [ -f "$settings" ] && grep -q '"CLV2_CONFIG"' "$settings" 2>/dev/null; then
if grep -q '\.claude/homunculus' "$settings" 2>/dev/null; then
cat >&2 <<WARN
Advisory: ~/.claude/settings.json still sets CLV2_CONFIG under the old path.
Update it to: ${NEW}/config.json
(Not editing settings.json automatically.)
WARN
fi
fi
+10 -2
View File
@@ -1,10 +1,18 @@
---
name: continuous-learning
description: Automatically extract reusable patterns from Claude Code sessions and save them as learned skills for future use.
description: "[DEPRECATED - use continuous-learning-v2] Legacy v1 stop-hook skill extractor. v2 is a strict superset with instinct-based, project-scoped, hook-reliable learning. Do not invoke v1; route continuous learning, session learning, and pattern extraction requests to continuous-learning-v2."
origin: ECC
---
# Continuous Learning Skill
# Continuous Learning Skill - DEPRECATED
> **DEPRECATED 2026-04-28.** Use `continuous-learning-v2` instead. v2 is a strict superset: stop-hook observation becomes PreToolUse/PostToolUse observation, full skills become atomic instincts with confidence scoring, and global-only storage becomes project-scoped plus global promotion.
>
> This file is kept for archival reference and backward compatibility with existing installs.
---
## Original v1 Documentation (archival)
Automatically evaluates Claude Code sessions on end to extract reusable patterns that can be saved as learned skills.
+496
View File
@@ -0,0 +1,496 @@
---
name: flox-environments
description: "Create reproducible, cross-platform development environments with Flox — a declarative environment manager built on Nix. ALWAYS use this skill when the user needs to: set up a project with system-level dependencies (compilers, databases, native libraries like openssl, libvips, BLAS, LAPACK); configure reproducible toolchains for Python, Node.js, Rust, Go, C/C++, Java, Ruby, Elixir, PHP, or any language; manage environments that must work identically across macOS and Linux; pin exact package versions for a team; run local services (PostgreSQL, Redis, Kafka) alongside development tools; onboard new developers with a single command; or solve 'works on my machine' problems. Especially valuable for AI-assisted and vibe coding — Flox lets agents install tools into a project-scoped environment without sudo, system pollution, or sandbox restrictions, and the resulting environment is committed to the repo so anyone can reproduce it instantly. Use this skill even if the user doesn't mention Flox — if they describe needing reproducible, declarative, cross-platform dev environments with system packages, this is the right tool. Also use when the user mentions .flox/, manifest.toml, flox activate, or FloxHub."
origin: Flox
---
# Flox Environments
Flox creates reproducible development environments defined in a single TOML manifest. Every developer on the team gets identical packages, tools, and configuration — across macOS and Linux — without containers or VMs. Built on Nix with access to over 150,000 packages.
## When to Activate
Use this skill when the user has an environment management problem — even if they haven't mentioned Flox. Flox is the right tool when:
- The project needs **system-level packages** (compilers, databases, CLI tools) alongside language-specific dependencies
- **Reproducibility matters** — the setup should work identically on a teammate's machine, in CI, or on a fresh laptop
- The user needs **multiple tools to coexist** — e.g., Python 3.11 + PostgreSQL 16 + Redis + Node.js in one environment
- **Cross-platform support** is needed (macOS and Linux from the same config)
- **AI agents need to install tools** — Flox lets agents add packages to a project-scoped environment without sudo, system pollution, or sandbox restrictions
If the user just needs a single language runtime with no system dependencies, standard tooling (nvm, pyenv, rustup alone) may suffice. If they need full OS-level isolation, containers might be more appropriate. Flox sits in the sweet spot: declarative, reproducible environments without container overhead.
**Prerequisite:** Flox must be installed first — see [flox.dev/docs](https://flox.dev/docs/install-flox/install/) for macOS, Linux, and Docker.
## Core Concepts
Flox environments are defined in `.flox/env/manifest.toml` and activated with `flox activate`. The manifest declares packages, environment variables, setup hooks, and shell configuration — everything needed to reproduce the environment anywhere.
**Key paths:**
- `.flox/env/manifest.toml` — Environment definition (commit this)
- `$FLOX_ENV` — Runtime path to installed packages (like `/usr` — contains `bin/`, `lib/`, `include/`)
- `$FLOX_ENV_CACHE` — Persistent local storage for caches, venvs, data (survives rebuilds)
- `$FLOX_ENV_PROJECT` — Project root directory (where `.flox/` lives)
## Essential Commands
```bash
flox init # Create new environment
flox search <package> [--all] # Search for packages
flox show <package> # Show available versions
flox install <package> # Add a package
flox list # List installed packages
flox activate # Enter environment
flox activate -- <cmd> # Run a command in the environment without a subshell
flox edit # Edit manifest interactively
```
## Manifest Structure
```toml
# .flox/env/manifest.toml
[install]
# Packages to install — the core of the environment
ripgrep.pkg-path = "ripgrep"
jq.pkg-path = "jq"
[vars]
# Static environment variables
DATABASE_URL = "postgres://localhost:5432/myapp"
[hook]
# Non-interactive setup scripts (run every activation)
on-activate = """
echo "Environment ready"
"""
[profile]
# Shell functions and aliases (available in interactive shell)
common = """
alias dev="npm run dev"
"""
[options]
# Supported platforms
systems = ["x86_64-linux", "aarch64-linux", "x86_64-darwin", "aarch64-darwin"]
```
## Package Installation Patterns
### Basic Installation
```toml
[install]
nodejs.pkg-path = "nodejs"
python.pkg-path = "python311"
rustup.pkg-path = "rustup"
```
### Version Pinning
```toml
[install]
nodejs.pkg-path = "nodejs"
nodejs.version = "^20.0" # Semver range: latest 20.x
postgres.pkg-path = "postgresql"
postgres.version = "16.2" # Exact version
```
### Platform-Specific Packages
```toml
[install]
# Linux-only tools
valgrind.pkg-path = "valgrind"
valgrind.systems = ["x86_64-linux", "aarch64-linux"]
# macOS frameworks
Security.pkg-path = "darwin.apple_sdk.frameworks.Security"
Security.systems = ["x86_64-darwin", "aarch64-darwin"]
# GNU tools on macOS (where BSD defaults differ)
coreutils.pkg-path = "coreutils"
coreutils.systems = ["x86_64-darwin", "aarch64-darwin"]
```
### Resolving Package Conflicts
When two packages install the same binary, use `priority` (lower number wins):
```toml
[install]
gcc.pkg-path = "gcc12"
gcc.priority = 3
clang.pkg-path = "clang_18"
clang.priority = 5 # gcc wins file conflicts
```
Use `pkg-group` to group packages that should resolve versions together:
```toml
[install]
python.pkg-path = "python311"
python.pkg-group = "python-stack"
pip.pkg-path = "python311Packages.pip"
pip.pkg-group = "python-stack" # Resolves together with python
```
## Language-Specific Recipes
### Python with uv
```toml
[install]
python.pkg-path = "python311"
uv.pkg-path = "uv"
[vars]
UV_CACHE_DIR = "$FLOX_ENV_CACHE/uv-cache"
PIP_CACHE_DIR = "$FLOX_ENV_CACHE/pip-cache"
[hook]
on-activate = """
venv="$FLOX_ENV_CACHE/venv"
if [ ! -d "$venv" ]; then
uv venv "$venv" --python python3
fi
if [ -f "$venv/bin/activate" ]; then
source "$venv/bin/activate"
fi
if [ -f requirements.txt ] && [ ! -f "$FLOX_ENV_CACHE/.deps_installed" ]; then
uv pip install --python "$venv/bin/python" -r requirements.txt --quiet
touch "$FLOX_ENV_CACHE/.deps_installed"
fi
"""
```
### Node.js
```toml
[install]
nodejs.pkg-path = "nodejs"
nodejs.version = "^20.0"
[hook]
on-activate = """
if [ -f package.json ] && [ ! -d node_modules ]; then
npm install --silent
fi
"""
```
### Rust
```toml
[install]
rustup.pkg-path = "rustup"
pkg-config.pkg-path = "pkg-config"
openssl.pkg-path = "openssl"
[vars]
RUSTUP_HOME = "$FLOX_ENV_CACHE/rustup"
CARGO_HOME = "$FLOX_ENV_CACHE/cargo"
[profile]
common = """
export PATH="$CARGO_HOME/bin:$PATH"
"""
```
### Go
```toml
[install]
go.pkg-path = "go"
gopls.pkg-path = "gopls"
delve.pkg-path = "delve"
[vars]
GOPATH = "$FLOX_ENV_CACHE/go"
GOBIN = "$FLOX_ENV_CACHE/go/bin"
[profile]
common = """
export PATH="$GOBIN:$PATH"
"""
```
### C/C++
```toml
[install]
gcc.pkg-path = "gcc13"
gcc.pkg-group = "compilers"
# IMPORTANT: gcc alone doesn't expose libstdc++ headers — you need gcc-unwrapped
gcc-unwrapped.pkg-path = "gcc-unwrapped"
gcc-unwrapped.pkg-group = "libraries"
cmake.pkg-path = "cmake"
cmake.pkg-group = "build"
gnumake.pkg-path = "gnumake"
gnumake.pkg-group = "build"
gdb.pkg-path = "gdb"
gdb.systems = ["x86_64-linux", "aarch64-linux"]
```
## Hooks and Profile
### Hooks — Non-Interactive Setup
Hooks run on every activation. Keep them fast and idempotent. Rule of thumb: **if it should happen automatically, put it in `[hook]`; if the user should be able to type it, put it in `[profile]`.**
```toml
[hook]
on-activate = """
setup_database() {
if [ ! -d "$FLOX_ENV_CACHE/pgdata" ]; then
initdb -D "$FLOX_ENV_CACHE/pgdata" --no-locale --encoding=UTF8
fi
}
setup_database
"""
```
### Profile — Interactive Shell Configuration
Profile code is available in the user's shell session.
```toml
[profile]
common = """
dev() { npm run dev; }
test() { npm run test -- "$@"; }
"""
```
## Anti-Patterns
### Absolute Paths
```toml
# BAD — breaks on other machines
[vars]
PROJECT_DIR = "/home/alice/projects/myapp"
# GOOD — use Flox environment variables
[vars]
PROJECT_DIR = "$FLOX_ENV_PROJECT"
```
### Using exit in Hooks
```toml
# BAD — kills the shell
[hook]
on-activate = """
if [ ! -f config.json ]; then
echo "Missing config"
exit 1
fi
"""
# GOOD — return from hook, don't exit
[hook]
on-activate = """
if [ ! -f config.json ]; then
echo "Missing config — run setup first"
return 1
fi
"""
```
### Storing Secrets in Manifest
```toml
# BAD — manifest is committed to git
[vars]
API_KEY = "<set-at-runtime>"
# GOOD — reference external config or pass at runtime
# Use: API_KEY="<your-api-key>" flox activate
[vars]
API_KEY = "${API_KEY:-}"
```
### Slow Hooks Without Idempotency Guards
```toml
# BAD — reinstalls every activation
[hook]
on-activate = """
pip install -r requirements.txt
"""
# GOOD — skip if already installed
[hook]
on-activate = """
if [ ! -f "$FLOX_ENV_CACHE/.deps_installed" ]; then
uv pip install -r requirements.txt --quiet
touch "$FLOX_ENV_CACHE/.deps_installed"
fi
"""
```
### Putting User Commands in Hooks
```toml
# BAD — hook functions aren't available in the interactive shell
[hook]
on-activate = """
deploy() { kubectl apply -f k8s/; }
"""
# GOOD — use [profile] for user-invokable functions
[profile]
common = """
deploy() { kubectl apply -f k8s/; }
"""
```
## Full-Stack Example
A complete environment for a Python API with PostgreSQL:
```toml
[install]
python.pkg-path = "python311"
uv.pkg-path = "uv"
postgresql.pkg-path = "postgresql_16"
redis.pkg-path = "redis"
jq.pkg-path = "jq"
curl.pkg-path = "curl"
[vars]
UV_CACHE_DIR = "$FLOX_ENV_CACHE/uv-cache"
DATABASE_URL = "postgres://localhost:5432/myapp"
REDIS_URL = "redis://localhost:6379"
[hook]
on-activate = """
if [ ! -d "$FLOX_ENV_CACHE/pgdata" ]; then
initdb -D "$FLOX_ENV_CACHE/pgdata" --no-locale --encoding=UTF8
fi
venv="$FLOX_ENV_CACHE/venv"
if [ ! -d "$venv" ]; then
uv venv "$venv" --python python3
fi
if [ -f "$venv/bin/activate" ]; then
source "$venv/bin/activate"
fi
if [ -f requirements.txt ] && [ ! -f "$FLOX_ENV_CACHE/.deps_installed" ]; then
uv pip install --python "$venv/bin/python" -r requirements.txt --quiet
touch "$FLOX_ENV_CACHE/.deps_installed"
fi
"""
[profile]
common = """
serve() { uvicorn app.main:app --reload --host 0.0.0.0 --port 8000; }
migrate() { alembic upgrade head; }
"""
[services]
postgres.command = "postgres -D $FLOX_ENV_CACHE/pgdata -k $FLOX_ENV_CACHE"
redis.command = "redis-server --port 6379 --daemonize no"
[options]
systems = ["x86_64-linux", "aarch64-linux", "x86_64-darwin", "aarch64-darwin"]
```
Activate with services: `flox activate --start-services`
## Environment Sharing
Flox environments are git-native. Commit the `.flox/` directory and every collaborator gets the same environment:
```bash
git add .flox/
git commit -m "Add Flox environment"
# Teammates just run:
git clone <repo> && cd <repo> && flox activate
```
For reusable base environments across projects, push to FloxHub:
```bash
flox push # Push environment to FloxHub
flox activate -r owner/env-name # Activate remote environment anywhere
```
Compose environments with `[include]`:
```toml
[include]
base.floxhub = "myorg/python-base"
[install]
# Project-specific additions on top of base
fastapi.pkg-path = "python311Packages.fastapi"
```
## AI-Assisted and Vibe Coding
Flox is ideal for AI-assisted development and vibe coding workflows. When an AI agent needs a tool that isn't available in the current environment — a compiler, a database, a linter, a CLI utility — it can add it to the project's Flox manifest without requiring sudo access, polluting system packages, or hitting sandbox restrictions.
**Why this matters for agents:**
- **No sudo required**`flox install` works entirely in user space, so agents can add packages without elevated permissions
- **Project-scoped** — packages are installed into the project environment only, not globally, so different projects can have different versions without conflict
- **Sandbox-friendly** — agents running in sandboxed or restricted environments can still install the tools they need through Flox
- **Reversible** — every change is captured in `manifest.toml`, so unwanted packages can be removed cleanly with no system residue
- **Reproducible** — when an agent sets up an environment, that exact setup is committed to git and works for everyone
**Agent workflow pattern:**
```bash
# Agent discovers it needs a tool (e.g., jq for JSON processing)
flox search jq # Verify the package exists
flox install jq # Install into project environment
# Or for more control, edit the manifest directly
tmp_manifest="$(mktemp)"
flox list -c > "$tmp_manifest"
# Add the package to [install] section, then apply
flox edit -f "$tmp_manifest"
# Run a command with the tool available
flox activate -- jq '.results[]' data.json
```
This makes Flox a natural fit for any workflow where Claude Code or other AI agents need to bootstrap project tooling on the fly.
## Debugging
```bash
flox list -c # Show raw manifest
flox activate -- which python # Check which binary resolves
flox activate -- env | grep FLOX # See Flox environment variables
flox search <package> --all # Broader package search (case-sensitive)
```
**Common issues:**
- **Package not found:** Search is case-sensitive — try `flox search --all`
- **File conflicts between packages:** Add `priority` to the package that should win
- **Hook failures:** Use `return` not `exit`; guard with `${FLOX_ENV_CACHE:-}`
- **Stale dependencies:** Delete the `$FLOX_ENV_CACHE/.deps_installed` flag file
## Related Skills
The following skills are available as part of the [Flox Claude Code plugin](https://github.com/flox/flox-agentic) for deeper integration:
- **flox-services** — Service management, database setup, background processes
- **flox-builds** — Reproducible builds and packaging with Flox
- **flox-containers** — Create Docker/OCI containers from Flox environments
- **flox-sharing** — Environment composition, remote environments, team patterns
- **flox-cuda** — CUDA and GPU development environments
Learn more and install at [flox.dev/docs](https://flox.dev/docs/install-flox/install/)
+4
View File
@@ -94,6 +94,10 @@ Triggers on: `rm -rf`, `git reset --hard`, `git push --force`, `drop table`, etc
The hook at `scripts/hooks/gateguard-fact-force.js` is included in this plugin. Enable it via hooks.json.
If GateGuard blocks setup or repair work, start the session with
`ECC_GATEGUARD=off`. For hook-level control, keep using
`ECC_DISABLED_HOOKS` with the GateGuard hook ID.
### Option B: Full package with config
```bash
+157
View File
@@ -0,0 +1,157 @@
---
name: ios-icon-gen
description: Generate iOS app icons as PNG imagesets for Xcode asset catalogs from SF Symbols (5000+ Apple-native) or Iconify API (275k+ open source icons from 200+ collections). Use when generating icons, creating icon assets, adding icons to asset catalog, or searching for icons for iOS projects.
origin: community
---
# iOS Icon Generator
Generate PNG icon imagesets for Xcode asset catalogs from two sources.
## When to Activate
- Generating icon assets for an iOS/macOS Xcode project
- Searching for icons across open source collections
- Creating PNG imagesets (1x, 2x, 3x) for asset catalogs
- Replacing placeholder icons with production-quality assets
- Matching existing icon styles in an Xcode project
## Core Principles
### 1. Two Sources, One Output Format
Both sources produce identical Xcode-compatible imagesets. Choose based on need:
| Source | Icons | Requires | Best for |
|--------|-------|----------|----------|
| **Iconify API** | 275,000+ from 200+ collections | Internet | Wide selection, specific styles, open source icons |
| **SF Symbols** | 5,000+ Apple symbols | macOS only | Apple-native style, offline use |
### 2. Always Match Existing Style
Before generating, check the project's existing icons for size, color, and weight consistency.
### 3. Output Structure
Both methods produce a complete Xcode imageset:
```
<output-dir>/<asset-name>.imageset/
Contents.json
<asset-name>.png # 1x (68px default)
<asset-name>@2x.png # 2x (136px default)
<asset-name>@3x.png # 3x (204px default)
```
## Examples
### Step 1: Assess Requirements
Determine icon needs: what the icon represents, preferred style, target color, and size.
If the project already has icons, check existing style:
```bash
# Check dimensions of existing icon
sips -g pixelWidth -g pixelHeight path/to/existing@2x.png
```
### Step 2: Search for Icons
**Iconify API (recommended for wide selection):**
```bash
# Search all collections
$SKILL_DIR/scripts/iconify_gen.sh search "receipt"
# Search within a specific collection
$SKILL_DIR/scripts/iconify_gen.sh search "business card" --prefix mdi
# List available collections
$SKILL_DIR/scripts/iconify_gen.sh collections
```
**SF Symbols (for Apple-native style):**
Browse the SF Symbols app or reference common names:
| Use Case | Symbol Name |
|----------|-------------|
| Document | `doc.text`, `doc.fill` |
| Receipt | `doc.text.below.ecg`, `receipt` |
| Person | `person.crop.rectangle`, `person.text.rectangle` |
| Camera | `camera`, `camera.fill` |
| Scan | `doc.viewfinder`, `qrcode.viewfinder` |
| Settings | `gearshape`, `slider.horizontal.3` |
### Step 3: Preview (Optional)
```bash
# Iconify preview
$SKILL_DIR/scripts/iconify_gen.sh preview mdi:receipt-text-outline
```
### Step 4: Generate
**Iconify API:**
```bash
# Basic generation
$SKILL_DIR/scripts/iconify_gen.sh mdi:receipt-text-outline editTool_expenseReport
# Custom color and output location
$SKILL_DIR/scripts/iconify_gen.sh mdi:receipt-text-outline myIcon --color 007AFF --output ./Assets.xcassets/icons
```
Options: `--size <pt>` (default: 68), `--color <hex>` (default: 8E8E93), `--output <dir>` (default: /tmp/icons)
**SF Symbols:**
```bash
# Basic generation
swift $SKILL_DIR/scripts/generate_icons.swift doc.text.below.ecg editTool_expenseReport
# Custom color, weight, and output
swift $SKILL_DIR/scripts/generate_icons.swift person.crop.rectangle myIcon --color 007AFF --weight regular --output ./Assets.xcassets/icons
```
Options: `--size <pt>` (default: 68), `--color <hex>` (default: 8E8E93), `--weight <name>` (default: thin), `--output <dir>` (default: /tmp/icons)
### Step 5: Verify and Integrate
1. Read the generated @2x PNG to verify visually
2. Copy to asset catalog if not output there directly:
```bash
cp -r /tmp/icons/<name>.imageset path/to/Assets.xcassets/<group>/
```
3. Build the project to verify Xcode picks up the new assets
## Popular Iconify Collections
| Prefix | Name | Count | Style |
|--------|------|-------|-------|
| `mdi` | Material Design Icons | 7400+ | Filled + outline variants |
| `ph` | Phosphor | 9000+ | 6 weights per icon |
| `solar` | Solar | 7400+ | Bold, linear, outline |
| `tabler` | Tabler Icons | 6000+ | Consistent stroke width |
| `lucide` | Lucide | 1700+ | Clean, minimal |
| `ri` | Remix Icon | 3100+ | Filled + line variants |
| `carbon` | Carbon | 2400+ | IBM design language |
| `heroicons` | HeroIcons | 1200+ | Tailwind CSS companion |
Browse all: <https://icon-sets.iconify.design/>
## Scripts Reference
| Script | Source | Path |
|--------|--------|------|
| `iconify_gen.sh` | Iconify API (275k+ icons) | `$SKILL_DIR/scripts/iconify_gen.sh` |
| `generate_icons.swift` | SF Symbols (5k+ icons) | `$SKILL_DIR/scripts/generate_icons.swift` |
## Best Practices
- **Search before generating** -- browse available icons to find the best match
- **Match existing project style** -- check dimensions, color, and weight of existing icons before generating new ones
- **Use Iconify for variety** -- 200+ collections means you can find the exact style you need
- **Use SF Symbols for Apple consistency** -- they match system UI perfectly
- **Generate directly to asset catalog** -- use `--output ./Assets.xcassets/icons` to skip manual copying
- **Verify visually** -- always preview the @2x PNG before committing
## Anti-Patterns
- Generating icons without checking existing project icon style
- Using default colors when the project has a defined color palette
- Generating at wrong sizes (check existing icons first)
- Committing generated icons without visual verification
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env swift
import AppKit
import Foundation
// MARK: - Configuration
struct IconSpec {
let symbolName: String
let assetName: String
let baseSize: CGFloat
let color: NSColor
let weight: NSFont.Weight
}
func parseColor(_ hex: String) -> NSColor {
var hex = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if hex.hasPrefix("#") { hex.removeFirst() }
guard hex.count == 6, let value = UInt64(hex, radix: 16) else {
return NSColor(red: 142/255, green: 142/255, blue: 147/255, alpha: 1.0)
}
return NSColor(
red: CGFloat((value >> 16) & 0xFF) / 255,
green: CGFloat((value >> 8) & 0xFF) / 255,
blue: CGFloat(value & 0xFF) / 255,
alpha: 1.0
)
}
func parseWeight(_ name: String) -> NSFont.Weight {
switch name.lowercased() {
case "ultralight": return .ultraLight
case "thin": return .thin
case "light": return .light
case "regular": return .regular
case "medium": return .medium
case "semibold": return .semibold
case "bold": return .bold
case "heavy": return .heavy
case "black": return .black
default: return .thin
}
}
// MARK: - Generation
enum IconError: Error, CustomStringConvertible {
case directoryCreation(String)
case symbolNotFound(String)
case configurationFailed(String)
case pngCreation(String)
case fileWrite(String)
var description: String {
switch self {
case .directoryCreation(let msg): return msg
case .symbolNotFound(let msg): return msg
case .configurationFailed(let msg): return msg
case .pngCreation(let msg): return msg
case .fileWrite(let msg): return msg
}
}
}
func generateIcon(_ spec: IconSpec, outputDir: String) throws {
let dir = "\(outputDir)/\(spec.assetName).imageset"
do {
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
} catch {
throw IconError.directoryCreation("Could not create output directory '\(dir)': \(error.localizedDescription)")
}
let scales: [(suffix: String, multiplier: CGFloat)] = [("", 1), ("@2x", 2), ("@3x", 3)]
for scale in scales {
let pixelSize = spec.baseSize * scale.multiplier
let imageSize = NSSize(width: pixelSize, height: pixelSize)
let config = NSImage.SymbolConfiguration(
pointSize: pixelSize * 0.40,
weight: spec.weight,
scale: .large
)
guard let symbol = NSImage(systemSymbolName: spec.symbolName, accessibilityDescription: nil) else {
throw IconError.symbolNotFound("SF Symbol '\(spec.symbolName)' not found. Run 'SF Symbols' app to browse available names.")
}
guard let configured = symbol.withSymbolConfiguration(config) else {
throw IconError.configurationFailed("Could not apply symbol configuration to '\(spec.symbolName)'")
}
let image = NSImage(size: imageSize, flipped: false) { rect in
let symSize = configured.size
let x = (rect.width - symSize.width) / 2
let y = (rect.height - symSize.height) / 2
let drawRect = NSRect(x: x, y: y, width: symSize.width, height: symSize.height)
let tinted = NSImage(size: symSize, flipped: false) { tintRect in
configured.draw(in: tintRect)
spec.color.set()
tintRect.fill(using: .sourceAtop)
return true
}
tinted.draw(in: drawRect, from: .zero, operation: .sourceOver, fraction: 1.0)
return true
}
guard let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData),
let pngData = bitmap.representation(using: .png, properties: [:]) else {
throw IconError.pngCreation("Failed to create PNG for \(spec.assetName)\(scale.suffix)")
}
let fileName = "\(spec.assetName)\(scale.suffix).png"
do {
try pngData.write(to: URL(fileURLWithPath: "\(dir)/\(fileName)"))
} catch {
throw IconError.fileWrite("Failed to write \(fileName): \(error.localizedDescription)")
}
print(" \(fileName) (\(Int(pixelSize))x\(Int(pixelSize)))")
}
// Write Contents.json
let json = """
{
"images" : [
{
"filename" : "\(spec.assetName).png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "\(spec.assetName)@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "\(spec.assetName)@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
"""
do {
try json.write(toFile: "\(dir)/Contents.json", atomically: true, encoding: .utf8)
} catch {
throw IconError.fileWrite("Failed to write Contents.json: \(error.localizedDescription)")
}
}
func requireOptionValue(_ args: [String], at index: Int, flag: String) -> String {
guard index < args.count else {
fputs("ERROR: Missing value for \(flag)\n", stderr)
exit(1)
}
let value = args[index]
if value.hasPrefix("--") {
fputs("ERROR: Missing value for \(flag)\n", stderr)
exit(1)
}
return value
}
// MARK: - CLI
let args = CommandLine.arguments
if args.count < 3 || args.contains("--help") || args.contains("-h") {
print("""
Usage: generate_icons.swift <sf-symbol-name> <asset-name> [options]
Options:
--size <pt> Base size in points (default: 68)
--color <hex> Color hex code (default: 8E8E93)
--weight <name> Font weight: ultralight|thin|light|regular|medium|semibold|bold|heavy|black (default: thin)
--output <dir> Output directory (default: /tmp/icons)
Examples:
generate_icons.swift doc.text.below.ecg editTool_expenseReport
generate_icons.swift person.crop.rectangle editTool_businessCard --color 007AFF --weight regular
generate_icons.swift receipt myReceipt --size 48 --output ./Assets.xcassets/icons
Browse SF Symbol names: open the SF Symbols app (free from Apple) or https://developer.apple.com/sf-symbols/
""")
exit(0)
}
let symbolName = args[1]
let assetName = args[2]
var baseSize: CGFloat = 68
var colorHex = "8E8E93"
var weightName = "thin"
var outputDir = "/tmp/icons"
var i = 3
while i < args.count {
switch args[i] {
case "--size":
let raw = requireOptionValue(args, at: i + 1, flag: "--size")
guard let size = Double(raw), size > 0 else {
fputs("ERROR: --size must be a positive number\n", stderr)
exit(1)
}
baseSize = CGFloat(size)
i += 2
continue
case "--color":
colorHex = requireOptionValue(args, at: i + 1, flag: "--color")
let stripped = colorHex.hasPrefix("#") ? String(colorHex.dropFirst()) : colorHex
guard stripped.count == 6, UInt64(stripped, radix: 16) != nil else {
fputs("ERROR: --color must be a 6-digit hex code (e.g. 007AFF)\n", stderr)
exit(1)
}
i += 2
continue
case "--weight":
weightName = requireOptionValue(args, at: i + 1, flag: "--weight")
let validWeights = ["ultralight", "thin", "light", "regular", "medium", "semibold", "bold", "heavy", "black"]
guard validWeights.contains(weightName.lowercased()) else {
fputs("ERROR: --weight must be one of: \(validWeights.joined(separator: ", "))\n", stderr)
exit(1)
}
i += 2
continue
case "--output":
outputDir = requireOptionValue(args, at: i + 1, flag: "--output")
i += 2
continue
default:
fputs("WARNING: Unknown option \(args[i])\n", stderr)
}
i += 1
}
let spec = IconSpec(
symbolName: symbolName,
assetName: assetName,
baseSize: baseSize,
color: parseColor(colorHex),
weight: parseWeight(weightName)
)
print("Generating \(assetName) from SF Symbol '\(symbolName)':")
do {
try generateIcon(spec, outputDir: outputDir)
print("Output: \(outputDir)/\(assetName).imageset/")
} catch {
fputs("ERROR: \(error)\n", stderr)
exit(1)
}
+235
View File
@@ -0,0 +1,235 @@
#!/bin/bash
#
# Generate iOS icon imagesets from Iconify API (275k+ open source icons)
# Uses: curl (download SVG) + sips (SVG->PNG conversion, built into macOS)
#
# Usage:
# iconify_gen.sh <icon-id> <asset-name> [options]
# iconify_gen.sh search <query> [--prefix <collection>] [--limit <n>]
#
# Examples:
# iconify_gen.sh mdi:receipt-text-outline myExpenseIcon
# iconify_gen.sh search "business card"
# iconify_gen.sh search receipt --prefix mdi
set -euo pipefail
API_BASE="https://api.iconify.design"
readonly CURL_OPTS=(--fail --silent --show-error --connect-timeout 10 --max-time 30)
# Defaults
SIZE=68
COLOR="8E8E93"
OUTPUT="/tmp/icons"
LIMIT=20
require_value() {
local flag="$1"
local value="${2-}"
if [[ -z "$value" || "$value" == --* ]]; then
echo "ERROR: ${flag} requires a value" >&2
exit 1
fi
}
usage() {
cat <<'EOF'
Usage:
iconify_gen.sh <icon-id> <asset-name> [options] Generate an icon imageset
iconify_gen.sh search <query> [options] Search for icons
iconify_gen.sh preview <icon-id> Download preview SVG
iconify_gen.sh collections List popular icon collections
Generate Options:
--size <pt> Base size in points (default: 68)
--color <hex> Color hex without # (default: 8E8E93)
--output <dir> Output directory (default: /tmp/icons)
Search Options:
--prefix <name> Filter by collection (e.g., mdi, lucide, tabler, ph)
--limit <n> Max results (default: 20)
Icon ID Format: <collection>:<icon-name>
Examples: mdi:receipt-text-outline, lucide:credit-card, ph:address-book
Popular Collections:
mdi Material Design Icons (7400+ icons)
lucide Lucide (1700+ icons)
tabler Tabler Icons (6000+ icons)
ph Phosphor (9000+ icons)
ri Remix Icon (2800+ icons)
carbon Carbon (2100+ icons)
EOF
exit 0
}
search_icons() {
local query="$1"
shift
local prefix=""
while [[ $# -gt 0 ]]; do
case "$1" in
--prefix) require_value --prefix "${2-}"; prefix="$2"; shift 2 ;;
--limit) require_value --limit "${2-}"; LIMIT="$2"; shift 2 ;;
*) shift ;;
esac
done
local encoded_query
encoded_query="$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$query")"
local url="${API_BASE}/search?query=${encoded_query}&limit=${LIMIT}"
if [[ -n "$prefix" ]]; then
url="${url}&prefix=${prefix}"
fi
local response
response=$(curl "${CURL_OPTS[@]}" "$url") || { echo "ERROR: Search request failed"; exit 1; }
local total
total=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total',0))")
echo "Found ${total} icons for '${query}':"
echo ""
echo "$response" | python3 -c "
import sys, json
data = json.load(sys.stdin)
for icon in data.get('icons', []):
print(f' {icon}')
"
echo ""
echo "Generate with: iconify_gen.sh <icon-id> <asset-name>"
echo "Preview with: iconify_gen.sh preview <icon-id>"
}
list_collections() {
echo "Popular Iconify collections:"
echo ""
local resp
resp=$(curl "${CURL_OPTS[@]}" "${API_BASE}/collections") || { echo "ERROR: Failed to fetch collections list"; exit 1; }
echo "$resp" | python3 -c "
import sys, json
data = json.load(sys.stdin)
popular = ['mdi','lucide','tabler','ph','ri','carbon','solar','heroicons','bi','octicon','ion','fe','charm','ci','iconoir','basil','uil','mingcute','flowbite','mynaui']
for k in popular:
if k in data:
v = data[k]
name = v.get('name','')
total = v.get('total',0)
print(f' {k:12s} {name} ({total} icons)')
"
echo ""
echo "Full list: https://icon-sets.iconify.design/"
}
preview_icon() {
local icon_id="$1"
local collection="${icon_id%%:*}"
local name="${icon_id#*:}"
local url="${API_BASE}/${collection}/${name}.svg?width=136&height=136&color=%23${COLOR}"
local outfile="/tmp/iconify_preview_${collection}_${name}.svg"
curl "${CURL_OPTS[@]}" "$url" -o "$outfile" || { echo "ERROR: Icon '${icon_id}' not found"; exit 1; }
echo "Preview SVG: ${outfile}"
echo "URL: ${url}"
# Also convert to PNG for visual check
local pngfile="/tmp/iconify_preview_${collection}_${name}.png"
sips -s format png "$outfile" --out "$pngfile" >/dev/null 2>&1 || echo "WARNING: sips conversion failed; PNG may be incorrect"
echo "Preview PNG: ${pngfile}"
}
generate_icon() {
local icon_id="$1"
local asset_name="$2"
shift 2
while [[ $# -gt 0 ]]; do
case "$1" in
--size) require_value --size "${2-}"; SIZE="$2"; shift 2 ;;
--color) require_value --color "${2-}"; COLOR="$2"; shift 2 ;;
--output) require_value --output "${2-}"; OUTPUT="$2"; shift 2 ;;
*) shift ;;
esac
done
local collection="${icon_id%%:*}"
local name="${icon_id#*:}"
local imageset_dir="${OUTPUT}/${asset_name}.imageset"
mkdir -p "$imageset_dir"
echo "Generating ${asset_name} from Iconify '${icon_id}':"
local scales=("1:${SIZE}" "2:$((SIZE * 2))" "3:$((SIZE * 3))")
for scale_info in "${scales[@]}"; do
local scale="${scale_info%%:*}"
local px="${scale_info#*:}"
local suffix=""
[[ "$scale" != "1" ]] && suffix="@${scale}x"
local svg_url="${API_BASE}/${collection}/${name}.svg?width=${px}&height=${px}&color=%23${COLOR}"
local svg_file="${imageset_dir}/${asset_name}${suffix}.svg"
local png_file="${imageset_dir}/${asset_name}${suffix}.png"
curl "${CURL_OPTS[@]}" "$svg_url" -o "$svg_file" || { echo "ERROR: Failed to download icon '${icon_id}'"; exit 1; }
sips -s format png "$svg_file" --out "$png_file" >/dev/null 2>&1 || echo "WARNING: sips conversion may have failed for ${svg_file}"
rm "$svg_file"
echo " ${asset_name}${suffix}.png (${px}x${px})"
done
# Write Contents.json
cat > "${imageset_dir}/Contents.json" <<JSONEOF
{
"images" : [
{
"filename" : "${asset_name}.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "${asset_name}@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "${asset_name}@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
JSONEOF
echo "Output: ${imageset_dir}/"
}
# Main
[[ $# -eq 0 ]] && usage
[[ "$1" == "--help" || "$1" == "-h" ]] && usage
case "$1" in
search)
shift
[[ $# -eq 0 ]] && { echo "Usage: iconify_gen.sh search <query>"; exit 1; }
search_icons "$@"
;;
preview)
shift
[[ $# -eq 0 ]] && { echo "Usage: iconify_gen.sh preview <icon-id>"; exit 1; }
preview_icon "$1"
;;
collections)
list_collections
;;
*)
[[ $# -lt 2 ]] && { echo "Usage: iconify_gen.sh <icon-id> <asset-name> [options]"; exit 1; }
generate_icon "$@"
;;
esac
+1 -9
View File
@@ -1,14 +1,6 @@
---
name: openclaw-persona-forge
description: |-
为 OpenClaw AI Agent 锻造完整的龙虾灵魂方案。根据用户偏好或随机抽卡,
输出身份定位、灵魂描述(SOUL.md)、角色化底线规则、名字和头像生图提示词。
如当前环境提供已审核的生图 skill,可自动生成统一风格头像图片。
当用户需要创建、设计或定制 OpenClaw 龙虾灵魂时使用。
不适用于:微调已有 SOUL.md、非 OpenClaw 平台的角色设计、纯工具型无性格 Agent。
触发词:龙虾灵魂、虾魂、OpenClaw 灵魂、养虾灵魂、龙虾角色、龙虾定位、
龙虾剧本杀角色、龙虾游戏角色、龙虾 NPC、龙虾性格、龙虾背景故事、
lobster soul、lobster character、抽卡、随机龙虾、龙虾 SOUL、gacha。
description: "为 OpenClaw AI Agent 锻造完整的龙虾灵魂方案。根据用户偏好或随机抽卡, 输出身份定位、灵魂描述(SOUL.md)、角色化底线规则、名字和头像生图提示词。 如当前环境提供已审核的生图 skill,可自动生成统一风格头像图片。 当用户需要创建、设计或定制 OpenClaw 龙虾灵魂时使用。 不适用于:微调已有 SOUL.md、非 OpenClaw 平台的角色设计、纯工具型无性格 Agent。 触发词:龙虾灵魂、虾魂、OpenClaw 灵魂、养虾灵魂、龙虾角色、龙虾定位、 龙虾剧本杀角色、龙虾游戏角色、龙虾 NPC、龙虾性格、龙虾背景故事、 lobster soul、lobster character、抽卡、随机龙虾、龙虾 SOUL、gacha。"
origin: community
---
+1
View File
@@ -1,4 +1,5 @@
---
name: skill-stocktake
description: "Use when auditing Claude skills and commands for quality. Supports Quick Scan (changed skills only) and Full Stocktake modes with sequential subagent batch evaluation."
origin: ECC
---
+2 -2
View File
@@ -46,11 +46,11 @@ Add to your `~/.claude/settings.json`:
"PreToolUse": [
{
"matcher": "Edit",
"hooks": [{ "type": "command", "command": "node ~/.claude/skills/strategic-compact/suggest-compact.js" }]
"hooks": [{ "type": "command", "command": "node ~/.claude/scripts/hooks/suggest-compact.js" }]
},
{
"matcher": "Write",
"hooks": [{ "type": "command", "command": "node ~/.claude/skills/strategic-compact/suggest-compact.js" }]
"hooks": [{ "type": "command", "command": "node ~/.claude/scripts/hooks/suggest-compact.js" }]
}
]
}
+131
View File
@@ -0,0 +1,131 @@
---
name: tinystruct-patterns
description: Use when developing application modules or microservices with the tinystruct Java framework. Covers routing, context management, JSON handling with Builder, and CLI/HTTP dual-mode patterns.
origin: ECC
---
# tinystruct Development Patterns
Architecture and implementation patterns for building modules with the **tinystruct** Java framework a lightweight system where CLI and HTTP are equal citizens.
## When to Use
- Creating new `Application` modules by extending `AbstractApplication`.
- Defining routes and command-line actions using `@Action`.
- Handling per-request state via `Context`.
- Performing JSON serialization using the native `Builder` component.
- Configuring database connections or system settings in `application.properties`.
- Generating or re-generating the standard `bin/dispatcher` entry point via `ApplicationManager.init()`.
- Debugging routing conflicts (Actions) or CLI argument parsing.
## How It Works
The tinystruct framework treats any method annotated with `@Action` as a routable endpoint for both terminal and web environments. Applications are created by extending `AbstractApplication`, which provides core lifecycle hooks like `init()` and access to the request `Context`.
Routing is handled by the `ActionRegistry`, which automatically maps path segments to method arguments and injects dependencies. For data-only services, the native `Builder` component should be used for JSON serialization to maintain a zero-dependency footprint. The framework also includes a utility in `ApplicationManager` to bootstrap the project's execution environment by generating the `bin/dispatcher` script.
## Examples
### Basic Application (MyService)
```java
public class MyService extends AbstractApplication {
@Override
public void init() {
this.setTemplateRequired(false); // Disable .view lookup for data/API apps
}
@Override public String version() { return "1.0.0"; }
@Action("greet")
public String greet() {
return "Hello from tinystruct!";
}
}
```
### Parameterized Routing (getUser)
```java
// Handles /api/user/123 (Web) or "bin/dispatcher api/user/123" (CLI)
@Action("api/user/(\\d+)")
public String getUser(int userId) {
return "User ID: " + userId;
}
```
### HTTP Mode Disambiguation (login)
```java
@Action(value = "login", mode = Mode.HTTP_POST)
public boolean doLogin() {
// Process login logic
return true;
}
```
### Native JSON Data Handling (getData)
```java
@Action("api/data")
public Builder getData() throws ApplicationException {
Builder builder = new Builder();
builder.put("status", "success");
Builder nested = new Builder();
nested.put("id", 1);
nested.put("name", "James");
builder.put("data", nested);
return builder;
}
```
## Configuration
Settings are managed in `src/main/resources/application.properties`.
```properties
# Database
driver=org.h2.Driver
database.url=jdbc:h2:~/mydb
# App specific
my.service.endpoint=https://api.example.com
```
## Testing Patterns
Use JUnit 5 to test actions by verifying they are registered in the `ActionRegistry`.
```java
@Test
void testActionRegistration() {
Application app = new MyService();
app.init();
ActionRegistry registry = ActionRegistry.getInstance();
assertNotNull(registry.get("greet"));
}
```
## Red Flags & Anti-patterns
| Symptom | Correct Pattern |
|---|---|
| Importing `com.google.gson` or `com.fasterxml.jackson` | Use `org.tinystruct.data.component.Builder`. |
| `FileNotFoundException` for `.view` files | Call `setTemplateRequired(false)` in `init()` for API-only apps. |
| Annotating `private` methods with `@Action` | Actions must be `public` to be registered by the framework. |
| Hardcoding `main(String[] args)` in apps | Use `bin/dispatcher` as the entry point for all modules. |
| Manual `ActionRegistry` registration | Prefer the `@Action` annotation for automatic discovery. |
## Technical Reference
Detailed guides are available in the `references/` directory:
- [Architecture & Config](references/architecture.md) — Abstractions, Package Map, Properties
- [Routing & @Action](references/routing.md) — Annotation details, Modes, Parameters
- [Data Handling](references/data-handling.md) — Using the native `Builder` for JSON
- [System & Usage](references/system-usage.md) — Context, Sessions, Events, CLI usage
- [Testing Patterns](references/testing.md) — JUnit 5 integration and ActionRegistry testing
## Reference Source Files (Internal)
- `src/main/java/org/tinystruct/AbstractApplication.java` — Core base class
- `src/main/java/org/tinystruct/system/annotation/Action.java` — Annotation & Modes
- `src/main/java/org/tinystruct/application/ActionRegistry.java` — Routing Engine
- `src/main/java/org/tinystruct/data/component/Builder.java` — JSON/Data Serializer
@@ -0,0 +1,77 @@
# tinystruct Architecture and Configuration
## When to Use
Choose **tinystruct** when you need a lightweight, high-performance Java framework that treats CLI and HTTP as equal citizens. It is ideal for building microservices, command-line utilities, and data-driven applications where a small footprint and zero-dependency JSON handling are required. Use it when you want to write logic once and expose it via both a terminal and a web server without modification.
## How It Works
### Core Architecture
The framework operates on a singleton `ActionRegistry` that maps URL patterns (or command strings) to `Action` objects. When a request arrives, the system resolves the path and invokes the corresponding method handle.
#### Key Abstractions
| Class/Interface | Role |
|---|---|
| `AbstractApplication` | Base class for all tinystruct applications. Extend this. |
| `@Action` annotation | Maps a method to a URI path (web) or command name (CLI). The single routing primitive. |
| `ActionRegistry` | Singleton that maps URL patterns to `Action` objects via regex. Never instantiate directly. |
| `Action` | Wraps a `MethodHandle` + regex pattern + priority + `Mode` for dispatch. |
| `Context` | Per-request state store. Access via `getContext()`. Holds CLI args and HTTP request/response. |
| `Dispatcher` | CLI entry point (`bin/dispatcher`). Reads `--import` to load applications. |
| `HttpServer` | Built-in Netty-based HTTP server. Start with `bin/dispatcher start --import org.tinystruct.system.HttpServer`. |
### Package Map
```
org.tinystruct/
├── AbstractApplication.java ← extend this
├── Application.java ← interface
├── ApplicationException.java ← checked exception
├── ApplicationRuntimeException.java ← unchecked exception
├── application/
│ ├── Action.java ← runtime action wrapper
│ ├── ActionRegistry.java ← singleton route registry
│ └── Context.java ← request context
├── system/
│ ├── annotation/Action.java ← @Action annotation + Mode enum
│ ├── Dispatcher.java ← CLI dispatcher
│ ├── HttpServer.java ← built-in HTTP server
│ ├── EventDispatcher.java ← event bus
│ └── Settings.java ← reads application.properties
├── data/component/Builder.java ← JSON serialization (use instead of Gson/Jackson)
└── http/ ← Request, Response, Constants
```
### Template Behavior and Dispatch Flow
By default, the framework assumes a view template is required. If `templateRequired` is `true`, `toString()` looks for a `.view` file in `src/main/resources/themes/<ClassName>.view`. Use `getContext()` to manage state and `setVariable("name", value)` to pass data to templates, which use `[%name%]` for interpolation.
## Examples
### Minimal Application Initialization
```java
@Override
public void init() {
this.setTemplateRequired(false); // Skip .view template lookup for data-only apps
}
```
### Action Definition and CLI Invocation
```java
@Action("hello")
public String hello() {
return "Hello, tinystruct!";
}
```
**Execution via Dispatcher:**
```bash
bin/dispatcher hello
```
### Configuration Access
Located at `src/main/resources/application.properties`:
```java
String port = this.getConfiguration("server.port");
```
@@ -0,0 +1,35 @@
# tinystruct Data Handling (JSON)
## When to Use
Prefer `org.tinystruct.data.component.Builder` in scenarios where you need a lightweight, high-performance JSON solution with **zero external dependencies**. It is specifically designed to keep your tinystruct applications lean and fast, making it the ideal choice for microservices and CLI tools where including heavy libraries like Jackson or Gson would be overkill.
## How It Works
The `Builder` class provides a simple key-value interface for both creating and reading JSON structures. It integrates directly with `AbstractApplication` result handling; when an action method returns a `Builder` object, the framework automatically serializes it to the response stream. This prevents the need for manual string conversion and ensures consistent data formatting across your application modules.
## Examples
### Serialization
```java
import org.tinystruct.data.component.Builder;
// Create and populate
Builder response = new Builder();
response.put("status", "success");
response.put("count", 42);
response.put("data", someList);
return response; // {"status":"success","count":42,...}
```
### Parsing
```java
import org.tinystruct.data.component.Builder;
// Parse a JSON string
Builder parsed = new Builder();
parsed.parse(jsonString);
String status = parsed.get("status").toString();
```
@@ -0,0 +1,57 @@
# tinystruct @Action Routing Reference
## When to Use
Use the `@Action` annotation in your applications to define routes for both CLI commands and HTTP endpoints. It is appropriate whenever you need to map logic to a specific path, handle parameterized requests (e.g., retrieving a resource by ID), or restrict execution to specific HTTP methods (GET, POST, etc.) while maintaining a consistent command structure across environments.
## How It Works
The `ActionRegistry` parses `@Action` annotations to build a routing table. For parameterized methods, the framework automatically maps Java parameter types (int, String, etc.) to corresponding regex segments to generate an internal matching pattern. For instance, `getUser(int id)` generates a regex targeting digits, while `search(String query)` targets generic path segments.
When a request is dispatched, the `ActionRegistry` automatically injects dependencies like `Request` and `Response` into the action method if they are specified as parameters, drawing them directly from the current request's `Context`. Execution is further filtered by the `Mode` value, allowing a single path to invoke different logic depending on whether the trigger was a terminal command or a specific type of HTTP request.
### Mode Values
| Mode | When it triggers |
|---|---|
| `DEFAULT` | Both CLI and HTTP (GET, POST, etc.) |
| `CLI` | CLI dispatcher only |
| `HTTP_GET` | HTTP GET only |
| `HTTP_POST` | HTTP POST only |
| `HTTP_PUT` | HTTP PUT only |
| `HTTP_DELETE` | HTTP DELETE only |
| `HTTP_PATCH` | HTTP PATCH only |
## Examples
### Basic Action Declaration
```java
@Action(
value = "path/subpath", // required: URI segment or CLI command
description = "What it does", // shown in --help output
mode = Mode.HTTP_POST, // default: Mode.DEFAULT (both CLI + HTTP)
options = {}, // CLI option flags
example = "curl -X POST http://localhost:8080/path/subpath/42"
)
public String myAction(int id) { ... }
```
### Parameterized Paths (Regex Generation)
```java
@Action("user/{id}")
public String getUser(int id) { ... }
// → pattern: ^/?user/(-?\d+)$
@Action("search")
public String search(String query) { ... }
// → pattern: ^/?search/([^/]+)$
```
### Request and Response Injection
```java
@Action(value = "upload", mode = Mode.HTTP_POST)
public String upload(Request<?, ?> req, Response<?, ?> res) throws ApplicationException {
// req.getParameter("file"), res.setHeader(...), etc.
return "ok";
}
```
@@ -0,0 +1,74 @@
# tinystruct System and Usage Reference
## When to Use
Use the system and usage patterns described here when you need to handle stateful interactions across CLI and HTTP modes, manage user sessions in web applications, or implement loosely coupled communication between application modules using an event-driven architecture.
## How It Works
The framework's `Context` serves as the primary data store for request-specific state. In CLI mode, flags passed as `--key value` are automatically parsed and stored in the `Context` with the `--` prefix, allowing action methods to retrieve command parameters easily. For web applications, the system provides standard session management via the `Request` object, enabling the storage of user data across multiple HTTP requests.
The internal `EventDispatcher` facilitates an asynchronous event bus. By defining custom `Event` classes and registering handlers (typically within an application's `init()` method), you can trigger background tasks—such as sending emails or logging audit trails—without blocking the main execution path.
## Examples
### Context and CLI Arguments
```java
@Action("echo")
public String echo() {
// CLI: bin/dispatcher echo --words "Hello World"
Object words = getContext().getAttribute("--words");
if (words != null) return words.toString();
return "No words provided";
}
```
### Session Management (Web Mode)
```java
@Action(value = "login", mode = Mode.HTTP_POST)
public String login(Request request) {
request.getSession().setAttribute("userId", "42");
return "Logged in";
}
@Action("profile")
public String profile(Request request) {
Object userId = request.getSession().getAttribute("userId");
if (userId == null) return "Not logged in";
return "User: " + userId;
}
```
### Event System
```java
// 1. Define an event
public class OrderCreatedEvent implements org.tinystruct.system.Event<Order> {
private final Order order;
public OrderCreatedEvent(Order order) { this.order = order; }
@Override public String getName() { return "order_created"; }
@Override public Order getPayload() { return order; }
}
// 2. Register a handler
EventDispatcher.getInstance().registerHandler(OrderCreatedEvent.class, event -> {
CompletableFuture.runAsync(() -> sendConfirmationEmail(event.getPayload()));
});
// 3. Dispatch
EventDispatcher.getInstance().dispatch(new OrderCreatedEvent(newOrder));
```
### Running the Application
```bash
# CLI mode
bin/dispatcher hello
bin/dispatcher echo --words "Hello" --import com.example.HelloApp
# HTTP server (listens on :8080 by default)
bin/dispatcher start --import org.tinystruct.system.HttpServer
# Database utilities
bin/dispatcher generate --table users
bin/dispatcher sql-query "SELECT * FROM users"
```
@@ -0,0 +1,59 @@
# tinystruct Testing Patterns
## When to Use
Use the testing patterns described here when writing units tests for your tinystruct applications with **JUnit 5**. These patterns are essential for verifying that your `@Action` methods return the correct results and that your routing logic is properly registered within the singleton `ActionRegistry`.
## How It Works
Testing tinystruct applications requires a specific setup to ensure framework-level features like annotation processing and configuration management are active. By creating a new instance of your application and passing it a `Settings` object in the `setUp()` method, you trigger the `init()` lifecycle. This ensures all `@Action` methods are discovered and registered.
Because the `ActionRegistry` is a singleton, it is critical to maintain isolation between tests by properly initializing your application state before each test execution, preventing side effects from leaking across the test suite.
## Examples
### Unit Testing an Application
```java
import org.junit.jupiter.api.*;
import org.tinystruct.application.ActionRegistry;
import org.tinystruct.system.Settings;
class MyAppTest {
private MyApp app;
@BeforeEach
void setUp() {
app = new MyApp();
Settings config = new Settings();
app.setConfiguration(config);
app.init(); // triggers @Action annotation processing
}
void testHello() throws Exception {
// Direct invocation via the application object
Object result = app.invoke("hello");
Assertions.assertEquals("Hello, tinystruct!", result);
}
@Test
void testGreet() throws Exception {
// Invocation with arguments
Object result = app.invoke("greet", new Object[]{"James"});
Assertions.assertEquals("Hello, James!", result);
}
}
```
### Testing via ActionRegistry
If you need to test the routing logic itself, use the `ActionRegistry` singleton to verify path matching:
```java
@Test
void testRouting() {
ActionRegistry registry = ActionRegistry.getInstance();
// Verify a path matches an action
Action action = registry.getAction("greet/James");
Assertions.assertNotNull(action);
}
```
Reference: `src/test/java/org/tinystruct/application/ActionRegistryTest.java`
+18
View File
@@ -57,6 +57,24 @@ class ToolDefinition:
"strict": self.strict,
}
def to_openai_tool(self) -> dict[str, Any]:
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
"strict": self.strict,
},
}
def to_anthropic_tool(self) -> dict[str, Any]:
return {
"name": self.name,
"description": self.description,
"input_schema": self.parameters,
}
@dataclass(frozen=True)
class ToolCall:
+1 -1
View File
@@ -60,7 +60,7 @@ class ClaudeProvider(LLMProvider):
else:
params["max_tokens"] = 8192 # required by Anthropic API
if input.tools:
params["tools"] = [tool.to_dict() for tool in input.tools]
params["tools"] = [tool.to_anthropic_tool() for tool in input.tools]
response = self.client.messages.create(**params)
+1 -1
View File
@@ -67,7 +67,7 @@ class OpenAIProvider(LLMProvider):
if input.max_tokens:
params["max_tokens"] = input.max_tokens
if input.tools:
params["tools"] = [tool.to_dict() for tool in input.tools]
params["tools"] = [tool.to_openai_tool() for tool in input.tools]
response = self.client.chat.completions.create(**params)
choice = response.choices[0]
+38 -2
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import os
from pathlib import Path
from llm.core.interface import LLMProvider
from llm.core.types import ProviderType
@@ -17,10 +18,45 @@ _PROVIDER_MAP: dict[ProviderType, type[LLMProvider]] = {
ProviderType.OLLAMA: OllamaProvider,
}
LLM_ENV_FILE = ".llm.env"
def _strip_env_value(value: str) -> str:
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
return value[1:-1]
return value
def _read_saved_llm_config(env_path: str | Path = LLM_ENV_FILE) -> dict[str, str]:
path = Path(env_path)
if not path.is_file():
return {}
config: dict[str, str] = {}
for line in path.read_text().splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, value = stripped.split("=", 1)
config[key.strip()] = _strip_env_value(value)
return config
def _resolve_provider_type(provider_type: ProviderType | str | None) -> ProviderType | str:
if provider_type is not None:
return provider_type
env_provider = os.environ.get("LLM_PROVIDER")
if env_provider:
return _strip_env_value(env_provider).lower()
saved_config = _read_saved_llm_config()
return saved_config.get("LLM_PROVIDER", "claude").lower()
def get_provider(provider_type: ProviderType | str | None = None, **kwargs: str) -> LLMProvider:
if provider_type is None:
provider_type = os.environ.get("LLM_PROVIDER", "claude").lower()
provider_type = _resolve_provider_type(provider_type)
if isinstance(provider_type, str):
try:
+26
View File
@@ -44,6 +44,7 @@ function writeEnglishReadme(root, counts, options = {}) {
const unrelatedSkillsCount = options.unrelatedSkillsCount || 16;
fs.writeFileSync(path.join(root, 'README.md'), `Access to ${counts.agents} agents, ${counts.skills} skills, and ${counts.commands} commands.
|-- agents/ # ${counts.agents} specialized subagents for delegation
| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode |
| --- | --- | --- | --- | --- |
| Agents | PASS: ${tableCounts.agents} agents |
@@ -64,6 +65,22 @@ function writeEnglishReadme(root, counts, options = {}) {
`);
}
function writePluginMetadata(root, counts) {
const pluginDir = path.join(root, '.claude-plugin');
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(path.join(pluginDir, 'plugin.json'), JSON.stringify({
name: 'ecc',
description: `Fixture plugin — ${counts.agents} agents, ${counts.skills} skills, ${counts.commands} legacy command shims`,
}, null, 2));
fs.writeFileSync(path.join(pluginDir, 'marketplace.json'), JSON.stringify({
plugins: [{
name: 'ecc',
description: `Fixture marketplace plugin — ${counts.agents} agents, ${counts.skills} skills, ${counts.commands} legacy command shims`,
}],
}, null, 2));
}
function writeEnglishAgents(root, counts, options = {}) {
const plus = options.skillsMinimum ? '+' : '';
@@ -143,6 +160,7 @@ function writeCatalogFixture(root, options = {}) {
writeZhRootReadme(root, documentedCounts);
writeZhDocsReadme(root, documentedCounts, { unrelatedSkillsCount });
writeZhAgents(root, documentedCounts, { skillsMinimum });
writePluginMetadata(root, documentedCounts);
}
function test(name, fn) {
@@ -203,7 +221,10 @@ function runTests() {
.join('\n');
assert.ok(formatted.includes('README.md quick-start summary'));
assert.ok(formatted.includes('README.md project tree'));
assert.ok(formatted.includes('AGENTS.md summary'));
assert.ok(formatted.includes('.claude-plugin/plugin.json description'));
assert.ok(formatted.includes('.claude-plugin/marketplace.json plugin description'));
assert.ok(formatted.includes('README.zh-CN.md quick-start summary'));
assert.ok(formatted.includes('docs/zh-CN/README.md parity table'));
assert.ok(formatted.includes('docs/zh-CN/AGENTS.md project structure'));
@@ -230,14 +251,19 @@ function runTests() {
const agentsDoc = fs.readFileSync(path.join(testDir, 'AGENTS.md'), 'utf8');
const zhReadme = fs.readFileSync(path.join(testDir, 'docs', 'zh-CN', 'README.md'), 'utf8');
const zhAgentsDoc = fs.readFileSync(path.join(testDir, 'docs', 'zh-CN', 'AGENTS.md'), 'utf8');
const pluginJson = fs.readFileSync(path.join(testDir, '.claude-plugin', 'plugin.json'), 'utf8');
const marketplaceJson = fs.readFileSync(path.join(testDir, '.claude-plugin', 'marketplace.json'), 'utf8');
assert.ok(readme.includes('Access to 1 agents, 1 skills, and 1 legacy command shims'));
assert.ok(readme.includes('|-- agents/ # 1 specialized subagents for delegation'));
assert.ok(readme.includes('| Skills | 42 | .agents/skills/ |'));
assert.ok(agentsDoc.includes('providing 1 specialized agents, 1+ skills, 1 commands'));
assert.ok(agentsDoc.includes('skills/ - 1+ workflow skills and domain knowledge'));
assert.ok(zhReadme.includes('| 技能 | 42 | .agents/skills/ |'));
assert.ok(zhAgentsDoc.includes('提供 1 个专业代理、1+ 项技能、1 条命令'));
assert.ok(zhAgentsDoc.includes('skills/ - 1+ 个工作流技能和领域知识'));
assert.ok(pluginJson.includes('1 agents, 1 skills, 1 legacy command shims'));
assert.ok(marketplaceJson.includes('1 agents, 1 skills, 1 legacy command shims'));
} finally {
cleanupTestDir(testDir);
}
+211
View File
@@ -0,0 +1,211 @@
/**
* Tests for scripts/ci/validate-no-personal-paths.js.
*
* Run with: node tests/ci/no-personal-paths.test.js
*/
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const repoRoot = path.join(__dirname, '..', '..');
const validatorPath = path.join(repoRoot, 'scripts', 'ci', 'validate-no-personal-paths.js');
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (err) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${err.message}`);
return false;
}
}
function createTestDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'no-personal-paths-test-'));
}
function cleanupTestDir(testDir) {
fs.rmSync(testDir, { recursive: true, force: true });
}
function writeFile(filePath, content) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
function stripShebang(source) {
let result = source;
if (result.charCodeAt(0) === 0xFEFF) result = result.slice(1);
if (result.startsWith('#!')) {
const newline = result.indexOf('\n');
result = newline === -1 ? '' : result.slice(newline + 1);
}
return result;
}
function runValidatorAgainst(testDir) {
let source = fs.readFileSync(validatorPath, 'utf8');
source = stripShebang(source);
source = source.replace(
/const ROOT = .*?;/,
`const ROOT = ${JSON.stringify(testDir)};`,
);
const tmpFile = path.join(
os.tmpdir(),
`no-personal-paths-${Date.now()}-${Math.random().toString(36).slice(2)}.js`,
);
try {
fs.writeFileSync(tmpFile, source, 'utf8');
const stdout = execFileSync('node', [tmpFile], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 10000,
cwd: repoRoot,
});
return { code: 0, stdout, stderr: '' };
} catch (err) {
return {
code: err.status || 1,
stdout: err.stdout || '',
stderr: err.stderr || '',
};
} finally {
try { fs.unlinkSync(tmpFile); } catch (_) { /* ignore cleanup errors */ }
}
}
function runValidatorAgainstRealRepo() {
try {
const stdout = execFileSync('node', [validatorPath], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 10000,
cwd: repoRoot,
});
return { code: 0, stdout, stderr: '' };
} catch (err) {
return {
code: err.status || 1,
stdout: err.stdout || '',
stderr: err.stderr || '',
};
}
}
console.log('\n=== Testing validate-no-personal-paths.js ===\n');
let passed = 0;
let failed = 0;
function record(ok) {
if (ok) passed += 1;
else failed += 1;
}
record(test('passes against the real repository', () => {
const result = runValidatorAgainstRealRepo();
assert.strictEqual(result.code, 0, `expected exit 0; stderr: ${result.stderr}`);
assert.ok(result.stdout.includes('Validated:'), 'expected success line in stdout');
}));
record(test('flags a leaked /Users/<name> path', () => {
const testDir = createTestDir();
try {
writeFile(path.join(testDir, 'skills', 'leaky', 'SKILL.md'), 'See /Users/sugig/.claude/settings.json\n');
const result = runValidatorAgainst(testDir);
assert.strictEqual(result.code, 1, 'expected non-zero exit on leak');
assert.ok(result.stderr.includes('/Users/sugig'), `expected stderr to mention leaked path; got: ${result.stderr}`);
assert.ok(result.stderr.includes('skills/leaky/SKILL.md'), `expected normalized file path; got: ${result.stderr}`);
} finally {
cleanupTestDir(testDir);
}
}));
record(test('flags a leaked C:\\Users\\<name> path case-insensitively', () => {
const testDir = createTestDir();
try {
writeFile(path.join(testDir, 'docs', 'guide.md'), 'See C:\\Users\\Affaan\\projects\\thing\n');
const result = runValidatorAgainst(testDir);
assert.strictEqual(result.code, 1, 'expected non-zero exit on leak');
assert.ok(result.stderr.includes('C:\\Users\\Affaan'), `expected stderr to mention leaked path; got: ${result.stderr}`);
} finally {
cleanupTestDir(testDir);
}
}));
record(test('allows /Users/<placeholder> templates', () => {
const testDir = createTestDir();
try {
writeFile(path.join(testDir, 'commands', 'demo.md'), [
'/Users/you/.claude/session.json',
'/Users/example/.claude/rules/foo.md',
'/Users/yourname/projects/app',
'/Users/your-username/.claude/settings.json',
'C:\\Users\\USER\\.claude\\settings.json',
].join('\n'));
const result = runValidatorAgainst(testDir);
assert.strictEqual(result.code, 0, `expected exit 0 for placeholders; stderr: ${result.stderr}`);
} finally {
cleanupTestDir(testDir);
}
}));
record(test('exempts docs/fixes forensic reports', () => {
const testDir = createTestDir();
try {
writeFile(
path.join(testDir, 'docs', 'fixes', 'HOOK-FIX-EXAMPLE.md'),
'Reporter ran: C:\\Users\\sugig\\.claude\\settings.local.json\n',
);
const result = runValidatorAgainst(testDir);
assert.strictEqual(result.code, 0, `expected exit 0 for docs/fixes; stderr: ${result.stderr}`);
} finally {
cleanupTestDir(testDir);
}
}));
record(test('only scans configured file extensions', () => {
const testDir = createTestDir();
try {
writeFile(path.join(testDir, 'skills', 'demo', 'image.png'), 'binary /Users/sugig/secret');
const result = runValidatorAgainst(testDir);
assert.strictEqual(result.code, 0, `expected non-text extensions to be skipped; stderr: ${result.stderr}`);
} finally {
cleanupTestDir(testDir);
}
}));
record(test('reports every leak on a single offending file', () => {
const testDir = createTestDir();
try {
writeFile(path.join(testDir, 'skills', 'multi', 'SKILL.md'), [
'/Users/sugig/.claude/a.json',
'/Users/sugig/.claude/b.json',
'C:\\Users\\foo\\bar',
].join('\n'));
const result = runValidatorAgainst(testDir);
assert.strictEqual(result.code, 1, 'expected non-zero exit on leak');
const sugigCount = (result.stderr.match(/\/Users\/sugig/g) || []).length;
const fooCount = (result.stderr.match(/C:\\Users\\foo/g) || []).length;
assert.strictEqual(sugigCount, 2, `expected both /Users/sugig occurrences reported; got: ${result.stderr}`);
assert.strictEqual(fooCount, 1, `expected C:\\Users\\foo reported once; got: ${result.stderr}`);
} finally {
cleanupTestDir(testDir);
}
}));
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}\n`);
if (failed > 0) {
process.exit(1);
}
@@ -81,6 +81,24 @@ function run() {
assert.match(result.stderr, /pull_request\.head\.sha/);
})) passed++; else failed++;
// Quoted action names are valid YAML. The checkout-step filter must still
// inspect their `with.ref` values in privileged workflows.
if (test('rejects pull_request_target checkout when uses is double-quoted', () => {
const result = runValidator({
'unsafe-double-quoted.yml': `name: Unsafe\non:\n pull_request_target:\n branches: [main]\njobs:\n inspect:\n runs-on: ubuntu-latest\n steps:\n - uses: "actions/checkout@v4"\n with:\n ref: \${{ github.event.pull_request.head.sha }}\n`,
});
assert.notStrictEqual(result.status, 0, 'Expected validator to fail on double-quoted uses:');
assert.match(result.stderr, /pull_request\.head\.sha/);
})) passed++; else failed++;
if (test('rejects pull_request_target checkout when uses is single-quoted', () => {
const result = runValidator({
'unsafe-single-quoted.yml': `name: Unsafe\non:\n pull_request_target:\n branches: [main]\njobs:\n inspect:\n runs-on: ubuntu-latest\n steps:\n - uses: 'actions/checkout@v4'\n with:\n ref: \${{ github.event.pull_request.head.sha }}\n`,
});
assert.notStrictEqual(result.status, 0, 'Expected validator to fail on single-quoted uses:');
assert.match(result.stderr, /pull_request\.head\.sha/);
})) passed++; else failed++;
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
+265 -3
View File
@@ -11,7 +11,7 @@ const assert = require('assert');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { execFileSync } = require('child_process');
const { execFileSync, spawnSync } = require('child_process');
const validatorsDir = path.join(__dirname, '..', '..', 'scripts', 'ci');
const repoRoot = path.join(__dirname, '..', '..');
@@ -169,6 +169,8 @@ function runCatalogValidator(overrides = {}) {
README_ZH_CN_PATH: path.join(repoRoot, 'README.zh-CN.md'),
DOCS_ZH_CN_README_PATH: path.join(repoRoot, 'docs', 'zh-CN', 'README.md'),
DOCS_ZH_CN_AGENTS_PATH: path.join(repoRoot, 'docs', 'zh-CN', 'AGENTS.md'),
PLUGIN_JSON_PATH: path.join(repoRoot, '.claude-plugin', 'plugin.json'),
MARKETPLACE_JSON_PATH: path.join(repoRoot, '.claude-plugin', 'marketplace.json'),
...overrides,
};
@@ -180,9 +182,52 @@ function runCatalogValidator(overrides = {}) {
return runSourceViaTempFile(source);
}
// Run validate-skills.js against a fixture dir, optionally passing
// extra argv (e.g. '--strict') and env overrides (e.g.
// CI_STRICT_SKILLS=1) so the frontmatter finding suite can exercise
// both warn and strict modes via argv and env code paths.
//
// Captures stderr on both success and failure (the shared
// runSourceViaTempFile helper only surfaces stderr when the child
// exits non-zero, which hides WARN lines in the default mode).
function runSkillsValidator(testDir, argv = [], envOverrides = {}) {
const validatorPath = path.join(validatorsDir, 'validate-skills.js');
let source = fs.readFileSync(validatorPath, 'utf8');
source = stripShebang(source);
source = source.replace(
/const SKILLS_DIR = .*?;/,
`const SKILLS_DIR = ${JSON.stringify(testDir)};`,
);
if (argv.length > 0) {
const argvPreamble = argv
.map(arg => `process.argv.push(${JSON.stringify(arg)});`)
.join('\n');
source = `${argvPreamble}\n${source}`;
}
const tmpFile = path.join(repoRoot,
`.tmp-validator-${Date.now()}-${Math.random().toString(36).slice(2)}.js`);
try {
fs.writeFileSync(tmpFile, source, 'utf8');
const r = spawnSync('node', [tmpFile], {
encoding: 'utf8',
timeout: 10000,
cwd: repoRoot,
env: { ...process.env, CI_STRICT_SKILLS: '', ...envOverrides },
});
return {
code: typeof r.status === 'number' ? r.status : 1,
stdout: r.stdout || '',
stderr: r.stderr || '',
};
} finally {
try { fs.unlinkSync(tmpFile); } catch (_) { /* ignore */ }
}
}
function writeCatalogFixture(testDir, options = {}) {
const {
readmeCounts = { agents: 1, skills: 1, commands: 1 },
readmeProjectTreeAgents = readmeCounts.agents,
readmeTableCounts = readmeCounts,
readmeParityCounts = readmeCounts,
readmeUnrelatedSkillsCount = 16,
@@ -203,6 +248,8 @@ function writeCatalogFixture(testDir, options = {}) {
'skills/ — 1 个工作流技能和领域知识',
'commands/ — 1 个斜杠命令',
],
pluginCounts = { agents: 1, skills: 1, commands: 1 },
marketplaceCounts = { agents: 1, skills: 1, commands: 1 },
} = options;
const readmePath = path.join(testDir, 'README.md');
@@ -210,23 +257,36 @@ function writeCatalogFixture(testDir, options = {}) {
const zhRootReadmePath = path.join(testDir, 'README.zh-CN.md');
const zhDocsReadmePath = path.join(testDir, 'docs', 'zh-CN', 'README.md');
const zhAgentsPath = path.join(testDir, 'docs', 'zh-CN', 'AGENTS.md');
const pluginJsonPath = path.join(testDir, '.claude-plugin', 'plugin.json');
const marketplaceJsonPath = path.join(testDir, '.claude-plugin', 'marketplace.json');
fs.mkdirSync(path.join(testDir, 'agents'), { recursive: true });
fs.mkdirSync(path.join(testDir, 'commands'), { recursive: true });
fs.mkdirSync(path.join(testDir, 'skills', 'demo-skill'), { recursive: true });
fs.mkdirSync(path.join(testDir, 'docs', 'zh-CN'), { recursive: true });
fs.mkdirSync(path.join(testDir, '.claude-plugin'), { recursive: true });
fs.writeFileSync(path.join(testDir, 'agents', 'planner.md'), '---\nmodel: sonnet\ntools: Read\n---\n# Planner');
fs.writeFileSync(path.join(testDir, 'commands', 'plan.md'), '---\ndescription: Plan\n---\n# Plan');
fs.writeFileSync(path.join(testDir, 'skills', 'demo-skill', 'SKILL.md'), '---\nname: demo-skill\ndescription: Demo skill\norigin: ECC\n---\n# Demo Skill');
fs.writeFileSync(readmePath, `Access to ${readmeCounts.agents} agents, ${readmeCounts.skills} skills, and ${readmeCounts.commands} commands.\n| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode |\n|---------|------------|------------|-----------|----------|\n| Agents | PASS: ${readmeTableCounts.agents} agents | Shared | Shared | 1 |\n| Commands | PASS: ${readmeTableCounts.commands} commands | Shared | Shared | 1 |\n| Skills | PASS: ${readmeTableCounts.skills} skills | Shared | Shared | 1 |\n\n| Feature | Count | Format |\n|-----------|-------|---------|\n| Skills | ${readmeUnrelatedSkillsCount} | .agents/skills/ |\n\n## Cross-Tool Feature Parity\n\n| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode |\n|---------|------------|------------|-----------|----------|\n| **Agents** | ${readmeParityCounts.agents} | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |\n| **Commands** | ${readmeParityCounts.commands} | Shared | Instruction-based | 31 |\n| **Skills** | ${readmeParityCounts.skills} | Shared | 10 (native format) | 37 |\n`);
fs.writeFileSync(readmePath, `Access to ${readmeCounts.agents} agents, ${readmeCounts.skills} skills, and ${readmeCounts.commands} commands.\n|-- agents/ # ${readmeProjectTreeAgents} specialized subagents for delegation\n| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode |\n|---------|------------|------------|-----------|----------|\n| Agents | PASS: ${readmeTableCounts.agents} agents | Shared | Shared | 1 |\n| Commands | PASS: ${readmeTableCounts.commands} commands | Shared | Shared | 1 |\n| Skills | PASS: ${readmeTableCounts.skills} skills | Shared | Shared | 1 |\n\n| Feature | Count | Format |\n|-----------|-------|---------|\n| Skills | ${readmeUnrelatedSkillsCount} | .agents/skills/ |\n\n## Cross-Tool Feature Parity\n\n| Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode |\n|---------|------------|------------|-----------|----------|\n| **Agents** | ${readmeParityCounts.agents} | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |\n| **Commands** | ${readmeParityCounts.commands} | Shared | Instruction-based | 31 |\n| **Skills** | ${readmeParityCounts.skills} | Shared | 10 (native format) | 37 |\n`);
fs.writeFileSync(agentsPath, `This is a **production-ready AI coding plugin** providing ${summaryCounts.agents} specialized agents, ${summaryCounts.skills} skills, ${summaryCounts.commands} commands, and automated hook workflows for software development.\n\n\`\`\`\n${structureLines.join('\n')}\n\`\`\`\n`);
fs.writeFileSync(zhRootReadmePath, `**完成!** 你现在可以使用 ${zhRootReadmeCounts.agents} 个代理、${zhRootReadmeCounts.skills} 个技能和 ${zhRootReadmeCounts.commands} 个命令。\n`);
fs.writeFileSync(zhDocsReadmePath, `**搞定!** 你现在可以使用 ${zhDocsReadmeCounts.agents} 个智能体、${zhDocsReadmeCounts.skills} 项技能和 ${zhDocsReadmeCounts.commands} 个命令了。\n| 功能特性 | Claude Code | OpenCode | 状态 |\n|---------|-------------|----------|--------|\n| 智能体 | \u2705 ${zhDocsTableCounts.agents} 个 | \u2705 12 个 | **Claude Code 领先** |\n| 命令 | \u2705 ${zhDocsTableCounts.commands} 个 | \u2705 31 个 | **Claude Code 领先** |\n| 技能 | \u2705 ${zhDocsTableCounts.skills} 项 | \u2705 37 项 | **Claude Code 领先** |\n\n| 功能特性 | 数量 | 格式 |\n|-----------|-------|---------|\n| 技能 | ${zhDocsUnrelatedSkillsCount} | .agents/skills/ |\n\n## 跨工具功能对等\n\n| 功能特性 | Claude Code | Cursor IDE | Codex CLI | OpenCode |\n|---------|------------|------------|-----------|----------|\n| **智能体** | ${zhDocsParityCounts.agents} | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |\n| **命令** | ${zhDocsParityCounts.commands} | 共享 | 基于指令 | 31 |\n| **技能** | ${zhDocsParityCounts.skills} | 共享 | 10 (原生格式) | 37 |\n`);
fs.writeFileSync(zhAgentsPath, `这是一个**生产就绪的 AI 编码插件**,提供 ${zhAgentsSummaryCounts.agents} 个专业代理、${zhAgentsSummaryCounts.skills} 项技能、${zhAgentsSummaryCounts.commands} 条命令以及自动化钩子工作流,用于软件开发。\n\n\`\`\`\n${zhAgentsStructureLines.join('\n')}\n\`\`\`\n`);
fs.writeFileSync(pluginJsonPath, JSON.stringify({
name: 'ecc',
description: `Battle-tested plugin — ${pluginCounts.agents} agents, ${pluginCounts.skills} skills, ${pluginCounts.commands} legacy command shims`,
}, null, 2));
fs.writeFileSync(marketplaceJsonPath, JSON.stringify({
plugins: [{
name: 'ecc',
description: `Marketplace plugin — ${marketplaceCounts.agents} agents, ${marketplaceCounts.skills} skills, ${marketplaceCounts.commands} legacy command shims`,
}],
}, null, 2));
return { readmePath, agentsPath, zhRootReadmePath, zhDocsReadmePath, zhAgentsPath };
return { readmePath, agentsPath, zhRootReadmePath, zhDocsReadmePath, zhAgentsPath, pluginJsonPath, marketplaceJsonPath };
}
function runTests() {
@@ -375,6 +435,8 @@ function runTests() {
zhRootReadmePath,
zhDocsReadmePath,
zhAgentsPath,
pluginJsonPath,
marketplaceJsonPath,
} = writeCatalogFixture(testDir, {
readmeCounts: { agents: 99, skills: 99, commands: 99 },
readmeTableCounts: { agents: 99, skills: 99, commands: 99 },
@@ -404,6 +466,8 @@ function runTests() {
README_ZH_CN_PATH: zhRootReadmePath,
DOCS_ZH_CN_README_PATH: zhDocsReadmePath,
DOCS_ZH_CN_AGENTS_PATH: zhAgentsPath,
PLUGIN_JSON_PATH: pluginJsonPath,
MARKETPLACE_JSON_PATH: marketplaceJsonPath,
});
assert.strictEqual(result.code, 1, 'Should fail when catalog counts drift');
@@ -419,6 +483,8 @@ function runTests() {
zhRootReadmePath,
zhDocsReadmePath,
zhAgentsPath,
pluginJsonPath,
marketplaceJsonPath,
} = writeCatalogFixture(testDir, {
readmeCounts: { agents: 1, skills: 1, commands: 1 },
readmeTableCounts: { agents: 1, skills: 1, commands: 1 },
@@ -433,6 +499,8 @@ function runTests() {
README_ZH_CN_PATH: zhRootReadmePath,
DOCS_ZH_CN_README_PATH: zhDocsReadmePath,
DOCS_ZH_CN_AGENTS_PATH: zhAgentsPath,
PLUGIN_JSON_PATH: pluginJsonPath,
MARKETPLACE_JSON_PATH: marketplaceJsonPath,
});
assert.strictEqual(result.code, 1, 'Should fail when README parity table drifts');
@@ -450,6 +518,8 @@ function runTests() {
agentsPath,
zhRootReadmePath,
zhDocsReadmePath,
pluginJsonPath,
marketplaceJsonPath,
} = writeCatalogFixture(testDir);
const missingZhAgentsPath = path.join(testDir, 'docs', 'zh-CN', 'AGENTS.md');
fs.rmSync(missingZhAgentsPath);
@@ -461,6 +531,8 @@ function runTests() {
README_ZH_CN_PATH: zhRootReadmePath,
DOCS_ZH_CN_README_PATH: zhDocsReadmePath,
DOCS_ZH_CN_AGENTS_PATH: missingZhAgentsPath,
PLUGIN_JSON_PATH: pluginJsonPath,
MARKETPLACE_JSON_PATH: marketplaceJsonPath,
});
assert.strictEqual(result.code, 1, 'Should fail when a tracked doc is missing');
@@ -479,6 +551,8 @@ function runTests() {
zhRootReadmePath,
zhDocsReadmePath,
zhAgentsPath,
pluginJsonPath,
marketplaceJsonPath,
} = writeCatalogFixture(testDir, {
readmeCounts: { agents: 9, skills: 9, commands: 9 },
readmeTableCounts: { agents: 8, skills: 8, commands: 8 },
@@ -489,6 +563,8 @@ function runTests() {
zhDocsTableCounts: { agents: 12, skills: 12, commands: 12 },
zhDocsParityCounts: { agents: 13, skills: 13, commands: 13 },
zhAgentsSummaryCounts: { agents: 14, skills: 14, commands: 14 },
pluginCounts: { agents: 18, skills: 18, commands: 18 },
marketplaceCounts: { agents: 19, skills: 19, commands: 19 },
zhAgentsStructureLines: [
'agents/ — 15 个专业子代理',
'skills/ — 16 个工作流技能和领域知识',
@@ -504,6 +580,8 @@ function runTests() {
README_ZH_CN_PATH: zhRootReadmePath,
DOCS_ZH_CN_README_PATH: zhDocsReadmePath,
DOCS_ZH_CN_AGENTS_PATH: zhAgentsPath,
PLUGIN_JSON_PATH: pluginJsonPath,
MARKETPLACE_JSON_PATH: marketplaceJsonPath,
});
assert.strictEqual(result.code, 0, `Should sync and pass, got stderr: ${result.stderr}`);
@@ -513,8 +591,11 @@ function runTests() {
const zhRootReadme = fs.readFileSync(zhRootReadmePath, 'utf8');
const zhDocsReadme = fs.readFileSync(zhDocsReadmePath, 'utf8');
const zhAgentsDoc = fs.readFileSync(zhAgentsPath, 'utf8');
const pluginJson = fs.readFileSync(pluginJsonPath, 'utf8');
const marketplaceJson = fs.readFileSync(marketplaceJsonPath, 'utf8');
assert.ok(readme.includes('Access to 1 agents, 1 skills, and 1 legacy command shims'), 'Should sync README quick-start summary');
assert.ok(readme.includes('|-- agents/ # 1 specialized subagents for delegation'), 'Should sync README project tree agents count');
assert.ok(readme.includes('| Agents | PASS: 1 agents |'), 'Should sync README comparison table');
assert.ok(readme.includes('| Skills | 16 | .agents/skills/ |'), 'Should not rewrite unrelated README tables');
assert.ok(readme.includes('| **Agents** | 1 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 |'), 'Should sync README parity table');
@@ -527,6 +608,8 @@ function runTests() {
assert.ok(zhDocsReadme.includes('| **智能体** | 1 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |'), 'Should sync docs/zh-CN/README parity table');
assert.ok(zhAgentsDoc.includes('提供 1 个专业代理、1 项技能、1 条命令'), 'Should sync docs/zh-CN/AGENTS summary');
assert.ok(zhAgentsDoc.includes('commands/ — 1 个斜杠命令'), 'Should sync docs/zh-CN/AGENTS structure');
assert.ok(pluginJson.includes('1 agents, 1 skills, 1 legacy command shims'), 'Should sync plugin manifest catalog description');
assert.ok(marketplaceJson.includes('1 agents, 1 skills, 1 legacy command shims'), 'Should sync marketplace plugin catalog description');
cleanupTestDir(testDir);
})) passed++; else failed++;
@@ -539,6 +622,8 @@ function runTests() {
zhRootReadmePath,
zhDocsReadmePath,
zhAgentsPath,
pluginJsonPath,
marketplaceJsonPath,
} = writeCatalogFixture(testDir, {
structureLines: [
' agents/ - 1 specialized subagents ',
@@ -559,6 +644,8 @@ function runTests() {
README_ZH_CN_PATH: zhRootReadmePath,
DOCS_ZH_CN_README_PATH: zhDocsReadmePath,
DOCS_ZH_CN_AGENTS_PATH: zhAgentsPath,
PLUGIN_JSON_PATH: pluginJsonPath,
MARKETPLACE_JSON_PATH: marketplaceJsonPath,
});
assert.strictEqual(result.code, 0, `Should accept formatting variations, got stderr: ${result.stderr}`);
@@ -801,6 +888,181 @@ function runTests() {
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('warns when frontmatter is missing name (default mode)', () => {
const testDir = createTestDir();
const skillDir = path.join(testDir, 'no-name-skill');
fs.mkdirSync(skillDir);
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
'---\ndescription: "X"\norigin: ECC\n---\n# Skill');
const result = runSkillsValidator(testDir);
assert.strictEqual(result.code, 0,
`Default mode must not fail CI; got stderr: ${result.stderr}`);
assert.ok(
result.stderr.includes('WARN') && result.stderr.includes('missing required field: name'),
`Should warn on missing name; got stderr: ${result.stderr}`);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('errors when frontmatter is missing name (strict mode)', () => {
const testDir = createTestDir();
const skillDir = path.join(testDir, 'no-name-skill');
fs.mkdirSync(skillDir);
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
'---\ndescription: "X"\norigin: ECC\n---\n# Skill');
const result = runSkillsValidator(testDir, ['--strict']);
assert.strictEqual(result.code, 1, '--strict must fail CI on missing name');
assert.ok(result.stderr.includes('missing required field: name'),
'Should report missing name');
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('warns on literal block-scalar description (|-)', () => {
const testDir = createTestDir();
const skillDir = path.join(testDir, 'block-desc-skill');
fs.mkdirSync(skillDir);
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
'---\nname: block-desc-skill\ndescription: |-\n line one\n line two\norigin: ECC\n---\n# Skill');
const result = runSkillsValidator(testDir);
assert.strictEqual(result.code, 0, 'Default mode should not fail CI');
assert.ok(
result.stderr.includes('WARN') && result.stderr.includes('literal block scalar'),
`Should warn on |- description; got stderr: ${result.stderr}`);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('accepts folded (>) and inline descriptions', () => {
const testDir = createTestDir();
const folded = path.join(testDir, 'folded-skill');
fs.mkdirSync(folded);
fs.writeFileSync(path.join(folded, 'SKILL.md'),
'---\nname: folded-skill\ndescription: >\n joined\n on spaces\norigin: ECC\n---\n# Skill');
const inline = path.join(testDir, 'inline-skill');
fs.mkdirSync(inline);
fs.writeFileSync(path.join(inline, 'SKILL.md'),
'---\nname: inline-skill\ndescription: "single line"\norigin: ECC\n---\n# Skill');
const result = runSkillsValidator(testDir, ['--strict']);
assert.strictEqual(result.code, 0,
`Folded and inline should pass strict; got stderr: ${result.stderr}`);
assert.ok(result.stdout.includes('Validated 2'),
`Should count both skills; got stdout: ${result.stdout}`);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('skips hidden directories under skills/', () => {
const testDir = createTestDir();
// A dot-prefixed directory (e.g. .DS_Store-adjacent junk or legacy
// cache) must not count as a skill and must not error.
fs.mkdirSync(path.join(testDir, '.cache'));
fs.writeFileSync(path.join(testDir, '.cache', 'SKILL.md'), '# ignored');
const real = path.join(testDir, 'real-skill');
fs.mkdirSync(real);
fs.writeFileSync(path.join(real, 'SKILL.md'),
'---\nname: real-skill\ndescription: "x"\norigin: ECC\n---\n# Skill');
const result = runSkillsValidator(testDir, ['--strict']);
assert.strictEqual(result.code, 0, 'Hidden dirs should be skipped');
assert.ok(result.stdout.includes('Validated 1'),
`Should only count the non-hidden skill; got stdout: ${result.stdout}`);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('warns when name: value is empty or whitespace', () => {
const testDir = createTestDir();
const skillDir = path.join(testDir, 'empty-name-skill');
fs.mkdirSync(skillDir);
// `name:` key present but value is blank.
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
'---\nname: \ndescription: "X"\norigin: ECC\n---\n# Skill');
const result = runSkillsValidator(testDir);
assert.strictEqual(result.code, 0,
`Default mode must not fail CI; got stderr: ${result.stderr}`);
assert.ok(
result.stderr.includes('WARN') && result.stderr.includes("'name' is empty"),
`Should warn on empty name; got stderr: ${result.stderr}`);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('warns on literal block-scalar description with |+ chomp', () => {
const testDir = createTestDir();
const skillDir = path.join(testDir, 'keep-desc-skill');
fs.mkdirSync(skillDir);
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
'---\nname: keep-desc-skill\ndescription: |+\n line one\n line two\norigin: ECC\n---\n# Skill');
const result = runSkillsValidator(testDir);
assert.strictEqual(result.code, 0, 'Default mode should not fail CI');
assert.ok(result.stderr.includes('literal block scalar'),
`Should warn on |+ description; got stderr: ${result.stderr}`);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('warns on block-scalar description with indent indicator and trailing comment', () => {
const testDir = createTestDir();
const skillDir = path.join(testDir, 'indent-desc-skill');
fs.mkdirSync(skillDir);
// `|-2 # note` is still a literal block scalar in YAML 1.2.
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
'---\nname: indent-desc-skill\ndescription: |-2 # trimmed two-space indent\n line one\n line two\norigin: ECC\n---\n# Skill');
const result = runSkillsValidator(testDir);
assert.strictEqual(result.code, 0, 'Default mode should not fail CI');
assert.ok(result.stderr.includes('literal block scalar'),
`Should warn on |-2 description; got stderr: ${result.stderr}`);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('honors CI_STRICT_SKILLS=1 env flag', () => {
const testDir = createTestDir();
const skillDir = path.join(testDir, 'no-name-skill-env');
fs.mkdirSync(skillDir);
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
'---\ndescription: "X"\norigin: ECC\n---\n# Skill');
const result = runSkillsValidator(testDir, [], { CI_STRICT_SKILLS: '1' });
assert.strictEqual(result.code, 1, 'CI_STRICT_SKILLS=1 must fail CI on missing name');
assert.ok(result.stderr.includes('missing required field: name'),
'Should report missing name');
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('flags comment-only name value as empty (strict)', () => {
const testDir = createTestDir();
const skillDir = path.join(testDir, 'comment-only-name');
fs.mkdirSync(skillDir);
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
'---\nname: # todo\ndescription: "X"\norigin: ECC\n---\n# Skill');
const result = runSkillsValidator(testDir, ['--strict']);
assert.strictEqual(result.code, 1, 'Strict mode must fail CI on empty name');
assert.ok(result.stderr.includes("'name' is empty"),
`Should report empty name; got stderr: ${result.stderr}`);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('tolerates ---trailing text outside frontmatter block', () => {
// A SKILL.md whose body contains a line starting with '---text'
// must not be parsed as frontmatter. Regression guard for
// closing-delimiter tightening: the old regex would greedily
// match '---trailing'.
const testDir = createTestDir();
const skillDir = path.join(testDir, 'no-frontmatter-dashes');
fs.mkdirSync(skillDir);
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
'# Skill\n\nSome body text.\n\n---trailing content\nmore body\n');
const result = runSkillsValidator(testDir, ['--strict']);
assert.strictEqual(result.code, 0,
`Should not flag frontmatter findings when no valid frontmatter exists; got stderr: ${result.stderr}`);
assert.ok(!result.stderr.includes('missing required field: name'),
'Must not treat ---trailing as a frontmatter closer');
cleanupTestDir(testDir);
})) passed++; else failed++;
// ==========================================
// validate-commands.js
// ==========================================
+8 -8
View File
@@ -35,12 +35,12 @@ console.log('\n=== Testing public install identifiers ===\n');
for (const relativePath of publicInstallDocs) {
const content = fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
test(`${relativePath} does not use the stale ecc@ecc plugin identifier`, () => {
assert.ok(!content.includes('ecc@ecc'));
test(`${relativePath} does not use the overlong legacy marketplace plugin identifier`, () => {
assert.ok(!content.includes('everything-claude-code@everything-claude-code'));
});
test(`${relativePath} documents the canonical marketplace plugin identifier`, () => {
assert.ok(content.includes('everything-claude-code@everything-claude-code'));
test(`${relativePath} documents the short marketplace plugin identifier`, () => {
assert.ok(content.includes('ecc@ecc'));
});
}
@@ -86,12 +86,12 @@ for (const relativePath of publicCommandNamespaceDocs) {
test(`${relativePath} uses the canonical plugin command namespace`, () => {
assert.ok(
!content.includes('/ecc:'),
'Expected docs not to advertise the unsupported /ecc: plugin alias'
!content.includes('/everything-claude-code:'),
'Expected docs not to advertise the overlong legacy plugin command namespace'
);
assert.ok(
content.includes('/everything-claude-code:plan'),
'Expected docs to show the canonical plugin command namespace'
content.includes('/ecc:plan'),
'Expected docs to show the short plugin command namespace'
);
});
}
+260 -1
View File
@@ -408,7 +408,104 @@ function runTests() {
}
})) passed++; else failed++;
// --- Test 10: MultiEdit gates first unchecked file ---
// --- Test 10: respects direct GateGuard env disable for recovery sessions ---
clearState();
if (test('respects ECC_GATEGUARD=off without writing gate state', () => {
const input = {
tool_name: 'Write',
tool_input: { file_path: '/src/env-disabled.js', content: 'export const ok = true;' }
};
const result = runHook(input, { ECC_GATEGUARD: 'off' });
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
assert.strictEqual(output.tool_name, 'Write', 'disabled gate should pass through raw input');
assert.ok(!output.hookSpecificOutput, 'disabled gate should not deny the operation');
assert.ok(!fs.existsSync(stateFile), 'disabled gate should not create or mutate gate state');
})) passed++; else failed++;
// --- Test 11: respects legacy GATEGUARD_DISABLED env disable ---
clearState();
if (test('respects GATEGUARD_DISABLED=1 for Bash recovery', () => {
const input = {
tool_name: 'Bash',
tool_input: { command: 'npm test' }
};
const result = runBashHook(input, { GATEGUARD_DISABLED: '1' });
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
assert.strictEqual(output.tool_name, 'Bash', 'disabled gate should pass Bash through raw input');
assert.ok(!output.hookSpecificOutput, 'disabled gate should not deny Bash');
assert.ok(!fs.existsSync(stateFile), 'disabled gate should not create or mutate gate state');
})) passed++; else failed++;
// --- Test 12: legacy GATEGUARD_DISABLED compatibility is scoped to =1 ---
clearState();
if (test('does not treat GATEGUARD_DISABLED=true as a disable flag', () => {
const input = {
tool_name: 'Bash',
tool_input: { command: 'npm test' }
};
const result = runBashHook(input, { GATEGUARD_DISABLED: 'true' });
const output = parseOutput(result.stdout);
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('current user request'));
})) passed++; else failed++;
// --- Test 13: denial messages show an escape hatch ---
clearState();
if (test('denial messages include direct recovery escape hatch', () => {
const input = {
tool_name: 'Write',
tool_input: { file_path: '/src/recovery-hint.js', content: 'export const ok = true;' }
};
const result = runHook(input);
const output = parseOutput(result.stdout);
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('ECC_GATEGUARD=off'),
'denial reason should show the direct recovery env toggle');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('ECC_DISABLED_HOOKS'),
'denial reason should mention the existing hook-id disable control');
})) passed++; else failed++;
// --- Test 14: routine Bash denial messages show the Bash hook escape hatch ---
clearState();
if (test('routine Bash denials include Bash hook disable id', () => {
const input = {
tool_name: 'Bash',
tool_input: { command: 'npm test' }
};
const result = runBashHook(input);
const output = parseOutput(result.stdout);
const reason = output.hookSpecificOutput.permissionDecisionReason;
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(reason.includes('pre:bash:gateguard-fact-force'),
'routine Bash denial should show the Bash hook ID');
assert.ok(!reason.includes('pre:edit-write:gateguard-fact-force'),
'routine Bash denial should not show the Edit/Write hook ID as the targeted disable');
})) passed++; else failed++;
// --- Test 15: destructive Bash denials do not advertise the recovery escape hatch ---
clearState();
if (test('destructive Bash denials omit recovery escape hatch', () => {
const input = {
tool_name: 'Bash',
tool_input: { command: 'rm -rf /tmp/demo' }
};
const result = runBashHook(input);
const output = parseOutput(result.stdout);
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Destructive command detected'));
assert.ok(!output.hookSpecificOutput.permissionDecisionReason.includes('ECC_GATEGUARD=off'),
'destructive gate should not advertise disabling GateGuard');
})) passed++; else failed++;
// --- Test 16: MultiEdit gates first unchecked file ---
clearState();
if (test('denies first MultiEdit with unchecked file', () => {
const input = {
@@ -884,6 +981,168 @@ function runTests() {
assert.ok(fs.existsSync(freshState), 'fresh state file should remain');
})) passed++; else failed++;
function runFreshSessionEdit(filePath, extra = {}) {
return runHook({
tool_name: 'Edit',
tool_input: { file_path: filePath, old_string: 'a', new_string: 'b' },
session_id: 'subagent-fresh-session',
...extra
}, { CLAUDE_SESSION_ID: '', ECC_SESSION_ID: '' });
}
function runFreshSessionBash(command, extra = {}) {
return runBashHook({
tool_name: 'Bash',
tool_input: { command },
session_id: 'subagent-fresh-session',
...extra
}, { CLAUDE_SESSION_ID: '', ECC_SESSION_ID: '' });
}
// --- Test 30: top-level Edit denies; subagent Edit allows ---
clearState();
if (test('A/B: same Edit denies at top level and allows with agent_id', () => {
const topLevel = runFreshSessionEdit('/src/subagent-edit.js');
const topOut = parseOutput(topLevel.stdout);
assert.ok(topOut, 'top-level edit should produce JSON output');
assert.strictEqual(topOut.hookSpecificOutput.permissionDecision, 'deny');
clearState();
const subagent = runFreshSessionEdit('/src/subagent-edit.js', { agent_id: 'agent-abc-123' });
const subOut = parseOutput(subagent.stdout);
assert.ok(subOut, 'subagent edit should produce JSON output');
assert.ok(!subOut.hookSpecificOutput || subOut.hookSpecificOutput.permissionDecision !== 'deny',
'subagent edit should bypass the first-touch file gate');
})) passed++; else failed++;
// --- Test 31: top-level Write denies; subagent Write allows ---
clearState();
if (test('A/B: same Write denies at top level and allows with agent_id', () => {
const topLevel = runHook({
tool_name: 'Write',
tool_input: { file_path: '/src/subagent-write.js', content: 'module.exports = {};' },
session_id: 'subagent-fresh-session'
}, { CLAUDE_SESSION_ID: '', ECC_SESSION_ID: '' });
const topOut = parseOutput(topLevel.stdout);
assert.ok(topOut, 'top-level write should produce JSON output');
assert.strictEqual(topOut.hookSpecificOutput.permissionDecision, 'deny');
clearState();
const subagent = runHook({
tool_name: 'Write',
tool_input: { file_path: '/src/subagent-write.js', content: 'module.exports = {};' },
session_id: 'subagent-fresh-session',
agent_id: 'agent-abc-123'
}, { CLAUDE_SESSION_ID: '', ECC_SESSION_ID: '' });
const subOut = parseOutput(subagent.stdout);
assert.ok(subOut, 'subagent write should produce JSON output');
assert.ok(!subOut.hookSpecificOutput || subOut.hookSpecificOutput.permissionDecision !== 'deny',
'subagent write should bypass the first-touch file gate');
})) passed++; else failed++;
// --- Test 32: top-level MultiEdit denies; subagent MultiEdit allows ---
clearState();
if (test('A/B: same MultiEdit denies at top level and allows with agent_id', () => {
const edits = [
{ file_path: '/src/subagent-multi-a.js', old_string: 'a', new_string: 'b' },
{ file_path: '/src/subagent-multi-b.js', old_string: 'c', new_string: 'd' }
];
const topLevel = runHook({
tool_name: 'MultiEdit',
tool_input: { edits },
session_id: 'subagent-fresh-session'
}, { CLAUDE_SESSION_ID: '', ECC_SESSION_ID: '' });
const topOut = parseOutput(topLevel.stdout);
assert.ok(topOut, 'top-level MultiEdit should produce JSON output');
assert.strictEqual(topOut.hookSpecificOutput.permissionDecision, 'deny');
clearState();
const subagent = runHook({
tool_name: 'MultiEdit',
tool_input: { edits },
session_id: 'subagent-fresh-session',
agent_id: 'agent-abc-123'
}, { CLAUDE_SESSION_ID: '', ECC_SESSION_ID: '' });
const subOut = parseOutput(subagent.stdout);
assert.ok(subOut, 'subagent MultiEdit should produce JSON output');
assert.ok(!subOut.hookSpecificOutput || subOut.hookSpecificOutput.permissionDecision !== 'deny',
'subagent MultiEdit should bypass the first-touch file gate');
})) passed++; else failed++;
// --- Test 33: Bash stays gated inside subagents ---
clearState();
if (test('routine Bash remains gated in subagent context', () => {
const result = runFreshSessionBash('pwd', { agent_id: 'agent-abc-123' });
const output = parseOutput(result.stdout);
assert.ok(output, 'subagent Bash should produce JSON output');
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('current user request'));
})) passed++; else failed++;
// --- Test 34: destructive Bash stays gated inside subagents ---
clearState();
if (test('destructive Bash remains gated in subagent context', () => {
const result = runFreshSessionBash('rm -rf /tmp/demo-path', { agent_id: 'agent-abc-123' });
const output = parseOutput(result.stdout);
assert.ok(output, 'subagent destructive Bash should produce JSON output');
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Destructive command detected'));
})) passed++; else failed++;
// --- Test 35: parent tool IDs also mark subagent context ---
clearState();
if (test('parent_tool_use_id and parentToolUseId mark subagent file edits', () => {
const snake = runFreshSessionEdit('/src/subagent-parent-snake.js', { parent_tool_use_id: 'toolu_parent_01' });
const snakeOut = parseOutput(snake.stdout);
assert.ok(snakeOut, 'snake-case parent marker should produce JSON output');
assert.ok(!snakeOut.hookSpecificOutput || snakeOut.hookSpecificOutput.permissionDecision !== 'deny',
'parent_tool_use_id should bypass the first-touch file gate');
clearState();
const camel = runFreshSessionEdit('/src/subagent-parent-camel.js', { parentToolUseId: 'toolu_parent_02' });
const camelOut = parseOutput(camel.stdout);
assert.ok(camelOut, 'camel-case parent marker should produce JSON output');
assert.ok(!camelOut.hookSpecificOutput || camelOut.hookSpecificOutput.permissionDecision !== 'deny',
'parentToolUseId should bypass the first-touch file gate');
})) passed++; else failed++;
// --- Test 36: only non-empty string markers count ---
clearState();
if (test('empty and non-string subagent markers do not bypass file gates', () => {
const cases = [
['empty', { agent_id: '' }],
['whitespace', { agent_id: ' ' }],
['numeric', { agent_id: 12345 }],
['null', { agent_id: null }]
];
for (const [name, extra] of cases) {
clearState();
const result = runFreshSessionEdit(`/src/subagent-marker-${name}.js`, extra);
const output = parseOutput(result.stdout);
assert.ok(output, `${name} marker should produce JSON output`);
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
`${name} marker should not bypass the first-touch file gate`);
}
})) passed++; else failed++;
// --- Test 37: two sequential subagent Edits on different files pass ---
clearState();
if (test('two sequential subagent Edits on different files both pass', () => {
const first = runFreshSessionEdit('/src/subagent-seq-a.js', { agent_id: 'agent-seq' });
const firstOut = parseOutput(first.stdout);
assert.ok(firstOut, 'first subagent edit should produce JSON output');
assert.ok(!firstOut.hookSpecificOutput || firstOut.hookSpecificOutput.permissionDecision !== 'deny',
'first subagent edit should pass');
const second = runFreshSessionEdit('/src/subagent-seq-b.js', { agent_id: 'agent-seq' });
const secondOut = parseOutput(second.stdout);
assert.ok(secondOut, 'second subagent edit should produce JSON output');
assert.ok(!secondOut.hookSpecificOutput || secondOut.hookSpecificOutput.permissionDecision !== 'deny',
'second subagent edit should pass even on a new file');
})) passed++; else failed++;
// Cleanup only the temp directory created by this test file.
try {
if (fs.existsSync(stateDir)) {
+109 -5
View File
@@ -248,7 +248,7 @@ function withPrependedPath(binDir, env = {}) {
}
function assertNoProjectDetectionSideEffects(homeDir, testName) {
const homunculusDir = path.join(homeDir, '.claude', 'homunculus');
const homunculusDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus');
const registryPath = path.join(homunculusDir, 'projects.json');
const projectsDir = path.join(homunculusDir, 'projects');
@@ -1178,6 +1178,47 @@ async function runTests() {
passed++;
else failed++;
if (
await asyncTest('reads session_id from stdin JSON (Claude Code wire format)', async () => {
const sessionId = 'test-stdin-' + Date.now();
const stdinJson = JSON.stringify({ session_id: sessionId, tool_name: 'Edit' });
const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), stdinJson, {});
assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`);
const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`);
assert.ok(fs.existsSync(counterFile), `Counter file should be created from stdin session_id at ${counterFile}`);
const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10);
assert.strictEqual(count, 1, `Counter should be 1, got ${count}`);
fs.unlinkSync(counterFile);
})
)
passed++;
else failed++;
if (
await asyncTest('stdin session_id takes precedence over env CLAUDE_SESSION_ID', async () => {
const stdinSession = 'stdin-wins-' + Date.now();
const envSession = 'env-loses-' + Date.now();
const stdinJson = JSON.stringify({ session_id: stdinSession });
const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), stdinJson, {
CLAUDE_SESSION_ID: envSession
});
assert.strictEqual(result.code, 0);
const stdinCounter = path.join(os.tmpdir(), `claude-tool-count-${stdinSession}`);
const envCounter = path.join(os.tmpdir(), `claude-tool-count-${envSession}`);
assert.ok(fs.existsSync(stdinCounter), 'Stdin session counter must exist');
assert.ok(!fs.existsSync(envCounter), 'Env session counter must NOT exist when stdin provides session_id');
fs.unlinkSync(stdinCounter);
})
)
passed++;
else failed++;
// evaluate-session.js tests
console.log('\nevaluate-session.js:');
@@ -2691,6 +2732,68 @@ async function runTests() {
passed++;
else failed++;
if (
await asyncTest('blocks Windows shell metacharacters before shell:true formatter execution', async () => {
const hookPath = path.join(scriptsDir, 'post-edit-format.js');
const resolverPath = path.join(scriptsDir, '..', 'lib', 'resolve-formatter.js');
const childProcess = require('child_process');
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
const originalSpawnSync = childProcess.spawnSync;
const originalExecFileSync = childProcess.execFileSync;
const resolvedResolverPath = require.resolve(resolverPath);
const resolvedHookPath = require.resolve(hookPath);
const originalResolverCache = require.cache[resolvedResolverPath];
const originalHookCache = require.cache[resolvedHookPath];
const blockedPaths = ['semicolon;test.js', 'backtick`test.js', 'subshell$(test).js', 'group(test).js'];
try {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
let spawnCalls = [];
childProcess.spawnSync = (...args) => {
spawnCalls.push(args);
return { status: 0, stderr: Buffer.from('') };
};
childProcess.execFileSync = () => {
throw new Error('execFileSync should not run for Windows .cmd formatter shims');
};
require.cache[resolvedResolverPath] = {
id: resolvedResolverPath,
filename: resolvedResolverPath,
loaded: true,
exports: {
findProjectRoot: () => process.cwd(),
detectFormatter: () => 'prettier',
resolveFormatterBin: () => ({ bin: 'formatter.cmd', prefix: [] })
}
};
delete require.cache[resolvedHookPath];
const { run } = require(hookPath);
for (const filePath of blockedPaths) {
spawnCalls = [];
const stdinJson = JSON.stringify({ tool_input: { file_path: filePath } });
assert.strictEqual(run(stdinJson), stdinJson, 'Should pass through original stdin JSON');
assert.strictEqual(spawnCalls.length, 0, `Should reject ${filePath} before spawnSync`);
}
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
childProcess.spawnSync = originalSpawnSync;
childProcess.execFileSync = originalExecFileSync;
if (originalResolverCache) require.cache[resolvedResolverPath] = originalResolverCache;
else delete require.cache[resolvedResolverPath];
if (originalHookCache) require.cache[resolvedHookPath] = originalHookCache;
else delete require.cache[resolvedHookPath];
}
})
)
passed++;
else failed++;
if (
await asyncTest('matches .tsx extension for formatting', async () => {
const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/component.tsx' } });
@@ -2844,11 +2947,12 @@ async function runTests() {
assert.strictEqual(code, 0, `detect-project should source cleanly, stderr: ${stderr}`);
const [projectId, projectDir] = stdout.trim().split(/\r?\n/);
const registryPath = path.join(homeDir, '.claude', 'homunculus', 'projects.json');
const registryPath = path.join(homeDir, '.local', 'share', 'ecc-homunculus', 'projects.json');
const expectedProjectDir = path.join(
homeDir,
'.claude',
'homunculus',
'.local',
'share',
'ecc-homunculus',
'projects',
projectId
);
@@ -2922,7 +3026,7 @@ async function runTests() {
assert.strictEqual(result.code, 0, `observe.sh should exit successfully, stderr: ${result.stderr}`);
const projectsDir = path.join(homeDir, '.claude', 'homunculus', 'projects');
const projectsDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus', 'projects');
const projectIds = fs.readdirSync(projectsDir);
assert.strictEqual(projectIds.length, 1, 'observe.sh should create one project-scoped observation directory');
+97
View File
@@ -952,6 +952,103 @@ async function runTests() {
}
})) passed++; else failed++;
// Windows-only: child_process.spawn cannot resolve .cmd/.bat shims for
// bare PATH commands without an extension, and Node 18.20+/20.12+ refuse
// to spawn .cmd targets without `shell: true` (CVE-2024-27980). The probe
// must retry bare command names with platform extensions and route .cmd/.bat
// through the shell, otherwise tools like `npx` are misclassified as
// unhealthy on first use. Path-like commands keep single-candidate ENOENT
// semantics.
if (process.platform === 'win32') {
if (await asyncTest('windows: probes bare PATH commands via .cmd fallback', async () => {
const tempDir = createTempDir();
const binDir = path.join(tempDir, 'bin');
const configPath = path.join(tempDir, 'claude.json');
const statePath = path.join(tempDir, 'mcp-health.json');
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(
path.join(binDir, 'winfallback.cmd'),
['@echo off', 'node -e "setInterval(()=>{},1000)"', ''].join('\r\n')
);
try {
writeConfig(configPath, {
mcpServers: {
winfallback: {
command: 'winfallback',
args: []
}
}
});
const input = { tool_name: 'mcp__winfallback__list', tool_input: {} };
const result = runHook(input, {
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
ECC_MCP_CONFIG_PATH: configPath,
ECC_MCP_HEALTH_STATE_PATH: statePath,
ECC_MCP_HEALTH_TIMEOUT_MS: '500',
PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`
});
assert.strictEqual(
result.code,
0,
`Expected bare command to be probed via .cmd fallback: ${hookFailureDetails(result, statePath)}`
);
const state = readState(statePath);
assert.strictEqual(
state.servers.winfallback.status,
'healthy',
'Expected bare command to be marked healthy via .cmd fallback'
);
} finally {
cleanupTempDir(tempDir);
}
})) passed++; else failed++;
} else {
console.log(' - skipped: windows: probes bare PATH commands via .cmd fallback (non-Windows)');
}
if (await asyncTest('probes command servers using non-absolute commands (e.g. npx) via PATH resolution', async () => {
const tempDir = createTempDir();
const configPath = path.join(tempDir, 'claude.json');
const statePath = path.join(tempDir, 'mcp-health.json');
const serverScript = path.join(tempDir, 'shell-server.js');
try {
// Create a server script that stays alive
fs.writeFileSync(serverScript, "setInterval(() => {}, 1000);\n");
// Use 'node' (non-absolute) as the command to exercise PATH-based
// resolution without depending on npx being available in the environment.
writeConfig(configPath, {
mcpServers: {
shelltest: {
command: 'node',
args: [serverScript]
}
}
});
const input = { tool_name: 'mcp__shelltest__ping', tool_input: {} };
const result = runHook(input, {
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
ECC_MCP_CONFIG_PATH: configPath,
ECC_MCP_HEALTH_STATE_PATH: statePath,
ECC_MCP_HEALTH_TIMEOUT_MS: '100'
});
assert.strictEqual(result.code, 0, `Expected non-absolute command to resolve via PATH, got ${result.code}`);
const state = readState(statePath);
assert.strictEqual(state.servers.shelltest.status, 'healthy', 'Expected PATH-resolved server to be marked healthy');
} finally {
cleanupTempDir(tempDir);
}
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
@@ -112,7 +112,7 @@ function runObserve({ homeDir, cwd }) {
}
function readSingleProjectMetadata(homeDir) {
const projectsDir = path.join(homeDir, '.claude', 'homunculus', 'projects');
const projectsDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus', 'projects');
const projectIds = fs.readdirSync(projectsDir);
assert.strictEqual(projectIds.length, 1, 'Expected exactly one project directory');
const projectDir = path.join(projectsDir, projectIds[0]);
+21 -1
View File
@@ -96,7 +96,8 @@ test('observer-loop.sh defines ANALYZING guard variable', () => {
test('on_usr1 checks ANALYZING before starting analysis', () => {
const content = fs.readFileSync(observerLoopPath, 'utf8');
assert.ok(content.includes('if [ "$ANALYZING" -eq 1 ]'), 'on_usr1 should check ANALYZING flag');
assert.ok(content.includes('Analysis already in progress, skipping signal'), 'on_usr1 should log when skipping due to re-entrancy');
assert.ok(content.includes('Analysis already in progress, deferring signal'), 'on_usr1 should log when deferring due to re-entrancy');
assert.ok(content.includes('PENDING_ANALYSIS=1'), 'on_usr1 should preserve re-entrant nudges for the next loop iteration');
});
test('on_usr1 sets ANALYZING=1 before and ANALYZING=0 after analysis', () => {
@@ -110,6 +111,15 @@ test('on_usr1 sets ANALYZING=1 before and ANALYZING=0 after analysis', () => {
assert.ok(analyzeReset > analyzeObsCall, 'ANALYZING=0 should follow analyze_observations');
});
test('observer-loop checks pending analysis before sleeping', () => {
const content = fs.readFileSync(observerLoopPath, 'utf8');
assert.ok(/^PENDING_ANALYSIS=0$/m.test(content), 'PENDING_ANALYSIS should initialize to 0');
assert.ok(
/if \[ "\$PENDING_ANALYSIS" -eq 1 \]; then[\s\S]*?analyze_observations[\s\S]*?continue[\s\S]*?sleep "\$OBSERVER_INTERVAL_SECONDS"/.test(content),
'observer-loop should process deferred analysis before the interval sleep'
);
});
// ──────────────────────────────────────────────────────
// Test group 3: observer-loop.sh cooldown throttle
// ──────────────────────────────────────────────────────
@@ -334,8 +344,10 @@ test('observe.sh creates counter file and increments on each call', () => {
// Create a minimal detect-project.sh that sets required vars
const skillRoot = path.join(testDir, 'skill');
const scriptsDir = path.join(skillRoot, 'scripts');
const scriptsLibDir = path.join(scriptsDir, 'lib');
const hooksDir = path.join(skillRoot, 'hooks');
fs.mkdirSync(scriptsDir, { recursive: true });
fs.mkdirSync(scriptsLibDir, { recursive: true });
fs.mkdirSync(hooksDir, { recursive: true });
// Minimal detect-project.sh stub
@@ -351,6 +363,14 @@ test('observe.sh creates counter file and increments on each call', () => {
''
].join('\n')
);
fs.writeFileSync(
path.join(scriptsLibDir, 'homunculus-dir.sh'),
[
'#!/bin/bash',
'_ecc_resolve_homunculus_dir() { printf "%s\\n" "$HOME/.local/share/ecc-homunculus"; }',
''
].join('\n')
);
// Copy observe.sh but patch SKILL_ROOT to our test dir
let observeContent = fs.readFileSync(observeShPath, 'utf8');
+23 -9
View File
@@ -226,6 +226,15 @@ function cleanupTestDir(testDir) {
fs.rmSync(testDir, { recursive: true, force: true });
}
function getTestHomunculusEnv(testDir) {
const xdgDataHome = path.join(testDir, '.local', 'share');
return {
HOME: testDir,
XDG_DATA_HOME: xdgDataHome,
homunculusDir: path.join(xdgDataHome, 'ecc-homunculus'),
};
}
function writeInstinctFile(filePath, entries) {
const body = entries.map(entry => `---
id: ${entry.id}
@@ -380,19 +389,20 @@ async function runTests() {
try {
const sessionId = `session-${Date.now()}`;
const homunculusEnv = getTestHomunculusEnv(testDir);
const result = await runHookWithInput(
path.join(scriptsDir, 'session-start.js'),
{},
{
HOME: testDir,
HOME: homunculusEnv.HOME,
XDG_DATA_HOME: homunculusEnv.XDG_DATA_HOME,
CLAUDE_PROJECT_DIR: projectDir,
CLAUDE_SESSION_ID: sessionId
}
);
assert.strictEqual(result.code, 0, 'SessionStart should exit 0');
const homunculusDir = path.join(testDir, '.claude', 'homunculus');
const projectsDir = path.join(homunculusDir, 'projects');
const projectsDir = path.join(homunculusEnv.homunculusDir, 'projects');
const projectEntries = fs.existsSync(projectsDir) ? fs.readdirSync(projectsDir) : [];
assert.ok(projectEntries.length > 0, 'SessionStart should create a homunculus project directory');
const leaseDir = path.join(projectsDir, projectEntries[0], '.observer-sessions');
@@ -410,7 +420,8 @@ async function runTests() {
try {
const projectId = crypto.createHash('sha256').update(projectDir).digest('hex').slice(0, 12);
const homunculusDir = path.join(testDir, '.claude', 'homunculus');
const homunculusEnv = getTestHomunculusEnv(testDir);
const homunculusDir = homunculusEnv.homunculusDir;
const projectInstinctDir = path.join(homunculusDir, 'projects', projectId, 'instincts', 'personal');
const globalInstinctDir = path.join(homunculusDir, 'instincts', 'inherited');
@@ -445,7 +456,8 @@ async function runTests() {
path.join(scriptsDir, 'session-start.js'),
{},
{
HOME: testDir,
HOME: homunculusEnv.HOME,
XDG_DATA_HOME: homunculusEnv.XDG_DATA_HOME,
CLAUDE_PROJECT_DIR: projectDir,
}
);
@@ -474,18 +486,19 @@ async function runTests() {
});
try {
const homunculusEnv = getTestHomunculusEnv(testDir);
await runHookWithInput(
path.join(scriptsDir, 'session-start.js'),
{},
{
HOME: testDir,
HOME: homunculusEnv.HOME,
XDG_DATA_HOME: homunculusEnv.XDG_DATA_HOME,
CLAUDE_PROJECT_DIR: projectDir,
CLAUDE_SESSION_ID: sessionId
}
);
const homunculusDir = path.join(testDir, '.claude', 'homunculus');
const projectsDir = path.join(homunculusDir, 'projects');
const projectsDir = path.join(homunculusEnv.homunculusDir, 'projects');
const projectEntries = fs.existsSync(projectsDir) ? fs.readdirSync(projectsDir) : [];
assert.ok(projectEntries.length > 0, 'Expected SessionStart to create a homunculus project directory');
const projectStorageDir = path.join(projectsDir, projectEntries[0]);
@@ -497,7 +510,8 @@ async function runTests() {
path.join(scriptsDir, 'session-end-marker.js'),
markerInput,
{
HOME: testDir,
HOME: homunculusEnv.HOME,
XDG_DATA_HOME: homunculusEnv.XDG_DATA_HOME,
CLAUDE_PROJECT_DIR: projectDir,
CLAUDE_SESSION_ID: sessionId
}
+17
View File
@@ -145,6 +145,23 @@ function runTests() {
assert.match(component.description, /continuous-learning-v2/, 'Should point new installs to continuous-learning-v2');
})) passed++; else failed++;
if (test('exposes continuous-learning-v2 as a single-skill install surface', () => {
const component = getInstallComponent('skill:continuous-learning-v2');
assert.strictEqual(component.id, 'skill:continuous-learning-v2');
assert.deepStrictEqual(component.moduleIds, ['skill-continuous-learning-v2']);
assert.ok(component.targets.includes('claude'), 'Should support Claude installs');
const plan = resolveInstallPlan({
includeComponentIds: ['skill:continuous-learning-v2'],
target: 'claude',
});
assert.deepStrictEqual(plan.selectedModuleIds, ['skill-continuous-learning-v2']);
assert.ok(
plan.operations.some(operation => operation.sourceRelativePath === 'skills/continuous-learning-v2'),
'Should plan only the continuous-learning-v2 skill path'
);
})) passed++; else failed++;
if (test('lists supported legacy compatibility languages', () => {
const languages = listLegacyCompatibilityLanguages();
assert.ok(languages.includes('typescript'));
+134
View File
@@ -0,0 +1,134 @@
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const {
getHomunculusDir,
normalizeRemoteUrl,
resolveProjectContext,
} = require('../../scripts/lib/observer-sessions');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed += 1;
} catch (error) {
console.log(`${name}`);
console.log(` ${error.message}`);
failed += 1;
}
}
function createTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-observer-sessions-'));
}
function cleanup(dir) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
}
function withEnv(overrides, fn) {
const previous = {};
for (const key of Object.keys(overrides)) {
previous[key] = process.env[key];
if (overrides[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = overrides[key];
}
}
try {
return fn();
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
function initRepo(repoDir, remoteUrl) {
fs.mkdirSync(repoDir, { recursive: true });
spawnSync('git', ['init'], { cwd: repoDir, stdio: 'ignore' });
spawnSync('git', ['remote', 'add', 'origin', remoteUrl], { cwd: repoDir, stdio: 'ignore' });
}
console.log('\n=== observer-sessions tests ===\n');
test('getHomunculusDir prefers absolute CLV2_HOMUNCULUS_DIR', () => {
const root = createTempDir();
try {
const override = path.join(root, 'custom-store');
withEnv({ CLV2_HOMUNCULUS_DIR: override, XDG_DATA_HOME: path.join(root, 'xdg') }, () => {
assert.strictEqual(getHomunculusDir(), override);
});
} finally {
cleanup(root);
}
});
test('getHomunculusDir ignores relative overrides and uses XDG_DATA_HOME', () => {
const root = createTempDir();
try {
const xdg = path.join(root, 'xdg');
withEnv({ CLV2_HOMUNCULUS_DIR: 'relative-store', XDG_DATA_HOME: xdg }, () => {
assert.strictEqual(getHomunculusDir(), path.join(xdg, 'ecc-homunculus'));
});
} finally {
cleanup(root);
}
});
test('normalizeRemoteUrl collapses common network remote variants', () => {
const expected = 'github.com/owner/repo';
assert.strictEqual(normalizeRemoteUrl('git@github.com:Owner/Repo.git'), expected);
assert.strictEqual(normalizeRemoteUrl('https://github.com/owner/repo.git'), expected);
assert.strictEqual(normalizeRemoteUrl('ssh://git@github.com/Owner/Repo.git'), expected);
assert.strictEqual(normalizeRemoteUrl('https://token@github.com/owner/repo.git'), expected);
});
test('normalizeRemoteUrl preserves local path case', () => {
assert.strictEqual(normalizeRemoteUrl('/tmp/Repos/MyProject'), '/tmp/Repos/MyProject');
assert.strictEqual(normalizeRemoteUrl('file:///tmp/Repos/MyProject.git'), '/tmp/Repos/MyProject');
});
test('resolveProjectContext gives SSH and HTTPS clones the same project id', () => {
const root = createTempDir();
try {
const storage = path.join(root, 'store');
const sshRepo = path.join(root, 'ssh-clone');
const httpsRepo = path.join(root, 'https-clone');
initRepo(sshRepo, 'git@github.com:Owner/Repo.git');
initRepo(httpsRepo, 'https://github.com/owner/repo.git');
withEnv({
CLV2_HOMUNCULUS_DIR: storage,
XDG_DATA_HOME: undefined,
CLAUDE_PROJECT_DIR: undefined,
}, () => {
const sshContext = resolveProjectContext(sshRepo);
const httpsContext = resolveProjectContext(httpsRepo);
assert.strictEqual(sshContext.projectId, httpsContext.projectId);
assert.strictEqual(sshContext.projectDir, httpsContext.projectDir);
});
} finally {
cleanup(root);
}
});
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+73
View File
@@ -81,6 +81,25 @@ function runTests() {
]);
})) passed++; else failed++;
if (test('parses --skills as skill component selections', () => {
const parsed = parseInstallArgs([
'node', 'install-apply.js',
'--skills', 'continuous-learning-v2,security-review',
]);
assert.deepStrictEqual(parsed.includeComponentIds, [
'skill:continuous-learning-v2',
'skill:security-review',
]);
})) passed++; else failed++;
if (test('parses --skill when caller already includes the skill: prefix', () => {
const parsed = parseInstallArgs([
'node', 'install-apply.js',
'--skill', 'skill:continuous-learning-v2',
]);
assert.deepStrictEqual(parsed.includeComponentIds, ['skill:continuous-learning-v2']);
})) passed++; else failed++;
if (test('parses multiple --without flags', () => {
const parsed = parseInstallArgs([
'node', 'install-apply.js',
@@ -244,6 +263,7 @@ function runTests() {
const components = listInstallComponents({ family: 'skill' });
assert.ok(components.length > 0, 'Should have at least one skill component');
assert.ok(components.some(c => c.id === 'skill:continuous-learning'), 'Should have skill:continuous-learning');
assert.ok(components.some(c => c.id === 'skill:continuous-learning-v2'), 'Should have skill:continuous-learning-v2');
})) passed++; else failed++;
// ─── Install Plan Resolution with --with ───
@@ -430,6 +450,22 @@ function runTests() {
'Should include workflow-quality module from skill:continuous-learning');
})) passed++; else failed++;
if (test('--with skill:continuous-learning-v2 installs only that skill module', () => {
const plan = resolveInstallPlan({
includeComponentIds: ['skill:continuous-learning-v2'],
target: 'claude',
});
assert.deepStrictEqual(plan.selectedModuleIds, ['skill-continuous-learning-v2']);
assert.ok(
plan.operations.some(operation => operation.sourceRelativePath === 'skills/continuous-learning-v2'),
'Should install the continuous-learning-v2 skill directory'
);
assert.ok(
!plan.operations.some(operation => operation.sourceRelativePath === 'skills/tdd-workflow'),
'Should not install the whole workflow-quality skill module'
);
})) passed++; else failed++;
// ─── Help Text ───
if (test('help text documents --with and --without flags', () => {
@@ -670,6 +706,43 @@ function runTests() {
}
})) passed++; else failed++;
if (test('end-to-end: --skills continuous-learning-v2 installs only that skill', () => {
const { execFileSync } = require('child_process');
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-skill-install-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'selective-skill-install-project-'));
try {
execFileSync('node', [
scriptPath,
'--skills', 'continuous-learning-v2',
], {
cwd: projectDir,
env: { ...process.env, HOME: homeDir },
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
});
const claudeRoot = path.join(homeDir, '.claude');
assert.ok(
fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'continuous-learning-v2', 'SKILL.md')),
'Should install continuous-learning-v2'
);
assert.ok(
!fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'tdd-workflow', 'SKILL.md')),
'Should not install unrelated workflow-quality skills'
);
const statePath = path.join(claudeRoot, 'ecc', 'install-state.json');
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
assert.deepStrictEqual(state.request.includeComponents, ['skill:continuous-learning-v2']);
assert.deepStrictEqual(state.resolution.selectedModules, ['skill-continuous-learning-v2']);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(projectDir, { recursive: true, force: true });
}
})) passed++; else failed++;
// ─── JSON output mode ───
if (test('end-to-end: --dry-run --json includes component selections in output', () => {
+10 -9
View File
@@ -206,8 +206,8 @@ test('claude plugin.json version matches package.json', () => {
assert.strictEqual(claudePlugin.version, expectedVersion);
});
test('claude plugin.json uses published plugin name', () => {
assert.strictEqual(claudePlugin.name, 'everything-claude-code');
test('claude plugin.json uses short plugin slug', () => {
assert.strictEqual(claudePlugin.name, 'ecc');
});
test('claude plugin.json does NOT have agents field (unsupported by Claude Code validator)', () => {
@@ -226,7 +226,8 @@ test('claude plugin.json commands is an array', () => {
});
test('claude plugin.json disables bundled MCP servers for provider tool-name compatibility', () => {
const reportedOverlongToolName = `mcp__plugin_${claudePlugin.name}_github__create_pull_request_review`;
const legacyPluginName = 'everything-claude-code';
const reportedOverlongToolName = `mcp__plugin_${legacyPluginName}_github__create_pull_request_review`;
assert.ok(
reportedOverlongToolName.length > 64,
@@ -270,8 +271,8 @@ test('claude marketplace.json keeps only Claude-supported top-level keys', () =>
test('claude marketplace.json has plugins array with the published plugin entry', () => {
assert.ok(Array.isArray(claudeMarketplace.plugins) && claudeMarketplace.plugins.length > 0, 'Expected plugins array');
assert.strictEqual(claudeMarketplace.name, 'everything-claude-code');
assert.strictEqual(claudeMarketplace.plugins[0].name, 'everything-claude-code');
assert.strictEqual(claudeMarketplace.name, 'ecc');
assert.strictEqual(claudeMarketplace.plugins[0].name, 'ecc');
});
test('claude marketplace.json plugin version matches package.json', () => {
@@ -466,18 +467,18 @@ test('README version row matches package.json', () => {
assert.strictEqual(match[1], expectedVersion);
});
test('user-facing docs do not use deprecated ecc@ecc install commands', () => {
test('user-facing docs do not use overlong legacy marketplace install commands', () => {
const markdownFiles = [
path.join(repoRoot, 'README.md'),
path.join(repoRoot, 'README.zh-CN.md'),
path.join(repoRoot, 'skills', 'configure-ecc', 'SKILL.md'),
...collectMarkdownFiles(path.join(repoRoot, 'docs')),
];
].filter(filePath => !path.relative(repoRoot, filePath).startsWith(`docs${path.sep}drafts${path.sep}`));
const offenders = [];
for (const filePath of markdownFiles) {
const source = fs.readFileSync(filePath, 'utf8');
if (/\/plugin\s+(install|list)\s+ecc@ecc\b/.test(source)) {
if (/\/plugin\s+(install|list)\s+everything-claude-code(?:@everything-claude-code)?\b/.test(source)) {
offenders.push(path.relative(repoRoot, filePath));
}
}
@@ -485,7 +486,7 @@ test('user-facing docs do not use deprecated ecc@ecc install commands', () => {
assert.deepStrictEqual(
offenders,
[],
`Deprecated ecc@ecc install commands must not appear in user-facing docs: ${offenders.join(', ')}`,
`Overlong legacy install commands must not appear in user-facing docs: ${offenders.join(', ')}`,
);
});
+18 -4
View File
@@ -79,13 +79,27 @@ argv, kwargs = module.build_terminal_launch(r'C:\\\\Users\\\\user\\\\proj & del
print(json.dumps({'argv': argv, 'kwargs': kwargs}))
`);
const parsed = JSON.parse(output);
assert.deepStrictEqual(parsed.argv.slice(0, 4), ['cmd.exe', '/k', 'cd', '/d']);
assert.strictEqual(parsed.argv[4], parsed.kwargs.cwd);
assert.ok(parsed.argv[4].includes('proj & del'), 'path should remain a literal argv entry');
assert.ok(parsed.argv[4].includes('C:'), 'windows drive prefix should be preserved');
assert.deepStrictEqual(parsed.argv, ['cmd.exe']);
assert.ok(parsed.kwargs.cwd.includes('proj & del'), 'path should remain a literal cwd value');
assert.ok(parsed.kwargs.cwd.includes('C:'), 'windows drive prefix should be preserved');
assert.ok(Object.prototype.hasOwnProperty.call(parsed.kwargs, 'creationflags'));
})) passed++; else failed++;
if (test('launch_terminal rejects missing or non-directory paths', () => {
const output = runPython(`
import importlib.util, json
spec = importlib.util.spec_from_file_location("ecc_dashboard_runtime", r"""${runtimeHelpersPath}""")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
try:
module.launch_terminal('/definitely/not/a/real/ecc/path')
except ValueError as exc:
print(json.dumps({'error': str(exc)}))
`);
const parsed = JSON.parse(output);
assert.ok(parsed.error.includes('Path is not a valid directory'));
})) passed++; else failed++;
if (test('maximize_window falls back to Linux zoom attribute when zoomed state is unsupported', () => {
const output = runPython(`
import importlib.util, json
+14
View File
@@ -109,6 +109,20 @@ function runTests() {
assert.ok(parsed.operations.length > 0);
})) passed++; else failed++;
if (test('emits JSON for --skills without pulling parent module', () => {
const result = run([
'--skills', 'continuous-learning-v2',
'--target', 'claude',
'--json',
]);
assert.strictEqual(result.code, 0);
const parsed = JSON.parse(result.stdout);
assert.deepStrictEqual(parsed.includedComponentIds, ['skill:continuous-learning-v2']);
assert.deepStrictEqual(parsed.selectedModuleIds, ['skill-continuous-learning-v2']);
assert.ok(parsed.operations.some(operation => operation.sourceRelativePath === 'skills/continuous-learning-v2'));
assert.ok(!parsed.operations.some(operation => operation.sourceRelativePath === 'skills/tdd-workflow'));
})) passed++; else failed++;
if (test('loads planning intent from ecc-install.json', () => {
const configDir = path.join(__dirname, '..', 'fixtures', 'tmp-install-plan-config');
const configPath = path.join(configDir, 'ecc-install.json');
+315 -21
View File
@@ -6,10 +6,16 @@ const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const { spawnSync } = require('child_process');
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'loop-status.js');
const { analyzeTranscript, buildStatus, parseArgs } = require('../../scripts/loop-status');
const {
analyzeTranscript,
buildStatus,
getStatusExitCode,
parseArgs,
writeStatusSnapshots,
} = require('../../scripts/loop-status');
const NOW = '2026-04-30T10:00:00.000Z';
function run(args = [], options = {}) {
@@ -25,25 +31,22 @@ function run(args = [], options = {}) {
envOverrides.HOME = envOverrides.USERPROFILE;
}
try {
const stdout = execFileSync('node', [SCRIPT, ...args], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 10000,
cwd: options.cwd || process.cwd(),
env: {
...process.env,
...envOverrides,
},
});
return { code: 0, stdout, stderr: '' };
} catch (error) {
return {
code: error.status || 1,
stdout: error.stdout || '',
stderr: error.stderr || '',
};
}
const result = spawnSync('node', [SCRIPT, ...args], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 10000,
cwd: options.cwd || process.cwd(),
env: {
...process.env,
...envOverrides,
},
});
return {
code: result.status || (result.signal ? 1 : 0),
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
function createTempHome() {
@@ -400,6 +403,7 @@ function runTests() {
const options = parseArgs([
'node',
'scripts/loop-status.js',
'--exit-code',
'--watch',
'--watch-count',
'2',
@@ -407,11 +411,74 @@ function runTests() {
'0.01',
]);
assert.strictEqual(options.exitCode, true);
assert.strictEqual(options.watch, true);
assert.strictEqual(options.watchCount, 2);
assert.strictEqual(options.watchIntervalSeconds, 0.01);
})) passed++; else failed++;
if (test('parses write-dir snapshot option', () => {
const options = parseArgs([
'node',
'scripts/loop-status.js',
'--write-dir',
'/tmp/ecc-loop-snapshots',
]);
assert.strictEqual(options.writeDir, '/tmp/ecc-loop-snapshots');
})) passed++; else failed++;
if (test('exit-code mode returns 2 when attention signals are present', () => {
const homeDir = createTempHome();
try {
writeTranscript(homeDir, '-Users-affoon-project-exit-code', 'session-exit-code.jsonl', [
toolUse('2026-04-30T09:10:00.000Z', 'session-exit-code', 'toolu_exit_bash', 'Bash', {
command: 'pytest tests/integration/test_pipeline.py',
}),
]);
const result = run(['--home', homeDir, '--now', NOW, '--json', '--exit-code']);
assert.strictEqual(result.code, 2, result.stderr);
const payload = parsePayload(result.stdout);
assert.strictEqual(payload.sessions[0].state, 'attention');
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('exit-code mode returns 1 for scan errors without attention signals', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-missing-'));
const missingTranscript = path.join(tempDir, 'missing.jsonl');
const result = run(['--transcript', missingTranscript, '--now', NOW, '--json', '--exit-code']);
try {
assert.strictEqual(result.code, 1, result.stderr);
const payload = parsePayload(result.stdout);
assert.strictEqual(payload.sessions.length, 0);
assert.strictEqual(payload.errors.length, 1);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('exit-code mode rejects unbounded watch mode', () => {
const result = run(['--watch', '--exit-code']);
assert.strictEqual(result.code, 1);
assert.match(result.stderr, /--exit-code with --watch requires --watch-count/);
})) passed++; else failed++;
if (test('getStatusExitCode prioritizes attention signals over scan errors', () => {
const payload = {
errors: [{ message: 'unreadable' }],
sessions: [{ state: 'attention' }],
};
assert.strictEqual(getStatusExitCode(payload), 2);
})) passed++; else failed++;
if (test('watch mode emits repeated JSON status frames', () => {
const homeDir = createTempHome();
@@ -448,6 +515,233 @@ function runTests() {
}
})) passed++; else failed++;
if (test('watch mode honors exit-code after bounded refreshes', () => {
const homeDir = createTempHome();
try {
writeTranscript(homeDir, '-Users-affoon-project-watch-exit', 'session-watch-exit.jsonl', [
toolUse('2026-04-30T09:00:00.000Z', 'session-watch-exit', 'toolu_watch_exit', 'ScheduleWakeup', {
delaySeconds: 300,
reason: 'Loop checkpoint',
}),
]);
const result = run([
'--home',
homeDir,
'--now',
NOW,
'--json',
'--watch',
'--watch-count',
'1',
'--watch-interval-seconds',
'0.01',
'--exit-code',
]);
assert.strictEqual(result.code, 2, result.stderr);
const frame = JSON.parse(result.stdout.trim());
assert.strictEqual(frame.sessions[0].state, 'attention');
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('writes per-session status snapshots and index when write-dir is set', () => {
const homeDir = createTempHome();
const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-snapshots-'));
try {
writeTranscript(homeDir, '-Users-affoon-project-snapshot', 'session-snapshot.jsonl', [
toolUse('2026-04-30T09:00:00.000Z', 'session-snapshot', 'toolu_snapshot', 'ScheduleWakeup', {
delaySeconds: 300,
reason: 'Loop checkpoint',
}),
]);
const result = run([
'--home',
homeDir,
'--now',
NOW,
'--json',
'--write-dir',
snapshotDir,
]);
assert.strictEqual(result.code, 0, result.stderr);
const stdoutPayload = parsePayload(result.stdout);
assert.strictEqual(stdoutPayload.schemaVersion, 'ecc.loop-status.v1');
const indexPath = path.join(snapshotDir, 'index.json');
const snapshotPath = path.join(snapshotDir, 'session-snapshot.json');
assert.ok(fs.existsSync(indexPath), 'write-dir should include an index.json file');
assert.ok(fs.existsSync(snapshotPath), 'write-dir should include a per-session snapshot');
const indexPayload = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
assert.strictEqual(indexPayload.schemaVersion, 'ecc.loop-status.index.v1');
assert.strictEqual(indexPayload.sessions.length, 1);
assert.strictEqual(indexPayload.sessions[0].sessionId, 'session-snapshot');
assert.strictEqual(indexPayload.sessions[0].state, 'attention');
assert.strictEqual(indexPayload.sessions[0].snapshotPath, snapshotPath);
const snapshotPayload = JSON.parse(fs.readFileSync(snapshotPath, 'utf8'));
assert.strictEqual(snapshotPayload.schemaVersion, 'ecc.loop-status.session.v1');
assert.strictEqual(snapshotPayload.generatedAt, NOW);
assert.strictEqual(snapshotPayload.session.sessionId, 'session-snapshot');
assert.ok(snapshotPayload.session.signals.some(signal => signal.type === 'schedule_wakeup_overdue'));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(snapshotDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('keeps index.json reserved when session id sanitizes to index', () => {
const homeDir = createTempHome();
const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-index-collision-'));
try {
writeTranscript(homeDir, '-Users-affoon-project-index-collision', 'index.jsonl', [
assistantMessage('2026-04-30T09:55:00.000Z', 'index', 'Loop checkpoint.'),
]);
const result = run([
'--home',
homeDir,
'--now',
NOW,
'--json',
'--write-dir',
snapshotDir,
]);
assert.strictEqual(result.code, 0, result.stderr);
const indexPath = path.join(snapshotDir, 'index.json');
const indexPayload = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
assert.strictEqual(indexPayload.schemaVersion, 'ecc.loop-status.index.v1');
assert.strictEqual(indexPayload.sessions.length, 1);
assert.strictEqual(indexPayload.sessions[0].sessionId, 'index');
assert.notStrictEqual(indexPayload.sessions[0].snapshotPath, indexPath);
const snapshotPayload = JSON.parse(fs.readFileSync(indexPayload.sessions[0].snapshotPath, 'utf8'));
assert.strictEqual(snapshotPayload.schemaVersion, 'ecc.loop-status.session.v1');
assert.strictEqual(snapshotPayload.session.sessionId, 'index');
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(snapshotDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('avoids Windows reserved basenames for session snapshots', () => {
const homeDir = createTempHome();
const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-windows-name-'));
try {
writeTranscript(homeDir, '-Users-affoon-project-windows-name', 'con.jsonl', [
assistantMessage('2026-04-30T09:55:00.000Z', 'con', 'Loop checkpoint.'),
]);
writeTranscript(homeDir, '-Users-affoon-project-windows-name', 'con-txt.jsonl', [
assistantMessage('2026-04-30T09:56:00.000Z', 'con.txt', 'Loop checkpoint.'),
]);
const result = run([
'--home',
homeDir,
'--now',
NOW,
'--json',
'--write-dir',
snapshotDir,
]);
assert.strictEqual(result.code, 0, result.stderr);
const indexPath = path.join(snapshotDir, 'index.json');
const indexPayload = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
assert.strictEqual(indexPayload.sessions.length, 2);
for (const sessionIndex of indexPayload.sessions) {
const snapshotName = path.basename(sessionIndex.snapshotPath);
assert.notStrictEqual(snapshotName.toLowerCase(), `${sessionIndex.sessionId}.json`);
assert.ok(!/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(snapshotName.split('.')[0]));
const snapshotPayload = JSON.parse(fs.readFileSync(sessionIndex.snapshotPath, 'utf8'));
assert.strictEqual(snapshotPayload.schemaVersion, 'ecc.loop-status.session.v1');
assert.strictEqual(snapshotPayload.session.sessionId, sessionIndex.sessionId);
}
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(snapshotDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('cleans temporary snapshot files when atomic rename fails', () => {
const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-rename-failure-'));
const originalRenameSync = fs.renameSync;
try {
fs.renameSync = () => {
throw new Error('simulated rename failure');
};
assert.throws(() => writeStatusSnapshots({
errors: [],
generatedAt: NOW,
sessions: [
{
eventCount: 1,
lastEventAt: NOW,
pendingTools: [],
recommendedAction: 'No action needed.',
sessionId: 'rename-failure',
signals: [],
state: 'ok',
transcriptPath: path.join(snapshotDir, 'rename-failure.jsonl'),
},
],
source: {},
}, snapshotDir), /simulated rename failure/);
const tempFiles = fs.readdirSync(snapshotDir).filter(fileName => fileName.endsWith('.tmp'));
assert.deepStrictEqual(tempFiles, []);
} finally {
fs.renameSync = originalRenameSync;
fs.rmSync(snapshotDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('write-dir failures do not suppress normal stdout', () => {
const homeDir = createTempHome();
try {
const blockedPath = path.join(homeDir, 'snapshot-target-is-a-file');
fs.writeFileSync(blockedPath, 'not a directory\n', 'utf8');
writeTranscript(homeDir, '-Users-affoon-project-write-error', 'session-write-error.jsonl', [
assistantMessage('2026-04-30T09:55:00.000Z', 'session-write-error', 'Loop checkpoint.'),
]);
const result = run([
'--home',
homeDir,
'--now',
NOW,
'--json',
'--write-dir',
blockedPath,
]);
assert.strictEqual(result.code, 0, result.stderr);
const payload = parsePayload(result.stdout);
assert.strictEqual(payload.schemaVersion, 'ecc.loop-status.v1');
assert.strictEqual(payload.sessions[0].sessionId, 'session-write-error');
assert.match(result.stderr, /\[loop-status\] WARNING: could not write status snapshots:/);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
+88
View File
@@ -0,0 +1,88 @@
from types import SimpleNamespace
from llm.core.types import LLMInput, Message, Role, ToolDefinition
from llm.providers.claude import ClaudeProvider
from llm.providers.openai import OpenAIProvider
def _tool() -> ToolDefinition:
return ToolDefinition(
name="search",
description="Search",
parameters={"type": "object", "properties": {"query": {"type": "string"}}},
)
class _OpenAICompletions:
def __init__(self) -> None:
self.params = None
def create(self, **params):
self.params = params
return SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="ok", tool_calls=None), finish_reason="stop")],
model=params["model"],
usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1, total_tokens=2),
)
class _OpenAIClient:
def __init__(self) -> None:
self.completions = _OpenAICompletions()
self.chat = SimpleNamespace(completions=self.completions)
class _AnthropicMessages:
def __init__(self) -> None:
self.params = None
def create(self, **params):
self.params = params
return SimpleNamespace(
content=[SimpleNamespace(text="ok", type="text")],
model=params["model"],
usage=SimpleNamespace(input_tokens=1, output_tokens=1),
stop_reason="end_turn",
)
class _AnthropicClient:
def __init__(self) -> None:
self.messages = _AnthropicMessages()
self.api_key = "test"
def test_openai_provider_serializes_tools_for_chat_completions():
provider = OpenAIProvider(api_key="test")
client = _OpenAIClient()
provider.client = client
provider.generate(LLMInput(messages=[Message(role=Role.USER, content="hi")], tools=[_tool()]))
assert client.completions.params["tools"] == [
{
"type": "function",
"function": {
"name": "search",
"description": "Search",
"parameters": {"type": "object", "properties": {"query": {"type": "string"}}},
"strict": True,
},
}
]
def test_claude_provider_serializes_tools_for_messages_api():
provider = ClaudeProvider(api_key="test")
client = _AnthropicClient()
provider.client = client
provider.generate(LLMInput(messages=[Message(role=Role.USER, content="hi")], tools=[_tool()]))
assert client.messages.params["tools"] == [
{
"name": "search",
"description": "Search",
"input_schema": {"type": "object", "properties": {"query": {"type": "string"}}},
}
]
+34
View File
@@ -26,3 +26,37 @@ class TestGetProvider:
def test_invalid_provider_raises(self):
with pytest.raises(ValueError, match="Unknown provider type"):
get_provider("invalid")
def test_saved_llm_env_selects_provider(self, monkeypatch, tmp_path):
monkeypatch.delenv("LLM_PROVIDER", raising=False)
monkeypatch.chdir(tmp_path)
tmp_path.joinpath(".llm.env").write_text("LLM_PROVIDER=ollama\nLLM_MODEL=llama3.2\n")
provider = get_provider()
assert isinstance(provider, OllamaProvider)
def test_env_provider_overrides_saved_llm_env(self, monkeypatch, tmp_path):
monkeypatch.setenv("LLM_PROVIDER", "ollama")
monkeypatch.chdir(tmp_path)
tmp_path.joinpath(".llm.env").write_text("LLM_PROVIDER=openai\n")
provider = get_provider()
assert isinstance(provider, OllamaProvider)
def test_env_provider_is_normalized(self, monkeypatch):
monkeypatch.setenv("LLM_PROVIDER", "OLLAMA")
provider = get_provider()
assert isinstance(provider, OllamaProvider)
def test_explicit_provider_overrides_saved_llm_env(self, monkeypatch, tmp_path):
monkeypatch.delenv("LLM_PROVIDER", raising=False)
monkeypatch.chdir(tmp_path)
tmp_path.joinpath(".llm.env").write_text("LLM_PROVIDER=openai\n")
provider = get_provider("ollama")
assert isinstance(provider, OllamaProvider)
+31
View File
@@ -63,6 +63,37 @@ class TestToolDefinition:
assert result["name"] == "search"
assert result["strict"] is True
def test_tool_to_openai_tool(self):
tool = ToolDefinition(
name="search",
description="Search",
parameters={"type": "object"},
strict=False,
)
assert tool.to_openai_tool() == {
"type": "function",
"function": {
"name": "search",
"description": "Search",
"parameters": {"type": "object"},
"strict": False,
},
}
def test_tool_to_anthropic_tool(self):
tool = ToolDefinition(
name="search",
description="Search",
parameters={"type": "object"},
)
assert tool.to_anthropic_tool() == {
"name": "search",
"description": "Search",
"input_schema": {"type": "object"},
}
class TestToolCall:
def test_create_tool_call(self):
+371 -25
View File
@@ -138,6 +138,15 @@ __metadata:
languageName: node
linkType: hard
"@isaacs/fs-minipass@npm:^4.0.0":
version: 4.0.1
resolution: "@isaacs/fs-minipass@npm:4.0.1"
dependencies:
minipass: "npm:^7.0.4"
checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2
languageName: node
linkType: hard
"@istanbuljs/schema@npm:^0.1.2, @istanbuljs/schema@npm:^0.1.3":
version: 0.1.3
resolution: "@istanbuljs/schema@npm:0.1.3"
@@ -169,30 +178,80 @@ __metadata:
languageName: node
linkType: hard
"@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.3":
version: 3.0.3
resolution: "@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.3"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@msgpackr-extract/msgpackr-extract-darwin-x64@npm:3.0.3":
version: 3.0.3
resolution: "@msgpackr-extract/msgpackr-extract-darwin-x64@npm:3.0.3"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@msgpackr-extract/msgpackr-extract-linux-arm64@npm:3.0.3":
version: 3.0.3
resolution: "@msgpackr-extract/msgpackr-extract-linux-arm64@npm:3.0.3"
conditions: os=linux & cpu=arm64
languageName: node
linkType: hard
"@msgpackr-extract/msgpackr-extract-linux-arm@npm:3.0.3":
version: 3.0.3
resolution: "@msgpackr-extract/msgpackr-extract-linux-arm@npm:3.0.3"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"@msgpackr-extract/msgpackr-extract-linux-x64@npm:3.0.3":
version: 3.0.3
resolution: "@msgpackr-extract/msgpackr-extract-linux-x64@npm:3.0.3"
conditions: os=linux & cpu=x64
languageName: node
linkType: hard
"@msgpackr-extract/msgpackr-extract-win32-x64@npm:3.0.3":
version: 3.0.3
resolution: "@msgpackr-extract/msgpackr-extract-win32-x64@npm:3.0.3"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@opencode-ai/plugin@npm:^1.0.0":
version: 1.3.15
resolution: "@opencode-ai/plugin@npm:1.3.15"
version: 1.14.33
resolution: "@opencode-ai/plugin@npm:1.14.33"
dependencies:
"@opencode-ai/sdk": "npm:1.3.15"
"@opencode-ai/sdk": "npm:1.14.33"
effect: "npm:4.0.0-beta.57"
zod: "npm:4.1.8"
peerDependencies:
"@opentui/core": ">=0.1.96"
"@opentui/solid": ">=0.1.96"
"@opentui/core": ">=0.2.2"
"@opentui/solid": ">=0.2.2"
peerDependenciesMeta:
"@opentui/core":
optional: true
"@opentui/solid":
optional: true
checksum: 10c0/1a662ff700812223310612f3c8c7fd4465eda5763d726ec4d29d0eae26babf344ef176c9b987d79fe1e29c8a498178881a47d7080bb9f4db3e70dad59eb8cd9e
checksum: 10c0/0ce3e9876e12e4d9afc664c1a03bc3bebb12147bdea9b640a1bc3ed3b871b284a75b294f72e8afc86af8140f0d89cb223367c12ee7af4d6c25e9c1373893d13a
languageName: node
linkType: hard
"@opencode-ai/sdk@npm:1.3.15":
version: 1.3.15
resolution: "@opencode-ai/sdk@npm:1.3.15"
"@opencode-ai/sdk@npm:1.14.33":
version: 1.14.33
resolution: "@opencode-ai/sdk@npm:1.14.33"
dependencies:
cross-spawn: "npm:7.0.6"
checksum: 10c0/3957ae62e0ec1e339d9493e03a2440c95afdd64a608a2dc9db8383338650318a294280b2142305db5b0147badacbefa0d07e949d31167e5a4a49c9d057d016fa
checksum: 10c0/10a52b224428fb05be055dbf2b96adbf1198a489a9eeedc68848231720559ec2cb98edf44b25afb2945a7609c485eea4265ea089d16fcdb4537113b78318ea21
languageName: node
linkType: hard
"@standard-schema/spec@npm:^1.1.0":
version: 1.1.0
resolution: "@standard-schema/spec@npm:1.1.0"
checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526
languageName: node
linkType: hard
@@ -256,6 +315,13 @@ __metadata:
languageName: node
linkType: hard
"abbrev@npm:^4.0.0":
version: 4.0.0
resolution: "abbrev@npm:4.0.0"
checksum: 10c0/b4cc16935235e80702fc90192e349e32f8ef0ed151ef506aa78c81a7c455ec18375c4125414b99f84b2e055199d66383e787675f0bcd87da7a4dbd59f9eac1d5
languageName: node
linkType: hard
"acorn-jsx@npm:^5.3.2":
version: 5.3.2
resolution: "acorn-jsx@npm:5.3.2"
@@ -275,26 +341,26 @@ __metadata:
linkType: hard
"ajv@npm:^6.12.4":
version: 6.14.0
resolution: "ajv@npm:6.14.0"
version: 6.15.0
resolution: "ajv@npm:6.15.0"
dependencies:
fast-deep-equal: "npm:^3.1.1"
fast-json-stable-stringify: "npm:^2.0.0"
json-schema-traverse: "npm:^0.4.1"
uri-js: "npm:^4.2.2"
checksum: 10c0/a2bc39b0555dc9802c899f86990eb8eed6e366cddbf65be43d5aa7e4f3c4e1a199d5460fd7ca4fb3d864000dbbc049253b72faa83b3b30e641ca52cb29a68c22
checksum: 10c0/67966499dd272ecde1c2e467084411132891523d057487587879d39ac04207f4351b7b2324c83198013967fbfa632c1612adc960114a30770fbe07a0773b32c2
languageName: node
linkType: hard
"ajv@npm:^8.18.0":
version: 8.18.0
resolution: "ajv@npm:8.18.0"
version: 8.20.0
resolution: "ajv@npm:8.20.0"
dependencies:
fast-deep-equal: "npm:^3.1.3"
fast-uri: "npm:^3.0.1"
json-schema-traverse: "npm:^1.0.0"
require-from-string: "npm:^2.0.2"
checksum: 10c0/e7517c426173513a07391be951879932bdf3348feaebd2199f5b901c20f99d60db8cd1591502d4d551dc82f594e82a05c4fe1c70139b15b8937f7afeaed9532f
checksum: 10c0/5df9a1c8f83863cde1bd3a9ddb426f599718f88e3dc9153616c79fb28e0be455335830d7f21d745576519f057b371352daa31047b6a33d7036fe08777d60cf2a
languageName: node
linkType: hard
@@ -425,6 +491,13 @@ __metadata:
languageName: node
linkType: hard
"chownr@npm:^3.0.0":
version: 3.0.0
resolution: "chownr@npm:3.0.0"
checksum: 10c0/43925b87700f7e3893296c8e9c56cc58f926411cce3a6e5898136daaf08f08b9a8eb76d37d3267e707d0dcc17aed2e2ebdf5848c0c3ce95cf910a919935c1b10
languageName: node
linkType: hard
"cliui@npm:^8.0.1":
version: 8.0.1
resolution: "cliui@npm:8.0.1"
@@ -533,6 +606,13 @@ __metadata:
languageName: node
linkType: hard
"detect-libc@npm:^2.0.1":
version: 2.1.2
resolution: "detect-libc@npm:2.1.2"
checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4
languageName: node
linkType: hard
"devlop@npm:^1.0.0":
version: 1.1.0
resolution: "devlop@npm:1.1.0"
@@ -563,6 +643,24 @@ __metadata:
languageName: unknown
linkType: soft
"effect@npm:4.0.0-beta.57":
version: 4.0.0-beta.57
resolution: "effect@npm:4.0.0-beta.57"
dependencies:
"@standard-schema/spec": "npm:^1.1.0"
fast-check: "npm:^4.6.0"
find-my-way-ts: "npm:^0.1.6"
ini: "npm:^6.0.0"
kubernetes-types: "npm:^1.30.0"
msgpackr: "npm:^1.11.9"
multipasta: "npm:^0.2.7"
toml: "npm:^4.1.1"
uuid: "npm:^13.0.0"
yaml: "npm:^2.8.3"
checksum: 10c0/0ae765176b305f6ec9c067122cdd0adae8c83b233973df57200b3fb68e417f94cd7e539e71fff520f9c98be59404a23d68989cd43a4b53d9926e9ae91ee13a44
languageName: node
linkType: hard
"emoji-regex@npm:^8.0.0":
version: 8.0.0
resolution: "emoji-regex@npm:8.0.0"
@@ -577,6 +675,13 @@ __metadata:
languageName: node
linkType: hard
"env-paths@npm:^2.2.0":
version: 2.2.1
resolution: "env-paths@npm:2.2.1"
checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4
languageName: node
linkType: hard
"escalade@npm:^3.1.1":
version: 3.2.0
resolution: "escalade@npm:3.2.0"
@@ -707,6 +812,22 @@ __metadata:
languageName: node
linkType: hard
"exponential-backoff@npm:^3.1.1":
version: 3.1.3
resolution: "exponential-backoff@npm:3.1.3"
checksum: 10c0/77e3ae682b7b1f4972f563c6dbcd2b0d54ac679e62d5d32f3e5085feba20483cf28bd505543f520e287a56d4d55a28d7874299941faf637e779a1aa5994d1267
languageName: node
linkType: hard
"fast-check@npm:^4.6.0":
version: 4.7.0
resolution: "fast-check@npm:4.7.0"
dependencies:
pure-rand: "npm:^8.0.0"
checksum: 10c0/7edce2b82d11d5325e9e79a2377e1f6e7200d27219edda2e3449d827e994c34461132fc149c90e41b78fc8e6ef4aae77d45350ac7bb1bc4a81110401d0a49fbc
languageName: node
linkType: hard
"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3":
version: 3.1.3
resolution: "fast-deep-equal@npm:3.1.3"
@@ -729,9 +850,9 @@ __metadata:
linkType: hard
"fast-uri@npm:^3.0.1":
version: 3.1.0
resolution: "fast-uri@npm:3.1.0"
checksum: 10c0/44364adca566f70f40d1e9b772c923138d47efeac2ae9732a872baafd77061f26b097ba2f68f0892885ad177becd065520412b8ffeec34b16c99433c5b9e2de7
version: 3.1.2
resolution: "fast-uri@npm:3.1.2"
checksum: 10c0/5b35641895959f3f7ab7a7b1b5542bded159346f25ec9f256817b206d50b64eda5828e90d605a2e2fc645c90519a7259c2bab2c942ee728c88b88e5be21b090d
languageName: node
linkType: hard
@@ -756,6 +877,13 @@ __metadata:
languageName: node
linkType: hard
"find-my-way-ts@npm:^0.1.6":
version: 0.1.6
resolution: "find-my-way-ts@npm:0.1.6"
checksum: 10c0/16ad4b15275b56ee0ec361d0c61afbdff4c75bd0ac04112f6910f188cb1058096ba63529c2363914da6bb60266aa4def1025af04af26368ff87eb0df52f2862f
languageName: node
linkType: hard
"find-up@npm:^5.0.0":
version: 5.0.0
resolution: "find-up@npm:5.0.0"
@@ -835,9 +963,16 @@ __metadata:
linkType: hard
"globals@npm:^17.4.0":
version: 17.4.0
resolution: "globals@npm:17.4.0"
checksum: 10c0/2be9e8c2b9035836f13d420b22f0247a328db82967d3bebfc01126d888ed609305f06c05895914e969653af5c6ba35fd7a0920f3e6c869afa60666c810630feb
version: 17.6.0
resolution: "globals@npm:17.6.0"
checksum: 10c0/cf94fb4329cc5c68cf81018fd68324f413181ee169f0235b0b33b82bc93fe7825a21beea951f83a80e8e4bbdad9c0c80515a145b5fd4b5cb52f2a80db899a93f
languageName: node
linkType: hard
"graceful-fs@npm:^4.2.6":
version: 4.2.11
resolution: "graceful-fs@npm:4.2.11"
checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2
languageName: node
linkType: hard
@@ -886,6 +1021,13 @@ __metadata:
languageName: node
linkType: hard
"ini@npm:^6.0.0":
version: 6.0.0
resolution: "ini@npm:6.0.0"
checksum: 10c0/9a7f55f306e2b25b41ae67c8b526e8f4673f057b70852b9025816ef4f15f07bf1ba35ed68ea4471ff7b31718f7ef1bc50d709f8d03cb012e10a3135eb99c7206
languageName: node
linkType: hard
"ini@npm:~4.1.0":
version: 4.1.3
resolution: "ini@npm:4.1.3"
@@ -954,6 +1096,13 @@ __metadata:
languageName: node
linkType: hard
"isexe@npm:^4.0.0":
version: 4.0.0
resolution: "isexe@npm:4.0.0"
checksum: 10c0/5884815115bceac452877659a9c7726382531592f43dc29e5d48b7c4100661aed54018cb90bd36cb2eaeba521092570769167acbb95c18d39afdccbcca06c5ce
languageName: node
linkType: hard
"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0":
version: 3.2.2
resolution: "istanbul-lib-coverage@npm:3.2.2"
@@ -1055,6 +1204,13 @@ __metadata:
languageName: node
linkType: hard
"kubernetes-types@npm:^1.30.0":
version: 1.30.0
resolution: "kubernetes-types@npm:1.30.0"
checksum: 10c0/de3641e4f50cfc123c4102a73c12932e1db8e51783c7cae4ea8ad3561bd56fab0f1c2346801f84a4c36aae8cea0b25d21e9514cc0fcecd4d64b1314043263076
languageName: node
linkType: hard
"levn@npm:^0.4.1":
version: 0.4.1
resolution: "levn@npm:0.4.1"
@@ -1488,13 +1644,22 @@ __metadata:
languageName: node
linkType: hard
"minipass@npm:^7.1.2, minipass@npm:^7.1.3":
"minipass@npm:^7.0.4, minipass@npm:^7.1.2, minipass@npm:^7.1.3":
version: 7.1.3
resolution: "minipass@npm:7.1.3"
checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb
languageName: node
linkType: hard
"minizlib@npm:^3.1.0":
version: 3.1.0
resolution: "minizlib@npm:3.1.0"
dependencies:
minipass: "npm:^7.1.2"
checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec
languageName: node
linkType: hard
"ms@npm:^2.1.3":
version: 2.1.3
resolution: "ms@npm:2.1.3"
@@ -1502,6 +1667,56 @@ __metadata:
languageName: node
linkType: hard
"msgpackr-extract@npm:^3.0.2":
version: 3.0.3
resolution: "msgpackr-extract@npm:3.0.3"
dependencies:
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "npm:3.0.3"
"@msgpackr-extract/msgpackr-extract-darwin-x64": "npm:3.0.3"
"@msgpackr-extract/msgpackr-extract-linux-arm": "npm:3.0.3"
"@msgpackr-extract/msgpackr-extract-linux-arm64": "npm:3.0.3"
"@msgpackr-extract/msgpackr-extract-linux-x64": "npm:3.0.3"
"@msgpackr-extract/msgpackr-extract-win32-x64": "npm:3.0.3"
node-gyp: "npm:latest"
node-gyp-build-optional-packages: "npm:5.2.2"
dependenciesMeta:
"@msgpackr-extract/msgpackr-extract-darwin-arm64":
optional: true
"@msgpackr-extract/msgpackr-extract-darwin-x64":
optional: true
"@msgpackr-extract/msgpackr-extract-linux-arm":
optional: true
"@msgpackr-extract/msgpackr-extract-linux-arm64":
optional: true
"@msgpackr-extract/msgpackr-extract-linux-x64":
optional: true
"@msgpackr-extract/msgpackr-extract-win32-x64":
optional: true
bin:
download-msgpackr-prebuilds: bin/download-prebuilds.js
checksum: 10c0/e504fd8bf86a29d7527c83776530ee6dc92dcb0273bb3679fd4a85173efead7f0ee32fb82c8410a13c33ef32828c45f81118ffc0fbed5d6842e72299894623b4
languageName: node
linkType: hard
"msgpackr@npm:^1.11.9":
version: 1.11.12
resolution: "msgpackr@npm:1.11.12"
dependencies:
msgpackr-extract: "npm:^3.0.2"
dependenciesMeta:
msgpackr-extract:
optional: true
checksum: 10c0/e9f1460e363dbd8c81a5c1b5829980edea7d76e91d570d094d0a4dae0d8ad12f64dea11b2be15f3d7b48d615fa9d3c9b600a6894fd272526087fa33753b5fd16
languageName: node
linkType: hard
"multipasta@npm:^0.2.7":
version: 0.2.7
resolution: "multipasta@npm:0.2.7"
checksum: 10c0/15917ac88aeefa5b8afac44b90d1e9d0d0ec7148b51e0766f07a69a220ecebcb6404539a856c45aa85a3d7fe517bc58febe81437146705f17ecd2961dc0b9fa5
languageName: node
linkType: hard
"natural-compare@npm:^1.4.0":
version: 1.4.0
resolution: "natural-compare@npm:1.4.0"
@@ -1509,6 +1724,50 @@ __metadata:
languageName: node
linkType: hard
"node-gyp-build-optional-packages@npm:5.2.2":
version: 5.2.2
resolution: "node-gyp-build-optional-packages@npm:5.2.2"
dependencies:
detect-libc: "npm:^2.0.1"
bin:
node-gyp-build-optional-packages: bin.js
node-gyp-build-optional-packages-optional: optional.js
node-gyp-build-optional-packages-test: build-test.js
checksum: 10c0/c81128c6f91873381be178c5eddcbdf66a148a6a89a427ce2bcd457593ce69baf2a8662b6d22cac092d24aa9c43c230dec4e69b3a0da604503f4777cd77e282b
languageName: node
linkType: hard
"node-gyp@npm:latest":
version: 12.3.0
resolution: "node-gyp@npm:12.3.0"
dependencies:
env-paths: "npm:^2.2.0"
exponential-backoff: "npm:^3.1.1"
graceful-fs: "npm:^4.2.6"
nopt: "npm:^9.0.0"
proc-log: "npm:^6.0.0"
semver: "npm:^7.3.5"
tar: "npm:^7.5.4"
tinyglobby: "npm:^0.2.12"
undici: "npm:^6.25.0"
which: "npm:^6.0.0"
bin:
node-gyp: bin/node-gyp.js
checksum: 10c0/9d9032b405cbe42f72a105259d9eb679376470c102df4a2dbaa51e07d59bf741dcffb85897087ea9d8318b9cabb824a8978af51508ae142f0239ae1e6a3c2329
languageName: node
linkType: hard
"nopt@npm:^9.0.0":
version: 9.0.0
resolution: "nopt@npm:9.0.0"
dependencies:
abbrev: "npm:^4.0.0"
bin:
nopt: bin/nopt.js
checksum: 10c0/1822eb6f9b020ef6f7a7516d7b64a8036e09666ea55ac40416c36e4b2b343122c3cff0e2f085675f53de1d2db99a2a89a60ccea1d120bcd6a5347bf6ceb4a7fd
languageName: node
linkType: hard
"optionator@npm:^0.9.3":
version: 0.9.4
resolution: "optionator@npm:0.9.4"
@@ -1589,7 +1848,7 @@ __metadata:
languageName: node
linkType: hard
"picomatch@npm:^4.0.3":
"picomatch@npm:^4.0.3, picomatch@npm:^4.0.4":
version: 4.0.4
resolution: "picomatch@npm:4.0.4"
checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0
@@ -1603,6 +1862,13 @@ __metadata:
languageName: node
linkType: hard
"proc-log@npm:^6.0.0":
version: 6.1.0
resolution: "proc-log@npm:6.1.0"
checksum: 10c0/4f178d4062733ead9d71a9b1ab24ebcecdfe2250916a5b1555f04fe2eda972a0ec76fbaa8df1ad9c02707add6749219d118a4fc46dc56bdfe4dde4b47d80bb82
languageName: node
linkType: hard
"punycode.js@npm:^2.3.1":
version: 2.3.1
resolution: "punycode.js@npm:2.3.1"
@@ -1617,6 +1883,13 @@ __metadata:
languageName: node
linkType: hard
"pure-rand@npm:^8.0.0":
version: 8.4.0
resolution: "pure-rand@npm:8.4.0"
checksum: 10c0/6414bbc1c6f45fb774173431c7205e79783b77cfae0e2145e741b6999363554dbd2f4210d2a5bc08683e0b2f6823198c9308766b1d0911e1dccd7beb8842f860
languageName: node
linkType: hard
"require-directory@npm:^2.1.1":
version: 2.1.1
resolution: "require-directory@npm:2.1.1"
@@ -1652,7 +1925,7 @@ __metadata:
languageName: node
linkType: hard
"semver@npm:^7.5.3":
"semver@npm:^7.3.5, semver@npm:^7.5.3":
version: 7.7.4
resolution: "semver@npm:7.7.4"
bin:
@@ -1753,6 +2026,19 @@ __metadata:
languageName: node
linkType: hard
"tar@npm:^7.5.4":
version: 7.5.13
resolution: "tar@npm:7.5.13"
dependencies:
"@isaacs/fs-minipass": "npm:^4.0.0"
chownr: "npm:^3.0.0"
minipass: "npm:^7.1.2"
minizlib: "npm:^3.1.0"
yallist: "npm:^5.0.0"
checksum: 10c0/5c65b8084799bde7a791593a1c1a45d3d6ee98182e3700b24c247b7b8f8654df4191642abbdb07ff25043d45dcff35620827c3997b88ae6c12040f64bed5076b
languageName: node
linkType: hard
"test-exclude@npm:^8.0.0":
version: 8.0.0
resolution: "test-exclude@npm:8.0.0"
@@ -1764,6 +2050,16 @@ __metadata:
languageName: node
linkType: hard
"tinyglobby@npm:^0.2.12":
version: 0.2.16
resolution: "tinyglobby@npm:0.2.16"
dependencies:
fdir: "npm:^6.5.0"
picomatch: "npm:^4.0.4"
checksum: 10c0/f2e09fd93dd95c41e522113b686ff6f7c13020962f8698a864a257f3d7737599afc47722b7ab726e12f8a813f779906187911ff8ee6701ede65072671a7e934b
languageName: node
linkType: hard
"tinyglobby@npm:~0.2.15":
version: 0.2.15
resolution: "tinyglobby@npm:0.2.15"
@@ -1774,6 +2070,13 @@ __metadata:
languageName: node
linkType: hard
"toml@npm:^4.1.1":
version: 4.1.1
resolution: "toml@npm:4.1.1"
checksum: 10c0/077bc02ac1ce82091ea073f675d7e2a1df487d1b18bbc7e653daba4956d545954b7095e979b8792f0837339b901ee190ad4464342e5e377c36bbdeca8903e079
languageName: node
linkType: hard
"type-check@npm:^0.4.0, type-check@npm:~0.4.0":
version: 0.4.0
resolution: "type-check@npm:0.4.0"
@@ -1817,6 +2120,13 @@ __metadata:
languageName: node
linkType: hard
"undici@npm:^6.25.0":
version: 6.25.0
resolution: "undici@npm:6.25.0"
checksum: 10c0/2597cc6689bdb02c210c557b1f85febbfda65becae6e6fc1061508e2f33734d25207f81cd8af56ada9956329eb3a7bd7431e87dcfeceba20ee87059b57dcf985
languageName: node
linkType: hard
"uri-js@npm:^4.2.2":
version: 4.4.1
resolution: "uri-js@npm:4.4.1"
@@ -1826,6 +2136,15 @@ __metadata:
languageName: node
linkType: hard
"uuid@npm:^13.0.0":
version: 13.0.1
resolution: "uuid@npm:13.0.1"
bin:
uuid: dist-node/bin/uuid
checksum: 10c0/7bb8ad18b11871b7bd1b9161a60610c2b6ce8f7300d93932f92117a2ab9b40479dd23e81929ac848e8a7c45f78b8ed3333f88694b71c17ff3265e443f8684642
languageName: node
linkType: hard
"v8-to-istanbul@npm:^9.0.0":
version: 9.3.0
resolution: "v8-to-istanbul@npm:9.3.0"
@@ -1848,6 +2167,17 @@ __metadata:
languageName: node
linkType: hard
"which@npm:^6.0.0":
version: 6.0.1
resolution: "which@npm:6.0.1"
dependencies:
isexe: "npm:^4.0.0"
bin:
node-which: bin/which.js
checksum: 10c0/7e710e54ea36d2d6183bee2f9caa27a3b47b9baf8dee55a199b736fcf85eab3b9df7556fca3d02b50af7f3dfba5ea3a45644189836df06267df457e354da66d5
languageName: node
linkType: hard
"word-wrap@npm:^1.2.5":
version: 1.2.5
resolution: "word-wrap@npm:1.2.5"
@@ -1873,6 +2203,22 @@ __metadata:
languageName: node
linkType: hard
"yallist@npm:^5.0.0":
version: 5.0.0
resolution: "yallist@npm:5.0.0"
checksum: 10c0/a499c81ce6d4a1d260d4ea0f6d49ab4da09681e32c3f0472dee16667ed69d01dae63a3b81745a24bd78476ec4fcf856114cb4896ace738e01da34b2c42235416
languageName: node
linkType: hard
"yaml@npm:^2.8.3":
version: 2.8.4
resolution: "yaml@npm:2.8.4"
bin:
yaml: bin.mjs
checksum: 10c0/0a33a1fa28d4bc79f61a12ec7ef7a2bce0ce5f8e80c6eaecfb4a0c88c08767dd1ede372b6a3bcd70891213b8c9f3169b355c97e77026d3b3459e10d2cccaef1e
languageName: node
linkType: hard
"yargs-parser@npm:^21.1.1":
version: 21.1.1
resolution: "yargs-parser@npm:21.1.1"