mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-18 15:54:13 +08:00
Compare commits
7 Commits
@@ -11,7 +11,7 @@
|
||||
{
|
||||
"name": "ecc",
|
||||
"source": "./",
|
||||
"description": "Harness-native ECC operator layer - 63 agents, 249 skills, 79 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 - 63 agents, 251 skills, 79 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.0.0-rc.1",
|
||||
"author": {
|
||||
"name": "Affaan Mustafa",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ecc",
|
||||
"version": "2.0.0-rc.1",
|
||||
"description": "Harness-native ECC plugin for engineering teams - 63 agents, 249 skills, 79 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 - 63 agents, 251 skills, 79 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"
|
||||
|
||||
+12
-10
@@ -12,15 +12,15 @@ This directory contains the **Codex plugin manifest** for ECC.
|
||||
|
||||
## What This Provides
|
||||
|
||||
- **200 skills** from `./skills/` — reusable Codex workflows for TDD, security,
|
||||
- **249 skills** from `./skills/` — reusable Codex workflows for TDD, security,
|
||||
code review, architecture, and more
|
||||
- **6 MCP servers** — GitHub, Context7, Exa, Memory, Playwright, Sequential Thinking
|
||||
|
||||
## Installation
|
||||
|
||||
Codex plugin support is currently marketplace-backed. The repo exposes a
|
||||
repo-scoped marketplace at `.agents/plugins/marketplace.json`; Codex can add and
|
||||
track that marketplace source from the CLI:
|
||||
Codex plugin support is marketplace-backed. The repo exposes a repo-scoped
|
||||
marketplace at `.agents/plugins/marketplace.json`; Codex can add and track that
|
||||
marketplace source from the CLI:
|
||||
|
||||
```bash
|
||||
# Add the public repo marketplace
|
||||
@@ -35,10 +35,12 @@ The marketplace entry points at the repository root so `.codex-plugin/plugin.jso
|
||||
or updating the marketplace, restart Codex and install or enable `ecc` from the
|
||||
plugin directory.
|
||||
|
||||
Official Plugin Directory publishing is coming soon in Codex. Until self-serve
|
||||
publishing exists, treat the public repo marketplace as the supported Codex
|
||||
distribution path and keep release copy framed as repo-marketplace/manual
|
||||
installation.
|
||||
Official Plugin Directory publishing is coming soon. For official OpenAI
|
||||
plugin-directory review, package this repo under the `openai/plugins`
|
||||
repository shape: `plugins/ecc/.codex-plugin/plugin.json`,
|
||||
`plugins/ecc/skills/`, and the supporting README/assets. Until that listing is
|
||||
accepted, treat the public repo marketplace as the supported Codex distribution
|
||||
path and keep release copy framed as repo-marketplace/manual installation.
|
||||
|
||||
The installed plugin registers under the short slug `ecc` so tool and command names
|
||||
stay below provider length limits.
|
||||
@@ -56,8 +58,8 @@ stay below provider length limits.
|
||||
|
||||
## Notes
|
||||
|
||||
- The `skills/` directory at the repo root is shared between Claude Code (`.claude-plugin/`)
|
||||
and Codex (`.codex-plugin/`) — same source of truth, no duplication
|
||||
- The `skills/` directory at the repo root is the source of truth for the Codex
|
||||
plugin package; do not duplicate skill content inside `.codex-plugin/`.
|
||||
- ECC is moving to a skills-first workflow surface. Legacy `commands/` remain for
|
||||
compatibility on harnesses that still expect slash-entry shims.
|
||||
- MCP server credentials are inherited from the launching environment (env vars)
|
||||
|
||||
@@ -15,12 +15,18 @@
|
||||
"mcpServers": "./.mcp.json",
|
||||
"interface": {
|
||||
"displayName": "ECC",
|
||||
"shortDescription": "207 battle-tested ECC skills plus MCP configs for TDD, security, code review, and autonomous development.",
|
||||
"shortDescription": "249 ECC skills plus MCP configs for TDD, security, code review, and autonomous development.",
|
||||
"longDescription": "ECC is a harness-native operator system for Codex and adjacent agent harnesses. It packages reusable skills, MCP configs, TDD workflows, security scanning, code review, architecture decisions, operator workflows, and release gates in one installable plugin.",
|
||||
"developerName": "Affaan Mustafa",
|
||||
"category": "Productivity",
|
||||
"capabilities": ["Read", "Write"],
|
||||
"category": "Coding",
|
||||
"capabilities": ["Interactive", "Read", "Write"],
|
||||
"websiteURL": "https://ecc.tools",
|
||||
"privacyPolicyURL": "https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement",
|
||||
"termsOfServiceURL": "https://docs.github.com/en/site-policy/github-terms/github-terms-of-service",
|
||||
"brandColor": "#E07856",
|
||||
"composerIcon": "./assets/ecc-icon.svg",
|
||||
"logo": "./assets/hero.png",
|
||||
"screenshots": [],
|
||||
"defaultPrompt": [
|
||||
"Use the tdd-workflow skill to write tests before implementation.",
|
||||
"Use the security-review skill to scan for OWASP Top 10 vulnerabilities.",
|
||||
|
||||
+3
-1
@@ -51,7 +51,9 @@ args = ["-y", "@upstash/context7-mcp@latest"]
|
||||
startup_timeout_sec = 30
|
||||
|
||||
[mcp_servers.exa]
|
||||
url = "https://mcp.exa.ai/mcp"
|
||||
command = "npx"
|
||||
args = ["-y", "mcp-remote", "https://mcp.exa.ai/mcp"]
|
||||
startup_timeout_sec = 30
|
||||
|
||||
[mcp_servers.memory]
|
||||
command = "npx"
|
||||
|
||||
@@ -44,6 +44,7 @@ yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
.yarn/
|
||||
lerna-debug.log*
|
||||
*.tgz
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Everything Claude Code (ECC) — Agent Instructions
|
||||
|
||||
This is a **production-ready AI coding plugin** providing 63 specialized agents, 249 skills, 79 commands, and automated hook workflows for software development.
|
||||
This is a **production-ready AI coding plugin** providing 63 specialized agents, 251 skills, 79 commands, and automated hook workflows for software development.
|
||||
|
||||
**Version:** 2.0.0-rc.1
|
||||
|
||||
@@ -150,7 +150,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat
|
||||
|
||||
```
|
||||
agents/ — 63 specialized subagents
|
||||
skills/ — 249 workflow skills and domain knowledge
|
||||
skills/ — 251 workflow skills and domain knowledge
|
||||
commands/ — 79 slash commands
|
||||
hooks/ — Trigger-based automations
|
||||
rules/ — Always-follow guidelines (common + per-language)
|
||||
|
||||
@@ -123,7 +123,7 @@ This repo is the raw code only. The guides explain everything.
|
||||
### v2.0.0-rc.1 — Surface Refresh, Operator Workflows, and ECC 2.0 Alpha (Apr 2026)
|
||||
|
||||
- **Dashboard GUI** — New Tkinter-based desktop application (`ecc_dashboard.py` or `npm run dashboard`) with dark/light theme toggle, font customization, and project logo in header and taskbar.
|
||||
- **Public surface synced to the live repo** — metadata, catalog counts, plugin manifests, and install-facing docs now match the actual OSS surface: 63 agents, 249 skills, and 79 legacy command shims.
|
||||
- **Public surface synced to the live repo** — metadata, catalog counts, plugin manifests, and install-facing docs now match the actual OSS surface: 63 agents, 251 skills, and 79 legacy command shims.
|
||||
- **Operator and outbound workflow expansion** — `brand-voice`, `social-graph-ranker`, `connections-optimizer`, `customer-billing-ops`, `ecc-tools-cost-audit`, `google-workspace-ops`, `project-flow-ops`, and `workspace-surface-audit` round out the operator lane.
|
||||
- **Media and launch tooling** — `manim-video`, `remotion-video-creation`, and upgraded social publishing surfaces make technical explainers and launch content part of the same system.
|
||||
- **Framework and product surface growth** — `nestjs-patterns`, richer Codex/OpenCode install surfaces, and expanded cross-harness packaging keep the repo usable beyond Claude Code alone.
|
||||
@@ -394,7 +394,7 @@ If you stacked methods, clean up in this order:
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**That's it!** You now have access to 63 agents, 249 skills, and 79 legacy command shims.
|
||||
**That's it!** You now have access to 63 agents, 251 skills, and 79 legacy command shims.
|
||||
|
||||
### Dashboard GUI
|
||||
|
||||
@@ -1427,7 +1427,7 @@ The configuration is automatically detected from `.opencode/opencode.json`.
|
||||
|---------|---------------------|----------|--------|
|
||||
| Agents | PASS: 63 agents | PASS: 12 agents | **Claude Code leads** |
|
||||
| Commands | PASS: 79 commands | PASS: 35 commands | **Claude Code leads** |
|
||||
| Skills | PASS: 249 skills | PASS: 37 skills | **Claude Code leads** |
|
||||
| Skills | PASS: 251 skills | PASS: 37 skills | **Claude Code leads** |
|
||||
| Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** |
|
||||
| Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** |
|
||||
| MCP Servers | PASS: 14 servers | PASS: Full | **Full parity** |
|
||||
@@ -1589,7 +1589,7 @@ ECC is the **first plugin to maximize every major AI coding tool**. Here's how e
|
||||
|---------|-----------------------|------------|-----------|----------|----------------|
|
||||
| **Agents** | 63 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 | N/A |
|
||||
| **Commands** | 79 | Shared | Instruction-based | 35 | 6 prompts |
|
||||
| **Skills** | 249 | Shared | 10 (native format) | 37 | Via instructions |
|
||||
| **Skills** | 251 | Shared | 10 (native format) | 37 | Via instructions |
|
||||
| **Hook Events** | 8 types | 15 types | None yet | 11 types | None |
|
||||
| **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks | N/A |
|
||||
| **Rules** | 34 (common + lang) | 34 (YAML frontmatter) | Instruction-based | 13 instructions | 1 always-on file |
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**完成!** 你现在可以使用 63 个代理、249 个技能和 79 个命令。
|
||||
**完成!** 你现在可以使用 63 个代理、251 个技能和 79 个命令。
|
||||
|
||||
### multi-* 命令需要额外配置
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-labelledby="title">
|
||||
<title id="title">ECC</title>
|
||||
<rect width="128" height="128" rx="28" fill="#111827"/>
|
||||
<path d="M26 39h55v13H42v12h34v12H42v13h40v13H26V39Z" fill="#F59E0B"/>
|
||||
<path d="M83 39h19v13H83V39Zm0 25h19v13H83V64Zm0 25h19v13H83V89Z" fill="#E07856"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 353 B |
@@ -15,6 +15,8 @@ Claude Code, Codex, OpenCode, Cursor, Gemini, and future harnesses should adapt
|
||||
|
||||
For the operator-facing support matrix and scorecard workflow, see
|
||||
[Harness Adapter Compliance Matrix](harness-adapter-compliance.md).
|
||||
For the full-stack platform framing and product-integration loop, see
|
||||
[ECC Platform Value Loop](platform-value-loop.md).
|
||||
|
||||
## Portability Model
|
||||
|
||||
@@ -118,6 +120,8 @@ Still maturing:
|
||||
- release packaging for `ecc2/`
|
||||
- cross-harness session resume semantics
|
||||
- deeper memory and operator planning layers
|
||||
- the full platform loop where external products contribute skill packs,
|
||||
gated APIs, evals, and case studies back into ECC
|
||||
|
||||
## Rule For New Work
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# ECC Platform Value Loop
|
||||
|
||||
ECC 2.0 is moving from a portable harness layer toward a full operator
|
||||
system. The product direction is three layers:
|
||||
|
||||
1. Meta-harness: portable skills, rules, hooks, MCP conventions, release gates,
|
||||
evals, and security evidence.
|
||||
2. Dedicated ECC agent: an agent that directly operates over ECC assets instead
|
||||
of only reading them as static instructions.
|
||||
3. Control pane / agentic IDE: a visible operator surface for sessions, queues,
|
||||
skills, memory, evidence, releases, and team workflows.
|
||||
|
||||
The control pane is still a release-candidate direction until it is backed by a
|
||||
reproducible demo. The public claim is:
|
||||
|
||||
```text
|
||||
ECC can be used full-stack as a meta-harness + agent + control pane, or
|
||||
selectively as the portable harness layer inside the AI coding tools teams
|
||||
already use.
|
||||
```
|
||||
|
||||
## OSS Platform Thesis
|
||||
|
||||
The older open-source infrastructure playbook was distribution first: free
|
||||
source and generous self-serve access created the default developer vocabulary,
|
||||
then hosted infrastructure, managed teams, support, and enterprise features
|
||||
captured value. Databases, app platforms, and edge platforms made this obvious:
|
||||
developers adopted the free surface, teams standardized on the brand, and the
|
||||
paid product made the workflow easier to run at scale.
|
||||
|
||||
AI-agent infrastructure should follow the same shape, but the hosted value is
|
||||
not just deployment. The paid or managed surface is:
|
||||
|
||||
- team memory and session routing;
|
||||
- observable queues, handoffs, and agent runs;
|
||||
- managed evals, release gates, and evidence packs;
|
||||
- security review, supply-chain findings, and policy enforcement;
|
||||
- billing, entitlement, sponsor, and partner workflows;
|
||||
- product-specific integrations that can become reusable ECC skills.
|
||||
|
||||
The open repo stays useful on its own. The platform earns value when serious
|
||||
teams want the same workflows managed, measured, secured, or connected to their
|
||||
own products.
|
||||
|
||||
## Product Integration Contract
|
||||
|
||||
External products can build on ECC without becoming ECC-branded products. The
|
||||
contract is:
|
||||
|
||||
| Layer | Product contributes | ECC receives |
|
||||
| --- | --- | --- |
|
||||
| Skill pack | Public, non-secret workflows in `skills/*/SKILL.md` | New reusable agent behavior and install surface |
|
||||
| Gated API | Optional product credentials such as `PRODUCT_API_KEY` | A clear upgrade/request path without leaking secrets |
|
||||
| Fixtures and docs | Sanitized examples, no private accounts or live keys | Testable public proof instead of claims |
|
||||
| Eval and risk gates | Advice, safety, data, and execution boundaries | Reusable release discipline and trust surface |
|
||||
| Case study | A real product workflow that works through ECC | Distribution, sponsors, Pro interest, consulting demand |
|
||||
|
||||
Every integration needs:
|
||||
|
||||
- a public workflow that works without private credentials;
|
||||
- a separate gated path for live product data or actions;
|
||||
- a clear business boundary so billing and ownership are not blurred;
|
||||
- tests or documented commands proving the integration surface;
|
||||
- a support route that does not require public secrets or private account data.
|
||||
|
||||
## Ito Example
|
||||
|
||||
Ito is a separate prediction-market basket product. ECC can still distribute
|
||||
Ito-shaped skills because the skill workflows are useful without making ECC
|
||||
Tools an Ito product.
|
||||
|
||||
The safe public surface is:
|
||||
|
||||
- research market, underlier, venue, and liquidity context;
|
||||
- compare baskets against a user's own notes, portfolio constraints, or thesis;
|
||||
- draft non-advisory trade-planning worksheets for manual review;
|
||||
- visualize market/concept relationships and backtesting outputs when data is
|
||||
available;
|
||||
- use prediction-market signals as one input into broader agent research.
|
||||
|
||||
The gated surface is:
|
||||
|
||||
- live Ito basket data;
|
||||
- account-specific state;
|
||||
- API-backed backtesting or visualization;
|
||||
- any workflow requiring `ITO_API_KEY`.
|
||||
|
||||
The boundary is strict: public ECC skills do not place trades, do not provide investment advice, do not expose private strategy, and do not merge ECC Tools billing with Ito billing.
|
||||
|
||||
## Value Loop
|
||||
|
||||
The platform loop should be explicit:
|
||||
|
||||
1. A product team builds a useful workflow as an ECC skill pack.
|
||||
2. The public skill pack works with public sources or local user-provided data.
|
||||
3. Serious users request gated access for live product data or hosted features.
|
||||
4. Product usage produces new operator patterns, failure modes, and examples.
|
||||
5. Sanitized patterns become better ECC skills, evals, gates, or docs.
|
||||
6. ECC gains distribution, maintainers, sponsors, Pro interest, and consulting leads.
|
||||
7. The product gains adoption because agent users can operate it through an
|
||||
already-installed harness.
|
||||
|
||||
This is different from enterprise consulting alone. Consulting can fund the
|
||||
work, but the platform goal is repeatable distribution: every useful product
|
||||
integration becomes another reason to install ECC, and every serious ECC user
|
||||
becomes a possible sponsor, Pro user, partner, or integration customer.
|
||||
|
||||
## Release Lane
|
||||
|
||||
Keep release claims separated:
|
||||
|
||||
- `1.10.1`: stable reliability and docs patch for released users.
|
||||
- `1.11.0`: public OSS workflow-catalog momentum that does not require the
|
||||
control pane to be GA.
|
||||
- `2.0.0-rc.x`: control-pane, dedicated-agent, platform, and release-evidence
|
||||
work while the full operator system remains prerelease.
|
||||
|
||||
Do not announce ORCA/CONDUCTOR-grade parity, marketplace billing, official
|
||||
plugin-directory listing, live trading, or native-payments readiness without
|
||||
fresh evidence and owner approval.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Team Agent Orchestration Content Pack
|
||||
|
||||
This pack turns the current ECC direction into publishable ideas without exposing private research sources. The core claim: agent tools are moving from solo chat windows into team orchestration systems with boards, control panes, dynamic workflows, eval gates, and shared skills.
|
||||
|
||||
## Positioning
|
||||
|
||||
ECC should be framed as an orchestration and control-plane layer for the multi-agent stack. The point is not "another prompt library." The point is a workflow operating system for teams that use Claude Code, Codex, OpenCode, Hermes-style desktops, terminal panes, browser agents, MCP gateways, and internal agent tools at the same time.
|
||||
|
||||
## Narrative Thesis
|
||||
|
||||
The old generation of agent Kanban failed because agents were not dependable enough to own real cards. They hallucinated context, skipped verification, and produced output that could not merge. The new generation can work because dynamic workflows, stronger code models, eval harnesses, local state, browser control, and MCP standardization make each card observable and gateable.
|
||||
|
||||
## Video Concepts
|
||||
|
||||
### 1. Why Agent Kanban Failed, And Why It Can Work Now
|
||||
|
||||
- Hook: "Agent Kanban used to be theater. Now it can become the operating surface."
|
||||
- Show: one card moving from backlog to running to review to merged.
|
||||
- Key beats:
|
||||
- Cards need owners, branches, evals, and merge gates.
|
||||
- Dynamic workflows let agents create task-local harnesses.
|
||||
- Control panes turn hidden chat output into operational state.
|
||||
- CTA: "Stop asking if agents can code. Ask whether your team can route, verify, and merge agent work."
|
||||
|
||||
### 2. The Control Pane Is The New IDE Primitive
|
||||
|
||||
- Hook: "The next IDE is not a text editor. It is a mission control surface."
|
||||
- Show: sessions, work items, memory, connectors, actions, and merge readiness.
|
||||
- Key beats:
|
||||
- Teams will run multiple harnesses at once.
|
||||
- The winning product coordinates context, tools, and evidence.
|
||||
- Desktop apps matter when they make state inspectable, not when they add another chat box.
|
||||
- CTA: "Build the pane that tells you what agents are doing, what failed, and what can ship."
|
||||
|
||||
### 3. A Harness For Every Task
|
||||
|
||||
- Hook: "The agent should not just write code. It should build the workflow that proves the code works."
|
||||
- Show: a dynamic workflow creating tests, browser smoke, and handoff artifacts.
|
||||
- Key beats:
|
||||
- Static workflows are good defaults.
|
||||
- Dynamic workflows are task-local harnesses.
|
||||
- Repeated dynamic workflows become shared skills.
|
||||
- CTA: "The real asset is the reusable workflow, not the one-off answer."
|
||||
|
||||
### 4. MCP Gateways And The End Of Reconfiguring Every Agent
|
||||
|
||||
- Hook: "If you configure every MCP server ten times, your agent stack is already broken."
|
||||
- Show: one tool registry feeding multiple harnesses.
|
||||
- Key beats:
|
||||
- Tooling must be centrally declared and locally enforceable.
|
||||
- The control pane should show connector health.
|
||||
- Agent portability depends on shared tool contracts.
|
||||
- CTA: "Treat tools as infrastructure, not per-chat settings."
|
||||
|
||||
### 5. Teams Will Run Like AI Labs
|
||||
|
||||
- Hook: "Every company becomes an AI lab when every workflow has an eval."
|
||||
- Show: a business workflow with a pass/fail evaluator and a work item queue.
|
||||
- Key beats:
|
||||
- Eval gates move agent work from demo to operations.
|
||||
- Shared skills are team best-practice files.
|
||||
- The control pane is where management sees throughput and risk.
|
||||
- CTA: "The future is not one agent. It is an evaluated team of agents."
|
||||
|
||||
## Article Angles
|
||||
|
||||
### 1. Agent Kanban Was Early, Not Wrong
|
||||
|
||||
Argument:
|
||||
|
||||
- Kanban for agents failed when cards were just prompts.
|
||||
- It starts working when cards carry ownership, branch scope, tests, evals, and handoff.
|
||||
- Dynamic workflows let each card generate its own proof harness.
|
||||
- A control pane makes the board honest because it shows state from the filesystem, tests, and sessions.
|
||||
|
||||
Suggested sections:
|
||||
|
||||
1. Why early agent Kanban felt fake.
|
||||
2. What changed: better models, dynamic workflows, MCP, local state, browser automation.
|
||||
3. The minimum viable card schema.
|
||||
4. Why merge gates matter more than task assignment.
|
||||
5. What teams should build now.
|
||||
|
||||
### 2. The Control Pane Era Of AI Development
|
||||
|
||||
Argument:
|
||||
|
||||
- The next developer surface is a control pane that coordinates agents, tools, memory, and gates.
|
||||
- Chat remains the interaction layer, but the product value lives in orchestration state.
|
||||
- ECC should be positioned as the shared layer across local harnesses, desktop agents, and team systems.
|
||||
|
||||
Suggested sections:
|
||||
|
||||
1. Chat is not enough for team work.
|
||||
2. Sessions, memory, tools, and work items need one pane.
|
||||
3. Dynamic workflows need visibility.
|
||||
4. Control panes become the product moat.
|
||||
5. Open source distribution comes from becoming infrastructure.
|
||||
|
||||
### 3. Shared Skills Are The New Team Playbooks
|
||||
|
||||
Argument:
|
||||
|
||||
- The best companies will not rely on every engineer inventing their own agent workflow.
|
||||
- A shared skill file is the new best-practices document, but executable by agents.
|
||||
- Dynamic workflows are discovery; skills are institutional memory.
|
||||
|
||||
Suggested sections:
|
||||
|
||||
1. Why team divergence in agent usage is expensive.
|
||||
2. What belongs in a skill.
|
||||
3. When to promote a task-local harness.
|
||||
4. How evals keep shared skills honest.
|
||||
5. How this becomes a platform layer.
|
||||
|
||||
## Short Posts
|
||||
|
||||
1. Agent Kanban did not fail because the board was wrong. It failed because the cards had no ownership, eval, branch, or merge gate. The new primitive is not "assign prompt to agent." It is "assign verified work item to agent team."
|
||||
|
||||
2. Dynamic workflows change the unit of reuse. The answer is disposable. The harness is valuable. If the same task-local harness works twice, promote it into a shared skill.
|
||||
|
||||
3. The control pane is where agent work becomes management-visible: who owns the card, what changed, what failed, what passed, and what can merge.
|
||||
|
||||
4. The future OSS wedge for agent infrastructure looks like old infra wedges: become the thing teams install first because it standardizes tools, workflows, evidence, and handoff.
|
||||
|
||||
5. Teams will not run one agent. They will run evaluated squads across code, browser, data, review, and content. The product layer is orchestration.
|
||||
|
||||
## Distribution Plan
|
||||
|
||||
1. Publish one short post on agent Kanban.
|
||||
2. Follow with a 90-second video showing a card moving through a control pane.
|
||||
3. Publish the article on shared skills as team playbooks.
|
||||
4. Release a demo clip of ECC control pane plus a dynamic workflow card.
|
||||
5. Turn comments into the next skill or article.
|
||||
|
||||
## Product Implications For ECC
|
||||
|
||||
- Build skills first; commands are compatibility shims.
|
||||
- Make the control pane show work items, agent Kanban state, gates, and reusable-skill candidates.
|
||||
- Treat dynamic workflows as a feeder system for shared skills.
|
||||
- Treat MCP and connector configuration as infrastructure that should be visible across harnesses.
|
||||
- Keep private research private; publish synthesized concepts and product evidence.
|
||||
@@ -51,6 +51,9 @@ not place trades. They can help a user:
|
||||
|
||||
## Growth Loop
|
||||
|
||||
For the general product-integration contract, see
|
||||
[`docs/architecture/platform-value-loop.md`](../../architecture/platform-value-loop.md).
|
||||
|
||||
The loop is intentionally simple:
|
||||
|
||||
1. ECC users discover useful public prediction-market skills.
|
||||
|
||||
@@ -43,9 +43,10 @@ persistence coverage.
|
||||
|
||||
There is still more to harden before GA, especially around packaging, installers, and the `ecc2/` control plane. But rc.1 is enough to show the shape clearly.
|
||||
|
||||
Public publication is still approval-gated until the GitHub release, npm
|
||||
`next` publish, plugin path, final URLs, and billing/native-payments claims have
|
||||
live evidence.
|
||||
The GitHub prerelease and npm `next` package are live now. Public publication
|
||||
still stays approval-gated for the plugin path, video URLs, final outbound URLs,
|
||||
and any billing/native-payments claim that has not been freshly rechecked.
|
||||
|
||||
The release URL ledger now separates links that already resolve from links that
|
||||
must wait for the approval-gated release, package, plugin, and billing checks.
|
||||
must wait for the remaining approval-gated plugin, video, billing, and outbound
|
||||
checks.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# ECC v2.0.0-rc.1 Naming And Publication Matrix
|
||||
|
||||
Snapshot date: 2026-05-19.
|
||||
Snapshot date: 2026-05-19. Publication state refreshed 2026-05-26 after the
|
||||
GitHub prerelease and npm `next` readbacks succeeded.
|
||||
|
||||
This matrix records the rc.1 identity after the public repository rename to
|
||||
`affaan-m/ECC`. It is evidence for planning, not a publication action.
|
||||
@@ -27,12 +28,12 @@ Reason:
|
||||
|
||||
## Current Values
|
||||
|
||||
| Surface | Current value | Evidence command | 2026-05-18 result | Release decision |
|
||||
| Surface | Current value | Evidence command | Current result | Release decision |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Product display name | `ECC` | `rg -n "^# ECC\|displayName.*ECC\|affaan-m/ECC" README.md .codex-plugin/plugin.json docs/releases/2.0.0-rc.1` | Present across README, plugin manifests, release copy, and URL ledger | Keep for rc.1 and GA |
|
||||
| GitHub repo | `affaan-m/ECC` | `git remote get-url origin` | `https://github.com/affaan-m/ECC.git` | Keep for rc.1 and GA |
|
||||
| npm package | `ecc-universal` | `node -p "require('./package.json').name"` | `ecc-universal` | Keep for rc.1 |
|
||||
| npm package version | `2.0.0-rc.1` local, `1.10.0` registry latest | `node -p "require('./package.json').version"` and `npm view ecc-universal name version dist-tags --json` | Local rc.1 is ready; registry latest remains `1.10.0` and no `next` dist-tag exists yet | Publish rc as `next`, not `latest` |
|
||||
| npm package version | `2.0.0-rc.1` local, `1.10.0` registry latest, `2.0.0-rc.1` registry next | `node -p "require('./package.json').version"` and `npm view ecc-universal name version dist-tags --json` | Local rc.1 is ready; registry latest remains `1.10.0`; `next` points to `2.0.0-rc.1` | Keep rc on `next`, not `latest` |
|
||||
| Exact npm short name | `ecc` | `npm view ecc name version description repository.url --json` | Occupied by `ecc@0.0.2`, "Elliptic curve cryptography functions." | Do not use |
|
||||
| Scoped npm short name | `@affaan-m/ecc` | `npm view @affaan-m/ecc name version --json` | Registry 404 | Possible future scoped package if npm scope policy permits |
|
||||
| Former package name | `everything-claude-code` | `npm view everything-claude-code name version dist-tags --json` | Registry reports unpublished on 2026-02-07 | Do not revive for rc.1 |
|
||||
@@ -50,21 +51,21 @@ Reason:
|
||||
|
||||
| Path | Current evidence | Required next action | Blocker |
|
||||
| --- | --- | --- | --- |
|
||||
| GitHub release | `docs/releases/2.0.0-rc.1/` and release notes are in-tree | Re-run required command evidence from the final release commit, then create/verify `v2.0.0-rc.1` prerelease | No tag/release yet |
|
||||
| npm | `ecc-universal` local package version is `2.0.0-rc.1`; registry latest is `1.10.0` | Publish rc with `npm publish --tag next` after final `npm pack --dry-run` and release tests | Do not publish before final release commit |
|
||||
| GitHub release | `v2.0.0-rc.1` prerelease is live at <https://github.com/affaan-m/ECC/releases/tag/v2.0.0-rc.1> | Keep release notes aligned with the URL ledger; rerun evidence before any follow-up release edit | Remaining plugin, video, billing, and outbound URLs still gated |
|
||||
| npm | `ecc-universal@2.0.0-rc.1` is live on `next`; registry latest remains `1.10.0` | Keep rc on `next`; do not move `latest` before GA approval | Remaining plugin, video, billing, and outbound URLs still gated |
|
||||
| Claude plugin | `claude plugin validate .claude-plugin/plugin.json` passed; `claude plugin tag --help` confirms the release tag flow creates `{name}--v{version}` tags and can push them | Run `claude plugin tag .claude-plugin --dry-run` from the clean release commit, then tag/push only after release approval | No plugin release tag created in this pass |
|
||||
| Claude marketplace | `.claude-plugin/marketplace.json` points at `ecc` and the public repo | Verify marketplace update/install path after tag exists | External marketplace propagation not verified |
|
||||
| Codex plugin | `codex plugin marketplace` supports local and Git marketplace sources; `.codex-plugin/plugin.json` is present; `.agents/plugins/marketplace.json` exposes `ecc` from the repo root; temp-home local and GitHub-ref marketplace adds passed | Publish rc.1 docs with the repo-marketplace command, then monitor OpenAI's official Plugin Directory path | Do not claim official Plugin Directory listing before OpenAI submission evidence |
|
||||
| OpenCode package | `.opencode/package.json` builds from source and ships inside npm package | Re-run `npm run build:opencode` and package dry-run from release commit | OpenCode CLI 1.2.21 does not expose a separate plugin publication command in this pass |
|
||||
| ECC Tools billing claim | README and launch copy mention ECC Tools / marketplace context | ECC-Tools #89/#90/#91 add selected-target billing readback, selected-target announcement gating, and ignored `--env-file` support; #92 adds the non-breaking operator bearer path; #93 records the live selected-target gate pass | Billing evidence ready; repeat the live selected-target gate before any payment announcement |
|
||||
| Social and longform copy | X thread, LinkedIn copy, article outline, GitHub release copy exist | Replace any stale URLs, then publish only after release/npm/plugin URLs work | Public URLs not final until release actions complete |
|
||||
| Social and longform copy | X thread, LinkedIn copy, article outline, GitHub release copy exist | Replace stale URLs and publish only after the remaining plugin/video/billing/outbound gates are approved | GitHub prerelease and npm URLs are live; plugin, video, billing, and outbound URLs are not final |
|
||||
|
||||
## ITO-46 Blocker Register
|
||||
|
||||
| Channel | Current status | Required metadata/evidence | Owner | Blocker or follow-up |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| GitHub release | Approval-gated; no `v2.0.0-rc.1` prerelease yet | Tag, release URL, prerelease flag, final release notes, URL ledger | Release owner | Create only after final clean-checkout evidence |
|
||||
| npm | `ecc-universal@2.0.0-rc.1` dry-run passed; registry latest is `1.10.0` | Pack summary, publish dry-run, `next` dist-tag readback, registry signature evidence | Package owner | Do not publish before approval and final release commit |
|
||||
| GitHub release | Live prerelease at <https://github.com/affaan-m/ECC/releases/tag/v2.0.0-rc.1> | Tag, release URL, prerelease flag, final release notes, URL ledger | Release owner | Keep release edits behind final evidence and owner approval |
|
||||
| npm | `ecc-universal@2.0.0-rc.1` is published on `next`; registry latest is `1.10.0` | Pack summary, publish readback, `next` dist-tag readback, registry signature evidence | Package owner | Do not move rc.1 to `latest` before GA approval |
|
||||
| Short npm name | `ecc` is occupied; `@affaan-m/ecc` returns 404 | Name availability outputs and migration plan | Release owner | Keep `ecc-universal` for rc.1; scoped rename is post-rc only |
|
||||
| Claude plugin | `ecc@2.0.0-rc.1` validates; tag dry run would create `ecc--v2.0.0-rc.1` | `claude plugin validate .`, `claude plugin tag .claude-plugin --dry-run`, marketplace install/update smoke | Plugin owner | Real tag push and marketplace propagation require release approval |
|
||||
| Claude marketplace | Docs and CLI support GitHub, git URL, remote marketplace JSON, and local path sources | Public repo marketplace JSON, support/contact metadata, post-tag install smoke | Plugin owner | No external official listing has been submitted in this pass |
|
||||
@@ -72,7 +73,7 @@ Reason:
|
||||
| Codex official Plugin Directory | OpenAI docs describe the curated official directory; ECC has not submitted or received listing evidence | Directory submission link or OpenAI approval path once available | Plugin owner | Track as an ITO-56/ITO-46 follow-up; do not claim an official listing |
|
||||
| OpenCode package | `npm run build:opencode` passed | Built `.opencode` package metadata inside npm tarball | Package owner | No separate public plugin channel identified; follows npm |
|
||||
| Billing/native payments | Marketplace Pro target readback, selected-target announcement preflight, env-file operator path, non-breaking operator bearer, and live selected-target gate have passed | 2026-05-20 selected-target readback, webhook provenance, selected-target announcement gate, ECC-Tools #91 `--env-file` support, ECC-Tools #92 operator bearer, ECC-Tools #93 live gate evidence | ECC Tools owner | Repeat the live gate immediately before rc.1 announcement; final copy still waits on release/plugin/live URL approvals |
|
||||
| Social/longform copy | Drafts exist | Final live GitHub, npm, Claude, Codex, billing URLs | Release owner | Publish only after release/package/plugin URLs exist |
|
||||
| Social/longform copy | Drafts exist; GitHub and npm links are live | Final live GitHub, npm, Claude, Codex, video, and billing URLs | Release owner | Publish only after remaining plugin/video/billing/outbound approvals exist |
|
||||
|
||||
## Package Rename After rc.1
|
||||
|
||||
@@ -110,7 +111,11 @@ npm view ecc name version description repository.url --json
|
||||
ecc@0.0.2 is occupied by an unrelated elliptic curve cryptography package.
|
||||
|
||||
npm view ecc-universal name version dist-tags --json
|
||||
registry latest is 1.10.0; no rc dist-tag exists yet.
|
||||
registry latest is 1.10.0; next is 2.0.0-rc.1.
|
||||
|
||||
npm view ecc-universal@2.0.0-rc.1 name version dist.tarball dist.integrity time --json
|
||||
registry returned version 2.0.0-rc.1, the rc tarball URL, and published time
|
||||
2026-05-26T00:36:22.940Z.
|
||||
|
||||
claude plugin validate .claude-plugin/plugin.json
|
||||
Validation passed on Claude Code 2.1.143.
|
||||
|
||||
@@ -17,8 +17,10 @@ Source commit for the clean evidence baseline this packet extends:
|
||||
| Platform audit | ready true, 0 open PRs, 0 open issues, 0 discussion gaps, 0 dirty files | yes |
|
||||
| Preview pack smoke | ready true, digest `531328aaaa53`, 5/5 checks | yes |
|
||||
| Release approval gate | ready false, digest `ef8f49f727b7`, 4/6 checks pass; owner decisions and live URL readbacks pending | yes |
|
||||
| GitHub prerelease | live at <https://github.com/affaan-m/ECC/releases/tag/v2.0.0-rc.1>; prerelease true, draft false, published `2026-05-25T18:29:31Z` | yes |
|
||||
| npm `next` publish | live at <https://www.npmjs.com/package/ecc-universal/v/2.0.0-rc.1>; `next` points to `2.0.0-rc.1`, `latest` remains `1.10.0` | yes |
|
||||
| Video suite | ready true, 15/15 source assets, 13/13 suite artifacts, 12/12 publish candidates | yes |
|
||||
| Release surface tests | 27/27 passed after this packet was added | yes |
|
||||
| Release surface tests | 28/28 passed after the May 26 URL/package refresh | yes |
|
||||
| Full local suite | 2568/2568 passed before PR #2013 merged; focused GateGuard regression passed 91/91 again before PR #2011 merged | yes |
|
||||
| GitHub CI | PR #1998, PR #1999, PR #2000, PR #2001, PR #2002, PR #2004, PR #2008, post-PR #2006 `main`, PR #2009, post-PR #2009 `main`, post-PR #2011 `main`, and post-PR #2013 `main` all merged or advanced after green required checks | verify current head |
|
||||
|
||||
@@ -26,8 +28,8 @@ Source commit for the clean evidence baseline this packet extends:
|
||||
|
||||
| Decision | Approve / defer / block | Evidence required first | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| GitHub prerelease | defer | final clean branch, URL ledger, release notes, attached video or video link | Approve only after final release notes contain live package/plugin/video URLs or explicitly marked blocked URLs. |
|
||||
| npm `next` publish | defer | `npm pack --dry-run`, `npm publish --tag next --dry-run`, registry dist-tag readback plan | Keep `ecc-universal@2.0.0-rc.1` on `next`; do not move `latest` during rc.1. |
|
||||
| GitHub prerelease | approve | live prerelease readback | Live at <https://github.com/affaan-m/ECC/releases/tag/v2.0.0-rc.1>. Remaining plugin/video/billing URLs stay approval-gated. |
|
||||
| npm `next` publish | approve | `npm pack --dry-run`, `npm publish --tag next`, registry dist-tag readback | `ecc-universal@2.0.0-rc.1` is published on `next`; `latest` remains `1.10.0` during rc.1. |
|
||||
| Claude plugin tag | defer | `claude plugin validate .claude-plugin/plugin.json`, `claude plugin tag .claude-plugin --dry-run` | Create and push the real tag only after release approval. |
|
||||
| Codex repo marketplace | defer | temp-home marketplace add smoke and current official Plugin Directory status | Claim repo-marketplace distribution only; do not claim official Plugin Directory listing without listing evidence. |
|
||||
| ECC Tools billing language | defer | live readiness readback for the target account and billing/product state | Do not announce native payments or Marketplace-managed Pro until the gate is live. |
|
||||
@@ -42,7 +44,7 @@ Update these surfaces after the approved publication actions finish:
|
||||
| Surface | Final value source | Update targets |
|
||||
| --- | --- | --- |
|
||||
| GitHub prerelease URL | `gh release view v2.0.0-rc.1 --repo affaan-m/ECC --json url` | release notes, URL ledger, social copy |
|
||||
| npm rc package URL | `npm view ecc-universal@2.0.0-rc.1 version dist-tags --json` | URL ledger, quickstart, release notes |
|
||||
| npm rc package URL | `npm view ecc-universal@2.0.0-rc.1 name version dist.tarball dist.integrity time --json` | URL ledger, quickstart, release notes |
|
||||
| Claude plugin tag URL | pushed `ecc--v2.0.0-rc.1` tag or marketplace readback | URL ledger, plugin docs, release notes |
|
||||
| Codex repo-marketplace evidence | temp-home `codex plugin marketplace add <local-checkout>` readback | URL ledger, publication readiness |
|
||||
| Primary launch video URL | uploaded owner-approved primary launch video | GitHub release, X, LinkedIn, longform |
|
||||
|
||||
@@ -171,8 +171,9 @@ It now has a reviewed public surface for:
|
||||
- a gated Itô prediction-market skill pack for research, comparison, planning,
|
||||
and risk review, with Itô API access kept separate and approval-based.
|
||||
|
||||
The release is still approval-gated until the GitHub prerelease, npm package,
|
||||
plugin paths, final URLs, and billing claims have live evidence.
|
||||
The GitHub prerelease and npm `next` package are live now. The release remains
|
||||
approval-gated for plugin paths, video URLs, final outbound URLs, and billing
|
||||
claims that have not been freshly rechecked.
|
||||
|
||||
Feedback wanted: install friction, cross-harness gaps, partner integrations,
|
||||
sponsor fit, prediction-market research use cases, and examples of teams using
|
||||
@@ -196,8 +197,9 @@ Use these with the release video suite:
|
||||
- The release URL ledger still has stale or placeholder links.
|
||||
- `npm run release:video-suite -- --format json` is not green against the
|
||||
intended video roots.
|
||||
- The GitHub prerelease, npm package, plugin path, or billing claim is described
|
||||
as live without evidence.
|
||||
- The GitHub prerelease or npm package readback is contradicted, or a plugin
|
||||
path, video URL, billing claim, or official directory listing is described as
|
||||
live without evidence.
|
||||
- The message claims native payments are ready before ECC Tools billing readback
|
||||
passes.
|
||||
- The recipient needs a custom promise that is not covered by `SPONSORS.md`,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# ECC v2.0.0-rc.1 Preview Pack Manifest
|
||||
|
||||
This manifest defines the reviewed preview pack for `2.0.0-rc.1`. It is not a
|
||||
release action by itself. Use it to verify that the public launch surface is
|
||||
assembled before creating the GitHub prerelease, publishing npm, tagging plugin
|
||||
surfaces, or posting announcements.
|
||||
release action by itself. Use it to verify that the public launch surface stays
|
||||
assembled after the GitHub prerelease and npm `next` publish, and before
|
||||
tagging plugin surfaces, uploading video, or posting announcements.
|
||||
|
||||
## Pack Contents
|
||||
|
||||
@@ -14,13 +14,14 @@ surfaces, or posting announcements.
|
||||
| `skills/hermes-imports/SKILL.md` | Sanitized Hermes-to-ECC import workflow | Includes import rules, sanitization checklist, conversion pattern, and output contract |
|
||||
| `docs/architecture/cross-harness.md` | Shared substrate model for Claude Code, Codex, OpenCode, Cursor, Gemini, Hermes, and terminal-only use | Names portability boundaries and does not claim unsupported native parity |
|
||||
| `docs/architecture/harness-adapter-compliance.md` | Adapter matrix and scorecard | Verified by `npm run harness:adapters -- --check` |
|
||||
| `docs/architecture/platform-value-loop.md` | Product integration and full-stack platform thesis | Keeps external product skill packs, gated APIs, case studies, sponsorship, Pro, and consulting loops separate from unsupported GA/control-pane claims |
|
||||
| `docs/architecture/observability-readiness.md` | Local operator-readiness gate | Verified by `npm run observability:ready` |
|
||||
| `docs/architecture/progress-sync-contract.md` | GitHub, Linear, handoff, roadmap, and work-item sync boundary | Checked by `node scripts/platform-audit.js --json` |
|
||||
| `scripts/preview-pack-smoke.js` | Deterministic preview-pack smoke gate | Verified by `npm run preview-pack:smoke` |
|
||||
| `scripts/release-approval-gate.js` | Final owner-decision, live-URL, and launch-copy gate | Must return ready true before any release publish, package publish, plugin tag, video upload, announcement, or outbound batch |
|
||||
| `docs/releases/2.0.0-rc.1/release-notes.md` | GitHub release copy source | Must be refreshed with final live release/package/plugin URLs before publication |
|
||||
| `scripts/release-approval-gate.js` | Final owner-decision, live-URL, and launch-copy gate | Must return ready true before any additional release/package action, plugin tag, video upload, announcement, or outbound batch |
|
||||
| `docs/releases/2.0.0-rc.1/release-notes.md` | GitHub release copy source | Must stay aligned with live GitHub/npm URLs and remaining plugin/video/billing gates before publication |
|
||||
| `docs/releases/2.0.0-rc.1/quickstart.md` | Clone-to-first-workflow path | Covers clone, install, verify, first skill, and harness switch |
|
||||
| `docs/releases/2.0.0-rc.1/launch-checklist.md` | Operator launch checklist | Must remain approval-gated for release, package, plugin, and announcement actions |
|
||||
| `docs/releases/2.0.0-rc.1/launch-checklist.md` | Operator launch checklist | Must remain approval-gated for plugin, video, billing, and announcement actions |
|
||||
| `docs/releases/2.0.0-rc.1/publication-readiness.md` | Release gate | Requires fresh evidence from the exact release commit |
|
||||
| `docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md` | Current May 15 queue, roadmap, security, supply-chain watch, no-lifecycle CI install hardening, AgentShield #86 evidence-pack provenance, ECC Tools billing-gate, Actions cache purge, and `ecc2` test evidence through PR #1941 | Must be superseded by a final clean-checkout evidence file before real publication |
|
||||
| `docs/releases/2.0.0-rc.1/publication-evidence-2026-05-16.md` | Current May 16/17 queue cleanup, recsys skill merge, GateGuard triage, PR #1947 supply-chain protection, AgentShield #87 plugin-cache confidence evidence, AgentShield #88 evidence-pack inspect/readback, AgentShield #89 evidence-pack fleet routing, AgentShield #90 fleet review items, AgentShield #91 policy export, AgentShield #92 policy promotion, ECC-Tools #76 fleet-summary consumption, ECC-Tools #77 hosted finding evidence paths, ECC-Tools #78 harness policy-route linking, dashboard refresh, and combined Node/Rust/release-surface gate evidence through the May 16 mirror | Must still be repeated from a strict clean checkout before real publication |
|
||||
@@ -36,9 +37,9 @@ surfaces, or posting announcements.
|
||||
| `docs/releases/2.0.0-rc.1/video-suite-production.md` | Release video production manifest | Gates local media inventory, rough primary render, captions, timeline, self-eval, and no-private-path publication rules |
|
||||
| `docs/releases/2.0.0-rc.1/partner-sponsor-talks-pack.md` | Partner, sponsor, consulting, conference, podcast, and discussion copy | Must stay approval-gated and avoid live billing, release, package, or plugin claims without evidence |
|
||||
| `docs/releases/2.0.0-rc.1/naming-and-publication-matrix.md` | Naming, slug, and publication-path decision record | Keeps `ECC`, npm `ecc-universal`, and plugin slug `ecc` for rc.1 |
|
||||
| `docs/releases/2.0.0-rc.1/release-name-plugin-publication-checklist-2026-05-18.md` | Release name, package, Claude plugin, Codex plugin, and publication-order checklist | Freezes rc.1 identity and requires final commit evidence before release, npm, plugin, billing, or announcement actions |
|
||||
| `docs/releases/2.0.0-rc.1/x-thread.md` | X launch draft | Must replace placeholders with live URLs after release/package/plugin publication |
|
||||
| `docs/releases/2.0.0-rc.1/linkedin-post.md` | LinkedIn launch draft | Must replace placeholders with live URLs after release/package/plugin publication |
|
||||
| `docs/releases/2.0.0-rc.1/release-name-plugin-publication-checklist-2026-05-18.md` | Release name, package, Claude plugin, Codex plugin, and publication-order checklist | Freezes rc.1 identity and requires final commit evidence before plugin, billing, or announcement actions |
|
||||
| `docs/releases/2.0.0-rc.1/x-thread.md` | X launch draft | Must use live GitHub/npm URLs and keep remaining plugin/video/billing URLs gated |
|
||||
| `docs/releases/2.0.0-rc.1/linkedin-post.md` | LinkedIn launch draft | Must use live GitHub/npm URLs and keep remaining plugin/video/billing URLs gated |
|
||||
| `docs/releases/2.0.0-rc.1/article-outline.md` | Longform launch outline | Must stay release-candidate framed until GA evidence exists |
|
||||
| `docs/releases/2.0.0-rc.1/telegram-handoff.md` | Internal/shareable handoff copy | Must not include private workspace or credential details |
|
||||
| `docs/releases/2.0.0-rc.1/demo-prompts.md` | Demo prompts and proof-of-work prompts | Must keep private Hermes workflows abstracted into public examples |
|
||||
@@ -46,6 +47,11 @@ surfaces, or posting announcements.
|
||||
|
||||
## Itô Skill Pack Boundary
|
||||
|
||||
The general product-integration contract is recorded in
|
||||
`docs/architecture/platform-value-loop.md`. The Itô pack is the first worked
|
||||
example: useful public workflows, separate gated API access, and sanitized
|
||||
operator patterns feeding back into ECC without merging business ownership.
|
||||
|
||||
The preview pack includes six public teaser skills for prediction-market and
|
||||
Itô-adjacent workflows:
|
||||
|
||||
@@ -115,16 +121,17 @@ cd ecc2 && cargo test
|
||||
|
||||
## Publication Blockers
|
||||
|
||||
The preview pack is assembled, but publication is still blocked until these live
|
||||
surfaces exist and are recorded in a final evidence file:
|
||||
The preview pack is assembled, and the first release/package surfaces are now
|
||||
live. Full publication is still blocked until these live surfaces and decisions
|
||||
are recorded in a final evidence file:
|
||||
|
||||
- final release URL ledger regenerated from the intended release commit;
|
||||
- `npm run release:approval-gate -- --format json` returning ready true after
|
||||
owner approvals and live URL readbacks are recorded;
|
||||
- final release name/plugin publication checklist rerun from the intended
|
||||
release commit;
|
||||
- GitHub prerelease `v2.0.0-rc.1`;
|
||||
- npm `ecc-universal@2.0.0-rc.1` on the `next` dist-tag;
|
||||
- GitHub prerelease `v2.0.0-rc.1` live readback;
|
||||
- npm `ecc-universal@2.0.0-rc.1` on the `next` dist-tag live readback;
|
||||
- Claude plugin tag / marketplace propagation for `ecc@ecc`;
|
||||
- Codex repo-marketplace distribution evidence plus official Plugin Directory
|
||||
availability status;
|
||||
@@ -137,5 +144,6 @@ surfaces exist and are recorded in a final evidence file:
|
||||
## Result
|
||||
|
||||
The rc.1 preview pack is ready for a final clean-checkout release gate, but not
|
||||
for public publication without the approval-gated release, package, plugin, and
|
||||
announcement steps above.
|
||||
for full public publication without the remaining approval-gated release, package, plugin, and
|
||||
announcement steps above. GitHub and npm are now recorded; plugin, video,
|
||||
billing, and outbound approvals remain open.
|
||||
|
||||
@@ -63,7 +63,8 @@ The current May 20 Marketplace Pro release-gate operator dashboard is
|
||||
For the final owner decision sheet across release, npm, plugin, video, billing,
|
||||
social, and outbound approvals, see
|
||||
[`owner-approval-packet-2026-05-19.md`](owner-approval-packet-2026-05-19.md).
|
||||
For the May 19 live/pending release URL ledger after the public repo rename, see
|
||||
For the May 26 live/pending release URL ledger after the GitHub prerelease and
|
||||
npm `next` readbacks, see
|
||||
[`release-url-ledger-2026-05-19.md`](release-url-ledger-2026-05-19.md).
|
||||
|
||||
## Release Identity Matrix
|
||||
@@ -72,10 +73,10 @@ For the May 19 live/pending release URL ledger after the public repo rename, see
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| Product name | ECC | `README.md`, plugin manifests, release notes | `rg -n "^# ECC\|displayName.*ECC\|affaan-m/ECC" README.md .codex-plugin/plugin.json docs/releases/2.0.0-rc.1` | `release-name-plugin-publication-checklist-2026-05-18.md` plus `release-url-ledger-2026-05-19.md` | Release owner | Evidence recorded |
|
||||
| GitHub repo | `affaan-m/ECC` | Git remote and release URLs | `git remote get-url origin` | `release-url-ledger-2026-05-19.md` | Release owner | Evidence recorded |
|
||||
| Git tag | `v2.0.0-rc.1` | GitHub releases | `gh release view v2.0.0-rc.1 --repo affaan-m/ECC` | `release not found` | Release owner | Blocked until release approval |
|
||||
| Git tag | `v2.0.0-rc.1` | GitHub releases | `gh release view v2.0.0-rc.1 --repo affaan-m/ECC` | Live prerelease at <https://github.com/affaan-m/ECC/releases/tag/v2.0.0-rc.1>; prerelease true, draft false | Release owner | Evidence recorded |
|
||||
| npm package | `ecc-universal` | `package.json` | `node -p "require('./package.json').name"` | `publication-evidence-2026-05-12.md` | Package owner | Evidence recorded |
|
||||
| npm version | `2.0.0-rc.1` | `VERSION`, `package.json`, lockfiles | `node -p "require('./package.json').version"` | `publication-evidence-2026-05-12.md` | Package owner | Evidence recorded |
|
||||
| npm dist-tag | `next` for rc, `latest` only for GA | npm registry | `npm view ecc-universal dist-tags --json` | Current registry only has `latest: 1.10.0`; `next` is pending publish | Package owner | Blocked until publish approval |
|
||||
| npm dist-tag | `next` for rc, `latest` only for GA | npm registry | `npm view ecc-universal dist-tags --json` | Registry has `latest: 1.10.0` and `next: 2.0.0-rc.1` | Package owner | Evidence recorded |
|
||||
| Claude plugin slug | `ecc` / `ecc@ecc` install path | `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json` | `node tests/hooks/hooks.test.js` | `publication-evidence-2026-05-12.md` | Plugin owner | Evidence recorded |
|
||||
| Claude plugin manifest | `2.0.0-rc.1`, no unsupported `agents` or explicit `hooks` fields | `.claude-plugin/plugin.json`, `.claude-plugin/PLUGIN_SCHEMA_NOTES.md` | `claude plugin validate .claude-plugin/plugin.json` | `publication-evidence-2026-05-12.md` | Plugin owner | Evidence recorded |
|
||||
| Codex plugin manifest | `2.0.0-rc.1` with shared skill source | `.codex-plugin/plugin.json` | `node tests/docs/ecc2-release-surface.test.js` | `publication-evidence-2026-05-12.md` | Plugin owner | Evidence recorded |
|
||||
@@ -88,13 +89,13 @@ For the May 19 live/pending release URL ledger after the public repo rename, see
|
||||
|
||||
| Gate | Required evidence | Fresh check | Blocker field | Owner | Status |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| GitHub release | Tag exists, release notes use final URLs, assets attached if needed | `gh release view v2.0.0-rc.1 --json tagName,url,isPrerelease` | `Blocker: release not found on 2026-05-12` | Release owner | Pending approval |
|
||||
| npm package | `npm pack --dry-run` has expected files, version matches, rc goes to `next` | `npm pack --dry-run` and `npm publish --tag next --dry-run` where supported | `Blocker: actual publish requires approval; dry run passed with next tag` | Package owner | Dry-run passed |
|
||||
| GitHub release | Tag exists, release notes use final URLs, assets attached if needed | `gh release view v2.0.0-rc.1 --json tagName,url,isPrerelease` | `Ready: v2.0.0-rc.1 prerelease is live; remaining plugin, video, billing, and outbound URLs are still gated` | Release owner | Evidence recorded |
|
||||
| npm package | `npm pack --dry-run` has expected files, version matches, rc goes to `next` | `npm pack --dry-run`; `npm view ecc-universal name version dist-tags --json`; `npm view ecc-universal@2.0.0-rc.1 name version dist.tarball dist.integrity time --json` | `Ready: ecc-universal@2.0.0-rc.1 is live on next; latest remains 1.10.0` | Package owner | Evidence recorded |
|
||||
| Claude plugin | Manifest validates, marketplace JSON points to public repo, install docs match slug | `claude plugin validate .claude-plugin/plugin.json`; `claude plugin tag .claude-plugin --dry-run`; isolated temp-home install smoke | `Blocker: real tag creation/push requires approval` | Plugin owner | Clean-checkout dry-run and install smoke recorded |
|
||||
| Codex plugin | Manifest version matches package and docs, repo marketplace points at the plugin root, and OpenAI's current official Plugin Directory status is recorded | `node tests/docs/ecc2-release-surface.test.js`; `node tests/plugin-manifest.test.js`; `codex plugin marketplace add --help`; temp-home `codex plugin marketplace add <local-checkout>` | `Blocker: official Plugin Directory listing requires OpenAI submission/listing evidence` | Plugin owner | Repo-marketplace distribution verified; official directory pending |
|
||||
| OpenCode package | Build output is regenerated from source and package metadata is current | `npm run build:opencode` | `Blocker: none for local build; public distribution still follows npm/plugin release` | Package owner | Evidence recorded |
|
||||
| ECC Tools billing reference | Any billing claim links to verified Marketplace/App state | `env -u GITHUB_TOKEN gh repo view ECC-Tools/ECC-Tools --json nameWithOwner,isPrivate,viewerPermission` plus internal `/api/billing/readiness?selectReadyTarget=1` readback using the operator bearer path | `Ready: ECC-Tools #92 main CI and ECC-Tools #93 main CI passed; live selected-target readback returned announcementGate.ready === true on 2026-05-20; repeat before payment announcement` | ECC Tools owner | Billing evidence ready; final copy still waits on release/plugin/live URL approvals |
|
||||
| Announcement copy | X, LinkedIn, GitHub release, and longform copy point to live URLs | placeholder-marker scan and `release-url-ledger-2026-05-19.md` | `Blocker: final live release/npm/plugin/billing URLs do not exist yet; live and pending URLs are separated in the May 19 ledger` | Release owner | URL ledger recorded; final URLs pending |
|
||||
| Announcement copy | X, LinkedIn, GitHub release, and longform copy point to live URLs | placeholder-marker scan and `release-url-ledger-2026-05-19.md` | `Blocker: GitHub and npm links are live; remaining plugin, video, billing, and outbound URLs still need approval/readback` | Release owner | URL ledger recorded; final URLs pending |
|
||||
| Privileged workflow hardening | Release and maintenance workflows avoid persisted checkout tokens | `node scripts/ci/validate-workflow-security.js` | `Blocker:` | Release owner | Evidence recorded in post-hardening refresh |
|
||||
|
||||
## Required Command Evidence
|
||||
@@ -105,7 +106,7 @@ Record the exact commit SHA and command output before any publication action:
|
||||
| --- | --- | --- | --- |
|
||||
| Clean release branch | `git status --short --branch` | On intended release commit; no unrelated files | Current May 20 baseline `c2471fe5c535310f8a8008c9ed7ea9f6757b33f2`: `## main...origin/main`; repeat from the exact final publication commit before release |
|
||||
| Preview-pack smoke | `npm run preview-pack:smoke` | Preview pack artifacts, Hermes boundary, final verification command list, and publication blockers pass | `publication-evidence-2026-05-19.md`: ready yes, digest `eebb8a66c33e`, 33 artifacts, 5 passed, 0 failed; repeat in the final strict clean-checkout release pass |
|
||||
| Release approval gate | `npm run release:approval-gate -- --format json` | Ready true only after owner decision rows are approved, live release/package/plugin/video/billing URLs are recorded, and launch/outbound copy has no placeholders or private paths | Current May 19 state is intentionally blocked because owner decisions and live URL readbacks remain approval-gated |
|
||||
| Release approval gate | `npm run release:approval-gate -- --format json` | Ready true only after owner decision rows are approved, live release/package/plugin/video/billing URLs are recorded, and launch/outbound copy has no placeholders or private paths | Current May 26 state is intentionally blocked because plugin/video/billing/outbound owner decisions and URL readbacks remain approval-gated |
|
||||
| Harness audit | `npm run harness:audit -- --format json` | 80/80 passing | Current release gate: 80/80 across 8 applicable categories, 0 top actions |
|
||||
| Adapter scorecard | `npm run harness:adapters -- --check` | PASS | Current release gate: PASS, 11 adapters |
|
||||
| Observability readiness | `npm run observability:ready` | 21/21 passing | Current release gate: 21/21, ready true |
|
||||
@@ -120,7 +121,7 @@ Record the exact commit SHA and command output before any publication action:
|
||||
| Discussion baseline | `node scripts/platform-audit.js --json` and `node scripts/discussion-audit.js --json` | No unmanaged active discussion queue and no answerable Q&A missing an accepted answer | Post-PR #2005 baseline: platform audit sampled 59 trunk discussions, 0 needing maintainer touch, 0 answerable discussions missing accepted answer; `docs/architecture/discussion-response-playbook.md` records response templates and security escalation rules |
|
||||
| Linear roadmap | Linear project and issue readback | Detailed roadmap exists with release, security, AgentShield, ECC Tools, legacy, and observability lanes | May 18 Linear comments include ITO-57 `3fe5b2b7-c4fe-401c-a317-b40d72119cb3` and ITO-44 `fb4a4f33-6c2d-421a-bbdb-63cfad3e3ee4`; earlier evidence records the project and 16 issue lanes |
|
||||
| Operator readiness dashboard | `npm run operator:dashboard -- --json` | Current queue state mapped to macro-goal deliverables and incomplete gaps | Current May 20 dashboard is refreshed from the post-PR #2020 baseline; platform audit ready true, 0 open PRs, 0 open issues, 0 discussion gaps, 0 dirty files, release video suite current, selected-target billing/env-file path mirrored, and publication gates still approval-gated |
|
||||
| Release URL ledger | `docs/releases/2.0.0-rc.1/release-url-ledger-2026-05-19.md` plus placeholder-marker scan | Live links and approval-gated links are separated before announcement copy is posted | Ledger records public repo/docs/npm/OpenAI Codex documentation URLs and blocks GitHub release/npm/plugin/billing/social URLs until approval-gated checks pass |
|
||||
| Release URL ledger | `docs/releases/2.0.0-rc.1/release-url-ledger-2026-05-19.md` plus placeholder-marker scan | Live links and approval-gated links are separated before announcement copy is posted | Ledger records public repo/docs/GitHub prerelease/npm/OpenAI Codex documentation URLs and blocks plugin/video/billing/social URLs until approval-gated checks pass |
|
||||
| Release name and plugin publication checklist | `docs/releases/2.0.0-rc.1/release-name-plugin-publication-checklist-2026-05-18.md` | Name/package/plugin values are frozen, final-release commands are listed, and Claude/Codex publication paths cite current official docs | Checklist keeps `ECC`, `ecc-universal`, and plugin slug `ecc` for rc.1; no npm rename, npm publish, plugin tag, official listing, billing claim, or announcement before final evidence |
|
||||
|
||||
## Do Not Publish If
|
||||
@@ -139,8 +140,8 @@ Record the exact commit SHA and command output before any publication action:
|
||||
|
||||
1. Merge the release-version PR.
|
||||
2. Record the required command evidence from the release commit.
|
||||
3. Create or verify the GitHub prerelease.
|
||||
4. Publish npm with the rc dist-tag.
|
||||
3. Verify the GitHub prerelease readback.
|
||||
4. Verify npm still points rc.1 at `next` and not `latest`.
|
||||
5. Submit or update plugin marketplace surfaces.
|
||||
6. Regenerate the release URL ledger and update release notes with final live
|
||||
URLs.
|
||||
|
||||
@@ -19,6 +19,15 @@ npm ci
|
||||
|
||||
This installs the Node-based validation and packaging toolchain used by the public release surface.
|
||||
|
||||
To install the rc.1 package from npm instead of working from a checkout:
|
||||
|
||||
```bash
|
||||
npm install ecc-universal@next
|
||||
```
|
||||
|
||||
`next` currently resolves to `ecc-universal@2.0.0-rc.1`; `latest` remains on
|
||||
`1.10.0` during the release-candidate window.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# ECC v2.0.0-rc.1 Release Name And Plugin Publication Checklist
|
||||
|
||||
Snapshot date: 2026-05-18. Canonical repo decision refreshed 2026-05-19
|
||||
after the public repo rename to `affaan-m/ECC`.
|
||||
after the public repo rename to `affaan-m/ECC`; release/package state refreshed
|
||||
2026-05-26 after the GitHub prerelease and npm `next` readbacks succeeded.
|
||||
|
||||
This checklist is the operator gate for release naming, package publication,
|
||||
and Claude/Codex plugin distribution. It is not a publication action by itself.
|
||||
@@ -30,11 +31,11 @@ Reasons:
|
||||
|
||||
## Current Surface Evidence
|
||||
|
||||
| Surface | Current value | Evidence command | 2026-05-18 result | Release action |
|
||||
| Surface | Current value | Evidence command | Current result | Release action |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Git commit | `67e63e63f9bfd074bd6a21bf6bac71f3dfefa58b` | `git rev-parse HEAD` | Recorded from clean `main` before this ITO-46 evidence refresh | Re-run from final release commit |
|
||||
| GitHub repo | `affaan-m/ECC` | `git remote get-url origin` | `https://github.com/affaan-m/ECC.git` | Keep for rc.1 and GA |
|
||||
| npm package | `ecc-universal@2.0.0-rc.1` local, `1.10.0` registry latest | `node -p "require('./package.json').name + '@' + require('./package.json').version"` and `npm view ecc-universal name version dist-tags --json` | Local rc.1 ready; registry still latest `1.10.0` | Publish rc.1 with `--tag next` after approval |
|
||||
| npm package | `ecc-universal@2.0.0-rc.1` local and registry next, `1.10.0` registry latest | `node -p "require('./package.json').name + '@' + require('./package.json').version"` and `npm view ecc-universal name version dist-tags --json` | Local rc.1 ready; registry `next` points to `2.0.0-rc.1`; `latest` remains `1.10.0` | Keep rc.1 on `next`; do not move to `latest` before GA approval |
|
||||
| Exact npm short name | `ecc` | `npm view ecc name version description repository.url --json` | Occupied by unrelated `ecc@0.0.2` | Do not use |
|
||||
| Scoped npm short name | `@affaan-m/ecc` | `npm view @affaan-m/ecc name version --json` | 404 | Candidate only after migration plan |
|
||||
| Claude plugin | `ecc@2.0.0-rc.1` | `claude plugin validate .claude-plugin/plugin.json`; `claude plugin validate .`; `claude plugin tag .claude-plugin --dry-run` | Validation passed on Claude Code `2.1.143`; full plugin validation has one expected root `CLAUDE.md` context warning; dry run would create `ecc--v2.0.0-rc.1` | Run dry-run tag again from the final commit, then tag/push only after approval |
|
||||
@@ -84,15 +85,15 @@ keep the related publication action blocked.
|
||||
| 6 | Verify Codex repo marketplace | `codex plugin marketplace add --help`; temp-home local and GitHub-ref repo marketplace add smoke; OpenAI official directory status recorded | Missing repo marketplace or unverified official-directory status |
|
||||
| 7 | Verify OpenCode package | `npm run build:opencode` | Build failure |
|
||||
| 8 | Regenerate release URL ledger | Live and approval-gated URLs separated in `release-url-ledger-YYYY-MM-DD.md` | Placeholder, private URL, or announcement URL drift |
|
||||
| 9 | Create GitHub prerelease | `gh release view v2.0.0-rc.1 --json tagName,url,isPrerelease` | Missing URL or wrong prerelease flag |
|
||||
| 10 | Publish npm rc | `npm view ecc-universal version dist-tags --json` shows rc.1 on `next` | rc.1 lands on `latest` or registry output is unclear |
|
||||
| 9 | Verify GitHub prerelease | `gh release view v2.0.0-rc.1 --json tagName,url,isPrerelease` | Missing URL or wrong prerelease flag |
|
||||
| 10 | Verify npm rc | `npm view ecc-universal version dist-tags --json` shows rc.1 on `next` and latest still on GA/stable | rc.1 lands on `latest` or registry output is unclear |
|
||||
| 11 | Publish/plugin-submit | Claude official submission and Codex repo marketplace evidence recorded | Form not submitted, listing not visible, or docs status changed |
|
||||
| 12 | Announce | X, LinkedIn, GitHub release, and longform copy use final live URLs | Any final URL is still pending |
|
||||
|
||||
## Do Not Proceed
|
||||
|
||||
- Do not publish npm before `npm pack --dry-run --json` is captured from the
|
||||
final release commit.
|
||||
- Do not publish an additional npm build before `npm pack --dry-run --json` is
|
||||
captured from the final release commit.
|
||||
- Do not create or push Claude plugin tags before `claude plugin tag
|
||||
.claude-plugin --dry-run` passes from the final release commit.
|
||||
- Do not claim an official Codex Plugin Directory listing unless OpenAI
|
||||
@@ -101,8 +102,8 @@ keep the related publication action blocked.
|
||||
Marketplace account readback returns ready.
|
||||
- Do not rename the npm package until rc.1 is published and a migration guide
|
||||
maps old install names to new names.
|
||||
- Do not post social copy while any release, npm, plugin, or billing URL is
|
||||
still approval-gated.
|
||||
- Do not post social copy while any required plugin, video, billing, or
|
||||
outbound URL is still approval-gated.
|
||||
|
||||
## External Distribution Sources
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ Claude Code remains a core target. Codex, OpenCode, Cursor, Gemini, and other ha
|
||||
IOC coverage, queue-zero/discussion checks, a detailed Linear roadmap gate,
|
||||
the May 18 operator dashboard snapshot, and a live/pending release URL
|
||||
ledger for announcement gating.
|
||||
- Published `ecc-universal@2.0.0-rc.1` to npm on the `next` dist-tag. The
|
||||
`latest` tag remains on `1.10.0` during the rc.1 window.
|
||||
|
||||
## Since v1.10.0
|
||||
|
||||
@@ -58,8 +60,8 @@ feature branch:
|
||||
- gated Itô skill distribution as a public workflow teaser, not a live trading
|
||||
claim or a merge of ECC Tools and Itô ownership.
|
||||
- a release URL ledger that separates links which already resolve from links
|
||||
that must wait for the GitHub release, npm rc package, plugin tag/directory,
|
||||
and ECC Tools billing readback.
|
||||
that must wait for the plugin tag/directory, video upload, and ECC Tools
|
||||
billing readback.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
@@ -107,9 +109,16 @@ What stays local:
|
||||
8. Treat `ecc2/` as an alpha control plane until release packaging and installer
|
||||
behavior is finalized.
|
||||
|
||||
## Do Not Treat This As Published Yet
|
||||
## Publication State
|
||||
|
||||
The release candidate copy is ready for final review, but the public release is
|
||||
still blocked on approval-gated actions: the GitHub prerelease, npm `next`
|
||||
publish, Claude plugin tag/marketplace path, Codex Plugin Directory status,
|
||||
final live URLs, and any billing or native-payments announcement.
|
||||
The GitHub prerelease and npm `next` package are live:
|
||||
|
||||
- GitHub prerelease:
|
||||
<https://github.com/affaan-m/ECC/releases/tag/v2.0.0-rc.1>
|
||||
- npm rc package:
|
||||
<https://www.npmjs.com/package/ecc-universal/v/2.0.0-rc.1>
|
||||
|
||||
This is still a release candidate, not a GA claim. Remaining public claims stay
|
||||
approval-gated until readback exists for the Claude plugin tag/marketplace path,
|
||||
Codex repo-marketplace or official Plugin Directory status, video upload URLs,
|
||||
ECC Tools billing/native-payments readiness, and final outbound copy.
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
# ECC v2.0.0-rc.1 Release URL Ledger
|
||||
|
||||
This ledger separates links that are already public from links that only become
|
||||
valid after the approval-gated release, package, plugin, and announcement
|
||||
steps. Regenerate it from the final release commit before posting any public
|
||||
announcement.
|
||||
valid after the remaining approval-gated plugin, video, billing, and
|
||||
announcement steps. Regenerate it from the final release commit before posting
|
||||
any public announcement.
|
||||
|
||||
Refreshed on 2026-05-19 after the public repository rename to
|
||||
`affaan-m/ECC`. The final release pass must replace commit-specific evidence
|
||||
with output from the exact release commit.
|
||||
Refreshed on 2026-05-26 after the GitHub prerelease and npm `next` package
|
||||
readbacks succeeded. Remaining plugin, video, billing, and outbound surfaces
|
||||
must still be checked from the exact release commit before publication.
|
||||
|
||||
## Live Now
|
||||
|
||||
| Surface | URL | Verification |
|
||||
| --- | --- | --- |
|
||||
| Repository | <https://github.com/affaan-m/ECC> | `git remote get-url origin` returns `https://github.com/affaan-m/ECC.git` |
|
||||
| GitHub prerelease URL | <https://github.com/affaan-m/ECC/releases/tag/v2.0.0-rc.1> | `gh release view v2.0.0-rc.1 --repo affaan-m/ECC --json tagName,url,isPrerelease,isDraft,publishedAt` returned prerelease `true`, draft `false`, published `2026-05-25T18:29:31Z` |
|
||||
| Release pack folder | <https://github.com/affaan-m/ECC/tree/main/docs/releases/2.0.0-rc.1> | In-tree release pack |
|
||||
| Release notes draft | <https://github.com/affaan-m/ECC/blob/main/docs/releases/2.0.0-rc.1/release-notes.md> | In-tree release copy |
|
||||
| Hermes setup guide | <https://github.com/affaan-m/ECC/blob/main/docs/HERMES-SETUP.md> | In-tree sanitized Hermes guide |
|
||||
@@ -22,7 +23,8 @@ with output from the exact release commit.
|
||||
| May 18 operator dashboard | <https://github.com/affaan-m/ECC/blob/main/docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-18.md> | Previous prompt-to-artifact dashboard |
|
||||
| May 19 operator dashboard | <https://github.com/affaan-m/ECC/blob/main/docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-19.md> | Previous prompt-to-artifact dashboard with hypergrowth, video, and outbound lanes |
|
||||
| May 20 operator dashboard | <https://github.com/affaan-m/ECC/blob/main/docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-20.md> | Current prompt-to-artifact dashboard with Marketplace Pro release-gate sync |
|
||||
| npm package page | <https://www.npmjs.com/package/ecc-universal> | `npm view ecc-universal name version dist-tags --json` returned `latest: 1.10.0`; rc.1 is not published yet |
|
||||
| npm package page | <https://www.npmjs.com/package/ecc-universal> | `npm view ecc-universal name version dist-tags versions --json` returned `latest: 1.10.0`, `next: 2.0.0-rc.1`, and included `2.0.0-rc.1` in `versions` |
|
||||
| npm rc package URL | <https://www.npmjs.com/package/ecc-universal/v/2.0.0-rc.1> | `npm view ecc-universal@2.0.0-rc.1 name version dist.tarball dist.integrity time --json` returned version `2.0.0-rc.1`, tarball `https://registry.npmjs.org/ecc-universal/-/ecc-universal-2.0.0-rc.1.tgz`, and published time `2026-05-26T00:36:22.940Z` |
|
||||
| Codex marketplace CLI docs | <https://developers.openai.com/codex/cli/reference#codex-plugin-marketplace> | Official docs list `codex plugin marketplace add` for GitHub shorthand, Git URLs, SSH URLs, and local marketplace roots |
|
||||
| Codex official Plugin Directory status | <https://developers.openai.com/codex/plugins/build#publish-official-public-plugins> | Official docs say public Plugin Directory publishing and self-serve management are coming soon |
|
||||
|
||||
@@ -30,12 +32,10 @@ with output from the exact release commit.
|
||||
|
||||
| Surface | Intended URL or command | Gate before use |
|
||||
| --- | --- | --- |
|
||||
| GitHub prerelease | <https://github.com/affaan-m/ECC/releases/tag/v2.0.0-rc.1> | `gh release view v2.0.0-rc.1 --repo affaan-m/ECC --json tagName,url,isPrerelease` must return the prerelease |
|
||||
| npm rc package | <https://www.npmjs.com/package/ecc-universal/v/2.0.0-rc.1> | `npm publish --tag next` approval and post-publish `npm view ecc-universal dist-tags --json` |
|
||||
| Claude plugin tag | `claude plugin tag .claude-plugin --dry-run`, then real tag only after approval | Clean release commit and plugin tag/push approval |
|
||||
| Codex repo marketplace install | `codex plugin marketplace add affaan-m/ECC --ref v2.0.0-rc.1` | GitHub tag must exist; official Plugin Directory submission remains separate |
|
||||
| ECC Tools native-payments announcement | ECC Tools Marketplace/App URL plus selected-target billing readiness readback through the operator bearer path | Marketplace-managed selected target returned `announcementGate.ready === true` on 2026-05-20; repeat immediately before publication |
|
||||
| Public announcements | X, LinkedIn, GitHub release, and longform URLs | GitHub release, npm, plugin, and billing URLs must resolve first |
|
||||
| Public announcements | X, LinkedIn, GitHub release, and longform URLs | Remaining plugin, video, and billing URLs must resolve or be explicitly marked blocked; exact outbound copy still needs owner approval |
|
||||
|
||||
## Pre-Post Check
|
||||
|
||||
@@ -45,11 +45,13 @@ Run these immediately before publication:
|
||||
git status --short --branch
|
||||
gh release view v2.0.0-rc.1 --repo affaan-m/ECC --json tagName,url,isPrerelease
|
||||
npm view ecc-universal name version dist-tags --json
|
||||
npm view ecc-universal@2.0.0-rc.1 name version dist.tarball dist.integrity time --json
|
||||
codex plugin marketplace add --help
|
||||
rg -n "TODO|TBD|PLACEHOLDER" docs/releases/2.0.0-rc.1
|
||||
npm run preview-pack:smoke
|
||||
npm run release:approval-gate -- --format json
|
||||
```
|
||||
|
||||
Do not post the social or notification copy until the approval-gated URLs above
|
||||
resolve from a clean release commit.
|
||||
Do not claim plugin propagation, official Codex Plugin Directory listing, video
|
||||
upload, ECC Tools billing/native payments, or final outbound readiness until the
|
||||
remaining approval-gated URLs above resolve from a clean release commit.
|
||||
|
||||
@@ -95,7 +95,7 @@ Together they make the work feel less like scattered chat windows and more like
|
||||
|
||||
The public docs and reusable surfaces are ready for review.
|
||||
|
||||
The deeper local integrations stay local until they are sanitized, and publication still waits on the GitHub release, npm, plugin, and final URL gates.
|
||||
The deeper local integrations stay local until they are sanitized. The GitHub prerelease and npm `next` package are live; plugin, video, billing, and final outbound URLs still stay behind the approval gate.
|
||||
|
||||
14/ Start here:
|
||||
|
||||
|
||||
@@ -66,6 +66,27 @@ The operating rhythm after launch should be weekly:
|
||||
5. one measurable funnel readback covering repo traffic, sponsor clicks, Pro
|
||||
conversions, MRR movement, and inbound replies.
|
||||
|
||||
## Platform Value Loop
|
||||
|
||||
The long-term platform thesis is recorded in
|
||||
[`docs/architecture/platform-value-loop.md`](../../architecture/platform-value-loop.md).
|
||||
ECC should stay useful as free OSS while the managed value accrues around team
|
||||
memory, observable sessions, release gates, evals, security evidence, hosted
|
||||
analysis, billing, partner workflows, and product-specific integrations.
|
||||
|
||||
Product integrations should behave like repeatable distribution loops:
|
||||
|
||||
1. ship a public skill pack that works without private credentials;
|
||||
2. keep live product data or actions behind an explicit gated API path;
|
||||
3. add fixtures, docs, evals, and risk gates so the workflow is testable;
|
||||
4. convert sanitized product usage back into ECC skills, docs, or evidence;
|
||||
5. route serious teams toward sponsors, Pro, partners, or consulting.
|
||||
|
||||
Itô is the current example: prediction-market research, basket comparison,
|
||||
manual non-advisory planning, and data-atlas workflows can be distributed
|
||||
through ECC, while live Itô data and account-specific calls remain gated by
|
||||
`ITO_API_KEY` and separate from ECC Tools billing.
|
||||
|
||||
## Release Gates
|
||||
|
||||
| Lane | Done when | Current action |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Everything Claude Code (ECC) — 智能体指令
|
||||
|
||||
这是一个**生产就绪的 AI 编码插件**,提供 63 个专业代理、249 项技能、79 条命令以及自动化钩子工作流,用于软件开发。
|
||||
这是一个**生产就绪的 AI 编码插件**,提供 63 个专业代理、251 项技能、79 条命令以及自动化钩子工作流,用于软件开发。
|
||||
|
||||
**版本:** 2.0.0-rc.1
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
|
||||
```
|
||||
agents/ — 63 个专业子代理
|
||||
skills/ — 249 个工作流技能和领域知识
|
||||
skills/ — 251 个工作流技能和领域知识
|
||||
commands/ — 79 个斜杠命令
|
||||
hooks/ — 基于触发的自动化
|
||||
rules/ — 始终遵循的指导方针(通用 + 每种语言)
|
||||
|
||||
@@ -224,7 +224,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
|
||||
/plugin list ecc@ecc
|
||||
```
|
||||
|
||||
**搞定!** 你现在可以使用 63 个智能体、249 项技能和 79 个命令了。
|
||||
**搞定!** 你现在可以使用 63 个智能体、251 项技能和 79 个命令了。
|
||||
|
||||
***
|
||||
|
||||
@@ -1138,7 +1138,7 @@ opencode
|
||||
|---------|---------------|----------|--------|
|
||||
| 智能体 | PASS: 63 个 | PASS: 12 个 | **Claude Code 领先** |
|
||||
| 命令 | PASS: 79 个 | PASS: 35 个 | **Claude Code 领先** |
|
||||
| 技能 | PASS: 249 项 | PASS: 37 项 | **Claude Code 领先** |
|
||||
| 技能 | PASS: 251 项 | PASS: 37 项 | **Claude Code 领先** |
|
||||
| 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** |
|
||||
| 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
|
||||
| MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** |
|
||||
@@ -1246,7 +1246,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以
|
||||
|---------|-----------------------|------------|-----------|----------|
|
||||
| **智能体** | 63 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
|
||||
| **命令** | 79 | 共享 | 基于指令 | 35 |
|
||||
| **技能** | 249 | 共享 | 10 (原生格式) | 37 |
|
||||
| **技能** | 251 | 共享 | 10 (原生格式) | 37 |
|
||||
| **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 |
|
||||
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 |
|
||||
| **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 |
|
||||
|
||||
@@ -631,12 +631,14 @@
|
||||
"skills/continuous-agent-loop",
|
||||
"skills/cost-aware-llm-pipeline",
|
||||
"skills/data-scraper-agent",
|
||||
"skills/dynamic-workflow-mode",
|
||||
"skills/enterprise-agent-ops",
|
||||
"skills/nanoclaw-repl",
|
||||
"skills/prompt-optimizer",
|
||||
"skills/ralphinho-rfc-pipeline",
|
||||
"skills/regex-vs-llm-structured-text",
|
||||
"skills/search-first",
|
||||
"skills/team-agent-orchestration",
|
||||
"skills/token-budget-advisor",
|
||||
"skills/team-builder"
|
||||
],
|
||||
|
||||
Generated
+1
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"bin": {
|
||||
"ecc": "scripts/ecc.js",
|
||||
"ecc-control-pane": "scripts/control-pane.js",
|
||||
"ecc-install": "scripts/install-apply.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"AGENTS.md",
|
||||
"VERSION",
|
||||
"agent.yaml",
|
||||
"assets/ecc-icon.svg",
|
||||
"assets/hero.png",
|
||||
"agents/",
|
||||
"commands/",
|
||||
"docs/de-DE/",
|
||||
@@ -77,6 +79,7 @@
|
||||
"scripts/consult.js",
|
||||
"scripts/auto-update.js",
|
||||
"scripts/claw.js",
|
||||
"scripts/control-pane.js",
|
||||
"scripts/codex/merge-codex-config.js",
|
||||
"scripts/codex/merge-mcp-config.js",
|
||||
"scripts/discussion-audit.js",
|
||||
@@ -165,6 +168,7 @@
|
||||
"skills/dmux-workflows/",
|
||||
"skills/docker-patterns/",
|
||||
"skills/dotnet-patterns/",
|
||||
"skills/dynamic-workflow-mode/",
|
||||
"skills/e2e-testing/",
|
||||
"skills/ecc-tools-cost-audit/",
|
||||
"skills/email-ops/",
|
||||
@@ -290,6 +294,7 @@
|
||||
"skills/swift-protocol-di-testing/",
|
||||
"skills/swiftui-patterns/",
|
||||
"skills/tdd-workflow/",
|
||||
"skills/team-agent-orchestration/",
|
||||
"skills/team-builder/",
|
||||
"skills/terminal-ops/",
|
||||
"skills/token-budget-advisor/",
|
||||
@@ -312,6 +317,7 @@
|
||||
],
|
||||
"bin": {
|
||||
"ecc": "scripts/ecc.js",
|
||||
"ecc-control-pane": "scripts/control-pane.js",
|
||||
"ecc-install": "scripts/install-apply.js"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -329,6 +335,7 @@
|
||||
"preview-pack:smoke": "node scripts/preview-pack-smoke.js",
|
||||
"release:approval-gate": "node scripts/release-approval-gate.js",
|
||||
"release:video-suite": "node scripts/release-video-suite.js",
|
||||
"control:pane": "node scripts/control-pane.js",
|
||||
"platform:audit": "node scripts/platform-audit.js",
|
||||
"discussion:audit": "node scripts/discussion-audit.js",
|
||||
"security:ioc-scan": "node scripts/ci/scan-supply-chain-iocs.js",
|
||||
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const {
|
||||
createControlPaneServer,
|
||||
parseArgs,
|
||||
usage,
|
||||
} = require('./lib/control-pane/server');
|
||||
|
||||
function openBrowser(url) {
|
||||
if (process.platform !== 'darwin') return;
|
||||
const child = spawn('open', [url], {
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
});
|
||||
child.on('error', error => {
|
||||
console.error(`[control-pane] failed to open browser: ${error.message}`);
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
async function main(argv = process.argv) {
|
||||
const args = parseArgs(argv);
|
||||
|
||||
if (args.help) {
|
||||
console.log(usage());
|
||||
return;
|
||||
}
|
||||
|
||||
const app = createControlPaneServer(args);
|
||||
await app.listen();
|
||||
|
||||
console.log(`ECC Control Pane: ${app.url}`);
|
||||
console.log(`ECC2 database: ${app.config.dbPath}`);
|
||||
console.log(`ECC state database: ${app.config.stateDbPath}`);
|
||||
console.log(args.allowActions ? 'Actions: enabled for local allowlist' : 'Actions: read-only');
|
||||
|
||||
if (args.openBrowser) {
|
||||
openBrowser(app.url);
|
||||
}
|
||||
|
||||
const shutdown = async () => {
|
||||
try {
|
||||
await app.close();
|
||||
} finally {
|
||||
process.exit(0);
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch(error => {
|
||||
console.error(`[control-pane] ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
main,
|
||||
openBrowser,
|
||||
};
|
||||
@@ -21,6 +21,10 @@ const COMMANDS = {
|
||||
script: 'consult.js',
|
||||
description: 'Recommend ECC components and profiles from a natural language query',
|
||||
},
|
||||
'control-pane': {
|
||||
script: 'control-pane.js',
|
||||
description: 'Run the local ECC2 operator control pane',
|
||||
},
|
||||
'install-plan': {
|
||||
script: 'install-plan.js',
|
||||
description: 'Alias for plan',
|
||||
@@ -80,6 +84,7 @@ const PRIMARY_COMMANDS = [
|
||||
'plan',
|
||||
'catalog',
|
||||
'consult',
|
||||
'control-pane',
|
||||
'list-installed',
|
||||
'doctor',
|
||||
'repair',
|
||||
@@ -118,6 +123,7 @@ Examples:
|
||||
ecc catalog components --family language
|
||||
ecc catalog show framework:nextjs
|
||||
ecc consult "security reviews"
|
||||
ecc control-pane --port 8765
|
||||
ecc list-installed --json
|
||||
ecc doctor --target cursor
|
||||
ecc repair --dry-run
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
const ACTION_DEFINITIONS = new Map([
|
||||
[
|
||||
'sync-knowledge',
|
||||
{
|
||||
label: 'Sync Knowledge',
|
||||
description: 'Import all configured ECC2 memory connectors into the context graph.',
|
||||
args: ({ limit }) => [
|
||||
'run',
|
||||
'--quiet',
|
||||
'--',
|
||||
'graph',
|
||||
'connector-sync',
|
||||
'--all',
|
||||
'--json',
|
||||
'--limit',
|
||||
String(limit),
|
||||
],
|
||||
executable: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'recall-knowledge',
|
||||
{
|
||||
label: 'Recall Knowledge',
|
||||
description: 'Run ECC2 context recall for the current operator query.',
|
||||
args: ({ query, limit }) => [
|
||||
'run',
|
||||
'--quiet',
|
||||
'--',
|
||||
'graph',
|
||||
'recall',
|
||||
query || 'ECC control pane',
|
||||
'--json',
|
||||
'--limit',
|
||||
String(limit),
|
||||
],
|
||||
executable: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'graph-sync',
|
||||
{
|
||||
label: 'Backfill Graph',
|
||||
description: 'Backfill the ECC2 graph from sessions, decisions, file activity, and messages.',
|
||||
args: ({ limit }) => [
|
||||
'run',
|
||||
'--quiet',
|
||||
'--',
|
||||
'graph',
|
||||
'sync',
|
||||
'--all',
|
||||
'--json',
|
||||
'--limit',
|
||||
String(limit),
|
||||
],
|
||||
executable: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'open-dashboard',
|
||||
{
|
||||
label: 'Open TUI',
|
||||
description: 'Launch the ECC2 terminal dashboard.',
|
||||
args: () => ['run', '--quiet', '--', 'dashboard'],
|
||||
executable: false,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
function normalizeLimit(value, fallback = 25) {
|
||||
const parsed = Number.parseInt(String(value ?? fallback), 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) return fallback;
|
||||
return Math.min(parsed, 500);
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
const text = String(value);
|
||||
if (text.length === 0) return "''";
|
||||
if (/^[A-Za-z0-9_./:=@%+-]+$/.test(text)) return text;
|
||||
return `'${text.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function commandLineFor(action) {
|
||||
return [
|
||||
`cd ${shellQuote(action.cwd)}`,
|
||||
'&&',
|
||||
shellQuote(action.command),
|
||||
...action.args.map(shellQuote),
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function buildControlPaneAction(actionId, options = {}) {
|
||||
const definition = ACTION_DEFINITIONS.get(actionId);
|
||||
if (!definition) {
|
||||
throw new Error(`Unknown control-pane action: ${actionId}`);
|
||||
}
|
||||
|
||||
const repoRoot = path.resolve(options.repoRoot || process.cwd());
|
||||
const cwd = path.join(repoRoot, 'ecc2');
|
||||
const limit = normalizeLimit(options.limit);
|
||||
const query = String(options.query || '').trim();
|
||||
const args = definition.args({ limit, query });
|
||||
const action = {
|
||||
id: actionId,
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
command: 'cargo',
|
||||
args,
|
||||
cwd,
|
||||
executable: definition.executable,
|
||||
};
|
||||
|
||||
return {
|
||||
...action,
|
||||
commandLine: commandLineFor(action),
|
||||
};
|
||||
}
|
||||
|
||||
function buildControlPaneActions(options = {}) {
|
||||
return Array.from(ACTION_DEFINITIONS.keys()).map(actionId =>
|
||||
buildControlPaneAction(actionId, options)
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildControlPaneAction,
|
||||
buildControlPaneActions,
|
||||
shellQuote,
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const { buildControlPaneAction } = require('./actions');
|
||||
const { buildControlPaneSnapshot, resolveControlPaneConfig } = require('./state');
|
||||
const { renderControlPaneHtml } = require('./ui');
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
'Usage:',
|
||||
' node scripts/control-pane.js [--host 127.0.0.1] [--port 8765] [--db <ecc2.db>] [--state-db <state.db>] [--config <ecc2.toml>] [--query <text>]',
|
||||
'',
|
||||
'Options:',
|
||||
' --state-db <path> Read agent work items from an ECC state-store database',
|
||||
' --read-only Disable action execution endpoints',
|
||||
' --no-open Do not open a browser after the server starts',
|
||||
' --help Show this help',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function valueAfter(args, name) {
|
||||
const index = args.indexOf(name);
|
||||
return index >= 0 ? args[index + 1] : null;
|
||||
}
|
||||
|
||||
function pathValueAfter(args, name) {
|
||||
const value = valueAfter(args, name);
|
||||
if (value === null) return null;
|
||||
if (!value || value.startsWith('-')) {
|
||||
throw new Error(`Invalid ${name} value: expected a path`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
const help = args.includes('--help') || args.includes('-h');
|
||||
const host = valueAfter(args, '--host') || '127.0.0.1';
|
||||
const portValue = valueAfter(args, '--port') || '8765';
|
||||
const port = Number.parseInt(portValue, 10);
|
||||
if (!Number.isFinite(port) || port < 0 || port > 65535) {
|
||||
throw new Error(`Invalid --port value: ${portValue}`);
|
||||
}
|
||||
|
||||
return {
|
||||
help,
|
||||
host,
|
||||
port,
|
||||
dbPath: valueAfter(args, '--db'),
|
||||
stateDbPath: pathValueAfter(args, '--state-db'),
|
||||
configPath: valueAfter(args, '--config'),
|
||||
query: valueAfter(args, '--query') || '',
|
||||
openBrowser: !args.includes('--no-open'),
|
||||
allowActions: !args.includes('--read-only'),
|
||||
};
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, payload) {
|
||||
const body = JSON.stringify(payload, null, 2);
|
||||
res.writeHead(statusCode, {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'cache-control': 'no-store',
|
||||
});
|
||||
res.end(`${body}\n`);
|
||||
}
|
||||
|
||||
function sendText(res, statusCode, body, contentType = 'text/plain; charset=utf-8') {
|
||||
res.writeHead(statusCode, {
|
||||
'content-type': contentType,
|
||||
'cache-control': 'no-store',
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
async function readRequestJson(req) {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
if (chunks.length === 0) return {};
|
||||
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function boundedOutput(value, limit = 20000) {
|
||||
const text = String(value || '');
|
||||
if (text.length <= limit) return text;
|
||||
return `${text.slice(0, limit)}\n[truncated ${text.length - limit} chars]`;
|
||||
}
|
||||
|
||||
function runAction(action, options = {}) {
|
||||
const timeoutMs = options.timeoutMs || 120000;
|
||||
return new Promise(resolve => {
|
||||
const startedAt = new Date().toISOString();
|
||||
const child = spawn(action.command, action.args, {
|
||||
cwd: action.cwd,
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += chunk.toString('utf8');
|
||||
});
|
||||
child.stderr.on('data', chunk => {
|
||||
stderr += chunk.toString('utf8');
|
||||
});
|
||||
child.on('error', error => {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
ok: false,
|
||||
action: action.id,
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
code: null,
|
||||
error: error.message,
|
||||
stdout: boundedOutput(stdout),
|
||||
stderr: boundedOutput(stderr),
|
||||
});
|
||||
});
|
||||
child.on('close', (code, signal) => {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
ok: code === 0,
|
||||
action: action.id,
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
code,
|
||||
signal,
|
||||
stdout: boundedOutput(stdout),
|
||||
stderr: boundedOutput(stderr),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createControlPaneServer(options = {}) {
|
||||
const repoRoot = path.resolve(options.repoRoot || path.join(__dirname, '..', '..', '..'));
|
||||
const host = options.host || '127.0.0.1';
|
||||
const port = options.port === null || options.port === undefined ? 8765 : options.port;
|
||||
const allowActions = options.allowActions !== false;
|
||||
const resolvedConfig = resolveControlPaneConfig({
|
||||
cwd: options.cwd || repoRoot,
|
||||
configPath: options.configPath,
|
||||
dbPath: options.dbPath,
|
||||
stateDbPath: options.stateDbPath,
|
||||
env: options.env || process.env,
|
||||
});
|
||||
const baseQuery = options.query || '';
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url, `http://${host}:${port || 0}`);
|
||||
|
||||
if (req.method === 'GET' && requestUrl.pathname === '/') {
|
||||
sendText(res, 200, renderControlPaneHtml(), 'text/html; charset=utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && requestUrl.pathname === '/assets/ecc-icon.svg') {
|
||||
const iconPath = path.join(repoRoot, 'assets', 'ecc-icon.svg');
|
||||
if (!fs.existsSync(iconPath)) {
|
||||
sendText(res, 404, 'not found');
|
||||
return;
|
||||
}
|
||||
sendText(res, 200, fs.readFileSync(iconPath, 'utf8'), 'image/svg+xml; charset=utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && requestUrl.pathname === '/api/health') {
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
repoRoot,
|
||||
dbPath: resolvedConfig.dbPath,
|
||||
stateDbPath: resolvedConfig.stateDbPath,
|
||||
allowActions,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && requestUrl.pathname === '/api/snapshot') {
|
||||
const snapshot = await buildControlPaneSnapshot({
|
||||
repoRoot,
|
||||
dbPath: resolvedConfig.dbPath,
|
||||
stateDbPath: resolvedConfig.stateDbPath,
|
||||
config: resolvedConfig,
|
||||
query: requestUrl.searchParams.get('query') || baseQuery,
|
||||
limit: requestUrl.searchParams.get('limit') || 12,
|
||||
allowActions,
|
||||
});
|
||||
sendJson(res, 200, snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
const actionMatch = requestUrl.pathname.match(/^\/api\/actions\/([^/]+)$/);
|
||||
if (req.method === 'POST' && actionMatch) {
|
||||
if (!allowActions) {
|
||||
sendJson(res, 403, {
|
||||
ok: false,
|
||||
error: 'Control-pane action execution is disabled by --read-only.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await readRequestJson(req);
|
||||
const action = buildControlPaneAction(decodeURIComponent(actionMatch[1]), {
|
||||
repoRoot,
|
||||
query: body.query || baseQuery,
|
||||
limit: body.limit || 25,
|
||||
});
|
||||
|
||||
if (!action.executable) {
|
||||
sendJson(res, 400, {
|
||||
ok: false,
|
||||
action: action.id,
|
||||
error: 'This action is copy-only and cannot be executed from the browser.',
|
||||
commandLine: action.commandLine,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await runAction(action);
|
||||
sendJson(res, result.ok ? 200 : 500, {
|
||||
...result,
|
||||
commandLine: action.commandLine,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 404, { ok: false, error: 'not found' });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, {
|
||||
ok: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
get url() {
|
||||
const address = server.address();
|
||||
const actualPort = address && typeof address === 'object' ? address.port : port;
|
||||
return `http://${host}:${actualPort}`;
|
||||
},
|
||||
server,
|
||||
config: resolvedConfig,
|
||||
listen() {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(port, host, () => {
|
||||
server.off('error', reject);
|
||||
resolve(this);
|
||||
});
|
||||
});
|
||||
},
|
||||
close() {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close(error => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createControlPaneServer,
|
||||
parseArgs,
|
||||
runAction,
|
||||
usage,
|
||||
};
|
||||
@@ -0,0 +1,631 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const initSqlJs = require('sql.js');
|
||||
const toml = require('@iarna/toml');
|
||||
|
||||
const { buildControlPaneActions } = require('./actions');
|
||||
|
||||
const SNAPSHOT_SCHEMA_VERSION = 'ecc.control-pane.snapshot.v1';
|
||||
const DEFAULT_STATE_STORE_RELATIVE_PATH = path.join('.claude', 'ecc', 'state.db');
|
||||
|
||||
function homeDir(env = process.env) {
|
||||
return env.HOME || env.USERPROFILE || os.homedir() || '.';
|
||||
}
|
||||
|
||||
function defaultDbPath(env = process.env) {
|
||||
return path.join(homeDir(env), '.claude', 'ecc2.db');
|
||||
}
|
||||
|
||||
function defaultStateDbPath(env = process.env) {
|
||||
return path.join(homeDir(env), DEFAULT_STATE_STORE_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
function defaultConfigPaths(cwd = process.cwd(), env = process.env) {
|
||||
const home = homeDir(env);
|
||||
const paths = [
|
||||
path.join(home, 'Library', 'Application Support', 'ecc2', 'config.toml'),
|
||||
path.join(home, '.config', 'ecc2', 'config.toml'),
|
||||
path.join(home, '.claude', 'ecc2.toml'),
|
||||
];
|
||||
|
||||
let current = path.resolve(cwd);
|
||||
while (current && current !== path.dirname(current)) {
|
||||
paths.push(path.join(current, '.claude', 'ecc2.toml'));
|
||||
paths.push(path.join(current, 'ecc2.toml'));
|
||||
current = path.dirname(current);
|
||||
}
|
||||
|
||||
return Array.from(new Set(paths));
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function deepMerge(base, override) {
|
||||
const merged = { ...base };
|
||||
for (const [key, value] of Object.entries(override || {})) {
|
||||
if (isPlainObject(value) && isPlainObject(merged[key])) {
|
||||
merged[key] = deepMerge(merged[key], value);
|
||||
} else {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function toCamelCase(value) {
|
||||
return String(value).replace(/_([a-z])/g, (_, char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function normalizeObjectKeys(value) {
|
||||
if (Array.isArray(value)) return value.map(normalizeObjectKeys);
|
||||
if (!isPlainObject(value)) return value;
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [toCamelCase(key), normalizeObjectKeys(item)])
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMemoryConnectors(connectors = {}) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(connectors || {})
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([name, connector]) => [name, normalizeObjectKeys(connector)])
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeConfig(rawConfig = {}, options = {}) {
|
||||
const {
|
||||
memory_connectors: snakeMemoryConnectors,
|
||||
memoryConnectors,
|
||||
state_db_path: snakeStateDbPath,
|
||||
stateDbPath: camelStateDbPath,
|
||||
...rest
|
||||
} = rawConfig;
|
||||
const normalized = normalizeObjectKeys(rest);
|
||||
const connectorConfig = memoryConnectors || snakeMemoryConnectors || normalized.memoryConnectors;
|
||||
return {
|
||||
dbPath: options.dbPath || normalized.dbPath || defaultDbPath(options.env),
|
||||
stateDbPath: options.stateDbPath
|
||||
|| camelStateDbPath
|
||||
|| snakeStateDbPath
|
||||
|| normalized.stateDbPath
|
||||
|| defaultStateDbPath(options.env),
|
||||
memoryConnectors: normalizeMemoryConnectors(connectorConfig),
|
||||
};
|
||||
}
|
||||
|
||||
function readTomlConfig(configPath) {
|
||||
const raw = fs.readFileSync(configPath, 'utf8');
|
||||
return toml.parse(raw);
|
||||
}
|
||||
|
||||
function resolveControlPaneConfig(options = {}) {
|
||||
const env = options.env || process.env;
|
||||
const cwd = options.cwd || process.cwd();
|
||||
const configPaths = options.configPath
|
||||
? [path.resolve(options.configPath)]
|
||||
: defaultConfigPaths(cwd, env);
|
||||
let merged = {};
|
||||
|
||||
for (const configPath of configPaths) {
|
||||
if (fs.existsSync(configPath)) {
|
||||
merged = deepMerge(merged, readTomlConfig(configPath));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...normalizeConfig(merged, {
|
||||
env,
|
||||
dbPath: options.dbPath || env.ECC2_DB_PATH || null,
|
||||
stateDbPath: options.stateDbPath || env.ECC_STATE_DB_PATH || null,
|
||||
}),
|
||||
configPaths: configPaths.filter(configPath => fs.existsSync(configPath)),
|
||||
};
|
||||
}
|
||||
|
||||
async function openSqlDatabase(dbPath) {
|
||||
if (!dbPath || !fs.existsSync(dbPath)) return null;
|
||||
const SQL = await initSqlJs();
|
||||
const buffer = fs.readFileSync(dbPath);
|
||||
return new SQL.Database(buffer);
|
||||
}
|
||||
|
||||
function execRows(db, sql, params = []) {
|
||||
const stmt = db.prepare(sql);
|
||||
try {
|
||||
stmt.bind(params);
|
||||
const rows = [];
|
||||
while (stmt.step()) rows.push(stmt.getAsObject());
|
||||
return rows;
|
||||
} finally {
|
||||
stmt.free();
|
||||
}
|
||||
}
|
||||
|
||||
function tableExists(db, tableName) {
|
||||
const rows = execRows(
|
||||
db,
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1",
|
||||
[tableName]
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
if (typeof value !== 'string' || value.trim() === '') return fallback;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function toNumber(value, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function normalizeSession(row, unreadMessages) {
|
||||
const id = String(row.id || '');
|
||||
return {
|
||||
id,
|
||||
task: String(row.task || ''),
|
||||
project: String(row.project || ''),
|
||||
taskGroup: String(row.task_group || ''),
|
||||
agentType: String(row.agent_type || ''),
|
||||
harness: String(row.harness || 'unknown'),
|
||||
detectedHarnesses: parseJson(row.detected_harnesses_json, []),
|
||||
workingDir: String(row.working_dir || '.'),
|
||||
state: String(row.state || 'pending'),
|
||||
pid: row.pid === null || row.pid === undefined ? null : toNumber(row.pid),
|
||||
worktree: row.worktree_path
|
||||
? {
|
||||
path: String(row.worktree_path),
|
||||
branch: row.worktree_branch ? String(row.worktree_branch) : null,
|
||||
base: row.worktree_base ? String(row.worktree_base) : null,
|
||||
}
|
||||
: null,
|
||||
metrics: {
|
||||
inputTokens: toNumber(row.input_tokens),
|
||||
outputTokens: toNumber(row.output_tokens),
|
||||
tokensUsed: toNumber(row.tokens_used),
|
||||
toolCalls: toNumber(row.tool_calls),
|
||||
filesChanged: toNumber(row.files_changed),
|
||||
durationSecs: toNumber(row.duration_secs),
|
||||
costUsd: toNumber(row.cost_usd),
|
||||
},
|
||||
unreadMessages: unreadMessages.get(id) || 0,
|
||||
createdAt: String(row.created_at || ''),
|
||||
updatedAt: String(row.updated_at || ''),
|
||||
lastHeartbeatAt: String(row.last_heartbeat_at || ''),
|
||||
};
|
||||
}
|
||||
|
||||
function readUnreadMessageCounts(db) {
|
||||
if (!tableExists(db, 'messages')) return new Map();
|
||||
return new Map(
|
||||
execRows(
|
||||
db,
|
||||
'SELECT to_session, COUNT(*) AS unread_count FROM messages WHERE read = 0 GROUP BY to_session'
|
||||
).map(row => [String(row.to_session), toNumber(row.unread_count)])
|
||||
);
|
||||
}
|
||||
|
||||
function readSessions(db) {
|
||||
if (!tableExists(db, 'sessions')) return [];
|
||||
const unreadMessages = readUnreadMessageCounts(db);
|
||||
return execRows(
|
||||
db,
|
||||
`SELECT *
|
||||
FROM sessions
|
||||
ORDER BY updated_at DESC, created_at DESC, id ASC
|
||||
LIMIT 100`
|
||||
).map(row => normalizeSession(row, unreadMessages));
|
||||
}
|
||||
|
||||
function summarizeSessions(sessions) {
|
||||
const summary = {
|
||||
totalSessions: sessions.length,
|
||||
runningSessions: 0,
|
||||
pendingSessions: 0,
|
||||
idleSessions: 0,
|
||||
failedSessions: 0,
|
||||
stoppedSessions: 0,
|
||||
completedSessions: 0,
|
||||
unreadMessages: 0,
|
||||
activeWorktrees: 0,
|
||||
totalTokens: 0,
|
||||
totalCostUsd: 0,
|
||||
};
|
||||
|
||||
for (const session of sessions) {
|
||||
if (session.state === 'running') summary.runningSessions += 1;
|
||||
if (session.state === 'pending') summary.pendingSessions += 1;
|
||||
if (session.state === 'idle') summary.idleSessions += 1;
|
||||
if (session.state === 'failed') summary.failedSessions += 1;
|
||||
if (session.state === 'stopped') summary.stoppedSessions += 1;
|
||||
if (session.state === 'completed') summary.completedSessions += 1;
|
||||
if (session.worktree) summary.activeWorktrees += 1;
|
||||
summary.unreadMessages += session.unreadMessages;
|
||||
summary.totalTokens += session.metrics.tokensUsed;
|
||||
summary.totalCostUsd += session.metrics.costUsd;
|
||||
}
|
||||
|
||||
summary.totalCostUsd = Number(summary.totalCostUsd.toFixed(6));
|
||||
return summary;
|
||||
}
|
||||
|
||||
function readEntities(db) {
|
||||
if (!tableExists(db, 'context_graph_entities')) return [];
|
||||
return execRows(
|
||||
db,
|
||||
`SELECT *
|
||||
FROM context_graph_entities
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT 500`
|
||||
).map(row => ({
|
||||
id: toNumber(row.id),
|
||||
sessionId: row.session_id ? String(row.session_id) : null,
|
||||
entityType: String(row.entity_type || ''),
|
||||
name: String(row.name || ''),
|
||||
path: row.path ? String(row.path) : null,
|
||||
summary: String(row.summary || ''),
|
||||
metadata: parseJson(row.metadata_json, {}),
|
||||
createdAt: String(row.created_at || ''),
|
||||
updatedAt: String(row.updated_at || ''),
|
||||
}));
|
||||
}
|
||||
|
||||
function readObservations(db) {
|
||||
if (!tableExists(db, 'context_graph_observations')) return [];
|
||||
return execRows(
|
||||
db,
|
||||
`SELECT *
|
||||
FROM context_graph_observations
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1000`
|
||||
).map(row => ({
|
||||
id: toNumber(row.id),
|
||||
sessionId: row.session_id ? String(row.session_id) : null,
|
||||
entityId: toNumber(row.entity_id),
|
||||
observationType: String(row.observation_type || ''),
|
||||
priority: toNumber(row.priority, 1),
|
||||
pinned: toNumber(row.pinned) === 1,
|
||||
summary: String(row.summary || ''),
|
||||
details: parseJson(row.details_json, {}),
|
||||
createdAt: String(row.created_at || ''),
|
||||
}));
|
||||
}
|
||||
|
||||
function readRelationCounts(db) {
|
||||
if (!tableExists(db, 'context_graph_relations')) return new Map();
|
||||
const rows = execRows(
|
||||
db,
|
||||
`SELECT entity_id, SUM(relation_count) AS relation_count
|
||||
FROM (
|
||||
SELECT from_entity_id AS entity_id, COUNT(*) AS relation_count
|
||||
FROM context_graph_relations
|
||||
GROUP BY from_entity_id
|
||||
UNION ALL
|
||||
SELECT to_entity_id AS entity_id, COUNT(*) AS relation_count
|
||||
FROM context_graph_relations
|
||||
GROUP BY to_entity_id
|
||||
)
|
||||
GROUP BY entity_id`
|
||||
);
|
||||
return new Map(rows.map(row => [toNumber(row.entity_id), toNumber(row.relation_count)]));
|
||||
}
|
||||
|
||||
function tokenize(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9_.-]+/g)
|
||||
.map(token => token.trim())
|
||||
.filter(token => token.length >= 2);
|
||||
}
|
||||
|
||||
function scoreEntity(entity, observations, relationCount, queryTerms) {
|
||||
const observationText = observations.map(observation => observation.summary).join(' ');
|
||||
const metadataText = Object.entries(entity.metadata || {})
|
||||
.map(([key, value]) => `${key} ${value}`)
|
||||
.join(' ');
|
||||
const haystacks = [
|
||||
{ text: entity.name, weight: 12 },
|
||||
{ text: entity.entityType, weight: 5 },
|
||||
{ text: entity.path || '', weight: 6 },
|
||||
{ text: entity.summary, weight: 8 },
|
||||
{ text: metadataText, weight: 5 },
|
||||
{ text: observationText, weight: 10 },
|
||||
].map(item => ({ ...item, text: item.text.toLowerCase() }));
|
||||
const matchedTerms = [];
|
||||
let score = 0;
|
||||
|
||||
for (const term of queryTerms) {
|
||||
let matched = false;
|
||||
for (const haystack of haystacks) {
|
||||
if (haystack.text.includes(term)) {
|
||||
score += haystack.weight;
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
if (matched) matchedTerms.push(term);
|
||||
}
|
||||
|
||||
const maxPriority = observations.reduce(
|
||||
(highest, observation) => Math.max(highest, observation.priority),
|
||||
0
|
||||
);
|
||||
const hasPinnedObservation = observations.some(observation => observation.pinned);
|
||||
score += Math.min(relationCount, 8);
|
||||
score += maxPriority * 3;
|
||||
if (hasPinnedObservation) score += 8;
|
||||
|
||||
return {
|
||||
score,
|
||||
matchedTerms,
|
||||
observationCount: observations.length,
|
||||
relationCount,
|
||||
maxObservationPriority: maxPriority,
|
||||
hasPinnedObservation,
|
||||
};
|
||||
}
|
||||
|
||||
function recallKnowledgeEntries({ entities, observations, relationCounts, query, limit = 12 }) {
|
||||
const queryTerms = Array.from(new Set(tokenize(query)));
|
||||
const observationsByEntity = new Map();
|
||||
for (const observation of observations) {
|
||||
const bucket = observationsByEntity.get(observation.entityId) || [];
|
||||
bucket.push(observation);
|
||||
observationsByEntity.set(observation.entityId, bucket);
|
||||
}
|
||||
|
||||
return entities
|
||||
.map(entity => {
|
||||
const entityObservations = observationsByEntity.get(entity.id) || [];
|
||||
const score = queryTerms.length > 0
|
||||
? scoreEntity(entity, entityObservations, relationCounts.get(entity.id) || 0, queryTerms)
|
||||
: {
|
||||
score: entityObservations.some(observation => observation.pinned) ? 10 : 1,
|
||||
matchedTerms: [],
|
||||
observationCount: entityObservations.length,
|
||||
relationCount: relationCounts.get(entity.id) || 0,
|
||||
maxObservationPriority: entityObservations.reduce(
|
||||
(highest, observation) => Math.max(highest, observation.priority),
|
||||
0
|
||||
),
|
||||
hasPinnedObservation: entityObservations.some(observation => observation.pinned),
|
||||
};
|
||||
return {
|
||||
entity,
|
||||
...score,
|
||||
latestObservation: entityObservations[0] || null,
|
||||
};
|
||||
})
|
||||
.filter(entry => queryTerms.length === 0 || entry.matchedTerms.length > 0)
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) return right.score - left.score;
|
||||
return String(right.entity.updatedAt).localeCompare(String(left.entity.updatedAt));
|
||||
})
|
||||
.slice(0, Math.max(1, Math.min(Number(limit) || 12, 50)));
|
||||
}
|
||||
|
||||
function readConnectorCheckpointRows(db) {
|
||||
if (!tableExists(db, 'context_graph_connector_checkpoints')) return [];
|
||||
return execRows(
|
||||
db,
|
||||
`SELECT connector_name, COUNT(*) AS synced_sources, MAX(updated_at) AS last_synced_at
|
||||
FROM context_graph_connector_checkpoints
|
||||
GROUP BY connector_name`
|
||||
);
|
||||
}
|
||||
|
||||
function connectorStatus(config, db) {
|
||||
const checkpoints = new Map(
|
||||
(db ? readConnectorCheckpointRows(db) : []).map(row => [
|
||||
String(row.connector_name),
|
||||
{
|
||||
syncedSources: toNumber(row.synced_sources),
|
||||
lastSyncedAt: row.last_synced_at ? String(row.last_synced_at) : null,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
return Object.entries(config.memoryConnectors || {})
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([name, connector]) => {
|
||||
const checkpoint = checkpoints.get(name) || { syncedSources: 0, lastSyncedAt: null };
|
||||
return {
|
||||
name,
|
||||
kind: connector.kind || 'unknown',
|
||||
path: connector.path || null,
|
||||
recurse: Boolean(connector.recurse),
|
||||
defaultEntityType: connector.defaultEntityType || null,
|
||||
defaultObservationType: connector.defaultObservationType || null,
|
||||
includeSafeValues: Boolean(connector.includeSafeValues),
|
||||
syncedSources: checkpoint.syncedSources,
|
||||
lastSyncedAt: checkpoint.lastSyncedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeWorkItemStatus(status) {
|
||||
const normalized = String(status || 'open').trim().toLowerCase();
|
||||
if (['done', 'closed', 'resolved', 'merged', 'cancelled'].includes(normalized)) return 'done';
|
||||
if (['blocked', 'needs-review', 'failed', 'stalled'].includes(normalized)) return 'blocked';
|
||||
if (['running', 'in-progress', 'active', 'working'].includes(normalized)) return 'running';
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
function normalizeWorkItem(row) {
|
||||
const parsedMetadata = parseJson(row.metadata, {});
|
||||
const metadata = isPlainObject(parsedMetadata) ? normalizeObjectKeys(parsedMetadata) : {};
|
||||
const kanbanState = normalizeWorkItemStatus(row.status);
|
||||
return {
|
||||
id: String(row.id || ''),
|
||||
source: String(row.source || ''),
|
||||
sourceId: row.source_id ? String(row.source_id) : null,
|
||||
title: String(row.title || ''),
|
||||
status: String(row.status || 'open'),
|
||||
kanbanState,
|
||||
priority: row.priority ? String(row.priority) : null,
|
||||
url: row.url ? String(row.url) : null,
|
||||
owner: row.owner ? String(row.owner) : null,
|
||||
repoRoot: row.repo_root ? String(row.repo_root) : null,
|
||||
sessionId: row.session_id ? String(row.session_id) : null,
|
||||
branch: metadata.branch || metadata.headRefName || null,
|
||||
mergeGate: metadata.mergeGate || metadata.mergeGateStatus || metadata.mergeStateStatus || null,
|
||||
blocker: metadata.blocker || null,
|
||||
acceptance: Array.isArray(metadata.acceptance) ? metadata.acceptance.map(String) : [],
|
||||
metadata,
|
||||
createdAt: String(row.created_at || ''),
|
||||
updatedAt: String(row.updated_at || ''),
|
||||
};
|
||||
}
|
||||
|
||||
function readWorkItems(db) {
|
||||
if (!tableExists(db, 'work_items')) return [];
|
||||
return execRows(
|
||||
db,
|
||||
`SELECT *
|
||||
FROM work_items
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT 100`
|
||||
).map(normalizeWorkItem);
|
||||
}
|
||||
|
||||
function summarizeWorkItems(items) {
|
||||
const summary = {
|
||||
totalCount: items.length,
|
||||
openCount: 0,
|
||||
blockedCount: 0,
|
||||
doneCount: 0,
|
||||
kanban: {
|
||||
ready: 0,
|
||||
running: 0,
|
||||
blocked: 0,
|
||||
done: 0,
|
||||
},
|
||||
items,
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const kanbanState = normalizeWorkItemStatus(item.kanbanState || item.status);
|
||||
summary.kanban[kanbanState] += 1;
|
||||
if (kanbanState === 'done') {
|
||||
summary.doneCount += 1;
|
||||
} else {
|
||||
summary.openCount += 1;
|
||||
}
|
||||
if (kanbanState === 'blocked') summary.blockedCount += 1;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function readWorkItemsSnapshot(stateDbPath) {
|
||||
let db = null;
|
||||
try {
|
||||
db = await openSqlDatabase(stateDbPath);
|
||||
if (!db) return summarizeWorkItems([]);
|
||||
return summarizeWorkItems(readWorkItems(db));
|
||||
} catch {
|
||||
return summarizeWorkItems([]);
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function buildControlPaneSnapshot(options = {}) {
|
||||
const repoRoot = path.resolve(options.repoRoot || path.join(__dirname, '..', '..', '..'));
|
||||
const config = options.config
|
||||
? normalizeConfig(options.config, {
|
||||
env: options.env || process.env,
|
||||
dbPath: options.dbPath || options.config.dbPath || null,
|
||||
stateDbPath: options.stateDbPath || options.config.stateDbPath || null,
|
||||
})
|
||||
: resolveControlPaneConfig(options);
|
||||
const dbPath = options.dbPath || config.dbPath;
|
||||
const stateDbPath = options.stateDbPath || config.stateDbPath;
|
||||
const query = String(options.query || '').trim();
|
||||
const limit = Math.max(1, Math.min(Number.parseInt(String(options.limit || 12), 10) || 12, 50));
|
||||
const generatedAt = new Date().toISOString();
|
||||
const workItems = await readWorkItemsSnapshot(stateDbPath);
|
||||
const base = {
|
||||
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
||||
generatedAt,
|
||||
repoRoot,
|
||||
dbPath,
|
||||
stateDbPath,
|
||||
database: {
|
||||
exists: Boolean(dbPath && fs.existsSync(dbPath)),
|
||||
},
|
||||
stateDatabase: {
|
||||
exists: Boolean(stateDbPath && fs.existsSync(stateDbPath)),
|
||||
},
|
||||
config: {
|
||||
configPaths: config.configPaths || [],
|
||||
memoryConnectorCount: Object.keys(config.memoryConnectors || {}).length,
|
||||
},
|
||||
execution: {
|
||||
allowActions: options.allowActions !== false,
|
||||
},
|
||||
summary: summarizeSessions([]),
|
||||
sessions: [],
|
||||
knowledge: {
|
||||
query,
|
||||
entityCount: 0,
|
||||
observationCount: 0,
|
||||
results: [],
|
||||
},
|
||||
connectors: connectorStatus(config, null),
|
||||
workItems,
|
||||
actions: buildControlPaneActions({ repoRoot, query, limit }),
|
||||
};
|
||||
|
||||
const db = await openSqlDatabase(dbPath);
|
||||
if (!db) {
|
||||
return base;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessions = readSessions(db);
|
||||
const entities = readEntities(db);
|
||||
const observations = readObservations(db);
|
||||
const relationCounts = readRelationCounts(db);
|
||||
return {
|
||||
...base,
|
||||
summary: summarizeSessions(sessions),
|
||||
sessions,
|
||||
knowledge: {
|
||||
query,
|
||||
entityCount: entities.length,
|
||||
observationCount: observations.length,
|
||||
results: recallKnowledgeEntries({
|
||||
entities,
|
||||
observations,
|
||||
relationCounts,
|
||||
query,
|
||||
limit,
|
||||
}),
|
||||
},
|
||||
connectors: connectorStatus(config, db),
|
||||
};
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SNAPSHOT_SCHEMA_VERSION,
|
||||
buildControlPaneSnapshot,
|
||||
defaultConfigPaths,
|
||||
defaultStateDbPath,
|
||||
recallKnowledgeEntries,
|
||||
resolveControlPaneConfig,
|
||||
};
|
||||
@@ -0,0 +1,633 @@
|
||||
'use strict';
|
||||
|
||||
function renderControlPaneHtml() {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ECC Control Pane</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #101312;
|
||||
--panel: #181d1b;
|
||||
--panel-2: #202622;
|
||||
--ink: #f4f0e8;
|
||||
--muted: #aab3aa;
|
||||
--line: #344038;
|
||||
--accent: #6fd8b5;
|
||||
--accent-2: #e6c35c;
|
||||
--danger: #ff7a72;
|
||||
--blue: #82aaff;
|
||||
--shadow: rgba(0, 0, 0, 0.28);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
button, input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #121715;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.brand img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.query {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-width: 220px;
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
input[type="search"] {
|
||||
width: 100%;
|
||||
color: var(--ink);
|
||||
background: #0c0f0e;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 9px 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="search"]:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button {
|
||||
color: var(--ink);
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
color: #06100c;
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 0.8fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.metric,
|
||||
section {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 12px 30px var(--shadow);
|
||||
}
|
||||
|
||||
.metric {
|
||||
padding: 12px;
|
||||
min-height: 84px;
|
||||
}
|
||||
|
||||
.metric span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
section {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #151a18;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.subtle {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(52, 64, 56, 0.7);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0;
|
||||
background: #131816;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.result,
|
||||
.connector,
|
||||
.work-item,
|
||||
.action {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(52, 64, 56, 0.7);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.result:last-child,
|
||||
.connector:last-child,
|
||||
.work-item:last-child,
|
||||
.action:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.kanban {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(52, 64, 56, 0.7);
|
||||
}
|
||||
|
||||
.kanban-lane {
|
||||
min-width: 0;
|
||||
padding: 9px;
|
||||
border: 1px solid rgba(52, 64, 56, 0.8);
|
||||
border-radius: 6px;
|
||||
background: #141917;
|
||||
}
|
||||
|
||||
.kanban-lane span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.kanban-lane strong {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: #222a26;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill.good { color: #07110d; background: var(--accent); }
|
||||
.pill.warn { color: #171000; background: var(--accent-2); }
|
||||
.pill.bad { color: #190706; background: var(--danger); }
|
||||
.pill.blue { color: #071020; background: var(--blue); }
|
||||
|
||||
code {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 9px;
|
||||
background: #0c0f0e;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: #d6e7dc;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#run-output {
|
||||
min-height: 44px;
|
||||
max-height: 260px;
|
||||
}
|
||||
|
||||
#app {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
max-width: min(640px, calc(100vw - 32px));
|
||||
max-height: 45vh;
|
||||
overflow: auto;
|
||||
padding: 12px 14px;
|
||||
background: #190706;
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: 8px;
|
||||
color: #ffe5e2;
|
||||
box-shadow: 0 12px 30px var(--shadow);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 18px 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
main { grid-template-columns: 1fr; }
|
||||
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
header { align-items: stretch; flex-direction: column; }
|
||||
.query { max-width: none; width: 100%; }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
main { padding: 12px; }
|
||||
.metrics { grid-template-columns: 1fr; }
|
||||
.kanban { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.query { flex-direction: column; }
|
||||
th:nth-child(4), td:nth-child(4) { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<header>
|
||||
<div class="brand">
|
||||
<img src="/assets/ecc-icon.svg" alt="">
|
||||
<h1>ECC Control Pane</h1>
|
||||
</div>
|
||||
<form class="query" id="query-form">
|
||||
<input id="query" type="search" placeholder="Recall operator memory, session context, runbooks">
|
||||
<button class="primary" type="submit">Recall</button>
|
||||
<button type="button" id="refresh">Refresh</button>
|
||||
</form>
|
||||
</header>
|
||||
<main>
|
||||
<div class="stack">
|
||||
<div class="metrics" id="metrics"></div>
|
||||
<section>
|
||||
<div class="section-head">
|
||||
<h2>Sessions</h2>
|
||||
<span class="subtle" id="db-path"></span>
|
||||
</div>
|
||||
<div id="sessions"></div>
|
||||
</section>
|
||||
<section>
|
||||
<div class="section-head">
|
||||
<h2>Work Items</h2>
|
||||
<span class="subtle" id="work-item-count"></span>
|
||||
</div>
|
||||
<div id="work-items"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="stack">
|
||||
<section>
|
||||
<div class="section-head">
|
||||
<h2>Knowledge</h2>
|
||||
<span class="subtle" id="knowledge-count"></span>
|
||||
</div>
|
||||
<div id="knowledge"></div>
|
||||
</section>
|
||||
<section>
|
||||
<div class="section-head">
|
||||
<h2>Connectors</h2>
|
||||
<span class="subtle" id="connector-count"></span>
|
||||
</div>
|
||||
<div id="connectors"></div>
|
||||
</section>
|
||||
<section>
|
||||
<div class="section-head">
|
||||
<h2>Actions</h2>
|
||||
<span class="subtle" id="action-status">local allowlist</span>
|
||||
</div>
|
||||
<div id="actions"></div>
|
||||
<div class="action">
|
||||
<code id="run-output">No action output yet.</code>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<div id="app" hidden></div>
|
||||
<script>
|
||||
const state = { query: '' };
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, char => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
})[char]);
|
||||
const fmt = new Intl.NumberFormat('en-US');
|
||||
|
||||
function formatError(error) {
|
||||
if (!error) return 'Unknown error';
|
||||
return error.stack || error.message || String(error);
|
||||
}
|
||||
|
||||
function showError(targetSelector, error) {
|
||||
const target = $(targetSelector);
|
||||
if (!target) return;
|
||||
target.hidden = false;
|
||||
target.textContent = formatError(error);
|
||||
}
|
||||
|
||||
function clearError(targetSelector) {
|
||||
const target = $(targetSelector);
|
||||
if (!target) return;
|
||||
target.hidden = true;
|
||||
target.textContent = '';
|
||||
}
|
||||
|
||||
async function readJsonResponse(response) {
|
||||
let payload;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
throw new Error('Expected JSON response from control pane: ' + error.message);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = payload && payload.error ? payload.error : response.status + ' ' + response.statusText;
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function statePill(stateName) {
|
||||
const state = String(stateName || 'unknown');
|
||||
const klass = ['running', 'done'].includes(state)
|
||||
? 'good'
|
||||
: ['failed', 'blocked'].includes(state)
|
||||
? 'bad'
|
||||
: ['pending', 'ready'].includes(state)
|
||||
? 'warn'
|
||||
: 'blue';
|
||||
return '<span class="pill ' + klass + '">' + escapeHtml(state) + '</span>';
|
||||
}
|
||||
|
||||
function renderMetrics(summary) {
|
||||
const items = [
|
||||
['Sessions', summary.totalSessions],
|
||||
['Running', summary.runningSessions],
|
||||
['Unread', summary.unreadMessages],
|
||||
['Tokens', fmt.format(summary.totalTokens || 0)],
|
||||
];
|
||||
$('#metrics').innerHTML = items.map(([label, value]) =>
|
||||
'<div class="metric"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(value) + '</strong></div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
function renderSessions(sessions) {
|
||||
if (!sessions.length) {
|
||||
$('#sessions').innerHTML = '<div class="empty">No ECC2 sessions found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$('#sessions').innerHTML = '<table><thead><tr><th>State</th><th>Session</th><th>Harness</th><th>Worktree</th><th>Updated</th></tr></thead><tbody>' +
|
||||
sessions.map(session => '<tr>' +
|
||||
'<td>' + statePill(session.state) + '</td>' +
|
||||
'<td><strong>' + escapeHtml(session.id) + '</strong><br><span class="subtle">' + escapeHtml(session.task) + '</span></td>' +
|
||||
'<td>' + escapeHtml(session.agentType || session.harness) + '<br><span class="subtle">' + escapeHtml((session.detectedHarnesses || []).join(', ')) + '</span></td>' +
|
||||
'<td>' + escapeHtml(session.worktree ? session.worktree.branch || session.worktree.path : '-') + '</td>' +
|
||||
'<td>' + escapeHtml(session.updatedAt || '-') + '</td>' +
|
||||
'</tr>').join('') +
|
||||
'</tbody></table>';
|
||||
}
|
||||
|
||||
function renderWorkItems(workItems) {
|
||||
const summary = workItems || { totalCount: 0, openCount: 0, blockedCount: 0, doneCount: 0, kanban: {}, items: [] };
|
||||
const items = Array.isArray(summary.items) ? summary.items : [];
|
||||
const kanban = summary.kanban || {};
|
||||
$('#work-item-count').textContent = summary.openCount + ' open / ' + summary.blockedCount + ' blocked';
|
||||
|
||||
const lanes = ['ready', 'running', 'blocked', 'done'];
|
||||
const laneHtml = '<div class="kanban">' + lanes.map(lane =>
|
||||
'<div class="kanban-lane"><span>' + escapeHtml(lane) + '</span><strong>' + escapeHtml(kanban[lane] || 0) + '</strong></div>'
|
||||
).join('') + '</div>';
|
||||
|
||||
if (!items.length) {
|
||||
$('#work-items').innerHTML = laneHtml + '<div class="empty">No agent work items found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$('#work-items').innerHTML = laneHtml + items.slice(0, 8).map(item => {
|
||||
const branch = item.branch || (item.metadata && item.metadata.branch) || '';
|
||||
const mergeGate = item.mergeGate || (item.metadata && item.metadata.mergeGate) || '';
|
||||
const blocker = item.blocker || (item.metadata && item.metadata.blocker) || '';
|
||||
const owner = item.owner || item.source || 'unassigned';
|
||||
return '<div class="work-item">' +
|
||||
'<div class="row"><strong>' + escapeHtml(item.title || item.id) + '</strong>' + statePill(item.kanbanState || item.status) + '</div>' +
|
||||
'<div class="subtle">' + escapeHtml(owner) + ' - ' + escapeHtml(item.source || 'manual') + (item.priority ? ' - ' + escapeHtml(item.priority) : '') + '</div>' +
|
||||
(branch ? '<div class="subtle">branch: ' + escapeHtml(branch) + '</div>' : '') +
|
||||
(mergeGate ? '<div class="subtle">merge gate: ' + escapeHtml(mergeGate) + '</div>' : '') +
|
||||
(blocker ? '<div class="subtle">blocker: ' + escapeHtml(blocker) + '</div>' : '') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderKnowledge(knowledge) {
|
||||
$('#knowledge-count').textContent = knowledge.entityCount + ' entities';
|
||||
if (!knowledge.results.length) {
|
||||
$('#knowledge').innerHTML = '<div class="empty">No recall results for this query.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$('#knowledge').innerHTML = knowledge.results.map(result => {
|
||||
const entity = result.entity;
|
||||
const obs = result.latestObservation;
|
||||
return '<div class="result">' +
|
||||
'<div class="row"><strong>' + escapeHtml(entity.name) + '</strong><span class="pill good">score ' + escapeHtml(result.score) + '</span></div>' +
|
||||
'<div class="subtle">' + escapeHtml(entity.entityType) + (entity.path ? ' - ' + escapeHtml(entity.path) : '') + '</div>' +
|
||||
'<div>' + escapeHtml(entity.summary || '') + '</div>' +
|
||||
(obs ? '<div class="subtle">' + (result.hasPinnedObservation ? 'Pinned - ' : '') + escapeHtml(obs.summary) + '</div>' : '') +
|
||||
'<div class="subtle">terms: ' + escapeHtml((result.matchedTerms || []).join(', ') || '-') + '</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderConnectors(connectors) {
|
||||
$('#connector-count').textContent = connectors.length + ' configured';
|
||||
if (!connectors.length) {
|
||||
$('#connectors').innerHTML = '<div class="empty">No memory connectors configured.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$('#connectors').innerHTML = connectors.map(connector => {
|
||||
const status = connector.syncedSources > 0 ? '<span class="pill good">synced</span>' : '<span class="pill warn">not synced</span>';
|
||||
return '<div class="connector">' +
|
||||
'<div class="row"><strong>' + escapeHtml(connector.name) + '</strong>' + status + '</div>' +
|
||||
'<div class="subtle">' + escapeHtml(connector.kind) + ' - ' + escapeHtml(connector.path || '-') + '</div>' +
|
||||
'<div class="subtle">sources ' + escapeHtml(connector.syncedSources) + ' - last ' + escapeHtml(connector.lastSyncedAt || '-') + '</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderActions(actions) {
|
||||
$('#actions').innerHTML = actions.map(action => '<div class="action">' +
|
||||
'<div class="row"><strong>' + escapeHtml(action.label) + '</strong>' +
|
||||
(action.executable ? '<button data-action="' + escapeHtml(action.id) + '">Run</button>' : '<span class="pill">copy</span>') + '</div>' +
|
||||
'<div class="subtle">' + escapeHtml(action.description) + '</div>' +
|
||||
'<code>' + escapeHtml(action.commandLine) + '</code>' +
|
||||
'</div>').join('');
|
||||
|
||||
document.querySelectorAll('[data-action]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
runAction(button.dataset.action);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runAction(actionId) {
|
||||
const output = $('#run-output');
|
||||
output.textContent = 'Running ' + actionId + '...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/actions/' + encodeURIComponent(actionId), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ query: state.query })
|
||||
});
|
||||
const payload = await readJsonResponse(response);
|
||||
output.textContent = JSON.stringify(payload, null, 2);
|
||||
await load();
|
||||
} catch (error) {
|
||||
output.textContent = formatError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const url = new URL('/api/snapshot', window.location.href);
|
||||
if (state.query) url.searchParams.set('query', state.query);
|
||||
const response = await fetch(url);
|
||||
const snapshot = await readJsonResponse(response);
|
||||
$('#query').value = snapshot.knowledge.query || state.query;
|
||||
$('#db-path').textContent = snapshot.database.exists ? snapshot.dbPath : 'database missing';
|
||||
$('#action-status').textContent = snapshot.execution.allowActions ? 'local allowlist' : 'read-only';
|
||||
renderMetrics(snapshot.summary);
|
||||
renderSessions(snapshot.sessions);
|
||||
renderWorkItems(snapshot.workItems);
|
||||
renderKnowledge(snapshot.knowledge);
|
||||
renderConnectors(snapshot.connectors);
|
||||
renderActions(snapshot.actions.map(action => ({
|
||||
...action,
|
||||
executable: snapshot.execution.allowActions && action.executable
|
||||
})));
|
||||
clearError('#app');
|
||||
}
|
||||
|
||||
$('#query-form').addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
state.query = $('#query').value.trim();
|
||||
load().catch(error => showError('#app', error));
|
||||
});
|
||||
$('#refresh').addEventListener('click', () => {
|
||||
load().catch(error => showError('#app', error));
|
||||
});
|
||||
load().catch(error => showError('#app', error));
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
renderControlPaneHtml,
|
||||
};
|
||||
@@ -36,6 +36,12 @@ function ensureString(value, fieldPath) {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureStringAllowEmpty(value, fieldPath) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`Canonical session snapshot requires ${fieldPath} to be a string`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureOptionalString(value, fieldPath) {
|
||||
if (value !== null && value !== undefined && typeof value !== 'string') {
|
||||
throw new Error(`Canonical session snapshot requires ${fieldPath} to be a string or null`);
|
||||
@@ -210,7 +216,7 @@ function validateCanonicalSnapshot(snapshot) {
|
||||
throw new Error(`Canonical session snapshot requires workers[${index}].intent to be an object`);
|
||||
}
|
||||
|
||||
ensureString(worker.intent.objective, `workers[${index}].intent.objective`);
|
||||
ensureStringAllowEmpty(worker.intent.objective, `workers[${index}].intent.objective`);
|
||||
ensureArrayOfStrings(worker.intent.seedPaths, `workers[${index}].intent.seedPaths`);
|
||||
|
||||
if (!isObject(worker.outputs)) {
|
||||
@@ -520,12 +526,119 @@ function normalizeClaudeHistorySession(session, sourceTarget) {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeCodexWorktreeSession(session, sourceTarget) {
|
||||
const state = session.active ? 'active' : 'recorded';
|
||||
const objective = typeof session.objective === 'string' ? session.objective : '';
|
||||
const worker = {
|
||||
id: session.sessionId,
|
||||
label: session.sessionId,
|
||||
state,
|
||||
health: 'healthy',
|
||||
branch: session.branch || null,
|
||||
worktree: session.cwd || null,
|
||||
runtime: {
|
||||
kind: 'codex-session',
|
||||
command: 'codex',
|
||||
pid: null,
|
||||
active: Boolean(session.active),
|
||||
dead: !session.active,
|
||||
},
|
||||
intent: {
|
||||
objective,
|
||||
seedPaths: []
|
||||
},
|
||||
outputs: {
|
||||
summary: [],
|
||||
validation: [],
|
||||
remainingRisks: []
|
||||
},
|
||||
artifacts: {
|
||||
sessionFile: session.sessionPath || null,
|
||||
model: session.model || null,
|
||||
originator: session.originator || null,
|
||||
cliVersion: session.cliVersion || null,
|
||||
startedAt: session.startedAt || null,
|
||||
recordCount: Number.isInteger(session.recordCount) ? session.recordCount : null
|
||||
}
|
||||
};
|
||||
|
||||
return validateCanonicalSnapshot({
|
||||
schemaVersion: SESSION_SCHEMA_VERSION,
|
||||
adapterId: 'codex-worktree',
|
||||
session: {
|
||||
id: session.sessionId,
|
||||
kind: 'codex-worktree',
|
||||
state,
|
||||
repoRoot: session.cwd || null,
|
||||
sourceTarget
|
||||
},
|
||||
workers: [worker],
|
||||
aggregates: buildAggregates([worker])
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeOpencodeSession(session, sourceTarget) {
|
||||
const state = session.active ? 'active' : 'recorded';
|
||||
const objective = typeof session.objective === 'string' ? session.objective : '';
|
||||
const worker = {
|
||||
id: session.sessionId,
|
||||
label: session.title || session.sessionId,
|
||||
state,
|
||||
health: 'healthy',
|
||||
branch: session.branch || null,
|
||||
worktree: session.cwd || null,
|
||||
runtime: {
|
||||
kind: 'opencode-session',
|
||||
command: 'opencode',
|
||||
pid: null,
|
||||
active: Boolean(session.active),
|
||||
dead: !session.active,
|
||||
},
|
||||
intent: {
|
||||
objective,
|
||||
seedPaths: []
|
||||
},
|
||||
outputs: {
|
||||
summary: [],
|
||||
validation: [],
|
||||
remainingRisks: []
|
||||
},
|
||||
artifacts: {
|
||||
sessionFile: session.sessionPath || null,
|
||||
projectId: session.projectId || null,
|
||||
version: session.version || null,
|
||||
model: session.model || null,
|
||||
provider: session.provider || null,
|
||||
title: session.title || null,
|
||||
createdAt: session.createdAt || null,
|
||||
updatedAt: session.updatedAt || null,
|
||||
messageCount: Number.isInteger(session.messageCount) ? session.messageCount : null
|
||||
}
|
||||
};
|
||||
|
||||
return validateCanonicalSnapshot({
|
||||
schemaVersion: SESSION_SCHEMA_VERSION,
|
||||
adapterId: 'opencode',
|
||||
session: {
|
||||
id: session.sessionId,
|
||||
kind: 'opencode',
|
||||
state,
|
||||
repoRoot: session.cwd || null,
|
||||
sourceTarget
|
||||
},
|
||||
workers: [worker],
|
||||
aggregates: buildAggregates([worker])
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SESSION_SCHEMA_VERSION,
|
||||
buildAggregates,
|
||||
getFallbackSessionRecordingPath,
|
||||
normalizeClaudeHistorySession,
|
||||
normalizeCodexWorktreeSession,
|
||||
normalizeDmuxSnapshot,
|
||||
normalizeOpencodeSession,
|
||||
persistCanonicalSnapshot,
|
||||
validateCanonicalSnapshot
|
||||
};
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const { normalizeCodexWorktreeSession, persistCanonicalSnapshot } = require('./canonical-session');
|
||||
|
||||
const CODEX_TARGET_PREFIXES = ['codex-worktree:', 'codex:'];
|
||||
const ROLLOUT_PREFIX = 'rollout-';
|
||||
const RECENT_ACTIVITY_THRESHOLD_MS = 5 * 60 * 1000;
|
||||
|
||||
function parseCodexTarget(target) {
|
||||
if (typeof target !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const prefix of CODEX_TARGET_PREFIXES) {
|
||||
if (target.startsWith(prefix)) {
|
||||
return target.slice(prefix.length).trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveSessionsDir(options = {}, context = {}) {
|
||||
const explicit = options.sessionsDir
|
||||
|| context.codexSessionsDir
|
||||
|| process.env.CODEX_SESSIONS_DIR;
|
||||
|
||||
if (typeof explicit === 'string' && explicit.length > 0) {
|
||||
return path.resolve(explicit);
|
||||
}
|
||||
|
||||
return path.join(os.homedir(), '.codex', 'sessions');
|
||||
}
|
||||
|
||||
function isRolloutFile(filePath) {
|
||||
const base = path.basename(filePath);
|
||||
return base.startsWith(ROLLOUT_PREFIX) && base.endsWith('.jsonl');
|
||||
}
|
||||
|
||||
function isCodexRolloutFileTarget(target, cwd) {
|
||||
if (typeof target !== 'string' || target.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const absoluteTarget = path.resolve(cwd, target);
|
||||
return fs.existsSync(absoluteTarget)
|
||||
&& fs.statSync(absoluteTarget).isFile()
|
||||
&& isRolloutFile(absoluteTarget);
|
||||
}
|
||||
|
||||
function listRolloutFiles(sessionsDir) {
|
||||
if (!fs.existsSync(sessionsDir) || !fs.statSync(sessionsDir).isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const stack = [sessionsDir];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(entryPath);
|
||||
} else if (entry.isFile() && isRolloutFile(entryPath)) {
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function findLatestRollout(sessionsDir) {
|
||||
const files = listRolloutFiles(sessionsDir);
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return files
|
||||
.map(filePath => ({ filePath, mtimeMs: fs.statSync(filePath).mtimeMs }))
|
||||
.sort((a, b) => b.mtimeMs - a.mtimeMs)[0].filePath;
|
||||
}
|
||||
|
||||
function findRolloutById(sessionsDir, sessionId) {
|
||||
return listRolloutFiles(sessionsDir)
|
||||
.find(filePath => path.basename(filePath).includes(sessionId)) || null;
|
||||
}
|
||||
|
||||
function resolveRolloutPath(target, cwd, options, context) {
|
||||
const explicitTarget = parseCodexTarget(target);
|
||||
const sessionsDir = resolveSessionsDir(options, context);
|
||||
|
||||
if (explicitTarget) {
|
||||
if (explicitTarget === 'latest') {
|
||||
const latest = findLatestRollout(sessionsDir);
|
||||
if (!latest) {
|
||||
throw new Error('No Codex rollout sessions found');
|
||||
}
|
||||
|
||||
return { rolloutPath: latest, sourceTarget: { type: 'codex-worktree', value: 'latest' } };
|
||||
}
|
||||
|
||||
const absoluteExplicit = path.resolve(cwd, explicitTarget);
|
||||
if (fs.existsSync(absoluteExplicit) && isRolloutFile(absoluteExplicit)) {
|
||||
return { rolloutPath: absoluteExplicit, sourceTarget: { type: 'codex-rollout-file', value: absoluteExplicit } };
|
||||
}
|
||||
|
||||
const byId = findRolloutById(sessionsDir, explicitTarget);
|
||||
if (byId) {
|
||||
return { rolloutPath: byId, sourceTarget: { type: 'codex-worktree', value: explicitTarget } };
|
||||
}
|
||||
|
||||
throw new Error(`Codex rollout session not found: ${explicitTarget}`);
|
||||
}
|
||||
|
||||
if (isCodexRolloutFileTarget(target, cwd)) {
|
||||
const absoluteTarget = path.resolve(cwd, target);
|
||||
return { rolloutPath: absoluteTarget, sourceTarget: { type: 'codex-rollout-file', value: absoluteTarget } };
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported Codex session target: ${target}`);
|
||||
}
|
||||
|
||||
function readJsonLines(filePath) {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const records = [];
|
||||
|
||||
for (const line of raw.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
records.push(JSON.parse(trimmed));
|
||||
} catch {
|
||||
// Rollout logs are append-only; skip partial/corrupt trailing lines.
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
function extractText(content) {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map(part => (part && typeof part.text === 'string' ? part.text : ''))
|
||||
.join('')
|
||||
.trim();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function stripLeadingMessageId(text) {
|
||||
// Codex rollouts sometimes prepend a message UUID directly onto the user
|
||||
// text (e.g. "019e52db-...please continue"). Drop it for a clean objective.
|
||||
return text.replace(/^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}/i, '').trim();
|
||||
}
|
||||
|
||||
function isPreambleText(text) {
|
||||
// The first user record in a Codex rollout is the injected harness preamble
|
||||
// (AGENTS.md / environment context), not the operator's actual objective.
|
||||
return text.startsWith('#')
|
||||
|| text.startsWith('<')
|
||||
|| text.includes('<cwd>')
|
||||
|| text.includes('AGENTS.md instructions');
|
||||
}
|
||||
|
||||
function deriveObjective(records) {
|
||||
for (const record of records) {
|
||||
const payload = record && record.payload;
|
||||
if (!payload || payload.type !== 'message' || payload.role !== 'user') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = stripLeadingMessageId(extractText(payload.content).trim());
|
||||
if (text.length === 0 || isPreambleText(text)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return text.length > 280 ? `${text.slice(0, 277)}...` : text;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function recordTimestampMs(record) {
|
||||
const ts = record && record.timestamp;
|
||||
if (typeof ts !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ms = Date.parse(ts);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
|
||||
function deriveLastActivityMs(records, fallbackPath) {
|
||||
for (let index = records.length - 1; index >= 0; index -= 1) {
|
||||
const ms = recordTimestampMs(records[index]);
|
||||
if (ms !== null) {
|
||||
return ms;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return fs.statSync(fallbackPath).mtimeMs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveModel(meta, records) {
|
||||
for (const record of records) {
|
||||
if (record && record.type === 'turn_context' && record.payload) {
|
||||
if (typeof record.payload.model === 'string' && record.payload.model.length > 0) {
|
||||
return record.payload.model;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (meta && typeof meta.model === 'string' && meta.model.length > 0) {
|
||||
return meta.model;
|
||||
}
|
||||
|
||||
if (meta && typeof meta.model_provider === 'string' && meta.model_provider.length > 0) {
|
||||
return meta.model_provider;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveGitBranch(cwd, resolveBranchImpl) {
|
||||
if (typeof resolveBranchImpl === 'function') {
|
||||
return resolveBranchImpl(cwd);
|
||||
}
|
||||
|
||||
if (typeof cwd !== 'string' || cwd.length === 0 || !fs.existsSync(cwd)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const branch = execFileSync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
encoding: 'utf8'
|
||||
}).trim();
|
||||
|
||||
return branch.length > 0 ? branch : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCodexRollout(rolloutPath, options = {}) {
|
||||
const records = readJsonLines(rolloutPath);
|
||||
const metaRecord = records.find(record => record && record.type === 'session_meta');
|
||||
const meta = (metaRecord && metaRecord.payload) || {};
|
||||
|
||||
const cwd = typeof meta.cwd === 'string' && meta.cwd.length > 0 ? meta.cwd : null;
|
||||
const lastActivityMs = deriveLastActivityMs(records, rolloutPath);
|
||||
const isRecent = lastActivityMs !== null && (Date.now() - lastActivityMs) <= RECENT_ACTIVITY_THRESHOLD_MS;
|
||||
|
||||
return {
|
||||
sessionId: typeof meta.id === 'string' && meta.id.length > 0
|
||||
? meta.id
|
||||
: path.basename(rolloutPath, '.jsonl'),
|
||||
sessionPath: rolloutPath,
|
||||
cwd,
|
||||
branch: resolveGitBranch(cwd, options.resolveBranchImpl),
|
||||
objective: deriveObjective(records),
|
||||
model: deriveModel(meta, records),
|
||||
originator: typeof meta.originator === 'string' ? meta.originator : null,
|
||||
cliVersion: typeof meta.cli_version === 'string' ? meta.cli_version : null,
|
||||
startedAt: typeof meta.timestamp === 'string' ? meta.timestamp : null,
|
||||
recordCount: records.length,
|
||||
active: isRecent
|
||||
};
|
||||
}
|
||||
|
||||
function createCodexWorktreeAdapter(options = {}) {
|
||||
const parseCodexRolloutImpl = options.parseCodexRolloutImpl || parseCodexRollout;
|
||||
const persistCanonicalSnapshotImpl = options.persistCanonicalSnapshotImpl || persistCanonicalSnapshot;
|
||||
|
||||
return {
|
||||
id: 'codex-worktree',
|
||||
description: 'Codex rollout sessions running in git worktrees, normalized to ecc.session.v1',
|
||||
targetTypes: ['codex-worktree', 'codex'],
|
||||
canOpen(target, context = {}) {
|
||||
if (context.adapterId && context.adapterId !== 'codex-worktree') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.adapterId === 'codex-worktree') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cwd = context.cwd || process.cwd();
|
||||
return parseCodexTarget(target) !== null || isCodexRolloutFileTarget(target, cwd);
|
||||
},
|
||||
open(target, context = {}) {
|
||||
const cwd = context.cwd || process.cwd();
|
||||
|
||||
return {
|
||||
adapterId: 'codex-worktree',
|
||||
getSnapshot() {
|
||||
const { rolloutPath, sourceTarget } = resolveRolloutPath(target, cwd, options, context);
|
||||
const session = parseCodexRolloutImpl(rolloutPath, options);
|
||||
const canonicalSnapshot = normalizeCodexWorktreeSession(session, sourceTarget);
|
||||
|
||||
persistCanonicalSnapshotImpl(canonicalSnapshot, {
|
||||
loadStateStoreImpl: options.loadStateStoreImpl,
|
||||
persist: context.persistSnapshots !== false && options.persistSnapshots !== false,
|
||||
recordingDir: context.recordingDir || options.recordingDir,
|
||||
stateStore: options.stateStore
|
||||
});
|
||||
|
||||
return canonicalSnapshot;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createCodexWorktreeAdapter,
|
||||
parseCodexTarget,
|
||||
parseCodexRollout,
|
||||
isCodexRolloutFileTarget,
|
||||
findLatestRollout,
|
||||
findRolloutById
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const { normalizeOpencodeSession, persistCanonicalSnapshot } = require('./canonical-session');
|
||||
|
||||
const OPENCODE_TARGET_PREFIXES = ['opencode:'];
|
||||
const RECENT_ACTIVITY_THRESHOLD_MS = 5 * 60 * 1000;
|
||||
const MAX_MESSAGE_SCAN = 40;
|
||||
|
||||
function parseOpencodeTarget(target) {
|
||||
if (typeof target !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const prefix of OPENCODE_TARGET_PREFIXES) {
|
||||
if (target.startsWith(prefix)) {
|
||||
return target.slice(prefix.length).trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveStorageDir(options = {}, context = {}) {
|
||||
const explicit = options.storageDir
|
||||
|| context.opencodeStorageDir
|
||||
|| process.env.OPENCODE_STORAGE_DIR;
|
||||
|
||||
if (typeof explicit === 'string' && explicit.length > 0) {
|
||||
return path.resolve(explicit);
|
||||
}
|
||||
|
||||
return path.join(os.homedir(), '.local', 'share', 'opencode', 'storage');
|
||||
}
|
||||
|
||||
function isSessionInfoFile(filePath) {
|
||||
const base = path.basename(filePath);
|
||||
return base.startsWith('ses_') && base.endsWith('.json');
|
||||
}
|
||||
|
||||
function isOpencodeSessionFileTarget(target, cwd) {
|
||||
if (typeof target !== 'string' || target.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const absoluteTarget = path.resolve(cwd, target);
|
||||
return fs.existsSync(absoluteTarget)
|
||||
&& fs.statSync(absoluteTarget).isFile()
|
||||
&& isSessionInfoFile(absoluteTarget)
|
||||
&& `${path.sep}session${path.sep}`.length > 0
|
||||
&& absoluteTarget.includes(`${path.sep}session${path.sep}`);
|
||||
}
|
||||
|
||||
function listSessionInfoFiles(storageDir) {
|
||||
const sessionDir = path.join(storageDir, 'session');
|
||||
if (!fs.existsSync(sessionDir) || !fs.statSync(sessionDir).isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const stack = [sessionDir];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(entryPath);
|
||||
} else if (entry.isFile() && isSessionInfoFile(entryPath)) {
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function readSessionUpdatedMs(filePath) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
if (info && info.time && Number.isFinite(info.time.updated)) {
|
||||
return info.time.updated;
|
||||
}
|
||||
} catch {
|
||||
// fall through to file mtime
|
||||
}
|
||||
|
||||
try {
|
||||
return fs.statSync(filePath).mtimeMs;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function findLatestSessionInfo(storageDir) {
|
||||
const files = listSessionInfoFiles(storageDir);
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return files
|
||||
.map(filePath => ({ filePath, updatedMs: readSessionUpdatedMs(filePath) }))
|
||||
.sort((a, b) => b.updatedMs - a.updatedMs)[0].filePath;
|
||||
}
|
||||
|
||||
function findSessionInfoById(storageDir, sessionId) {
|
||||
return listSessionInfoFiles(storageDir)
|
||||
.find(filePath => path.basename(filePath, '.json') === sessionId) || null;
|
||||
}
|
||||
|
||||
function resolveSessionInfoPath(target, cwd, options, context) {
|
||||
const explicitTarget = parseOpencodeTarget(target);
|
||||
const storageDir = resolveStorageDir(options, context);
|
||||
|
||||
if (explicitTarget) {
|
||||
if (explicitTarget === 'latest') {
|
||||
const latest = findLatestSessionInfo(storageDir);
|
||||
if (!latest) {
|
||||
throw new Error('No OpenCode sessions found');
|
||||
}
|
||||
|
||||
return { sessionInfoPath: latest, sourceTarget: { type: 'opencode', value: 'latest' } };
|
||||
}
|
||||
|
||||
const absoluteExplicit = path.resolve(cwd, explicitTarget);
|
||||
if (fs.existsSync(absoluteExplicit) && isSessionInfoFile(absoluteExplicit)) {
|
||||
return { sessionInfoPath: absoluteExplicit, sourceTarget: { type: 'opencode-session-file', value: absoluteExplicit } };
|
||||
}
|
||||
|
||||
const byId = findSessionInfoById(storageDir, explicitTarget);
|
||||
if (byId) {
|
||||
return { sessionInfoPath: byId, sourceTarget: { type: 'opencode', value: explicitTarget } };
|
||||
}
|
||||
|
||||
throw new Error(`OpenCode session not found: ${explicitTarget}`);
|
||||
}
|
||||
|
||||
if (isOpencodeSessionFileTarget(target, cwd)) {
|
||||
const absoluteTarget = path.resolve(cwd, target);
|
||||
return { sessionInfoPath: absoluteTarget, sourceTarget: { type: 'opencode-session-file', value: absoluteTarget } };
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported OpenCode session target: ${target}`);
|
||||
}
|
||||
|
||||
function readMessageFiles(messageDir) {
|
||||
if (!fs.existsSync(messageDir) || !fs.statSync(messageDir).isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return fs.readdirSync(messageDir)
|
||||
.filter(name => name.startsWith('msg_') && name.endsWith('.json'))
|
||||
.map(name => path.join(messageDir, name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function deriveModelFromMessages(messageFiles) {
|
||||
for (const filePath of messageFiles.slice(0, MAX_MESSAGE_SCAN)) {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message && message.role === 'assistant' && typeof message.modelID === 'string' && message.modelID.length > 0) {
|
||||
return {
|
||||
model: message.modelID,
|
||||
provider: typeof message.providerID === 'string' ? message.providerID : null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { model: null, provider: null };
|
||||
}
|
||||
|
||||
function deriveObjective(title) {
|
||||
if (typeof title !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const trimmed = title.trim();
|
||||
// OpenCode seeds an auto title ("New session - <ISO date>") until the model
|
||||
// renames it; treat that as no objective rather than noise.
|
||||
if (trimmed.length === 0 || /^New session\b/i.test(trimmed)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trimmed.length > 280 ? `${trimmed.slice(0, 277)}...` : trimmed;
|
||||
}
|
||||
|
||||
function resolveGitBranch(cwd, resolveBranchImpl) {
|
||||
if (typeof resolveBranchImpl === 'function') {
|
||||
return resolveBranchImpl(cwd);
|
||||
}
|
||||
|
||||
if (typeof cwd !== 'string' || cwd.length === 0 || !fs.existsSync(cwd)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const branch = execFileSync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
encoding: 'utf8'
|
||||
}).trim();
|
||||
|
||||
return branch.length > 0 ? branch : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseOpencodeSession(sessionInfoPath, options = {}) {
|
||||
const storageDir = options.storageDir
|
||||
? path.resolve(options.storageDir)
|
||||
: path.resolve(path.dirname(sessionInfoPath), '..', '..');
|
||||
const info = JSON.parse(fs.readFileSync(sessionInfoPath, 'utf8'));
|
||||
|
||||
const sessionId = typeof info.id === 'string' && info.id.length > 0
|
||||
? info.id
|
||||
: path.basename(sessionInfoPath, '.json');
|
||||
const directory = typeof info.directory === 'string' && info.directory.length > 0 ? info.directory : null;
|
||||
const updatedMs = info.time && Number.isFinite(info.time.updated) ? info.time.updated : null;
|
||||
const createdMs = info.time && Number.isFinite(info.time.created) ? info.time.created : null;
|
||||
|
||||
const messageFiles = readMessageFiles(path.join(storageDir, 'message', sessionId));
|
||||
const { model, provider } = deriveModelFromMessages(messageFiles);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
sessionPath: sessionInfoPath,
|
||||
cwd: directory,
|
||||
branch: resolveGitBranch(directory, options.resolveBranchImpl),
|
||||
objective: deriveObjective(info.title),
|
||||
title: typeof info.title === 'string' ? info.title : null,
|
||||
model,
|
||||
provider,
|
||||
version: typeof info.version === 'string' ? info.version : null,
|
||||
projectId: typeof info.projectID === 'string' ? info.projectID : null,
|
||||
createdAt: createdMs !== null ? new Date(createdMs).toISOString() : null,
|
||||
updatedAt: updatedMs !== null ? new Date(updatedMs).toISOString() : null,
|
||||
messageCount: messageFiles.length,
|
||||
active: updatedMs !== null && (Date.now() - updatedMs) <= RECENT_ACTIVITY_THRESHOLD_MS
|
||||
};
|
||||
}
|
||||
|
||||
function createOpencodeAdapter(options = {}) {
|
||||
const parseOpencodeSessionImpl = options.parseOpencodeSessionImpl || parseOpencodeSession;
|
||||
const persistCanonicalSnapshotImpl = options.persistCanonicalSnapshotImpl || persistCanonicalSnapshot;
|
||||
|
||||
return {
|
||||
id: 'opencode',
|
||||
description: 'OpenCode sessions normalized to ecc.session.v1',
|
||||
targetTypes: ['opencode'],
|
||||
canOpen(target, context = {}) {
|
||||
if (context.adapterId && context.adapterId !== 'opencode') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.adapterId === 'opencode') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cwd = context.cwd || process.cwd();
|
||||
return parseOpencodeTarget(target) !== null || isOpencodeSessionFileTarget(target, cwd);
|
||||
},
|
||||
open(target, context = {}) {
|
||||
const cwd = context.cwd || process.cwd();
|
||||
|
||||
return {
|
||||
adapterId: 'opencode',
|
||||
getSnapshot() {
|
||||
const { sessionInfoPath, sourceTarget } = resolveSessionInfoPath(target, cwd, options, context);
|
||||
const session = parseOpencodeSessionImpl(sessionInfoPath, options);
|
||||
const canonicalSnapshot = normalizeOpencodeSession(session, sourceTarget);
|
||||
|
||||
persistCanonicalSnapshotImpl(canonicalSnapshot, {
|
||||
loadStateStoreImpl: options.loadStateStoreImpl,
|
||||
persist: context.persistSnapshots !== false && options.persistSnapshots !== false,
|
||||
recordingDir: context.recordingDir || options.recordingDir,
|
||||
stateStore: options.stateStore
|
||||
});
|
||||
|
||||
return canonicalSnapshot;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createOpencodeAdapter,
|
||||
parseOpencodeTarget,
|
||||
parseOpencodeSession,
|
||||
isOpencodeSessionFileTarget,
|
||||
findLatestSessionInfo,
|
||||
findSessionInfoById
|
||||
};
|
||||
@@ -2,13 +2,18 @@
|
||||
|
||||
const { createClaudeHistoryAdapter } = require('./claude-history');
|
||||
const { createDmuxTmuxAdapter } = require('./dmux-tmux');
|
||||
const { createCodexWorktreeAdapter } = require('./codex-worktree');
|
||||
const { createOpencodeAdapter } = require('./opencode');
|
||||
|
||||
const TARGET_TYPE_TO_ADAPTER_ID = Object.freeze({
|
||||
plan: 'dmux-tmux',
|
||||
session: 'dmux-tmux',
|
||||
'claude-history': 'claude-history',
|
||||
'claude-alias': 'claude-history',
|
||||
'session-file': 'claude-history'
|
||||
'session-file': 'claude-history',
|
||||
'codex-worktree': 'codex-worktree',
|
||||
codex: 'codex-worktree',
|
||||
opencode: 'opencode'
|
||||
});
|
||||
|
||||
function buildDefaultAdapterOptions(options, adapterId) {
|
||||
@@ -30,7 +35,9 @@ function buildDefaultAdapterOptions(options, adapterId) {
|
||||
function createDefaultAdapters(options = {}) {
|
||||
return [
|
||||
createClaudeHistoryAdapter(buildDefaultAdapterOptions(options, 'claude-history')),
|
||||
createDmuxTmuxAdapter(buildDefaultAdapterOptions(options, 'dmux-tmux'))
|
||||
createDmuxTmuxAdapter(buildDefaultAdapterOptions(options, 'dmux-tmux')),
|
||||
createCodexWorktreeAdapter(buildDefaultAdapterOptions(options, 'codex-worktree')),
|
||||
createOpencodeAdapter(buildDefaultAdapterOptions(options, 'opencode'))
|
||||
];
|
||||
}
|
||||
|
||||
@@ -69,6 +76,20 @@ function normalizeStructuredTarget(target, context = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'codex-worktree' || type === 'codex') {
|
||||
return {
|
||||
target: `codex:${value}`,
|
||||
context: nextContext
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'opencode') {
|
||||
return {
|
||||
target: `opencode:${value}`,
|
||||
context: nextContext
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
target: value,
|
||||
context: nextContext
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
name: dynamic-workflow-mode
|
||||
description: "Design task-local harnesses, eval gates, and reusable skill extraction for Claude dynamic workflow mode and other adaptive agent harnesses."
|
||||
origin: ECC
|
||||
---
|
||||
|
||||
# Dynamic Workflow Mode
|
||||
|
||||
Use this skill when a coding agent can generate or adapt a task-local harness instead of only following a static command flow. The goal is to turn dynamic workflow mode into a disciplined system: temporary harnesses for one-off work, shared skill extraction for repeated work, and observable control pane checkpoints for teams.
|
||||
|
||||
## When To Activate
|
||||
|
||||
- The user mentions dynamic workflows, custom harnesses, harness-per-task, adaptive workflows, or Claude Code dynamic workflow mode.
|
||||
- A task needs a custom loop, evaluator, crawler, fixture generator, watcher, or local dashboard.
|
||||
- Multiple agents need the same repeatable process but the process is not yet captured as a shared skill.
|
||||
- A workflow needs durable handoff artifacts, eval evidence, or operator approval before merge.
|
||||
|
||||
## Core Contract
|
||||
|
||||
Dynamic workflow mode should produce a task-local harness only when the harness is cheaper and safer than manually driving the same steps. The harness must have:
|
||||
|
||||
- **Objective**: the outcome it owns and the outcome it explicitly does not own.
|
||||
- **Inputs**: files, URLs, prompts, data sources, credentials policy, and user-provided constraints.
|
||||
- **Outputs**: commits, reports, screenshots, status files, or control pane snapshots.
|
||||
- **Eval**: at least one pass/fail check tied to the task, not only "it ran".
|
||||
- **Handoff**: a short artifact that tells the next operator what happened, what is blocked, and how to resume.
|
||||
|
||||
## Dynamic Harness Decision Tree
|
||||
|
||||
1. **One-shot task**: keep it inline. Do not invent a harness.
|
||||
2. **Repeated task with changing inputs**: create a task-local harness and keep it under a temp or project-local working area.
|
||||
3. **Repeated task across teammates or repos**: extract the pattern into a shared skill.
|
||||
4. **Task with external state, queueing, or approvals**: add control pane visibility before adding more automation.
|
||||
5. **Task with safety risk**: add an eval gate and a human merge gate before autonomous execution.
|
||||
|
||||
## Task-Local Harness Template
|
||||
|
||||
Use this structure before writing code:
|
||||
|
||||
```markdown
|
||||
# Dynamic Workflow Harness
|
||||
|
||||
Objective:
|
||||
- Ship:
|
||||
- Do not ship:
|
||||
|
||||
Inputs:
|
||||
- Repo or workspace:
|
||||
- External systems:
|
||||
- Credentials policy:
|
||||
|
||||
Loop:
|
||||
1. Discover current state.
|
||||
2. Generate or update the smallest useful artifact.
|
||||
3. Run eval checks.
|
||||
4. Record status and handoff.
|
||||
5. Stop on failed gate, unclear ownership, or unsafe external action.
|
||||
|
||||
Eval:
|
||||
- Command:
|
||||
- Expected pass signal:
|
||||
- Failure owner:
|
||||
|
||||
Handoff:
|
||||
- Status:
|
||||
- Evidence:
|
||||
- Next action:
|
||||
```
|
||||
|
||||
## Shared Skill Extraction
|
||||
|
||||
Promote a task-local harness into a shared skill only when at least two of these are true:
|
||||
|
||||
- The same workflow appears in multiple sessions, repos, teams, or launches.
|
||||
- The workflow needs specific language, tool, or safety sequencing.
|
||||
- Failures repeat because operators skip a gate or lose context.
|
||||
- The workflow has a stable input/output contract.
|
||||
- The workflow benefits from a control pane, status board, or team handoff.
|
||||
|
||||
When extracting, write the skill first in `skills/<name>/SKILL.md`. Add command shims only if a legacy slash-entry surface is still required.
|
||||
|
||||
## Control Pane Checkpoints
|
||||
|
||||
Dynamic workflow mode becomes team-usable when it exposes state. Record these checkpoints whenever the task spans more than one session:
|
||||
|
||||
- **Plan**: objective, owner, acceptance criteria, and risky external systems.
|
||||
- **Queue**: work items, assigned agent role, branch/worktree, and dependency edges.
|
||||
- **Run**: active harness, current loop step, recent eval result, and token/cost signal if available.
|
||||
- **Gate**: test results, browser screenshots, security review, and merge readiness.
|
||||
- **Handoff**: what is done, what failed, what needs a human decision.
|
||||
|
||||
If the repo has ECC2 state enabled, prefer adding or reading checkpoints through the ECC control pane or state-store-backed scripts instead of scattering untracked notes.
|
||||
|
||||
## Eval Gates
|
||||
|
||||
Every dynamic harness needs a task-specific eval. Pick the cheapest reliable gate:
|
||||
|
||||
| Work Type | Eval Gate |
|
||||
| --- | --- |
|
||||
| Code feature | Focused test, lint, coverage, and one integration path |
|
||||
| UI/control pane | Browser smoke with screenshot and overflow/error checks |
|
||||
| Agent workflow | Fixture transcript or seeded work item with expected routing |
|
||||
| Research/content | Source-neutral brief, claim checklist, and publish-ready outline |
|
||||
| Integration | Dry-run command, config validation, and no-secret scan |
|
||||
|
||||
Do not claim a dynamic workflow is reusable until the eval can be rerun by another teammate.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Generating scripts that hide the real decision logic from the operator.
|
||||
- Treating dynamic workflow mode as permission to skip tests.
|
||||
- Creating one-off docs when a shared skill or status artifact is the real product.
|
||||
- Running multiple agents without ownership, merge gate, or conflict policy.
|
||||
- Letting raw private research data leak into public docs.
|
||||
|
||||
## Output Standard
|
||||
|
||||
Finish with:
|
||||
|
||||
- The harness or skill path.
|
||||
- The eval commands and results.
|
||||
- The control pane or handoff artifact path.
|
||||
- The next reusable extraction candidate.
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: team-agent-orchestration
|
||||
description: "Run team-based orchestration for agent squads using work items, ownership, agent Kanban, merge gates, and control pane handoffs."
|
||||
origin: ECC
|
||||
---
|
||||
|
||||
# Team Agent Orchestration
|
||||
|
||||
Use this skill when agents are being managed like a team rather than a single assistant. The purpose is to make team-based orchestration reliable: clear work items, explicit ownership, agent Kanban state, branch isolation, control pane visibility, and merge gates.
|
||||
|
||||
## When To Activate
|
||||
|
||||
- The task spans multiple agents, tools, harnesses, branches, or worktrees.
|
||||
- The user mentions team orchestration, agent Kanban, squad, conductor, control pane, manager, desktop app, Zellij, tmux, Hermes, Devin, Codex, Claude Code, or multi-agent work.
|
||||
- A project needs shared workflow state across people and agents.
|
||||
- Existing agent fan-out is producing output but not mergeable product.
|
||||
|
||||
## Operating Model
|
||||
|
||||
Treat every agent as a teammate with a narrow contract:
|
||||
|
||||
- **Owner**: the person or agent accountable for the work item.
|
||||
- **Scope**: files, branch, tool surface, and forbidden areas.
|
||||
- **State**: backlog, ready, running, review, blocked, merged, or archived.
|
||||
- **Evidence**: tests, screenshots, logs, review notes, or eval reports.
|
||||
- **Merge gate**: the exact condition that allows integration.
|
||||
|
||||
## Agent Kanban
|
||||
|
||||
Use agent Kanban when work must be visible across sessions.
|
||||
|
||||
| Column | Meaning | Exit Criteria |
|
||||
| --- | --- | --- |
|
||||
| Backlog | Candidate work item, not yet shaped | Acceptance criteria written |
|
||||
| Ready | Shaped and assignable | Owner and branch/worktree assigned |
|
||||
| Running | Agent is actively working | Handoff artifact and changed files exist |
|
||||
| Review | Work is complete but not merged | Tests, diff review, and risk check pass |
|
||||
| Blocked | Needs external input or failed gate | Blocker has owner and next action |
|
||||
| Merged | Integrated into mainline | PR merged or local main updated |
|
||||
| Archived | No longer relevant | Reason recorded |
|
||||
|
||||
Each card should fit this schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "agent-card-001",
|
||||
"title": "Build dynamic workflow skill",
|
||||
"owner": "codex",
|
||||
"state": "running",
|
||||
"branch": "product/dynamic-workflow-team-orchestration",
|
||||
"worktree": ".",
|
||||
"acceptance": [
|
||||
"Skill exists",
|
||||
"Tests cover required concepts",
|
||||
"Content artifact contains video and article angles"
|
||||
],
|
||||
"merge_gate": "lint, focused tests, and catalog check pass",
|
||||
"handoff": "path/to/handoff.md"
|
||||
}
|
||||
```
|
||||
|
||||
## Team-Based Orchestration Flow
|
||||
|
||||
1. **Shape the board**: convert fuzzy ambition into work items with owners and merge gates.
|
||||
2. **Pick execution mode**: single-agent, dynamic workflow mode, dmux/tmux, worktree fan-out, or external desktop orchestrator.
|
||||
3. **Assign boundaries**: one owner per card, clear file scope, and no overlapping writes without an integrator.
|
||||
4. **Run agents**: each agent writes evidence and handoff notes, not just code.
|
||||
5. **Review in sequence**: tests first, then diff review, then security/risk checks, then content/product polish.
|
||||
6. **Merge deliberately**: one integrator resolves conflicts and updates the control pane or status artifact.
|
||||
7. **Extract reusable skill**: if the card pattern repeats, promote it into `skills/`.
|
||||
|
||||
## Control Pane Requirements
|
||||
|
||||
A useful control pane for team orchestration should show:
|
||||
|
||||
- Active work items and their agent Kanban state.
|
||||
- Owner, harness, branch, worktree, and last heartbeat.
|
||||
- Links to handoff artifacts, tests, screenshots, and PRs.
|
||||
- Blockers grouped by owner and unblock action.
|
||||
- Merge readiness by gate, not vibes.
|
||||
- Reusable workflow candidates that should become shared skills.
|
||||
|
||||
Do not add more automation until the operator can answer: who owns this, what changed, what gate failed, and what can safely merge?
|
||||
|
||||
## Dynamic Workflow Compatibility
|
||||
|
||||
When a card needs dynamic workflow mode:
|
||||
|
||||
- Put the task-local harness under the card owner.
|
||||
- Store inputs and outputs on the card.
|
||||
- Require an eval before moving from Running to Review.
|
||||
- Promote the harness to a shared skill only after repeat use.
|
||||
|
||||
## Failure Modes To Watch
|
||||
|
||||
- **Agent soup**: many agents running, no owner or merge gate.
|
||||
- **Invisible work**: useful output exists only in a chat transcript.
|
||||
- **Board theater**: a Kanban board exists but cards have no acceptance criteria.
|
||||
- **Overlapping writes**: parallel agents edit the same files without worktrees.
|
||||
- **No product artifact**: the process produces docs but no runnable or publishable surface.
|
||||
|
||||
## Output Standard
|
||||
|
||||
Finish each orchestration pass with:
|
||||
|
||||
- Board/card changes.
|
||||
- Merged or pending branches.
|
||||
- Tests and eval evidence.
|
||||
- Blockers with owner and next action.
|
||||
- New shared skill candidates.
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Validate the dynamic workflow and team-orchestration public surface.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
|
||||
const SURFACES = [
|
||||
{
|
||||
path: 'skills/dynamic-workflow-mode/SKILL.md',
|
||||
required: [
|
||||
'dynamic workflow mode',
|
||||
'task-local harness',
|
||||
'shared skill',
|
||||
'eval',
|
||||
'control pane',
|
||||
'handoff'
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'skills/team-agent-orchestration/SKILL.md',
|
||||
required: [
|
||||
'team-based orchestration',
|
||||
'agent kanban',
|
||||
'work item',
|
||||
'ownership',
|
||||
'merge gate',
|
||||
'control pane'
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'docs/business/team-agent-orchestration-content-pack.md',
|
||||
required: [
|
||||
'Video Concepts',
|
||||
'Article Angles',
|
||||
'agent kanban',
|
||||
'team orchestration',
|
||||
'dynamic workflows',
|
||||
'distribution'
|
||||
],
|
||||
forbidden: [
|
||||
'https://x.com/',
|
||||
'http://x.com/',
|
||||
'twitter.com/'
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function readSurface(relativePath) {
|
||||
const absolutePath = path.join(REPO_ROOT, relativePath);
|
||||
assert.ok(fs.existsSync(absolutePath), `${relativePath} is missing`);
|
||||
return fs.readFileSync(absolutePath, 'utf8');
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` PASS ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing dynamic workflow team surface ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const surface of SURFACES) {
|
||||
if (test(`${surface.path} exists and carries required concepts`, () => {
|
||||
const content = readSurface(surface.path);
|
||||
const normalized = content.toLowerCase();
|
||||
|
||||
for (const term of surface.required) {
|
||||
assert.ok(
|
||||
normalized.includes(term.toLowerCase()),
|
||||
`${surface.path} is missing required concept: ${term}`
|
||||
);
|
||||
}
|
||||
|
||||
for (const forbidden of surface.forbidden || []) {
|
||||
assert.ok(
|
||||
!normalized.includes(forbidden.toLowerCase()),
|
||||
`${surface.path} must not expose private bookmark source URLs: ${forbidden}`
|
||||
);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,130 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passed++;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
console.log('\n=== Testing ECC platform value loop docs ===\n');
|
||||
|
||||
test('platform value loop doc defines the three-layer ECC 2.0 direction', () => {
|
||||
const source = read('docs/architecture/platform-value-loop.md');
|
||||
|
||||
for (const marker of [
|
||||
'Meta-harness',
|
||||
'Dedicated ECC agent',
|
||||
'Control pane / agentic IDE',
|
||||
'reproducible demo',
|
||||
'ECC can be used full-stack as a meta-harness + agent + control pane',
|
||||
]) {
|
||||
assert.ok(source.includes(marker), `platform value loop doc missing ${marker}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('platform value loop doc records the OSS-to-managed value thesis', () => {
|
||||
const source = read('docs/architecture/platform-value-loop.md');
|
||||
|
||||
for (const marker of [
|
||||
'open-source infrastructure playbook',
|
||||
'team memory and session routing',
|
||||
'managed evals, release gates, and evidence packs',
|
||||
'security review, supply-chain findings, and policy enforcement',
|
||||
'sponsors',
|
||||
'Pro interest',
|
||||
'consulting leads',
|
||||
]) {
|
||||
assert.ok(source.includes(marker), `platform value loop doc missing value marker ${marker}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('product integration contract keeps external products useful but separate', () => {
|
||||
const source = read('docs/architecture/platform-value-loop.md');
|
||||
|
||||
for (const marker of [
|
||||
'Skill pack',
|
||||
'Gated API',
|
||||
'Fixtures and docs',
|
||||
'Eval and risk gates',
|
||||
'Case study',
|
||||
'a public workflow that works without private credentials',
|
||||
'a separate gated path for live product data or actions',
|
||||
'a clear business boundary so billing and ownership are not blurred',
|
||||
]) {
|
||||
assert.ok(source.includes(marker), `platform value loop doc missing contract marker ${marker}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Ito example preserves non-advisory and gated-access boundaries', () => {
|
||||
const source = read('docs/architecture/platform-value-loop.md');
|
||||
|
||||
for (const marker of [
|
||||
'Ito is a separate prediction-market basket product',
|
||||
'visualize market/concept relationships and backtesting outputs',
|
||||
'ITO_API_KEY',
|
||||
'do not place trades',
|
||||
'do not provide investment advice',
|
||||
'do not merge ECC Tools billing with Ito billing',
|
||||
]) {
|
||||
assert.ok(source.includes(marker), `platform value loop doc missing Ito boundary ${marker}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('release docs link the platform value loop into the rc surface', () => {
|
||||
const crossHarness = read('docs/architecture/cross-harness.md');
|
||||
const previewManifest = read('docs/releases/2.0.0-rc.1/preview-pack-manifest.md');
|
||||
const itoPack = read('docs/releases/2.0.0-rc.1/ito-prediction-market-skill-pack.md');
|
||||
const hypergrowth = read('docs/releases/2.0.0/ecc-2-hypergrowth-release-command-center.md');
|
||||
|
||||
for (const source of [crossHarness, previewManifest, itoPack, hypergrowth]) {
|
||||
assert.ok(
|
||||
source.includes('platform-value-loop.md'),
|
||||
'expected release/cross-harness surface to link platform-value-loop.md'
|
||||
);
|
||||
}
|
||||
|
||||
assert.ok(previewManifest.includes('Product integration and full-stack platform thesis'));
|
||||
assert.ok(hypergrowth.includes('Product integrations should behave like repeatable distribution loops'));
|
||||
});
|
||||
|
||||
test('platform value loop does not overclaim release status or trading ability', () => {
|
||||
const source = read('docs/architecture/platform-value-loop.md');
|
||||
const forbidden = [
|
||||
'ORCA/CONDUCTOR-grade parity is live',
|
||||
'control pane is GA',
|
||||
'native-payments readiness is live',
|
||||
'official plugin-directory listing is live',
|
||||
'public ECC skills place trades',
|
||||
];
|
||||
|
||||
for (const phrase of forbidden) {
|
||||
assert.ok(!source.includes(phrase), `platform value loop should not include overclaim: ${phrase}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (failed > 0) {
|
||||
console.log(`\nFailed: ${failed}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Tests for allowlisted ECC2 control-pane actions.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
buildControlPaneActions,
|
||||
buildControlPaneAction,
|
||||
shellQuote,
|
||||
} = require('../../scripts/lib/control-pane/actions');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` PASS ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing control-pane actions ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('builds copyable and executable allowlisted ECC2 actions', () => {
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
const actions = buildControlPaneActions({
|
||||
repoRoot,
|
||||
query: 'Hermes Desktop Zellij',
|
||||
limit: 25,
|
||||
});
|
||||
|
||||
assert.ok(actions.some(action => action.id === 'sync-knowledge'));
|
||||
assert.ok(actions.some(action => action.id === 'recall-knowledge'));
|
||||
assert.ok(actions.some(action => action.id === 'open-dashboard'));
|
||||
|
||||
const sync = actions.find(action => action.id === 'sync-knowledge');
|
||||
assert.strictEqual(sync.executable, true);
|
||||
assert.strictEqual(sync.command, 'cargo');
|
||||
assert.deepStrictEqual(sync.args, [
|
||||
'run',
|
||||
'--quiet',
|
||||
'--',
|
||||
'graph',
|
||||
'connector-sync',
|
||||
'--all',
|
||||
'--json',
|
||||
'--limit',
|
||||
'25',
|
||||
]);
|
||||
assert.strictEqual(sync.cwd, path.join(repoRoot, 'ecc2'));
|
||||
assert.ok(sync.commandLine.includes('connector-sync'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('preserves recall query as a single argument instead of shell text', () => {
|
||||
const action = buildControlPaneAction('recall-knowledge', {
|
||||
repoRoot: '/repo/ecc',
|
||||
query: 'Hermes "Desktop"; rm -rf ~',
|
||||
limit: 7,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(action.args, [
|
||||
'run',
|
||||
'--quiet',
|
||||
'--',
|
||||
'graph',
|
||||
'recall',
|
||||
'Hermes "Desktop"; rm -rf ~',
|
||||
'--json',
|
||||
'--limit',
|
||||
'7',
|
||||
]);
|
||||
assert.ok(action.commandLine.includes("'Hermes \"Desktop\"; rm -rf ~'"));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects unknown action identifiers', () => {
|
||||
assert.throws(
|
||||
() => buildControlPaneAction('rm -rf', { repoRoot: '/repo/ecc' }),
|
||||
/Unknown control-pane action/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('shellQuote handles empty strings and single quotes', () => {
|
||||
assert.strictEqual(shellQuote(''), "''");
|
||||
assert.strictEqual(shellQuote("can't"), "'can'\\''t'");
|
||||
assert.strictEqual(shellQuote('simple'), 'simple');
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,811 @@
|
||||
/**
|
||||
* Tests for the local ECC2 control-pane state projection.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const initSqlJs = require('sql.js');
|
||||
|
||||
const {
|
||||
buildControlPaneSnapshot,
|
||||
recallKnowledgeEntries,
|
||||
resolveControlPaneConfig,
|
||||
} = require('../../scripts/lib/control-pane/state');
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` PASS ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSampleEcc2Database(dbPath) {
|
||||
const SQL = await initSqlJs();
|
||||
const db = new SQL.Database();
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
task TEXT NOT NULL,
|
||||
project TEXT NOT NULL DEFAULT '',
|
||||
task_group TEXT NOT NULL DEFAULT '',
|
||||
agent_type TEXT NOT NULL,
|
||||
harness TEXT NOT NULL DEFAULT 'unknown',
|
||||
detected_harnesses_json TEXT NOT NULL DEFAULT '[]',
|
||||
working_dir TEXT NOT NULL DEFAULT '.',
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
pid INTEGER,
|
||||
worktree_path TEXT,
|
||||
worktree_branch TEXT,
|
||||
worktree_base TEXT,
|
||||
input_tokens INTEGER DEFAULT 0,
|
||||
output_tokens INTEGER DEFAULT 0,
|
||||
tokens_used INTEGER DEFAULT 0,
|
||||
tool_calls INTEGER DEFAULT 0,
|
||||
files_changed INTEGER DEFAULT 0,
|
||||
duration_secs INTEGER DEFAULT 0,
|
||||
cost_usd REAL DEFAULT 0.0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_heartbeat_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_session TEXT NOT NULL,
|
||||
to_session TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
msg_type TEXT NOT NULL DEFAULT 'info',
|
||||
read INTEGER DEFAULT 0,
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE context_graph_entities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT,
|
||||
entity_key TEXT NOT NULL UNIQUE,
|
||||
entity_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE context_graph_observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT,
|
||||
entity_id INTEGER NOT NULL,
|
||||
observation_type TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 1,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
summary TEXT NOT NULL,
|
||||
details_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE context_graph_relations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT,
|
||||
from_entity_id INTEGER NOT NULL,
|
||||
to_entity_id INTEGER NOT NULL,
|
||||
relation_type TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE context_graph_connector_checkpoints (
|
||||
connector_name TEXT NOT NULL,
|
||||
source_path TEXT NOT NULL,
|
||||
source_signature TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (connector_name, source_path)
|
||||
);
|
||||
`);
|
||||
|
||||
const insertSession = db.prepare(`
|
||||
INSERT INTO sessions (
|
||||
id, task, project, task_group, agent_type, harness, detected_harnesses_json,
|
||||
working_dir, state, pid, worktree_path, worktree_branch, worktree_base,
|
||||
input_tokens, output_tokens, tokens_used, tool_calls, files_changed,
|
||||
duration_secs, cost_usd, created_at, updated_at, last_heartbeat_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
insertSession.run([
|
||||
'lead-hermes',
|
||||
'Coordinate Hermes desktop and ECC release work',
|
||||
'ECC',
|
||||
'2.0-control-pane',
|
||||
'claude',
|
||||
'claude',
|
||||
JSON.stringify(['claude', 'codex']),
|
||||
'/repo/ecc',
|
||||
'running',
|
||||
4242,
|
||||
'/tmp/ecc-worktrees/hermes',
|
||||
'ecc/hermes-control-pane',
|
||||
'main',
|
||||
1200,
|
||||
800,
|
||||
2000,
|
||||
19,
|
||||
6,
|
||||
540,
|
||||
0.42,
|
||||
'2026-06-03T10:00:00Z',
|
||||
'2026-06-03T10:15:00Z',
|
||||
'2026-06-03T10:15:00Z',
|
||||
]);
|
||||
insertSession.run([
|
||||
'worker-kb',
|
||||
'Index operator memory',
|
||||
'ECC',
|
||||
'knowledge',
|
||||
'codex',
|
||||
'codex',
|
||||
JSON.stringify(['codex']),
|
||||
'/repo/ecc',
|
||||
'idle',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
300,
|
||||
200,
|
||||
500,
|
||||
4,
|
||||
2,
|
||||
120,
|
||||
0.07,
|
||||
'2026-06-03T10:05:00Z',
|
||||
'2026-06-03T10:14:00Z',
|
||||
'2026-06-03T10:14:00Z',
|
||||
]);
|
||||
insertSession.free();
|
||||
|
||||
db.run(
|
||||
'INSERT INTO messages (from_session, to_session, content, msg_type, read, timestamp) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
['worker-kb', 'lead-hermes', 'Need approval for connector sync', 'approval_request', 0, '2026-06-03T10:16:00Z']
|
||||
);
|
||||
|
||||
const insertEntity = db.prepare(`
|
||||
INSERT INTO context_graph_entities (
|
||||
session_id, entity_key, entity_type, name, path, summary, metadata_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
insertEntity.run([
|
||||
'lead-hermes',
|
||||
'runbook:Hermes revenue runbook:/notes/hermes.md',
|
||||
'runbook',
|
||||
'Hermes revenue runbook',
|
||||
'/notes/hermes.md',
|
||||
'How Affaan routes Hermes Desktop, Zellij panes, Devin-style delegation, and ECC release control work.',
|
||||
JSON.stringify({ source: 'hermes_workspace', platform: 'desktop' }),
|
||||
'2026-06-03T10:10:00Z',
|
||||
'2026-06-03T10:10:00Z',
|
||||
]);
|
||||
insertEntity.run([
|
||||
null,
|
||||
'concept:gbrain memory:/notes/gbrain.md',
|
||||
'concept',
|
||||
'gbrain memory',
|
||||
'/notes/gbrain.md',
|
||||
'Operator knowledge base pattern for cross-platform agent memory.',
|
||||
JSON.stringify({ source: 'workspace_notes' }),
|
||||
'2026-06-03T10:11:00Z',
|
||||
'2026-06-03T10:11:00Z',
|
||||
]);
|
||||
insertEntity.free();
|
||||
|
||||
db.run(
|
||||
'INSERT INTO context_graph_observations (session_id, entity_id, observation_type, priority, pinned, summary, details_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
'lead-hermes',
|
||||
1,
|
||||
'operator_memory',
|
||||
3,
|
||||
1,
|
||||
'Hermes Desktop and ECC should share recall before dispatching work.',
|
||||
JSON.stringify({ note: 'safe public summary only' }),
|
||||
'2026-06-03T10:12:00Z',
|
||||
]
|
||||
);
|
||||
db.run(
|
||||
'INSERT INTO context_graph_relations (session_id, from_entity_id, to_entity_id, relation_type, summary, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
['lead-hermes', 1, 2, 'depends_on', 'Runbook uses durable memory concepts.', '2026-06-03T10:13:00Z']
|
||||
);
|
||||
db.run(
|
||||
'INSERT INTO context_graph_connector_checkpoints (connector_name, source_path, source_signature, updated_at) VALUES (?, ?, ?, ?)',
|
||||
['hermes_workspace', '/notes/hermes.md', 'sig-1', '2026-06-03T10:12:00Z']
|
||||
);
|
||||
|
||||
fs.writeFileSync(dbPath, Buffer.from(db.export()));
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function writeSampleWorkItemsDatabase(dbPath) {
|
||||
const SQL = await initSqlJs();
|
||||
const db = new SQL.Database();
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE work_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
source_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
priority TEXT,
|
||||
url TEXT,
|
||||
owner TEXT,
|
||||
repo_root TEXT,
|
||||
session_id TEXT,
|
||||
metadata TEXT NOT NULL CHECK (json_valid(metadata)),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
const insertWorkItem = db.prepare(`
|
||||
INSERT INTO work_items (
|
||||
id, source, source_id, title, status, priority, url, owner,
|
||||
repo_root, session_id, metadata, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
insertWorkItem.run([
|
||||
'agent-card-001',
|
||||
'manual',
|
||||
'agent-card-001',
|
||||
'Build dynamic workflow skill',
|
||||
'running',
|
||||
'high',
|
||||
null,
|
||||
'codex',
|
||||
'/repo/ecc',
|
||||
'lead-hermes',
|
||||
JSON.stringify({
|
||||
branch: 'product/dynamic-workflow-team-orchestration',
|
||||
mergeGate: 'focused tests and catalog check pass',
|
||||
acceptance: ['skill exists', 'content pack exists'],
|
||||
}),
|
||||
'2026-06-04T09:00:00Z',
|
||||
'2026-06-04T09:05:00Z',
|
||||
]);
|
||||
insertWorkItem.run([
|
||||
'agent-card-002',
|
||||
'github-pr',
|
||||
'2131',
|
||||
'Merge ECC control pane',
|
||||
'done',
|
||||
'normal',
|
||||
'https://github.com/affaan-m/ECC/pull/2131',
|
||||
'affaan',
|
||||
'/repo/ecc',
|
||||
null,
|
||||
JSON.stringify({
|
||||
branch: 'product/ecc2-knowledge-control-pane',
|
||||
mergeStateStatus: 'CLEAN',
|
||||
}),
|
||||
'2026-06-03T13:00:00Z',
|
||||
'2026-06-03T13:55:00Z',
|
||||
]);
|
||||
insertWorkItem.run([
|
||||
'agent-card-003',
|
||||
'manual',
|
||||
'blocked-content',
|
||||
'Record content pipeline',
|
||||
'blocked',
|
||||
'high',
|
||||
null,
|
||||
'operator',
|
||||
'/repo/ecc',
|
||||
null,
|
||||
JSON.stringify({
|
||||
blocker: 'needs publish approval',
|
||||
mergeGate: 'approval packet accepted',
|
||||
}),
|
||||
'2026-06-04T09:10:00Z',
|
||||
'2026-06-04T09:12:00Z',
|
||||
]);
|
||||
insertWorkItem.free();
|
||||
|
||||
fs.writeFileSync(dbPath, Buffer.from(db.export()));
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function mutateSqlDatabase(dbPath, mutator) {
|
||||
const SQL = await initSqlJs();
|
||||
const buffer = fs.readFileSync(dbPath);
|
||||
const db = new SQL.Database(buffer);
|
||||
try {
|
||||
await mutator(db);
|
||||
fs.writeFileSync(dbPath, Buffer.from(db.export()));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log('\n=== Testing control-pane state ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (await test('builds an operator snapshot from ECC2 SQLite and configured connectors', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-state-'));
|
||||
const dbPath = path.join(tempDir, 'ecc2.db');
|
||||
|
||||
try {
|
||||
await writeSampleEcc2Database(dbPath);
|
||||
const snapshot = await buildControlPaneSnapshot({
|
||||
dbPath,
|
||||
repoRoot: path.join(__dirname, '..', '..'),
|
||||
query: 'Hermes Desktop Zellij gbrain',
|
||||
config: {
|
||||
memoryConnectors: {
|
||||
hermes_workspace: {
|
||||
kind: 'markdown_directory',
|
||||
path: '/notes',
|
||||
recurse: true,
|
||||
},
|
||||
safe_env: {
|
||||
kind: 'dotenv_file',
|
||||
path: '/notes/.env',
|
||||
includeSafeValues: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(snapshot.schemaVersion, 'ecc.control-pane.snapshot.v1');
|
||||
assert.strictEqual(snapshot.summary.totalSessions, 2);
|
||||
assert.strictEqual(snapshot.summary.runningSessions, 1);
|
||||
assert.strictEqual(snapshot.summary.unreadMessages, 1);
|
||||
assert.strictEqual(snapshot.sessions[0].id, 'lead-hermes');
|
||||
assert.deepStrictEqual(snapshot.sessions[0].detectedHarnesses, ['claude', 'codex']);
|
||||
assert.strictEqual(snapshot.knowledge.query, 'Hermes Desktop Zellij gbrain');
|
||||
assert.strictEqual(snapshot.knowledge.results[0].entity.name, 'Hermes revenue runbook');
|
||||
assert.ok(snapshot.knowledge.results[0].matchedTerms.includes('hermes'));
|
||||
assert.strictEqual(snapshot.knowledge.results[0].hasPinnedObservation, true);
|
||||
assert.strictEqual(snapshot.connectors.length, 2);
|
||||
assert.strictEqual(snapshot.connectors[0].name, 'hermes_workspace');
|
||||
assert.strictEqual(snapshot.connectors[0].syncedSources, 1);
|
||||
assert.strictEqual(snapshot.connectors[1].syncedSources, 0);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('projects state-store work items into agent Kanban summary', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-work-items-'));
|
||||
const dbPath = path.join(tempDir, 'ecc2.db');
|
||||
const stateDbPath = path.join(tempDir, 'state.db');
|
||||
|
||||
try {
|
||||
await writeSampleEcc2Database(dbPath);
|
||||
await writeSampleWorkItemsDatabase(stateDbPath);
|
||||
|
||||
const snapshot = await buildControlPaneSnapshot({
|
||||
dbPath,
|
||||
stateDbPath,
|
||||
repoRoot: path.join(__dirname, '..', '..'),
|
||||
query: 'workflow',
|
||||
});
|
||||
|
||||
assert.strictEqual(snapshot.workItems.totalCount, 3);
|
||||
assert.strictEqual(snapshot.workItems.openCount, 2);
|
||||
assert.strictEqual(snapshot.workItems.blockedCount, 1);
|
||||
assert.strictEqual(snapshot.workItems.doneCount, 1);
|
||||
assert.strictEqual(snapshot.workItems.kanban.running, 1);
|
||||
assert.strictEqual(snapshot.workItems.kanban.blocked, 1);
|
||||
assert.strictEqual(snapshot.workItems.items[0].id, 'agent-card-003');
|
||||
assert.strictEqual(snapshot.workItems.items[0].mergeGate, 'approval packet accepted');
|
||||
assert.strictEqual(snapshot.workItems.items[1].branch, 'product/dynamic-workflow-team-orchestration');
|
||||
assert.strictEqual(
|
||||
snapshot.workItems.items.find(item => item.id === 'agent-card-002').mergeGate,
|
||||
'CLEAN'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('treats an unreadable optional state-store database as empty work items', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-corrupt-work-items-'));
|
||||
const dbPath = path.join(tempDir, 'ecc2.db');
|
||||
const stateDbPath = path.join(tempDir, 'corrupt-state.db');
|
||||
|
||||
try {
|
||||
await writeSampleEcc2Database(dbPath);
|
||||
fs.writeFileSync(stateDbPath, 'not a sqlite database', 'utf8');
|
||||
|
||||
const snapshot = await buildControlPaneSnapshot({
|
||||
dbPath,
|
||||
stateDbPath,
|
||||
repoRoot: path.join(__dirname, '..', '..'),
|
||||
query: 'workflow',
|
||||
});
|
||||
|
||||
assert.strictEqual(snapshot.stateDatabase.exists, true);
|
||||
assert.strictEqual(snapshot.workItems.totalCount, 0);
|
||||
assert.strictEqual(snapshot.summary.totalSessions, 2);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('resolves config from explicit db path and TOML connector file', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-config-'));
|
||||
const dbPath = path.join(tempDir, 'state.db');
|
||||
const configPath = path.join(tempDir, 'ecc2.toml');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
`db_path = "${dbPath.replace(/\\/g, '\\\\')}"`,
|
||||
'',
|
||||
'[memory_connectors.hermes_workspace]',
|
||||
'kind = "markdown_directory"',
|
||||
'path = "/tmp/hermes"',
|
||||
'recurse = true',
|
||||
'default_entity_type = "operator_note"',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const config = resolveControlPaneConfig({
|
||||
cwd: tempDir,
|
||||
configPath,
|
||||
});
|
||||
|
||||
assert.strictEqual(config.dbPath, dbPath);
|
||||
assert.strictEqual(config.memoryConnectors.hermes_workspace.kind, 'markdown_directory');
|
||||
assert.strictEqual(config.memoryConnectors.hermes_workspace.path, '/tmp/hermes');
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('prefers the operator home config over stale app-support config', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-precedence-'));
|
||||
const homeDir = path.join(tempDir, 'home');
|
||||
const homeConfigDir = path.join(homeDir, '.claude');
|
||||
const appConfigDir = path.join(homeDir, 'Library', 'Application Support', 'ecc2');
|
||||
const homeDbPath = path.join(tempDir, 'operator.db');
|
||||
const staleDbPath = path.join(tempDir, 'stale-smoke.db');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(homeConfigDir, { recursive: true });
|
||||
fs.mkdirSync(appConfigDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(appConfigDir, 'config.toml'),
|
||||
`db_path = "${staleDbPath.replace(/\\/g, '\\\\')}"\n`,
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(homeConfigDir, 'ecc2.toml'),
|
||||
`db_path = "${homeDbPath.replace(/\\/g, '\\\\')}"\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const config = resolveControlPaneConfig({
|
||||
cwd: tempDir,
|
||||
env: { HOME: homeDir },
|
||||
});
|
||||
|
||||
assert.strictEqual(config.dbPath, homeDbPath);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('shows configured connectors even when the SQLite database is missing', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-missing-db-'));
|
||||
|
||||
try {
|
||||
const snapshot = await buildControlPaneSnapshot({
|
||||
repoRoot: path.join(__dirname, '..', '..'),
|
||||
dbPath: path.join(tempDir, 'missing.db'),
|
||||
config: {
|
||||
memoryConnectors: {
|
||||
hermes_workspace: {
|
||||
kind: 'markdown_directory',
|
||||
path: '/notes/hermes',
|
||||
recurse: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(snapshot.database.exists, false);
|
||||
assert.strictEqual(snapshot.connectors.length, 1);
|
||||
assert.strictEqual(snapshot.connectors[0].name, 'hermes_workspace');
|
||||
assert.strictEqual(snapshot.connectors[0].syncedSources, 0);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('handles an existing SQLite database before ECC2 tables are created', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-empty-db-'));
|
||||
const dbPath = path.join(tempDir, 'empty.db');
|
||||
|
||||
try {
|
||||
const SQL = await initSqlJs();
|
||||
const db = new SQL.Database();
|
||||
fs.writeFileSync(dbPath, Buffer.from(db.export()));
|
||||
db.close();
|
||||
|
||||
const snapshot = await buildControlPaneSnapshot({
|
||||
repoRoot: path.join(__dirname, '..', '..'),
|
||||
dbPath,
|
||||
config: {
|
||||
memoryConnectors: {
|
||||
workspace_notes: {
|
||||
kind: 'markdown_directory',
|
||||
path: '/notes',
|
||||
includeSafeValues: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(snapshot.database.exists, true);
|
||||
assert.strictEqual(snapshot.summary.totalSessions, 0);
|
||||
assert.strictEqual(snapshot.knowledge.entityCount, 0);
|
||||
assert.strictEqual(snapshot.knowledge.observationCount, 0);
|
||||
assert.strictEqual(snapshot.connectors[0].name, 'workspace_notes');
|
||||
assert.strictEqual(snapshot.connectors[0].lastSyncedAt, null);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('recalls pinned knowledge when no query is provided', async () => {
|
||||
const results = recallKnowledgeEntries({
|
||||
entities: [
|
||||
{
|
||||
id: 1,
|
||||
entityType: 'runbook',
|
||||
name: 'Pinned runbook',
|
||||
path: '/notes/pinned.md',
|
||||
summary: 'Pinned operator context',
|
||||
metadata: {},
|
||||
updatedAt: '2026-06-03T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
entityType: 'concept',
|
||||
name: 'Unpinned concept',
|
||||
path: null,
|
||||
summary: 'Secondary context',
|
||||
metadata: {},
|
||||
updatedAt: '2026-06-03T11:00:00Z',
|
||||
},
|
||||
],
|
||||
observations: [
|
||||
{
|
||||
entityId: 1,
|
||||
priority: 4,
|
||||
pinned: true,
|
||||
summary: 'Pinned detail',
|
||||
},
|
||||
{
|
||||
entityId: 2,
|
||||
priority: 2,
|
||||
pinned: false,
|
||||
summary: 'Other detail',
|
||||
},
|
||||
],
|
||||
relationCounts: new Map([[1, 3]]),
|
||||
query: '',
|
||||
limit: 0,
|
||||
});
|
||||
|
||||
assert.strictEqual(results.length, 2);
|
||||
assert.strictEqual(results[0].entity.name, 'Pinned runbook');
|
||||
assert.strictEqual(results[0].hasPinnedObservation, true);
|
||||
assert.strictEqual(results[0].relationCount, 3);
|
||||
assert.strictEqual(results[1].entity.name, 'Unpinned concept');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('handles malformed JSON rows and all session state counters', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-edge-db-'));
|
||||
const dbPath = path.join(tempDir, 'ecc2.db');
|
||||
|
||||
try {
|
||||
await writeSampleEcc2Database(dbPath);
|
||||
await mutateSqlDatabase(dbPath, db => {
|
||||
const insertSession = db.prepare(`
|
||||
INSERT INTO sessions (
|
||||
id, task, project, task_group, agent_type, harness, detected_harnesses_json,
|
||||
working_dir, state, pid, worktree_path, worktree_branch, worktree_base,
|
||||
input_tokens, output_tokens, tokens_used, tool_calls, files_changed,
|
||||
duration_secs, cost_usd, created_at, updated_at, last_heartbeat_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const state of ['pending', 'failed', 'stopped', 'completed']) {
|
||||
insertSession.run([
|
||||
`session-${state}`,
|
||||
`Exercise ${state}`,
|
||||
'ECC',
|
||||
'coverage',
|
||||
'codex',
|
||||
'',
|
||||
state === 'failed' ? '{bad json' : '[]',
|
||||
'',
|
||||
state,
|
||||
state === 'pending' ? 'not-a-pid' : null,
|
||||
state === 'completed' ? '/tmp/worktree' : null,
|
||||
null,
|
||||
null,
|
||||
'not-input-tokens',
|
||||
null,
|
||||
state === 'pending' ? 'not-tokens' : 10,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
state === 'failed' ? 'not-cost' : 0.1,
|
||||
'2026-06-03T11:00:00Z',
|
||||
`2026-06-03T11:0${state.length % 10}:00Z`,
|
||||
'',
|
||||
]);
|
||||
}
|
||||
insertSession.free();
|
||||
|
||||
db.run(
|
||||
`INSERT INTO context_graph_entities (
|
||||
session_id, entity_key, entity_type, name, path, summary, metadata_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
'session-failed',
|
||||
'bad:json',
|
||||
'note',
|
||||
'Malformed JSON knowledge',
|
||||
'/notes/malformed.md',
|
||||
'This record should still be searchable.',
|
||||
'{bad json',
|
||||
'2026-06-03T11:20:00Z',
|
||||
'2026-06-03T11:20:00Z',
|
||||
]
|
||||
);
|
||||
db.run(
|
||||
'INSERT INTO context_graph_observations (session_id, entity_id, observation_type, priority, pinned, summary, details_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
'session-failed',
|
||||
3,
|
||||
'',
|
||||
'not-a-priority',
|
||||
0,
|
||||
'Malformed details should fall back safely.',
|
||||
'{bad json',
|
||||
'2026-06-03T11:21:00Z',
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
const snapshot = await buildControlPaneSnapshot({
|
||||
repoRoot: path.join(__dirname, '..', '..'),
|
||||
dbPath,
|
||||
query: 'Malformed',
|
||||
config: {
|
||||
memoryConnectors: {
|
||||
malformed_notes: {
|
||||
kind: 'markdown_directory',
|
||||
path: '/notes/malformed',
|
||||
recurse: false,
|
||||
defaultEntityType: 'note',
|
||||
defaultObservationType: 'operator_memory',
|
||||
includeSafeValues: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(snapshot.summary.pendingSessions, 1);
|
||||
assert.strictEqual(snapshot.summary.failedSessions, 1);
|
||||
assert.strictEqual(snapshot.summary.stoppedSessions, 1);
|
||||
assert.strictEqual(snapshot.summary.completedSessions, 1);
|
||||
assert.strictEqual(snapshot.summary.runningSessions, 1);
|
||||
assert.strictEqual(snapshot.summary.idleSessions, 1);
|
||||
assert.strictEqual(snapshot.summary.totalSessions, 6);
|
||||
|
||||
const failedSession = snapshot.sessions.find(session => session.id === 'session-failed');
|
||||
assert.deepStrictEqual(failedSession.detectedHarnesses, []);
|
||||
assert.strictEqual(failedSession.metrics.costUsd, 0);
|
||||
|
||||
const pendingSession = snapshot.sessions.find(session => session.id === 'session-pending');
|
||||
assert.strictEqual(pendingSession.pid, 0);
|
||||
assert.strictEqual(pendingSession.metrics.tokensUsed, 0);
|
||||
|
||||
assert.strictEqual(snapshot.knowledge.results[0].entity.name, 'Malformed JSON knowledge');
|
||||
assert.deepStrictEqual(snapshot.knowledge.results[0].entity.metadata, {});
|
||||
assert.deepStrictEqual(snapshot.knowledge.results[0].latestObservation.details, {});
|
||||
assert.strictEqual(snapshot.connectors[0].defaultEntityType, 'note');
|
||||
assert.strictEqual(snapshot.connectors[0].defaultObservationType, 'operator_memory');
|
||||
assert.strictEqual(snapshot.connectors[0].includeSafeValues, true);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('recall search covers metadata, relation caps, no matches, and tie ordering', async () => {
|
||||
const baseEntities = [
|
||||
{
|
||||
id: 1,
|
||||
entityType: 'note',
|
||||
name: 'First shared memory',
|
||||
path: '/notes/shared-a.md',
|
||||
summary: 'Platform context',
|
||||
metadata: { source: 'workspace' },
|
||||
updatedAt: '2026-06-03T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
entityType: 'note',
|
||||
name: 'Second shared memory',
|
||||
path: '/notes/shared-b.md',
|
||||
summary: 'Platform context',
|
||||
metadata: { source: 'workspace' },
|
||||
updatedAt: '2026-06-03T12:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
entityType: 'concept',
|
||||
name: 'Markets graph',
|
||||
path: null,
|
||||
summary: 'Correlation graph visualization',
|
||||
metadata: { flow: 'friction-flow' },
|
||||
updatedAt: '2026-06-03T09:00:00Z',
|
||||
},
|
||||
];
|
||||
const observations = [
|
||||
{
|
||||
entityId: 3,
|
||||
priority: 1,
|
||||
pinned: false,
|
||||
summary: 'Ito should expose market backtesting through ECC tools.',
|
||||
},
|
||||
];
|
||||
|
||||
const tied = recallKnowledgeEntries({
|
||||
entities: baseEntities,
|
||||
observations: [],
|
||||
relationCounts: new Map(),
|
||||
query: 'shared',
|
||||
limit: 50,
|
||||
});
|
||||
assert.deepStrictEqual(tied.map(entry => entry.entity.id), [2, 1]);
|
||||
|
||||
const metadataHit = recallKnowledgeEntries({
|
||||
entities: baseEntities,
|
||||
observations,
|
||||
relationCounts: new Map([[3, 20]]),
|
||||
query: 'friction-flow backtesting',
|
||||
limit: -5,
|
||||
});
|
||||
assert.strictEqual(metadataHit.length, 1);
|
||||
assert.strictEqual(metadataHit[0].entity.id, 3);
|
||||
assert.strictEqual(metadataHit[0].relationCount, 20);
|
||||
assert.ok(metadataHit[0].score >= 18);
|
||||
|
||||
const noHits = recallKnowledgeEntries({
|
||||
entities: baseEntities,
|
||||
observations,
|
||||
relationCounts: new Map(),
|
||||
query: 'unmatched',
|
||||
limit: 'wat',
|
||||
});
|
||||
assert.deepStrictEqual(noHits, []);
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -0,0 +1,208 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
createCodexWorktreeAdapter,
|
||||
parseCodexTarget,
|
||||
parseCodexRollout,
|
||||
isCodexRolloutFileTarget,
|
||||
findLatestRollout,
|
||||
findRolloutById
|
||||
} = require('../../scripts/lib/session-adapters/codex-worktree');
|
||||
const {
|
||||
normalizeCodexWorktreeSession,
|
||||
validateCanonicalSnapshot
|
||||
} = require('../../scripts/lib/session-adapters/canonical-session');
|
||||
const { createAdapterRegistry } = require('../../scripts/lib/session-adapters/registry');
|
||||
|
||||
console.log('=== Testing codex-worktree session adapter ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed += 1;
|
||||
console.log(` ok - ${name}`);
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
console.log(` FAIL - ${name}`);
|
||||
console.log(` ${error && error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeRolloutFixture() {
|
||||
const sessionsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-sessions-'));
|
||||
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-worktree-'));
|
||||
const dayDir = path.join(sessionsDir, '2026', '06', '02');
|
||||
fs.mkdirSync(dayDir, { recursive: true });
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const rolloutPath = path.join(dayDir, 'rollout-2026-06-02T03-01-58-019etest-codex-0001.jsonl');
|
||||
const lines = [
|
||||
{ type: 'session_meta', timestamp: now, payload: {
|
||||
id: '019etest-codex-0001', timestamp: now, cwd: repoRoot,
|
||||
originator: 'Codex Desktop', cli_version: '0.136.0', source: 'vscode', model_provider: 'openai'
|
||||
} },
|
||||
{ type: 'turn_context', timestamp: now, payload: { model: 'gpt-5.5-codex' } },
|
||||
{ type: 'response_item', timestamp: now, payload: {
|
||||
type: 'message', role: 'user',
|
||||
content: [{ type: 'text', text: '# AGENTS.md instructions for /repo\n<cwd>/repo</cwd>' }]
|
||||
} },
|
||||
{ type: 'response_item', timestamp: now, payload: {
|
||||
type: 'message', role: 'user',
|
||||
content: [{ type: 'text', text: 'continue our ecc 2.0 session and build the codex-worktree adapter' }]
|
||||
} }
|
||||
];
|
||||
|
||||
fs.writeFileSync(rolloutPath, lines.map(line => JSON.stringify(line)).join('\n') + '\n', 'utf8');
|
||||
return { sessionsDir, repoRoot, rolloutPath };
|
||||
}
|
||||
|
||||
test('normalizeCodexWorktreeSession produces a valid ecc.session.v1 snapshot', () => {
|
||||
const snapshot = normalizeCodexWorktreeSession({
|
||||
sessionId: 'abc', sessionPath: '/tmp/r.jsonl', cwd: '/repo', branch: 'feat/x',
|
||||
objective: 'do the thing', model: 'gpt-5.5-codex', originator: 'Codex Desktop',
|
||||
cliVersion: '0.136.0', startedAt: '2026-06-02T03:01:58Z', recordCount: 4, active: true
|
||||
}, { type: 'codex-worktree', value: 'abc' });
|
||||
|
||||
validateCanonicalSnapshot(snapshot);
|
||||
assert.strictEqual(snapshot.adapterId, 'codex-worktree');
|
||||
assert.strictEqual(snapshot.session.kind, 'codex-worktree');
|
||||
assert.strictEqual(snapshot.session.state, 'active');
|
||||
assert.strictEqual(snapshot.workers[0].runtime.kind, 'codex-session');
|
||||
assert.strictEqual(snapshot.workers[0].branch, 'feat/x');
|
||||
assert.strictEqual(snapshot.workers[0].artifacts.model, 'gpt-5.5-codex');
|
||||
});
|
||||
|
||||
test('parseCodexTarget strips codex prefixes', () => {
|
||||
assert.strictEqual(parseCodexTarget('codex:latest'), 'latest');
|
||||
assert.strictEqual(parseCodexTarget('codex-worktree:019eabc'), '019eabc');
|
||||
assert.strictEqual(parseCodexTarget('/some/path.jsonl'), null);
|
||||
});
|
||||
|
||||
test('adapter reads latest rollout, skips preamble, derives objective + model', () => {
|
||||
const { sessionsDir, repoRoot, rolloutPath } = writeRolloutFixture();
|
||||
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-rec-'));
|
||||
|
||||
assert.strictEqual(findLatestRollout(sessionsDir), rolloutPath);
|
||||
|
||||
const adapter = createCodexWorktreeAdapter({
|
||||
sessionsDir, recordingDir, loadStateStoreImpl: () => null, resolveBranchImpl: () => null
|
||||
});
|
||||
const snapshot = adapter.open('codex:latest', { cwd: repoRoot }).getSnapshot();
|
||||
|
||||
assert.strictEqual(snapshot.adapterId, 'codex-worktree');
|
||||
assert.strictEqual(snapshot.session.id, '019etest-codex-0001');
|
||||
assert.strictEqual(snapshot.session.state, 'active');
|
||||
assert.strictEqual(snapshot.workers.length, 1);
|
||||
assert.strictEqual(snapshot.workers[0].worktree, repoRoot);
|
||||
assert.strictEqual(snapshot.workers[0].runtime.command, 'codex');
|
||||
assert.strictEqual(snapshot.workers[0].runtime.active, true);
|
||||
assert.strictEqual(snapshot.workers[0].artifacts.model, 'gpt-5.5-codex');
|
||||
assert.strictEqual(
|
||||
snapshot.workers[0].intent.objective,
|
||||
'continue our ecc 2.0 session and build the codex-worktree adapter'
|
||||
);
|
||||
assert.strictEqual(snapshot.aggregates.workerCount, 1);
|
||||
assert.strictEqual(snapshot.aggregates.states.active, 1);
|
||||
});
|
||||
|
||||
test('registry routes structured codex-worktree target and direct rollout path', () => {
|
||||
const { sessionsDir, repoRoot, rolloutPath } = writeRolloutFixture();
|
||||
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-reg-'));
|
||||
|
||||
const registry = createAdapterRegistry({
|
||||
recordingDir,
|
||||
loadStateStoreImpl: () => null,
|
||||
adapterOptions: { 'codex-worktree': { sessionsDir, resolveBranchImpl: () => null } }
|
||||
});
|
||||
|
||||
const typed = registry.open({ type: 'codex-worktree', value: 'latest' }, { cwd: repoRoot }).getSnapshot();
|
||||
assert.strictEqual(typed.adapterId, 'codex-worktree');
|
||||
assert.strictEqual(typed.session.id, '019etest-codex-0001');
|
||||
|
||||
const byPath = registry.open(rolloutPath, { cwd: repoRoot }).getSnapshot();
|
||||
assert.strictEqual(byPath.adapterId, 'codex-worktree');
|
||||
|
||||
const listed = registry.listAdapters().map(a => a.id);
|
||||
assert.ok(listed.includes('codex-worktree'), 'registry lists codex-worktree adapter');
|
||||
});
|
||||
|
||||
|
||||
// --- branch/error coverage ---
|
||||
|
||||
function writeRollout(dir, name, lines) {
|
||||
const fp = require('path').join(dir, name);
|
||||
require('fs').writeFileSync(fp, lines.map(l => JSON.stringify(l)).join('\n') + '\n', 'utf8');
|
||||
return fp;
|
||||
}
|
||||
|
||||
test('parseCodexTarget handles non-string and unprefixed input', () => {
|
||||
assert.strictEqual(parseCodexTarget(null), null);
|
||||
assert.strictEqual(parseCodexTarget(42), null);
|
||||
assert.strictEqual(parseCodexTarget('/abs/path.jsonl'), null);
|
||||
});
|
||||
|
||||
test('adapter throws clear errors for missing sessions and unknown ids', () => {
|
||||
const sessionsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-empty-'));
|
||||
const adapter = createCodexWorktreeAdapter({ sessionsDir, loadStateStoreImpl: () => null });
|
||||
assert.throws(() => adapter.open('codex:latest', { cwd: os.tmpdir() }).getSnapshot(), /No Codex rollout sessions found/);
|
||||
assert.throws(() => adapter.open('codex:nope-not-real', { cwd: os.tmpdir() }).getSnapshot(), /not found/);
|
||||
assert.throws(() => adapter.open('/not/a/rollout.txt', { cwd: os.tmpdir() }).getSnapshot(), /Unsupported Codex session target/);
|
||||
});
|
||||
|
||||
test('findRolloutById + direct file target + isCodexRolloutFileTarget', () => {
|
||||
const sessionsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-byid-'));
|
||||
const day = path.join(sessionsDir, '2026', '06', '02');
|
||||
fs.mkdirSync(day, { recursive: true });
|
||||
const now = new Date().toISOString();
|
||||
const fp = writeRollout(day, 'rollout-2026-06-02T03-00-00-019eUNIQUEID0001.jsonl', [
|
||||
{ type: 'session_meta', timestamp: now, payload: { id: '019eUNIQUEID0001', cwd: sessionsDir } }
|
||||
]);
|
||||
|
||||
assert.strictEqual(findRolloutById(sessionsDir, '019eUNIQUEID0001'), fp);
|
||||
assert.ok(isCodexRolloutFileTarget(fp, os.tmpdir()));
|
||||
assert.ok(!isCodexRolloutFileTarget('not-a-file.jsonl', os.tmpdir()));
|
||||
|
||||
const adapter = createCodexWorktreeAdapter({ sessionsDir, loadStateStoreImpl: () => null, resolveBranchImpl: () => null });
|
||||
const byId = adapter.open('codex:019eUNIQUEID0001', { cwd: os.tmpdir() }).getSnapshot();
|
||||
assert.strictEqual(byId.session.id, '019eUNIQUEID0001');
|
||||
const byFile = adapter.open(fp, { cwd: os.tmpdir() }).getSnapshot();
|
||||
assert.strictEqual(byFile.session.id, '019eUNIQUEID0001');
|
||||
});
|
||||
|
||||
test('parseCodexRollout: model fallbacks, objective truncation, corrupt-line skip, mtime fallback', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-parse-'));
|
||||
const longObjective = 'x'.repeat(400);
|
||||
const fp = path.join(dir, 'rollout-2026-06-02T03-00-00-019eMODELFALL0002.jsonl');
|
||||
// include a corrupt line, no turn_context (force meta.model_provider fallback), no timestamps (force mtime)
|
||||
fs.writeFileSync(fp, [
|
||||
JSON.stringify({ type: 'session_meta', payload: { id: '019eMODELFALL0002', cwd: dir, model_provider: 'openai' } }),
|
||||
'{ this is corrupt json',
|
||||
JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'text', text: longObjective }] } })
|
||||
].join('\n') + '\n', 'utf8');
|
||||
|
||||
const parsed = parseCodexRollout(fp, { resolveBranchImpl: () => null });
|
||||
assert.strictEqual(parsed.model, 'openai', 'falls back to model_provider when no turn_context/model');
|
||||
assert.ok(parsed.objective.endsWith('...'), 'long objective is truncated');
|
||||
assert.ok(parsed.objective.length <= 280);
|
||||
assert.strictEqual(parsed.active, true, 'no record timestamps => falls back to (recent) file mtime');
|
||||
});
|
||||
|
||||
test('resolveGitBranch returns null when cwd is not a git repo (real path)', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-nogit-'));
|
||||
const fp = path.join(dir, 'rollout-2026-06-02T03-00-00-019eNOGIT00003.jsonl');
|
||||
fs.writeFileSync(fp, JSON.stringify({ type: 'session_meta', payload: { id: '019eNOGIT00003', cwd: dir } }) + '\n', 'utf8');
|
||||
// no resolveBranchImpl => exercises the real execFileSync + catch path
|
||||
const parsed = parseCodexRollout(fp, {});
|
||||
assert.strictEqual(parsed.branch, null);
|
||||
});
|
||||
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,219 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
createOpencodeAdapter,
|
||||
parseOpencodeTarget,
|
||||
parseOpencodeSession,
|
||||
isOpencodeSessionFileTarget,
|
||||
findLatestSessionInfo,
|
||||
findSessionInfoById
|
||||
} = require('../../scripts/lib/session-adapters/opencode');
|
||||
const {
|
||||
normalizeOpencodeSession,
|
||||
validateCanonicalSnapshot
|
||||
} = require('../../scripts/lib/session-adapters/canonical-session');
|
||||
const { createAdapterRegistry } = require('../../scripts/lib/session-adapters/registry');
|
||||
|
||||
console.log('=== Testing opencode session adapter ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed += 1;
|
||||
console.log(` ok - ${name}`);
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
console.log(` FAIL - ${name}`);
|
||||
console.log(` ${error && error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeOpencodeFixture({ title = 'rebuild the basket trader rebalancer', updatedAgoMs = 0 } = {}) {
|
||||
const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-opencode-store-'));
|
||||
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-opencode-repo-'));
|
||||
const projectHash = 'b43c6d2f5bbf6e71bc3d139c1656bf3afe1935aa';
|
||||
const sessionId = 'ses_66d5468bdffeVlx1Hy2KkdIshB';
|
||||
|
||||
const sessionDir = path.join(storageDir, 'session', projectHash);
|
||||
const messageDir = path.join(storageDir, 'message', sessionId);
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
fs.mkdirSync(messageDir, { recursive: true });
|
||||
|
||||
const updated = Date.now() - updatedAgoMs;
|
||||
fs.writeFileSync(path.join(sessionDir, `${sessionId}.json`), JSON.stringify({
|
||||
id: sessionId,
|
||||
version: '0.12.1',
|
||||
projectID: projectHash,
|
||||
directory: repoRoot,
|
||||
title,
|
||||
time: { created: updated - 10000, updated }
|
||||
}), 'utf8');
|
||||
|
||||
// one user message + one assistant message carrying the model
|
||||
fs.writeFileSync(path.join(messageDir, 'msg_user01.json'), JSON.stringify({
|
||||
id: 'msg_user01', sessionID: sessionId, role: 'user', time: { created: updated - 9000 }
|
||||
}), 'utf8');
|
||||
fs.writeFileSync(path.join(messageDir, 'msg_asst01.json'), JSON.stringify({
|
||||
id: 'msg_asst01', sessionID: sessionId, role: 'assistant',
|
||||
time: { created: updated - 8000, completed: updated - 7000 },
|
||||
modelID: 'claude-sonnet-4-5-20250929', providerID: 'anthropic'
|
||||
}), 'utf8');
|
||||
|
||||
return { storageDir, repoRoot, sessionId, sessionInfoPath: path.join(sessionDir, `${sessionId}.json`) };
|
||||
}
|
||||
|
||||
test('normalizeOpencodeSession produces a valid ecc.session.v1 snapshot', () => {
|
||||
const snapshot = normalizeOpencodeSession({
|
||||
sessionId: 'ses_x', sessionPath: '/tmp/s.json', cwd: '/repo', branch: 'main',
|
||||
objective: 'do the thing', title: 'do the thing', model: 'claude-sonnet-4-5-20250929',
|
||||
provider: 'anthropic', version: '0.12.1', projectId: 'proj', createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:05:00Z', messageCount: 2, active: false
|
||||
}, { type: 'opencode', value: 'ses_x' });
|
||||
|
||||
validateCanonicalSnapshot(snapshot);
|
||||
assert.strictEqual(snapshot.adapterId, 'opencode');
|
||||
assert.strictEqual(snapshot.session.kind, 'opencode');
|
||||
assert.strictEqual(snapshot.workers[0].runtime.kind, 'opencode-session');
|
||||
assert.strictEqual(snapshot.workers[0].artifacts.provider, 'anthropic');
|
||||
});
|
||||
|
||||
test('parseOpencodeTarget strips the opencode prefix', () => {
|
||||
assert.strictEqual(parseOpencodeTarget('opencode:latest'), 'latest');
|
||||
assert.strictEqual(parseOpencodeTarget('opencode:ses_abc'), 'ses_abc');
|
||||
assert.strictEqual(parseOpencodeTarget('codex:latest'), null);
|
||||
});
|
||||
|
||||
test('adapter reads latest session, extracts model from messages, derives objective from title', () => {
|
||||
const { storageDir, repoRoot, sessionInfoPath } = writeOpencodeFixture({ updatedAgoMs: 0 });
|
||||
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-opencode-rec-'));
|
||||
|
||||
assert.strictEqual(findLatestSessionInfo(storageDir), sessionInfoPath);
|
||||
|
||||
const adapter = createOpencodeAdapter({
|
||||
storageDir, recordingDir, loadStateStoreImpl: () => null, resolveBranchImpl: () => 'main'
|
||||
});
|
||||
const snapshot = adapter.open('opencode:latest', { cwd: repoRoot }).getSnapshot();
|
||||
|
||||
assert.strictEqual(snapshot.adapterId, 'opencode');
|
||||
assert.strictEqual(snapshot.session.state, 'active');
|
||||
assert.strictEqual(snapshot.workers[0].worktree, repoRoot);
|
||||
assert.strictEqual(snapshot.workers[0].branch, 'main');
|
||||
assert.strictEqual(snapshot.workers[0].artifacts.model, 'claude-sonnet-4-5-20250929');
|
||||
assert.strictEqual(snapshot.workers[0].artifacts.provider, 'anthropic');
|
||||
assert.strictEqual(snapshot.workers[0].artifacts.messageCount, 2);
|
||||
assert.strictEqual(snapshot.workers[0].intent.objective, 'rebuild the basket trader rebalancer');
|
||||
});
|
||||
|
||||
test('auto-title "New session - ..." yields empty objective; stale session is recorded', () => {
|
||||
const { storageDir, repoRoot } = writeOpencodeFixture({
|
||||
title: 'New session - 2025-09-28T23:32:22.978Z',
|
||||
updatedAgoMs: 60 * 60 * 1000
|
||||
});
|
||||
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-opencode-rec2-'));
|
||||
|
||||
const adapter = createOpencodeAdapter({
|
||||
storageDir, recordingDir, loadStateStoreImpl: () => null, resolveBranchImpl: () => null
|
||||
});
|
||||
const snapshot = adapter.open('opencode:latest', { cwd: repoRoot }).getSnapshot();
|
||||
|
||||
assert.strictEqual(snapshot.workers[0].intent.objective, '');
|
||||
assert.strictEqual(snapshot.session.state, 'recorded');
|
||||
assert.strictEqual(snapshot.workers[0].runtime.dead, true);
|
||||
});
|
||||
|
||||
test('registry routes structured opencode target and lists the adapter', () => {
|
||||
const { storageDir, repoRoot } = writeOpencodeFixture();
|
||||
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-opencode-reg-'));
|
||||
|
||||
const registry = createAdapterRegistry({
|
||||
recordingDir,
|
||||
loadStateStoreImpl: () => null,
|
||||
adapterOptions: { opencode: { storageDir, resolveBranchImpl: () => null } }
|
||||
});
|
||||
|
||||
const typed = registry.open({ type: 'opencode', value: 'latest' }, { cwd: repoRoot }).getSnapshot();
|
||||
assert.strictEqual(typed.adapterId, 'opencode');
|
||||
|
||||
const listed = registry.listAdapters().map(a => a.id);
|
||||
assert.ok(listed.includes('opencode'), 'registry lists opencode adapter');
|
||||
assert.ok(listed.includes('codex-worktree'), 'registry still lists codex-worktree adapter');
|
||||
});
|
||||
|
||||
|
||||
// --- branch/error coverage ---
|
||||
|
||||
function writeSession(storageDir, projectHash, sessionId, info, messages) {
|
||||
const sdir = path.join(storageDir, 'session', projectHash);
|
||||
const mdir = path.join(storageDir, 'message', sessionId);
|
||||
fs.mkdirSync(sdir, { recursive: true });
|
||||
fs.mkdirSync(mdir, { recursive: true });
|
||||
fs.writeFileSync(path.join(sdir, sessionId + '.json'), JSON.stringify(info), 'utf8');
|
||||
(messages || []).forEach((m, i) => fs.writeFileSync(path.join(mdir, 'msg_' + i + '.json'), JSON.stringify(m), 'utf8'));
|
||||
return path.join(sdir, sessionId + '.json');
|
||||
}
|
||||
|
||||
test('parseOpencodeTarget handles non-string and unprefixed input', () => {
|
||||
assert.strictEqual(parseOpencodeTarget(null), null);
|
||||
assert.strictEqual(parseOpencodeTarget('/abs/ses_x.json'), null);
|
||||
});
|
||||
|
||||
test('adapter throws for empty store and unknown id; findLatest on empty => null', () => {
|
||||
const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-oc-empty-'));
|
||||
assert.strictEqual(findLatestSessionInfo(storageDir), null);
|
||||
const adapter = createOpencodeAdapter({ storageDir, loadStateStoreImpl: () => null });
|
||||
assert.throws(() => adapter.open('opencode:latest', { cwd: os.tmpdir() }).getSnapshot(), /No OpenCode sessions found/);
|
||||
assert.throws(() => adapter.open('opencode:ses_missing', { cwd: os.tmpdir() }).getSnapshot(), /not found/);
|
||||
});
|
||||
|
||||
test('findSessionInfoById + direct file target + isOpencodeSessionFileTarget', () => {
|
||||
const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-oc-byid-'));
|
||||
const now = Date.now();
|
||||
const fp = writeSession(storageDir, 'projhash', 'ses_UNIQUE001', {
|
||||
id: 'ses_UNIQUE001', directory: storageDir, title: 'real title', time: { created: now - 5000, updated: now - 5000 }
|
||||
}, []);
|
||||
|
||||
assert.strictEqual(findSessionInfoById(storageDir, 'ses_UNIQUE001'), fp);
|
||||
assert.ok(isOpencodeSessionFileTarget(fp, os.tmpdir()));
|
||||
assert.ok(!isOpencodeSessionFileTarget('/tmp/not-session.json', os.tmpdir()));
|
||||
|
||||
const adapter = createOpencodeAdapter({ storageDir, loadStateStoreImpl: () => null, resolveBranchImpl: () => null });
|
||||
const byId = adapter.open('opencode:ses_UNIQUE001', { cwd: os.tmpdir() }).getSnapshot();
|
||||
assert.strictEqual(byId.session.id, 'ses_UNIQUE001');
|
||||
const byFile = adapter.open(fp, { cwd: os.tmpdir() }).getSnapshot();
|
||||
assert.strictEqual(byFile.session.id, 'ses_UNIQUE001');
|
||||
});
|
||||
|
||||
test('parseOpencodeSession: model from later assistant message, missing-time => recorded', () => {
|
||||
const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-oc-parse-'));
|
||||
const fp = writeSession(storageDir, 'ph', 'ses_MODEL01', {
|
||||
id: 'ses_MODEL01', directory: storageDir, title: 'do work'
|
||||
// no time block => updatedMs null => recorded/inactive
|
||||
}, [
|
||||
{ id: 'm0', role: 'user' },
|
||||
{ id: 'm1', role: 'assistant', modelID: 'claude-sonnet-4-5-20250929', providerID: 'anthropic' }
|
||||
]);
|
||||
const parsed = parseOpencodeSession(fp, { storageDir, resolveBranchImpl: () => null });
|
||||
assert.strictEqual(parsed.model, 'claude-sonnet-4-5-20250929');
|
||||
assert.strictEqual(parsed.provider, 'anthropic');
|
||||
assert.strictEqual(parsed.active, false);
|
||||
});
|
||||
|
||||
test('resolveGitBranch real path returns null outside a repo', () => {
|
||||
const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-oc-nogit-'));
|
||||
const fp = writeSession(storageDir, 'ph', 'ses_NOGIT01', {
|
||||
id: 'ses_NOGIT01', directory: storageDir, title: 't', time: { created: 1, updated: 1 }
|
||||
}, []);
|
||||
const parsed = parseOpencodeSession(fp, { storageDir }); // no resolveBranchImpl => real git path
|
||||
assert.strictEqual(parsed.branch, null);
|
||||
});
|
||||
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -356,6 +356,30 @@ test('codex plugin.json uses canonical ECC repo and display name', () => {
|
||||
assert.strictEqual(codexPlugin.interface.displayName, 'ECC');
|
||||
});
|
||||
|
||||
test('codex plugin presentation assets exist and ship in npm package', () => {
|
||||
assert.ok(Array.isArray(rootPackage.files), 'Expected package.json files array');
|
||||
const packageFiles = new Set(rootPackage.files);
|
||||
|
||||
for (const field of ['composerIcon', 'logo']) {
|
||||
const assetPath = codexPlugin.interface[field];
|
||||
assert.ok(assetPath, `Expected interface.${field}`);
|
||||
assert.ok(
|
||||
assetPath.startsWith('./assets/'),
|
||||
`Expected interface.${field} to point at a root assets path, got ${assetPath}`,
|
||||
);
|
||||
|
||||
const packagePath = assetPath.replace(/^\.\//, '');
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(repoRoot, packagePath)),
|
||||
`Expected interface.${field} asset to exist: ${packagePath}`,
|
||||
);
|
||||
assert.ok(
|
||||
packageFiles.has(packagePath),
|
||||
`Expected package.json files to include interface.${field} asset: ${packagePath}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ── .mcp.json at plugin root ──────────────────────────────────────────────────
|
||||
// Per official docs: keep .mcp.json at plugin root, NOT inside .codex-plugin/
|
||||
console.log('\n=== .mcp.json (plugin root) ===\n');
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* Tests for scripts/control-pane.js and its local HTTP API.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
|
||||
const initSqlJs = require('sql.js');
|
||||
|
||||
const {
|
||||
createControlPaneServer,
|
||||
parseArgs,
|
||||
runAction,
|
||||
} = require('../../scripts/lib/control-pane/server');
|
||||
const {
|
||||
main: runControlPaneCli,
|
||||
} = require('../../scripts/control-pane');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'control-pane.js');
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` PASS ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeMinimalDatabase(dbPath) {
|
||||
const SQL = await initSqlJs();
|
||||
const db = new SQL.Database();
|
||||
db.run(`
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
task TEXT NOT NULL,
|
||||
project TEXT NOT NULL DEFAULT '',
|
||||
task_group TEXT NOT NULL DEFAULT '',
|
||||
agent_type TEXT NOT NULL,
|
||||
harness TEXT NOT NULL DEFAULT 'unknown',
|
||||
detected_harnesses_json TEXT NOT NULL DEFAULT '[]',
|
||||
working_dir TEXT NOT NULL DEFAULT '.',
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
pid INTEGER,
|
||||
worktree_path TEXT,
|
||||
worktree_branch TEXT,
|
||||
worktree_base TEXT,
|
||||
input_tokens INTEGER DEFAULT 0,
|
||||
output_tokens INTEGER DEFAULT 0,
|
||||
tokens_used INTEGER DEFAULT 0,
|
||||
tool_calls INTEGER DEFAULT 0,
|
||||
files_changed INTEGER DEFAULT 0,
|
||||
duration_secs INTEGER DEFAULT 0,
|
||||
cost_usd REAL DEFAULT 0.0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_heartbeat_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO sessions (
|
||||
id, task, agent_type, harness, detected_harnesses_json, working_dir, state,
|
||||
created_at, updated_at, last_heartbeat_at
|
||||
) VALUES (
|
||||
'session-a', 'Build the control pane', 'codex', 'codex', '["codex"]', '/repo/ecc',
|
||||
'running', '2026-06-03T10:00:00Z', '2026-06-03T10:05:00Z', '2026-06-03T10:05:00Z'
|
||||
);
|
||||
`);
|
||||
fs.writeFileSync(dbPath, Buffer.from(db.export()));
|
||||
db.close();
|
||||
}
|
||||
|
||||
function waitForCliReady(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill('SIGTERM');
|
||||
reject(new Error(`Timed out waiting for control pane CLI.\nstdout:\n${stdout}\nstderr:\n${stderr}`));
|
||||
}, 5000);
|
||||
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += chunk.toString('utf8');
|
||||
if (!settled && stdout.includes('ECC Control Pane:') && stdout.includes('Actions:')) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
child.stderr.on('data', chunk => {
|
||||
stderr += chunk.toString('utf8');
|
||||
});
|
||||
child.on('error', error => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.on('exit', code => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`control pane CLI exited early with ${code}.\nstdout:\n${stdout}\nstderr:\n${stderr}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function waitForExit(child) {
|
||||
return new Promise(resolve => {
|
||||
child.once('exit', (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchLocal(url, options) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
return await fetch(url, options);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise(resolve => setTimeout(resolve, 25 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log('\n=== Testing control-pane server ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (await test('parses CLI arguments for local-only serving', async () => {
|
||||
const parsed = parseArgs([
|
||||
'node',
|
||||
'scripts/control-pane.js',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'8788',
|
||||
'--db',
|
||||
'/tmp/ecc2.db',
|
||||
'--state-db',
|
||||
'/tmp/ecc-state.db',
|
||||
'--query',
|
||||
'Hermes memory',
|
||||
'--no-open',
|
||||
]);
|
||||
|
||||
assert.strictEqual(parsed.host, '127.0.0.1');
|
||||
assert.strictEqual(parsed.port, 8788);
|
||||
assert.strictEqual(parsed.dbPath, '/tmp/ecc2.db');
|
||||
assert.strictEqual(parsed.stateDbPath, '/tmp/ecc-state.db');
|
||||
assert.strictEqual(parsed.query, 'Hermes memory');
|
||||
assert.strictEqual(parsed.openBrowser, false);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('rejects invalid CLI port values', async () => {
|
||||
assert.throws(
|
||||
() => parseArgs(['node', 'scripts/control-pane.js', '--port', '70000']),
|
||||
/Invalid --port value/
|
||||
);
|
||||
assert.throws(
|
||||
() => parseArgs(['node', 'scripts/control-pane.js', '--port', 'wat']),
|
||||
/Invalid --port value/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('rejects missing state database path values', async () => {
|
||||
assert.throws(
|
||||
() => parseArgs(['node', 'scripts/control-pane.js', '--state-db']),
|
||||
/Invalid --state-db value/
|
||||
);
|
||||
assert.throws(
|
||||
() => parseArgs(['node', 'scripts/control-pane.js', '--state-db', '--query', 'Hermes']),
|
||||
/Invalid --state-db value/
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('serves HTML and snapshot JSON from a temp ECC2 database', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-server-'));
|
||||
const dbPath = path.join(tempDir, 'ecc2.db');
|
||||
|
||||
try {
|
||||
await writeMinimalDatabase(dbPath);
|
||||
const app = await createControlPaneServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
dbPath,
|
||||
repoRoot: REPO_ROOT,
|
||||
query: 'control pane',
|
||||
allowActions: false,
|
||||
});
|
||||
|
||||
await app.listen();
|
||||
try {
|
||||
const html = await fetchLocal(`${app.url}/`).then(response => response.text());
|
||||
assert.ok(html.includes('ECC Control Pane'));
|
||||
assert.ok(html.includes('id="app"'));
|
||||
assert.ok(html.includes('id="work-items"'));
|
||||
assert.ok(html.includes('function renderWorkItems'));
|
||||
assert.ok(html.includes('function showError'));
|
||||
assert.ok(html.includes('response.ok'));
|
||||
|
||||
const snapshot = await fetchLocal(`${app.url}/api/snapshot?query=control`).then(response => response.json());
|
||||
assert.strictEqual(snapshot.schemaVersion, 'ecc.control-pane.snapshot.v1');
|
||||
assert.strictEqual(snapshot.summary.totalSessions, 1);
|
||||
assert.strictEqual(snapshot.workItems.totalCount, 0);
|
||||
assert.strictEqual(snapshot.sessions[0].id, 'session-a');
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('serves health, asset, not-found, invalid body, and read-only action responses', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-routes-'));
|
||||
|
||||
try {
|
||||
const app = await createControlPaneServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
dbPath: path.join(tempDir, 'missing.db'),
|
||||
repoRoot: tempDir,
|
||||
allowActions: false,
|
||||
});
|
||||
|
||||
await app.listen();
|
||||
try {
|
||||
const health = await fetchLocal(`${app.url}/api/health`).then(response => response.json());
|
||||
assert.strictEqual(health.ok, true);
|
||||
assert.strictEqual(health.allowActions, false);
|
||||
|
||||
const realAssetApp = await createControlPaneServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
dbPath: path.join(tempDir, 'missing.db'),
|
||||
repoRoot: REPO_ROOT,
|
||||
allowActions: false,
|
||||
});
|
||||
await realAssetApp.listen();
|
||||
try {
|
||||
const realAsset = await fetchLocal(`${realAssetApp.url}/assets/ecc-icon.svg`);
|
||||
assert.strictEqual(realAsset.status, 200);
|
||||
assert.match(await realAsset.text(), /<svg/);
|
||||
} finally {
|
||||
await realAssetApp.close();
|
||||
}
|
||||
|
||||
const missingAsset = await fetchLocal(`${app.url}/assets/ecc-icon.svg`);
|
||||
assert.strictEqual(missingAsset.status, 404);
|
||||
assert.strictEqual(await missingAsset.text(), 'not found');
|
||||
|
||||
const missing = await fetchLocal(`${app.url}/not-here`).then(response => response.json());
|
||||
assert.strictEqual(missing.ok, false);
|
||||
assert.strictEqual(missing.error, 'not found');
|
||||
|
||||
const blocked = await fetchLocal(`${app.url}/api/actions/sync-knowledge`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ query: 'memory' }),
|
||||
}).then(async response => ({ status: response.status, body: await response.json() }));
|
||||
assert.strictEqual(blocked.status, 403);
|
||||
assert.match(blocked.body.error, /disabled/);
|
||||
|
||||
const invalidBody = await fetchLocal(`${app.url}/api/actions/sync-knowledge`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{bad json',
|
||||
}).then(async response => ({ status: response.status, body: await response.json() }));
|
||||
assert.strictEqual(invalidBody.status, 403);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('guards copy-only and unknown action requests', async () => {
|
||||
const app = await createControlPaneServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
repoRoot: REPO_ROOT,
|
||||
allowActions: true,
|
||||
});
|
||||
|
||||
await app.listen();
|
||||
try {
|
||||
const copyOnly = await fetchLocal(`${app.url}/api/actions/open-dashboard`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{}',
|
||||
}).then(async response => ({ status: response.status, body: await response.json() }));
|
||||
assert.strictEqual(copyOnly.status, 400);
|
||||
assert.strictEqual(copyOnly.body.action, 'open-dashboard');
|
||||
assert.match(copyOnly.body.error, /copy-only/);
|
||||
|
||||
const unknown = await fetchLocal(`${app.url}/api/actions/nope`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{}',
|
||||
}).then(async response => ({ status: response.status, body: await response.json() }));
|
||||
assert.strictEqual(unknown.status, 500);
|
||||
assert.match(unknown.body.error, /Unknown control-pane action/);
|
||||
|
||||
const invalidBody = await fetchLocal(`${app.url}/api/actions/sync-knowledge`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{bad json',
|
||||
}).then(async response => ({ status: response.status, body: await response.json() }));
|
||||
assert.strictEqual(invalidBody.status, 500);
|
||||
assert.match(invalidBody.body.error, /JSON/);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('runAction captures success, failure, and bounded output', async () => {
|
||||
const repoRoot = REPO_ROOT;
|
||||
const success = await runAction({
|
||||
id: 'node-success',
|
||||
command: process.execPath,
|
||||
args: ['-e', 'process.stdout.write("x".repeat(21010))'],
|
||||
cwd: repoRoot,
|
||||
});
|
||||
assert.strictEqual(success.ok, true);
|
||||
assert.strictEqual(success.code, 0);
|
||||
assert.ok(success.stdout.includes('[truncated '));
|
||||
|
||||
const failure = await runAction({
|
||||
id: 'node-failure',
|
||||
command: process.execPath,
|
||||
args: ['-e', 'process.stderr.write("bad"); process.exit(7)'],
|
||||
cwd: repoRoot,
|
||||
});
|
||||
assert.strictEqual(failure.ok, false);
|
||||
assert.strictEqual(failure.code, 7);
|
||||
assert.strictEqual(failure.stderr, 'bad');
|
||||
|
||||
const spawnError = await runAction({
|
||||
id: 'spawn-error',
|
||||
command: 'definitely-not-ecc-control-pane-command',
|
||||
args: [],
|
||||
cwd: repoRoot,
|
||||
});
|
||||
assert.strictEqual(spawnError.ok, false);
|
||||
assert.strictEqual(spawnError.code, null);
|
||||
assert.match(spawnError.error, /ENOENT/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('runAction terminates commands that exceed the local timeout', async () => {
|
||||
const timedOut = await runAction(
|
||||
{
|
||||
id: 'node-timeout',
|
||||
command: process.execPath,
|
||||
args: ['-e', 'setTimeout(() => {}, 5000)'],
|
||||
cwd: REPO_ROOT,
|
||||
},
|
||||
{ timeoutMs: 25 }
|
||||
);
|
||||
|
||||
assert.strictEqual(timedOut.ok, false);
|
||||
assert.strictEqual(timedOut.signal, 'SIGTERM');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('CLI prints help', async () => {
|
||||
const result = spawnSync('node', [SCRIPT, '--help'], {
|
||||
encoding: 'utf8',
|
||||
cwd: REPO_ROOT,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Usage:'));
|
||||
assert.ok(result.stdout.includes('control-pane'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('CLI browser opener handles spawn errors', async () => {
|
||||
const source = fs.readFileSync(SCRIPT, 'utf8');
|
||||
|
||||
assert.match(source, /child\.on\('error'/);
|
||||
assert.match(source, /child\.unref\(\)/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('CLI main handles help without starting a server', async () => {
|
||||
const originalLog = console.log;
|
||||
const lines = [];
|
||||
console.log = line => {
|
||||
lines.push(String(line));
|
||||
};
|
||||
try {
|
||||
await runControlPaneCli(['node', 'scripts/control-pane.js', '--help']);
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
}
|
||||
|
||||
assert.match(lines.join('\n'), /Usage:/);
|
||||
assert.match(lines.join('\n'), /--read-only/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (await test('CLI starts a read-only local server and shuts down on SIGTERM', async () => {
|
||||
const child = spawn(process.execPath, [SCRIPT, '--host', '127.0.0.1', '--port', '0', '--read-only', '--no-open'], {
|
||||
cwd: REPO_ROOT,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
ECC2_DB_PATH: path.join(os.tmpdir(), 'missing-ecc2-cli.db'),
|
||||
},
|
||||
});
|
||||
const exitPromise = waitForExit(child);
|
||||
|
||||
try {
|
||||
const ready = await waitForCliReady(child);
|
||||
assert.match(ready.stdout, /ECC Control Pane: http:\/\/127\.0\.0\.1:\d+/);
|
||||
assert.match(ready.stdout, /Actions: read-only/);
|
||||
} finally {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
|
||||
const result = await exitPromise;
|
||||
assert.ok(
|
||||
result.code === 0 || result.signal === 'SIGTERM',
|
||||
`expected graceful shutdown or SIGTERM, got code=${result.code} signal=${result.signal}`
|
||||
);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
@@ -70,6 +70,7 @@ function main() {
|
||||
assert.match(result.stdout, /doctor/);
|
||||
assert.match(result.stdout, /auto-update/);
|
||||
assert.match(result.stdout, /consult/);
|
||||
assert.match(result.stdout, /control-pane/);
|
||||
assert.match(result.stdout, /loop-status/);
|
||||
assert.match(result.stdout, /work-items/);
|
||||
assert.match(result.stdout, /platform-audit/);
|
||||
@@ -114,6 +115,12 @@ function main() {
|
||||
assert.strictEqual(payload.schemaVersion, 'ecc.consult.v1');
|
||||
assert.strictEqual(payload.matches[0].componentId, 'capability:security');
|
||||
}],
|
||||
['supports help for the control-pane subcommand', () => {
|
||||
const result = runCli(['help', 'control-pane']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Usage:/);
|
||||
assert.match(result.stdout, /control-pane/);
|
||||
}],
|
||||
['delegates lifecycle commands', () => {
|
||||
const homeDir = createTempDir('ecc-cli-home-');
|
||||
const projectRoot = createTempDir('ecc-cli-project-');
|
||||
|
||||
@@ -46,6 +46,7 @@ function buildExpectedPublishPaths(repoRoot) {
|
||||
"scripts/ci/scan-supply-chain-iocs.js",
|
||||
"scripts/ci/supply-chain-advisory-sources.js",
|
||||
"scripts/consult.js",
|
||||
"scripts/control-pane.js",
|
||||
"scripts/claw.js",
|
||||
"scripts/discussion-audit.js",
|
||||
"scripts/doctor.js",
|
||||
@@ -78,6 +79,8 @@ function buildExpectedPublishPaths(repoRoot) {
|
||||
"schemas",
|
||||
"agent.yaml",
|
||||
"VERSION",
|
||||
"assets/ecc-icon.svg",
|
||||
"assets/hero.png",
|
||||
]
|
||||
const exclusionPaths = [
|
||||
"!**/__pycache__/**",
|
||||
@@ -130,6 +133,7 @@ function main() {
|
||||
"scripts/ci/scan-supply-chain-iocs.js",
|
||||
"scripts/ci/supply-chain-advisory-sources.js",
|
||||
"scripts/consult.js",
|
||||
"scripts/control-pane.js",
|
||||
"scripts/discussion-audit.js",
|
||||
"scripts/operator-readiness-dashboard.js",
|
||||
"scripts/preview-pack-smoke.js",
|
||||
@@ -141,6 +145,8 @@ function main() {
|
||||
".qwen/QWEN.md",
|
||||
".claude-plugin/plugin.json",
|
||||
".codex-plugin/plugin.json",
|
||||
"assets/ecc-icon.svg",
|
||||
"assets/hero.png",
|
||||
"schemas/install-state.schema.json",
|
||||
"skills/backend-patterns/SKILL.md",
|
||||
]) {
|
||||
|
||||
Reference in New Issue
Block a user