chore: import upstream snapshot with attribution
CI / Test (ubuntu-latest, Node 18.x, bun) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, npm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, pnpm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, yarn) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 20.x, bun) (push) Failing after 17m13s
CI / Test (ubuntu-latest, Node 20.x, npm) (push) Failing after 18m42s
CI / Test (ubuntu-latest, Node 20.x, pnpm) (push) Failing after 15m0s
CI / Test (ubuntu-latest, Node 20.x, yarn) (push) Failing after 49m44s
CI / Test (ubuntu-latest, Node 22.x, bun) (push) Failing after 51m55s
CI / Test (ubuntu-latest, Node 22.x, pnpm) (push) Failing after 21m57s
CI / Test (ubuntu-latest, Node 22.x, npm) (push) Failing after 37m39s
CI / Test (ubuntu-latest, Node 22.x, yarn) (push) Failing after 34m7s
CI / Validate Components (push) Failing after 37m15s
CI / Python Tests (push) Failing after 10m1s
CI / Security Scan (push) Failing after 10m1s
CI / Lint (push) Failing after 17m12s
CI / Coverage (push) Failing after 20m19s
CI / Test (macos-latest, Node 18.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (ubuntu-latest, Node 18.x, bun) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, npm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, pnpm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, yarn) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 20.x, bun) (push) Failing after 17m13s
CI / Test (ubuntu-latest, Node 20.x, npm) (push) Failing after 18m42s
CI / Test (ubuntu-latest, Node 20.x, pnpm) (push) Failing after 15m0s
CI / Test (ubuntu-latest, Node 20.x, yarn) (push) Failing after 49m44s
CI / Test (ubuntu-latest, Node 22.x, bun) (push) Failing after 51m55s
CI / Test (ubuntu-latest, Node 22.x, pnpm) (push) Failing after 21m57s
CI / Test (ubuntu-latest, Node 22.x, npm) (push) Failing after 37m39s
CI / Test (ubuntu-latest, Node 22.x, yarn) (push) Failing after 34m7s
CI / Validate Components (push) Failing after 37m15s
CI / Python Tests (push) Failing after 10m1s
CI / Security Scan (push) Failing after 10m1s
CI / Lint (push) Failing after 17m12s
CI / Coverage (push) Failing after 20m19s
CI / Test (macos-latest, Node 18.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, yarn) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter from a markdown string.
|
||||
* Returns { frontmatter: {}, body: string }.
|
||||
*/
|
||||
function parseFrontmatter(content) {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/);
|
||||
if (!match) {
|
||||
return { frontmatter: {}, body: content };
|
||||
}
|
||||
|
||||
const frontmatter = {};
|
||||
for (const line of match[1].split('\n')) {
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
let value = line.slice(colonIdx + 1).trim();
|
||||
|
||||
// Handle JSON arrays (e.g. tools: ["Read", "Grep"])
|
||||
if (value.startsWith('[') && value.endsWith(']')) {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch {
|
||||
// keep as string
|
||||
}
|
||||
}
|
||||
|
||||
// Strip surrounding quotes
|
||||
if (typeof value === 'string' && value.startsWith('"') && value.endsWith('"')) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
frontmatter[key] = value;
|
||||
}
|
||||
|
||||
return { frontmatter, body: match[2] || '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the first meaningful paragraph from agent body as a summary.
|
||||
* Skips headings, list items, code blocks, and table rows.
|
||||
*/
|
||||
function extractSummary(body, maxSentences = 1) {
|
||||
const lines = body.split('\n');
|
||||
const paragraphs = [];
|
||||
let current = [];
|
||||
let inCodeBlock = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Track fenced code blocks
|
||||
if (trimmed.startsWith('```')) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
continue;
|
||||
}
|
||||
if (inCodeBlock) continue;
|
||||
|
||||
if (trimmed === '') {
|
||||
if (current.length > 0) {
|
||||
paragraphs.push(current.join(' '));
|
||||
current = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip headings, list items (bold, plain, asterisk), numbered lists, table rows
|
||||
if (
|
||||
trimmed.startsWith('#') ||
|
||||
trimmed.startsWith('- ') ||
|
||||
trimmed.startsWith('* ') ||
|
||||
/^\d+\.\s/.test(trimmed) ||
|
||||
trimmed.startsWith('|')
|
||||
) {
|
||||
if (current.length > 0) {
|
||||
paragraphs.push(current.join(' '));
|
||||
current = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
current.push(trimmed);
|
||||
}
|
||||
if (current.length > 0) {
|
||||
paragraphs.push(current.join(' '));
|
||||
}
|
||||
|
||||
const firstParagraph = paragraphs.find(p => p.length > 0);
|
||||
if (!firstParagraph) return '';
|
||||
|
||||
const sentences = firstParagraph.match(/[^.!?]+[.!?]+/g) || [firstParagraph];
|
||||
return sentences.slice(0, maxSentences).map(s => s.trim()).join(' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and parse a single agent file.
|
||||
*/
|
||||
function loadAgent(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
const fileName = path.basename(filePath, '.md');
|
||||
|
||||
return {
|
||||
fileName,
|
||||
name: frontmatter.name || fileName,
|
||||
description: frontmatter.description || '',
|
||||
tools: Array.isArray(frontmatter.tools) ? frontmatter.tools : [],
|
||||
model: frontmatter.model || 'sonnet',
|
||||
body,
|
||||
byteSize: Buffer.byteLength(content, 'utf8'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all agents from a directory.
|
||||
*/
|
||||
function loadAgents(agentsDir) {
|
||||
if (!fs.existsSync(agentsDir)) return [];
|
||||
|
||||
return fs.readdirSync(agentsDir)
|
||||
.filter(f => f.endsWith('.md'))
|
||||
.sort()
|
||||
.map(f => loadAgent(path.join(agentsDir, f)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress an agent to catalog entry (metadata only).
|
||||
*/
|
||||
function compressToCatalog(agent) {
|
||||
return {
|
||||
name: agent.name,
|
||||
description: agent.description,
|
||||
tools: agent.tools,
|
||||
model: agent.model,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress an agent to summary entry (metadata + first paragraph).
|
||||
*/
|
||||
function compressToSummary(agent) {
|
||||
return {
|
||||
...compressToCatalog(agent),
|
||||
summary: extractSummary(agent.body),
|
||||
};
|
||||
}
|
||||
|
||||
const allowedModes = ['catalog', 'summary', 'full'];
|
||||
|
||||
/**
|
||||
* Build a compressed catalog from a directory of agents.
|
||||
*
|
||||
* Modes:
|
||||
* - 'catalog': name, description, tools, model only (~2-3k tokens for 27 agents)
|
||||
* - 'summary': catalog + first paragraph summary (~4-5k tokens)
|
||||
* - 'full': no compression, full body included
|
||||
*
|
||||
* Returns { agents: [], stats: { totalAgents, originalBytes, compressedBytes, compressedTokenEstimate, mode } }
|
||||
*/
|
||||
function buildAgentCatalog(agentsDir, options = {}) {
|
||||
const mode = options.mode || 'catalog';
|
||||
|
||||
if (!allowedModes.includes(mode)) {
|
||||
throw new Error(`Invalid mode "${mode}". Allowed modes: ${allowedModes.join(', ')}`);
|
||||
}
|
||||
|
||||
const filter = options.filter || null;
|
||||
|
||||
let agents = loadAgents(agentsDir);
|
||||
|
||||
if (typeof filter === 'function') {
|
||||
agents = agents.filter(filter);
|
||||
}
|
||||
|
||||
const originalBytes = agents.reduce((sum, a) => sum + a.byteSize, 0);
|
||||
|
||||
let compressed;
|
||||
if (mode === 'catalog') {
|
||||
compressed = agents.map(compressToCatalog);
|
||||
} else if (mode === 'summary') {
|
||||
compressed = agents.map(compressToSummary);
|
||||
} else {
|
||||
compressed = agents.map(a => ({
|
||||
name: a.name,
|
||||
description: a.description,
|
||||
tools: a.tools,
|
||||
model: a.model,
|
||||
body: a.body,
|
||||
}));
|
||||
}
|
||||
|
||||
const compressedJson = JSON.stringify(compressed);
|
||||
// Rough token estimate: ~4 chars per token for English text
|
||||
const compressedTokenEstimate = Math.ceil(compressedJson.length / 4);
|
||||
|
||||
return {
|
||||
agents: compressed,
|
||||
stats: {
|
||||
totalAgents: agents.length,
|
||||
originalBytes,
|
||||
compressedBytes: Buffer.byteLength(compressedJson, 'utf8'),
|
||||
compressedTokenEstimate,
|
||||
mode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-load a single agent's full content by name.
|
||||
* Returns null if not found.
|
||||
*/
|
||||
function lazyLoadAgent(agentsDir, agentName) {
|
||||
// Validate agentName: only allow alphanumeric, hyphen, underscore
|
||||
if (!/^[\w-]+$/.test(agentName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePath = path.resolve(agentsDir, `${agentName}.md`);
|
||||
|
||||
// Verify the resolved path is still within agentsDir
|
||||
const resolvedAgentsDir = path.resolve(agentsDir);
|
||||
if (!filePath.startsWith(resolvedAgentsDir + path.sep)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
return loadAgent(filePath);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildAgentCatalog,
|
||||
compressToCatalog,
|
||||
compressToSummary,
|
||||
extractSummary,
|
||||
lazyLoadAgent,
|
||||
loadAgent,
|
||||
loadAgents,
|
||||
parseFrontmatter,
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Resolve ECC agent data home (memory persistence root) across harnesses.
|
||||
*
|
||||
* Docstring policy: public entry points here are documented; small internal
|
||||
* helpers (e.g. `expandHomePath`, `readProjectConfigAt`) are left undocumented on
|
||||
* purpose, consistent with ECC script modules elsewhere. Automated PR reviewers
|
||||
* (e.g. CodeRabbit) may still flag low JSDoc coverage against a high threshold on
|
||||
* the diff—that check is informational for this repo, not a bar every helper in
|
||||
* touched files must meet. Prefer clarity in code and tests over blanket JSDoc on
|
||||
* private helpers unless maintainers adopt a project-wide coverage rule.
|
||||
*
|
||||
* @see https://github.com/affaan-m/ECC/issues/2065
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const AGENT_DATA_HOME_ENV = 'ECC_AGENT_DATA_HOME';
|
||||
const DEFAULT_CLAUDE_DIR_NAME = '.claude';
|
||||
const DEFAULT_CURSOR_ECC_DIR_SEGMENTS = ['.cursor', 'ecc'];
|
||||
const PROJECT_CONFIG_RELATIVE = path.join('.cursor', 'ecc-agent-data.json');
|
||||
|
||||
/**
|
||||
* Home directory for tilde expansion and default agent-data paths.
|
||||
*
|
||||
* Intentionally mirrors `getHomeDir()` in `scripts/lib/utils.js` (HOME/USERPROFILE,
|
||||
* then `os.homedir()`). Do not import `utils.getHomeDir` here: `utils.js` already
|
||||
* requires this module (`resolveAgentDataHome`), which would create a circular
|
||||
* dependency and risk divergent defaults for `~/.cursor/ecc` vs `~/.claude`.
|
||||
*
|
||||
* If consolidation is needed later, prefer one of:
|
||||
*
|
||||
* | Approach | Tradeoff |
|
||||
* | --- | --- |
|
||||
* | Shared `scripts/lib/home-dir.js` imported by both | Clean; breaks the cycle |
|
||||
* | Keep duplicate + cross-reference comment (this file) | Zero require risk |
|
||||
* | Move all resolution here; thin-wrap from `utils` | Larger refactor |
|
||||
*/
|
||||
function getHomeDirFromEnv() {
|
||||
const explicitHome = process.env.HOME || process.env.USERPROFILE;
|
||||
if (explicitHome && String(explicitHome).trim().length > 0) {
|
||||
return path.resolve(explicitHome);
|
||||
}
|
||||
return require('os').homedir();
|
||||
}
|
||||
|
||||
function expandHomePath(value, baseDir) {
|
||||
if (!value || typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.startsWith('~')) {
|
||||
const remainder = trimmed.slice(1).replace(/^[/\\]+/, '');
|
||||
return remainder ? path.join(getHomeDirFromEnv(), remainder) : getHomeDirFromEnv();
|
||||
}
|
||||
if (path.isAbsolute(trimmed)) {
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
const base = baseDir && String(baseDir).trim()
|
||||
? path.resolve(baseDir)
|
||||
: process.cwd();
|
||||
return path.resolve(base, trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project root for a config file under .cursor/ecc-agent-data.json.
|
||||
*/
|
||||
function resolveProjectRootFromConfigPath(configPath) {
|
||||
const configDir = path.dirname(path.resolve(configPath));
|
||||
if (path.basename(configDir) === '.cursor') {
|
||||
return path.dirname(configDir);
|
||||
}
|
||||
return configDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the current process is a Cursor hook subprocess.
|
||||
* Cursor documents CURSOR_VERSION and CURSOR_PROJECT_DIR for hook scripts.
|
||||
*/
|
||||
function isCursorHookRuntime() {
|
||||
if (process.env.CURSOR_VERSION && String(process.env.CURSOR_VERSION).trim()) {
|
||||
return true;
|
||||
}
|
||||
if (process.env.CURSOR_PROJECT_DIR && String(process.env.CURSOR_PROJECT_DIR).trim()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getDefaultCursorAgentDataHome() {
|
||||
return path.join(getHomeDirFromEnv(), ...DEFAULT_CURSOR_ECC_DIR_SEGMENTS);
|
||||
}
|
||||
|
||||
function getDefaultClaudeAgentDataHome() {
|
||||
return path.join(getHomeDirFromEnv(), DEFAULT_CLAUDE_DIR_NAME);
|
||||
}
|
||||
|
||||
function readProjectConfigAt(configPath) {
|
||||
if (!configPath || typeof configPath !== 'string') return null;
|
||||
if (!fs.existsSync(configPath)) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||
const candidate = parsed.agentDataHome || parsed.ECC_AGENT_DATA_HOME;
|
||||
if (typeof candidate !== 'string' || !candidate.trim()) return null;
|
||||
const projectRoot = resolveProjectRootFromConfigPath(configPath);
|
||||
return expandHomePath(candidate, projectRoot);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[ECC] Failed to read or parse agent data config at ${configPath}: ${error.message}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readProjectConfig(projectDir) {
|
||||
if (!projectDir || typeof projectDir !== 'string') return null;
|
||||
return readProjectConfigAt(path.join(path.resolve(projectDir), PROJECT_CONFIG_RELATIVE));
|
||||
}
|
||||
|
||||
function resolveProjectDir() {
|
||||
const candidates = [
|
||||
process.env.CURSOR_PROJECT_DIR,
|
||||
process.env.CLAUDE_PROJECT_DIR,
|
||||
process.cwd(),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate || typeof candidate !== 'string') continue;
|
||||
const resolved = path.resolve(candidate);
|
||||
if (fs.existsSync(path.join(resolved, '.cursor'))) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve agent data home without mutating process.env.
|
||||
*/
|
||||
function resolveAgentDataHome(options = {}) {
|
||||
const fromEnv = expandHomePath(process.env[AGENT_DATA_HOME_ENV]);
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
const projectDir = options.projectDir || resolveProjectDir();
|
||||
const fromProject = readProjectConfig(projectDir);
|
||||
if (fromProject) return fromProject;
|
||||
|
||||
if (options.preferCursorDefault === true || isCursorHookRuntime()) {
|
||||
return getDefaultCursorAgentDataHome();
|
||||
}
|
||||
|
||||
return getDefaultClaudeAgentDataHome();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set ECC_AGENT_DATA_HOME on the current process when unset (hook subprocess safety net).
|
||||
* @returns {string} Resolved agent data home
|
||||
*/
|
||||
function ensureAgentDataHomeEnv(options = {}) {
|
||||
const resolved = resolveAgentDataHome(options);
|
||||
if (!expandHomePath(process.env[AGENT_DATA_HOME_ENV])) {
|
||||
process.env[AGENT_DATA_HOME_ENV] = resolved;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Cursor sessionStart hook output env payload.
|
||||
*/
|
||||
function getCursorSessionEnvPayload(options = {}) {
|
||||
const agentDataHome = resolveAgentDataHome({
|
||||
...options,
|
||||
preferCursorDefault: true,
|
||||
});
|
||||
|
||||
return {
|
||||
ECC_AGENT_DATA_HOME: agentDataHome,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AGENT_DATA_HOME_ENV,
|
||||
DEFAULT_CLAUDE_DIR_NAME,
|
||||
DEFAULT_CURSOR_ECC_DIR_SEGMENTS,
|
||||
PROJECT_CONFIG_RELATIVE,
|
||||
expandHomePath,
|
||||
resolveProjectRootFromConfigPath,
|
||||
isCursorHookRuntime,
|
||||
getDefaultCursorAgentDataHome,
|
||||
getDefaultClaudeAgentDataHome,
|
||||
readProjectConfig,
|
||||
readProjectConfigAt,
|
||||
resolveProjectDir,
|
||||
resolveAgentDataHome,
|
||||
ensureAgentDataHomeEnv,
|
||||
getCursorSessionEnvPayload,
|
||||
};
|
||||
@@ -0,0 +1,330 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Agent-space distance metric + collision avoidance (ECC 2.0, Layer 4 v0).
|
||||
*
|
||||
* Two agents editing the same codebase are like two aircraft sharing airspace:
|
||||
* we want a continuous notion of "how close are they" so that, as they approach,
|
||||
* we fire a TCAS-style protocol — first a Traffic Advisory (exchange intent),
|
||||
* then a Resolution Advisory (one steers away) — *before* they collide at the
|
||||
* git/merge layer.
|
||||
*
|
||||
* ── The state of an agent ──────────────────────────────────────────────────
|
||||
* At time t, agent a has a working set
|
||||
* W_a = { (f, R_f, w_f) } (1)
|
||||
* where f is a file it has touched, R_f the set of line ranges it edited in f,
|
||||
* and w_f ∈ (0,1] a recency weight (older edits decay). Optionally an agent
|
||||
* declares an intent set I_a of files it is about to touch.
|
||||
*
|
||||
* ── Collision is multi-channel ─────────────────────────────────────────────
|
||||
* Two agents can collide through several independent channels, so we model a
|
||||
* per-channel collision probability r_i ∈ [0,1] and combine with a noisy-OR
|
||||
* (probability of colliding through *at least one* channel):
|
||||
* R(a,b) = 1 − Π_i (1 − ω_i · r_i) (2)
|
||||
* with channel weights ω_i ∈ [0,1]. R is the agent-distance's dual: we report
|
||||
* both the risk R ∈ [0,1] and a distance D = 1 − R.
|
||||
*
|
||||
* Channels (each defined below):
|
||||
* r_overlap — same file / overlapping line ranges (imminent)
|
||||
* r_dep — one agent's files depend on the other's (collision even when
|
||||
* far apart in the tree: edit there breaks here)
|
||||
* r_tree — proximity in the directory tree (a soft prior)
|
||||
*
|
||||
* ── Channel 1: edit overlap ────────────────────────────────────────────────
|
||||
* For each shared file f ∈ files(W_a) ∩ files(W_b), the overlap COEFFICIENT
|
||||
* (Szymkiewicz–Simpson) over the edited line ranges:
|
||||
* lineOverlap(f) = |R_f^a ∩ R_f^b| / min(|R_f^a|, |R_f^b|)
|
||||
* (the right collision measure — high when one agent's edit sits inside the
|
||||
* other's region even if that region is huge; =1 when either side is a whole-file
|
||||
* edit). The channel risk is the recency-weighted max across shared files:
|
||||
* r_overlap = max_{f∈S} w_f^a·w_f^b · lineOverlap(f). (3)
|
||||
* Different files ⇒ no shared f ⇒ r_overlap = 0 (tree/dep channels take over).
|
||||
*
|
||||
* ── Channel 2: dependency coupling ─────────────────────────────────────────
|
||||
* Build a directed dependency graph G=(V,E), V=files, edge f→g iff f imports g.
|
||||
* Even if f and g are in distant subtrees, if f (agent a) depends on g (agent b)
|
||||
* then b's edit to g can break a. Coupling decays with graph distance:
|
||||
* coupling(f,g) = γ^{ d_G(f,g) − 1 } (γ∈(0,1)), 0 if unreachable. (4)
|
||||
* A direct edge (d_G=1) ⇒ coupling=1. We take the recency-weighted max over
|
||||
* cross pairs:
|
||||
* r_dep = max_{f∈W_a, g∈W_b} w_f·w_g·max(coupling(f,g), coupling(g,f)). (5)
|
||||
*
|
||||
* ── Channel 3: tree proximity ──────────────────────────────────────────────
|
||||
* For two paths split into segments with lowest-common-ancestor depth L:
|
||||
* treeDistance(f,g) = ((depth_f − L) + (depth_g − L)) / (depth_f + depth_g) (6)
|
||||
* (0 = same file, 1 = disjoint roots). r_tree = 1 − min cross-pair treeDist.
|
||||
* Tree proximity alone rarely causes a collision, so ω_tree is small — it nudges
|
||||
* the metric, it does not dominate it.
|
||||
*
|
||||
* ── TCAS protocol ──────────────────────────────────────────────────────────
|
||||
* Two thresholds carve a protected zone:
|
||||
* R < τ_TA → CLEAR
|
||||
* τ_TA ≤ R < τ_RA → TRAFFIC ADVISORY: each agent transmits what it is
|
||||
* doing/has done to the other (the scout handshake)
|
||||
* R ≥ τ_RA → RESOLUTION ADVISORY: the lower-priority agent steers
|
||||
* away; the higher-priority one holds course.
|
||||
* Like TCAS coordinating climb/descend, the resolution is *coordinated* and
|
||||
* deterministic so both agents never pick the same maneuver: priority(a) breaks
|
||||
* the tie (right-of-way to the agent with more committed work / earlier start;
|
||||
* stable agentId as the final tiebreak). See advise().
|
||||
*
|
||||
* ── Vector-space view ──────────────────────────────────────────────────────
|
||||
* embedAgent() places each agent at the recency-weighted centroid of its files'
|
||||
* coordinates, where a file's coordinate is a low-dim hash of its path segments
|
||||
* smoothed toward its dependency neighbours. Then ‖v_a − v_b‖ tracks R, which is
|
||||
* what a 3D "where are the agents" visualization renders. See embed.js.
|
||||
*/
|
||||
|
||||
const DEFAULTS = {
|
||||
channelWeights: { overlap: 1.0, dependency: 0.9, tree: 0.25 },
|
||||
depDecay: 0.5, // γ in (4)
|
||||
recencyFloor: 0.15, // weight never decays below this so stale-but-relevant files still count
|
||||
thresholds: { ta: 0.35, ra: 0.7 } // τ_TA, τ_RA
|
||||
};
|
||||
|
||||
function clamp01(x) {
|
||||
if (!Number.isFinite(x)) return 0;
|
||||
return x < 0 ? 0 : x > 1 ? 1 : x;
|
||||
}
|
||||
|
||||
function normalizePath(p) {
|
||||
return String(p || '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function segments(p) {
|
||||
return normalizePath(p).split('/').filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tree distance ∈ [0,1] between two file paths — eq. (6). 0 = same file.
|
||||
*/
|
||||
function treeDistance(a, b) {
|
||||
const sa = segments(a);
|
||||
const sb = segments(b);
|
||||
if (sa.length === 0 || sb.length === 0) return 1;
|
||||
let lca = 0;
|
||||
while (lca < sa.length && lca < sb.length && sa[lca] === sb[lca]) lca += 1;
|
||||
const da = sa.length;
|
||||
const db = sb.length;
|
||||
if (da === db && lca === da) return 0; // identical path
|
||||
return clamp01((da - lca + (db - lca)) / (da + db));
|
||||
}
|
||||
|
||||
/**
|
||||
* Line-range overlap as the overlap COEFFICIENT (Szymkiewicz–Simpson):
|
||||
* |A ∩ B| / min(|A|, |B|). This is the right collision measure — if one agent's
|
||||
* edit sits largely inside the other's region the score is high even when the
|
||||
* other region is huge (Jaccard would dilute it by union size). Empty/absent
|
||||
* ranges ⇒ whole-file edit ⇒ full overlap (1). Each range is [start,end] inclusive.
|
||||
*/
|
||||
function lineRangeOverlap(rangesA, rangesB) {
|
||||
const a = Array.isArray(rangesA) ? rangesA : [];
|
||||
const b = Array.isArray(rangesB) ? rangesB : [];
|
||||
if (a.length === 0 || b.length === 0) return 1; // file-level edit ⇒ whole-file overlap
|
||||
const covered = ranges => {
|
||||
const set = new Set();
|
||||
for (const [s, e] of ranges) {
|
||||
const lo = Math.min(s, e);
|
||||
const hi = Math.max(s, e);
|
||||
for (let i = lo; i <= hi; i += 1) set.add(i);
|
||||
}
|
||||
return set;
|
||||
};
|
||||
const ca = covered(a);
|
||||
const cb = covered(b);
|
||||
if (ca.size === 0 || cb.size === 0) return 0;
|
||||
let inter = 0;
|
||||
for (const v of ca) if (cb.has(v)) inter += 1;
|
||||
return inter / Math.min(ca.size, cb.size);
|
||||
}
|
||||
|
||||
function jaccard(setA, setB) {
|
||||
if (setA.size === 0 && setB.size === 0) return 0;
|
||||
let inter = 0;
|
||||
for (const v of setA) if (setB.has(v)) inter += 1;
|
||||
const union = setA.size + setB.size - inter;
|
||||
return union === 0 ? 0 : inter / union;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel 1 — edit overlap, eq. (3).
|
||||
*/
|
||||
function overlapRisk(a, b) {
|
||||
const filesA = a.files || [];
|
||||
const filesB = b.files || [];
|
||||
const byPathB = new Map(filesB.map(f => [normalizePath(f.path), f]));
|
||||
// Per shared file, the (line-precise) overlap — lineRangeOverlap returns 1 when
|
||||
// either side lacks ranges (a whole-file edit). The risk is the max across
|
||||
// shared files: even one fully-overlapping file is a collision, while the same
|
||||
// file edited in disjoint line ranges scores low. No coarse file-set Jaccard
|
||||
// floor (it would max out for any shared file and mask line-level disjointness).
|
||||
let r = 0;
|
||||
for (const fa of filesA) {
|
||||
const fb = byPathB.get(normalizePath(fa.path));
|
||||
if (fb) {
|
||||
const w = (fa.weight ?? 1) * (fb.weight ?? 1);
|
||||
r = Math.max(r, w * lineRangeOverlap(fa.lines, fb.lines));
|
||||
}
|
||||
}
|
||||
return clamp01(r);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortest-path distance in a directed dependency graph, treated as undirected
|
||||
* for reachability (a depends-on edge couples both endpoints). BFS, capped.
|
||||
*/
|
||||
function graphDistance(graph, from, to, cap = 6) {
|
||||
const start = normalizePath(from);
|
||||
const goal = normalizePath(to);
|
||||
if (start === goal) return 0;
|
||||
const adj = graph && graph.adjacency ? graph.adjacency : graph || {};
|
||||
const seen = new Set([start]);
|
||||
let frontier = [start];
|
||||
for (let depth = 1; depth <= cap; depth += 1) {
|
||||
const next = [];
|
||||
for (const node of frontier) {
|
||||
const neighbours = adj[node] || [];
|
||||
for (const nb of neighbours) {
|
||||
const n = normalizePath(nb);
|
||||
if (n === goal) return depth;
|
||||
if (!seen.has(n)) {
|
||||
seen.add(n);
|
||||
next.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (next.length === 0) break;
|
||||
frontier = next;
|
||||
}
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel 2 — dependency coupling, eqs. (4)-(5).
|
||||
*/
|
||||
function dependencyRisk(a, b, graph, opts = {}) {
|
||||
const decay = opts.depDecay ?? DEFAULTS.depDecay;
|
||||
const filesA = a.files || [];
|
||||
const filesB = b.files || [];
|
||||
let r = 0;
|
||||
for (const fa of filesA) {
|
||||
for (const fb of filesB) {
|
||||
// A depends-on edge couples both endpoints, so use the smaller of the two
|
||||
// directed distances (importer→imported or imported→importer).
|
||||
const d = Math.min(graphDistance(graph, fa.path, fb.path), graphDistance(graph, fb.path, fa.path));
|
||||
if (d === Infinity || d === 0) continue;
|
||||
const coupling = Math.pow(decay, d - 1); // γ^{d-1}
|
||||
const w = (fa.weight ?? 1) * (fb.weight ?? 1);
|
||||
r = Math.max(r, w * coupling);
|
||||
}
|
||||
}
|
||||
return clamp01(r);
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel 3 — tree proximity (soft prior), eq. (6).
|
||||
*/
|
||||
function treeRisk(a, b) {
|
||||
const filesA = a.files || [];
|
||||
const filesB = b.files || [];
|
||||
let minDist = 1;
|
||||
for (const fa of filesA) {
|
||||
for (const fb of filesB) {
|
||||
minDist = Math.min(minDist, treeDistance(fa.path, fb.path));
|
||||
}
|
||||
}
|
||||
return clamp01(1 - minDist);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collision risk R(a,b) ∈ [0,1] via the noisy-OR of channels, eq. (2).
|
||||
* Returns the risk, its dual distance, and the per-channel breakdown.
|
||||
*/
|
||||
function collisionRisk(a, b, graph = {}, options = {}) {
|
||||
const weights = { ...DEFAULTS.channelWeights, ...(options.channelWeights || {}) };
|
||||
const channels = {
|
||||
overlap: overlapRisk(a, b),
|
||||
dependency: dependencyRisk(a, b, graph, options),
|
||||
tree: treeRisk(a, b)
|
||||
};
|
||||
let product = 1;
|
||||
for (const key of Object.keys(channels)) {
|
||||
const w = clamp01(weights[key] ?? 0);
|
||||
product *= 1 - w * channels[key];
|
||||
}
|
||||
const risk = clamp01(1 - product);
|
||||
return { risk, distance: clamp01(1 - risk), channels };
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-of-way priority: the agent with more committed work and the earlier
|
||||
* start holds course; the other steers. Higher number = higher priority.
|
||||
*/
|
||||
function agentPriority(agent) {
|
||||
const progress = (agent.files || []).reduce((s, f) => s + (f.weight ?? 1), 0);
|
||||
const startedAt = agent.startedAt ? Date.parse(agent.startedAt) || 0 : 0;
|
||||
// Earlier start ⇒ larger right-of-way term (negative ms, so earlier = larger).
|
||||
return { progress, ageMs: startedAt ? Date.now() - startedAt : 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* TCAS-style advisory between two agents given their collision risk.
|
||||
* Returns { level: 'clear'|'advisory'|'resolution', risk, transmit, steer, hold }.
|
||||
* - advisory: both should transmit intent to each other.
|
||||
* - resolution: `steer` is the agentId that must move; `hold` holds course.
|
||||
*/
|
||||
function advise(a, b, graph = {}, options = {}) {
|
||||
const thresholds = { ...DEFAULTS.thresholds, ...(options.thresholds || {}) };
|
||||
const { risk, channels, distance } = collisionRisk(a, b, graph, options);
|
||||
|
||||
if (risk < thresholds.ta) {
|
||||
return { level: 'clear', risk, distance, channels, transmit: false, steer: null, hold: null };
|
||||
}
|
||||
|
||||
const pa = agentPriority(a);
|
||||
const pb = agentPriority(b);
|
||||
// Right-of-way: more progress wins; tie → earlier start (greater age) wins;
|
||||
// final deterministic tiebreak on agentId so the maneuver is coordinated.
|
||||
let aHasPriority;
|
||||
if (pa.progress !== pb.progress) aHasPriority = pa.progress > pb.progress;
|
||||
else if (pa.ageMs !== pb.ageMs) aHasPriority = pa.ageMs > pb.ageMs;
|
||||
else aHasPriority = String(a.agentId) < String(b.agentId);
|
||||
|
||||
const hold = aHasPriority ? a.agentId : b.agentId;
|
||||
const steer = aHasPriority ? b.agentId : a.agentId;
|
||||
|
||||
if (risk < thresholds.ra) {
|
||||
// Traffic advisory: exchange intent, no one has to move yet.
|
||||
return { level: 'advisory', risk, distance, channels, transmit: true, steer: null, hold: null };
|
||||
}
|
||||
// Resolution advisory: the lower-priority agent steers away.
|
||||
return { level: 'resolution', risk, distance, channels, transmit: true, steer, hold };
|
||||
}
|
||||
|
||||
/**
|
||||
* Closure rate: how fast two agents are converging, from two risk samples
|
||||
* Δt apart (TCAS uses closure rate, not just separation, to decide urgency).
|
||||
* Positive ⇒ approaching. Used to escalate before the protected zone is reached.
|
||||
*/
|
||||
function closureRate(prevRisk, currRisk, dtMs) {
|
||||
const dt = Number(dtMs) > 0 ? Number(dtMs) : 1;
|
||||
return (clamp01(currRisk) - clamp01(prevRisk)) / (dt / 1000);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULTS,
|
||||
treeDistance,
|
||||
lineRangeOverlap,
|
||||
graphDistance,
|
||||
overlapRisk,
|
||||
dependencyRisk,
|
||||
treeRisk,
|
||||
collisionRisk,
|
||||
agentPriority,
|
||||
advise,
|
||||
closureRate,
|
||||
_internal: { normalizePath, segments, jaccard }
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Lightweight dependency-graph builder for the agent-proximity metric.
|
||||
*
|
||||
* Edge f → g iff f imports/requires g. This is the structure the dependency
|
||||
* channel (distance.js, eqs. 4-5) walks: two agents far apart in the tree still
|
||||
* collide if one edits a file the other imports.
|
||||
*
|
||||
* v0 scans JS/TS `require()` / `import ... from` / `import(...)` for relative
|
||||
* specifiers and resolves them to repo-relative paths. It is intentionally
|
||||
* static and dependency-free; richer languages and call-graph edges are future
|
||||
* channels that slot into the same adjacency shape.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SOURCE_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'];
|
||||
const RESOLVE_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.json'];
|
||||
|
||||
function toRepoRel(repoRoot, absPath) {
|
||||
return path.relative(repoRoot, absPath).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
// Match relative specifiers only (./ or ../). Bare specifiers are node_modules
|
||||
// and never the target of an in-repo collision.
|
||||
const SPEC_PATTERNS = [
|
||||
/require\(\s*['"](\.[^'"]+)['"]\s*\)/g,
|
||||
/import\s+(?:[^'"]*?\s+from\s+)?['"](\.[^'"]+)['"]/g,
|
||||
/import\(\s*['"](\.[^'"]+)['"]\s*\)/g,
|
||||
/export\s+(?:\*|\{[^}]*\})\s+from\s+['"](\.[^'"]+)['"]/g
|
||||
];
|
||||
|
||||
function extractRelativeSpecifiers(source) {
|
||||
const specs = new Set();
|
||||
for (const re of SPEC_PATTERNS) {
|
||||
re.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = re.exec(source)) !== null) {
|
||||
specs.add(m[1]);
|
||||
}
|
||||
}
|
||||
return [...specs];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a relative specifier from `fromFile` to a repo-relative path, trying
|
||||
* extension and /index resolution like Node/TS would.
|
||||
*/
|
||||
function resolveSpecifier(repoRoot, fromFile, spec) {
|
||||
const baseDir = path.dirname(path.join(repoRoot, fromFile));
|
||||
const target = path.resolve(baseDir, spec);
|
||||
const candidates = [target];
|
||||
for (const ext of RESOLVE_EXTENSIONS) candidates.push(target + ext);
|
||||
for (const ext of RESOLVE_EXTENSIONS) candidates.push(path.join(target, 'index' + ext));
|
||||
for (const cand of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(cand) && fs.statSync(cand).isFile()) {
|
||||
return toRepoRel(repoRoot, cand);
|
||||
}
|
||||
} catch {
|
||||
/* ignore unreadable candidate */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSourceFile(p) {
|
||||
return SOURCE_EXTENSIONS.includes(path.extname(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a dependency graph from an explicit list of repo-relative files.
|
||||
* Returns { adjacency: { file: [importedFile, ...] }, files: [...] }.
|
||||
*
|
||||
* @param {string} repoRoot
|
||||
* @param {string[]} files repo-relative paths to scan
|
||||
* @param {object} [deps] injectable fs for testing: { readFileSync, existsSync, statSync }
|
||||
*/
|
||||
function buildDependencyGraph(repoRoot, files, deps = {}) {
|
||||
const read = deps.readFileSync || fs.readFileSync;
|
||||
const adjacency = {};
|
||||
const scanned = [];
|
||||
for (const rel of files || []) {
|
||||
const normalized = String(rel).replace(/\\/g, '/');
|
||||
if (!isSourceFile(normalized)) continue;
|
||||
scanned.push(normalized);
|
||||
let source = '';
|
||||
try {
|
||||
source = String(read(path.join(repoRoot, normalized), 'utf8'));
|
||||
} catch {
|
||||
adjacency[normalized] = adjacency[normalized] || [];
|
||||
continue;
|
||||
}
|
||||
const edges = new Set(adjacency[normalized] || []);
|
||||
for (const spec of extractRelativeSpecifiers(source)) {
|
||||
const resolved = resolveSpecifier(repoRoot, normalized, spec);
|
||||
if (resolved && resolved !== normalized) edges.add(resolved);
|
||||
}
|
||||
adjacency[normalized] = [...edges];
|
||||
}
|
||||
return { adjacency, files: scanned };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a graph directly from an in-memory map of { file: sourceText }, for
|
||||
* callers that already have file contents (and for tests). Specifiers are
|
||||
* resolved against the provided file set rather than the filesystem.
|
||||
*/
|
||||
function buildDependencyGraphFromSources(sources = {}) {
|
||||
const adjacency = {};
|
||||
const fileList = Object.keys(sources).map(f => f.replace(/\\/g, '/'));
|
||||
const fileSet = new Set(fileList);
|
||||
const tryResolve = (fromFile, spec) => {
|
||||
const base = path.posix.dirname(fromFile);
|
||||
const target = path.posix.normalize(path.posix.join(base, spec));
|
||||
const candidates = [target];
|
||||
for (const ext of RESOLVE_EXTENSIONS) candidates.push(target + ext);
|
||||
for (const ext of RESOLVE_EXTENSIONS) candidates.push(path.posix.join(target, 'index' + ext));
|
||||
return candidates.find(c => fileSet.has(c)) || null;
|
||||
};
|
||||
for (const file of fileList) {
|
||||
const edges = new Set();
|
||||
for (const spec of extractRelativeSpecifiers(String(sources[file] || ''))) {
|
||||
const resolved = tryResolve(file, spec);
|
||||
if (resolved && resolved !== file) edges.add(resolved);
|
||||
}
|
||||
adjacency[file] = [...edges];
|
||||
}
|
||||
return { adjacency, files: fileList };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDependencyGraph,
|
||||
buildDependencyGraphFromSources,
|
||||
extractRelativeSpecifiers,
|
||||
resolveSpecifier,
|
||||
isSourceFile
|
||||
};
|
||||
@@ -0,0 +1,223 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Agent-proximity orchestration: scan all agents in a codebase, compute the
|
||||
* pairwise TCAS advisories that drive the steer/transmit triggers, and embed
|
||||
* each agent in 3D space for the "where are the agents" visualization.
|
||||
*
|
||||
* This is the call the control pane / hook layer makes each tick:
|
||||
* const scan = scanAirspace(agents, graph)
|
||||
* for (const a of scan.advisories) fireTrigger(a) // transmit / steer
|
||||
* renderViz(scan.positions, scan.advisories) // 3D crawl view
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { advise, collisionRisk, DEFAULTS } = require('./distance');
|
||||
const { buildDependencyGraph, buildDependencyGraphFromSources } = require('./graph');
|
||||
|
||||
const { normalizePath, segments } = require('./distance')._internal;
|
||||
|
||||
/**
|
||||
* Deterministic hash of a string to a unit-ish vector in R^dims (components in
|
||||
* roughly [-1, 1]). Used to place tree prefixes in space.
|
||||
*/
|
||||
function hashVec(str, dims) {
|
||||
const digest = crypto.createHash('sha256').update(String(str)).digest();
|
||||
const v = new Array(dims).fill(0);
|
||||
for (let d = 0; d < dims; d += 1) {
|
||||
// Two bytes per dim → [-1, 1).
|
||||
const hi = digest[(d * 2) % digest.length];
|
||||
const lo = digest[(d * 2 + 1) % digest.length];
|
||||
v[d] = ((hi << 8) | lo) / 32768 - 1;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinate of a file: a space-filling embedding of its path. Files that share
|
||||
* a long directory prefix share most of their coordinate (deeper segments
|
||||
* perturb less), so tree-close files are space-close — exactly what eq. (6)
|
||||
* wants the visualization to show.
|
||||
*/
|
||||
function fileCoordinate(filePath, dims = 3) {
|
||||
const segs = segments(filePath);
|
||||
const v = new Array(dims).fill(0);
|
||||
let prefix = '';
|
||||
for (let i = 0; i < segs.length; i += 1) {
|
||||
prefix += '/' + segs[i];
|
||||
const h = hashVec(prefix, dims);
|
||||
const scale = 1 / Math.pow(2, i);
|
||||
for (let d = 0; d < dims; d += 1) v[d] += h[d] * scale;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a file's coordinate toward the coordinates of its dependency neighbours
|
||||
* (one averaging step), so coupled files that are far in the tree are drawn
|
||||
* closer in space — the dependency channel made visible.
|
||||
*/
|
||||
function smoothByDependency(coords, graph, alpha = 0.35) {
|
||||
const adj = (graph && graph.adjacency) || {};
|
||||
const out = {};
|
||||
for (const file of Object.keys(coords)) {
|
||||
const base = coords[file];
|
||||
const neighbours = (adj[file] || []).map(normalizePath).filter(n => coords[n]);
|
||||
if (neighbours.length === 0) {
|
||||
out[file] = base.slice();
|
||||
continue;
|
||||
}
|
||||
const dims = base.length;
|
||||
const avg = new Array(dims).fill(0);
|
||||
for (const n of neighbours) for (let d = 0; d < dims; d += 1) avg[d] += coords[n][d];
|
||||
for (let d = 0; d < dims; d += 1) avg[d] /= neighbours.length;
|
||||
out[file] = base.map((x, d) => (1 - alpha) * x + alpha * avg[d]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function weightedCentroid(files, fileCoords, dims) {
|
||||
const v = new Array(dims).fill(0);
|
||||
let wsum = 0;
|
||||
for (const f of files) {
|
||||
const c = fileCoords[normalizePath(f.path)];
|
||||
if (!c) continue;
|
||||
const w = f.weight ?? 1;
|
||||
for (let d = 0; d < dims; d += 1) v[d] += c[d] * w;
|
||||
wsum += w;
|
||||
}
|
||||
if (wsum > 0) for (let d = 0; d < dims; d += 1) v[d] /= wsum;
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed agents in R^dims for visualization. Returns one position per agent plus
|
||||
* the file coordinates used, so a renderer can draw both the agents and the
|
||||
* file-cloud they sit in.
|
||||
*/
|
||||
function embedAgents(agents, graph = {}, options = {}) {
|
||||
const dims = options.dims || 3;
|
||||
const fileCoords = {};
|
||||
for (const agent of agents) {
|
||||
for (const f of agent.files || []) {
|
||||
const p = normalizePath(f.path);
|
||||
if (!fileCoords[p]) fileCoords[p] = fileCoordinate(p, dims);
|
||||
}
|
||||
}
|
||||
const smoothed = smoothByDependency(fileCoords, graph, options.dependencyPull ?? 0.35);
|
||||
const positions = agents.map(agent => ({
|
||||
agentId: agent.agentId,
|
||||
position: weightedCentroid(agent.files || [], smoothed, dims),
|
||||
fileCount: (agent.files || []).length
|
||||
}));
|
||||
return { dims, positions, fileCoordinates: smoothed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the whole airspace: pairwise advisories + 3D positions in one pass.
|
||||
*
|
||||
* @param {Array<{agentId,files,startedAt?,intent?}>} agents
|
||||
* @param {object} graph dependency graph (adjacency)
|
||||
* @param {object} [options]
|
||||
* @returns {{ advisories, positions, links, generatedAt }}
|
||||
*/
|
||||
function scanAirspace(agents, graph = {}, options = {}) {
|
||||
const list = Array.isArray(agents) ? agents.filter(a => a && a.agentId !== null && a.agentId !== undefined) : [];
|
||||
const advisories = [];
|
||||
const links = [];
|
||||
for (let i = 0; i < list.length; i += 1) {
|
||||
for (let j = i + 1; j < list.length; j += 1) {
|
||||
const a = list[i];
|
||||
const b = list[j];
|
||||
const verdict = advise(a, b, graph, options);
|
||||
links.push({
|
||||
a: a.agentId,
|
||||
b: b.agentId,
|
||||
risk: verdict.risk,
|
||||
distance: verdict.distance,
|
||||
level: verdict.level
|
||||
});
|
||||
if (verdict.level !== 'clear') {
|
||||
advisories.push({ a: a.agentId, b: b.agentId, ...verdict });
|
||||
}
|
||||
}
|
||||
}
|
||||
advisories.sort((x, y) => y.risk - x.risk);
|
||||
links.sort((x, y) => y.risk - x.risk);
|
||||
const embedding = embedAgents(list, graph, options);
|
||||
return {
|
||||
advisories,
|
||||
positions: embedding.positions,
|
||||
fileCoordinates: embedding.fileCoordinates,
|
||||
links,
|
||||
counts: {
|
||||
agents: list.length,
|
||||
advisories: advisories.length,
|
||||
resolutions: advisories.filter(a => a.level === 'resolution').length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function clamp01(x) {
|
||||
return !Number.isFinite(x) ? 0 : x < 0 ? 0 : x > 1 ? 1 : x;
|
||||
}
|
||||
function pct(x) {
|
||||
return Math.round(clamp01(x) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn airspace advisories into the messages to inject between agent sessions —
|
||||
* the concrete "transmit intent / steer away" actions. Transport-agnostic: each
|
||||
* trigger is { to, from, type, risk, content }; a dispatcher delivers them.
|
||||
*/
|
||||
function buildProximityTriggers(advisories) {
|
||||
const triggers = [];
|
||||
for (const adv of advisories || []) {
|
||||
if (adv.level === 'advisory') {
|
||||
// Traffic Advisory: both agents exchange intent.
|
||||
triggers.push({
|
||||
to: adv.a,
|
||||
from: adv.b,
|
||||
type: 'proximity_transmit',
|
||||
risk: adv.risk,
|
||||
content: `Proximity ${pct(adv.risk)}%: you and ${adv.b} are converging in code-space. Share what you're working on and check for overlap before continuing.`
|
||||
});
|
||||
triggers.push({
|
||||
to: adv.b,
|
||||
from: adv.a,
|
||||
type: 'proximity_transmit',
|
||||
risk: adv.risk,
|
||||
content: `Proximity ${pct(adv.risk)}%: you and ${adv.a} are converging in code-space. Share what you're working on and check for overlap before continuing.`
|
||||
});
|
||||
} else if (adv.level === 'resolution') {
|
||||
// Resolution Advisory: the lower-priority agent steers; the other holds.
|
||||
triggers.push({
|
||||
to: adv.steer,
|
||||
from: adv.hold,
|
||||
type: 'proximity_steer',
|
||||
risk: adv.risk,
|
||||
content: `Collision risk ${pct(adv.risk)}% with ${adv.hold}, which holds right-of-way. Steer away: move to a different file/area, or coordinate with ${adv.hold} before editing the shared region.`
|
||||
});
|
||||
triggers.push({
|
||||
to: adv.hold,
|
||||
from: adv.steer,
|
||||
type: 'proximity_hold',
|
||||
risk: adv.risk,
|
||||
content: `Collision risk ${pct(adv.risk)}% with ${adv.steer}; you hold right-of-way. ${adv.steer} has been asked to steer away — continue, but expect a handoff if they can't.`
|
||||
});
|
||||
}
|
||||
}
|
||||
return triggers;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULTS,
|
||||
scanAirspace,
|
||||
embedAgents,
|
||||
fileCoordinate,
|
||||
collisionRisk,
|
||||
advise,
|
||||
buildProximityTriggers,
|
||||
buildDependencyGraph,
|
||||
buildDependencyGraphFromSources
|
||||
};
|
||||
@@ -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,70 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Concrete message sink for proximity triggers: delivers a session-to-session
|
||||
* message through the canonical writer, the `ecc-tui messages send` CLI. The CLI
|
||||
* owns the ecc2 session DB (the `messages` table the control pane reads), so we
|
||||
* shell out to it rather than writing the SQLite directly and racing the daemon.
|
||||
*
|
||||
* Best-effort: if the binary is not found / the command fails, the call throws,
|
||||
* and the dispatcher counts it as skipped — proximity never blocks on delivery.
|
||||
* The command runner and binary path are injectable for tests.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
// Proximity trigger type → ecc-tui message kind (value_enum on `--kind`).
|
||||
// A steer/hold is a collision warning; a transmit is a "what are you doing" query.
|
||||
const KIND_BY_TYPE = {
|
||||
proximity_steer: 'conflict',
|
||||
proximity_hold: 'conflict',
|
||||
proximity_transmit: 'query'
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the ecc-tui binary: explicit override, env var, a built target in the
|
||||
* repo, then the bare name (hope it's on PATH).
|
||||
*/
|
||||
function resolveEccBin(deps = {}) {
|
||||
if (deps.binPath) return deps.binPath;
|
||||
if (process.env.ECC_TUI_BIN && process.env.ECC_TUI_BIN.trim()) return process.env.ECC_TUI_BIN.trim();
|
||||
const repoRoot = deps.repoRoot || path.join(__dirname, '..', '..', '..');
|
||||
for (const rel of ['ecc2/target/release/ecc-tui', 'ecc2/target/debug/ecc-tui']) {
|
||||
const candidate = path.join(repoRoot, rel);
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return 'ecc-tui';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `messages send` argv for a proximity message.
|
||||
*/
|
||||
function buildSendArgs({ fromSession, toSession, content, msgType }) {
|
||||
const kind = KIND_BY_TYPE[msgType] || 'query';
|
||||
return ['messages', 'send', '--from', String(fromSession), '--to', String(toSession), '--kind', kind, '--text', String(content)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `sendMessage({ fromSession, toSession, content, msgType })` sink that
|
||||
* delivers via `ecc-tui messages send`. Inject `runCommand(bin, args)` for tests.
|
||||
*/
|
||||
function createEccMessageSink(deps = {}) {
|
||||
const run = deps.runCommand || ((bin, args) => execFileSync(bin, args, { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'] }));
|
||||
const bin = resolveEccBin(deps);
|
||||
return function sendMessage(message) {
|
||||
run(bin, buildSendArgs(message));
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KIND_BY_TYPE,
|
||||
resolveEccBin,
|
||||
buildSendArgs,
|
||||
createEccMessageSink
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Self-contained 3D "agent airspace" visualization, served by the control pane.
|
||||
*
|
||||
* Renders each agent as a point in code-space (positions from the proximity
|
||||
* embedding), sized by working-set size and colored by collision risk, with
|
||||
* links between converging pairs (amber = transmit advisory, red = steer). The
|
||||
* scene auto-rotates so you can read the cloud. Dependency-free: a hand-rolled
|
||||
* 3D2D projection on a <canvas>, no external scripts (CSP/offline friendly).
|
||||
*
|
||||
* This is the operator/Enterprise view of Layer 4: multi-agent observability:
|
||||
* literally watch the swarm and watch one agent steer away from a collision.
|
||||
*/
|
||||
|
||||
function renderProximityVizHtml() {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>ECC Agent Airspace</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 14px/1.4 -apple-system, system-ui, sans-serif; background: #0b0e14; color: #e6edf3; }
|
||||
header { display: flex; align-items: baseline; gap: 12px; padding: 12px 16px; border-bottom: 1px solid #1f2630; }
|
||||
header h1 { font-size: 15px; margin: 0; }
|
||||
header .sub { color: #8b949e; font-size: 12px; }
|
||||
#wrap { display: grid; grid-template-columns: 1fr 320px; height: calc(100vh - 49px); }
|
||||
#stage { position: relative; }
|
||||
canvas { width: 100%; height: 100%; display: block; }
|
||||
#side { border-left: 1px solid #1f2630; padding: 12px 14px; overflow-y: auto; }
|
||||
#side h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #8b949e; margin: 0 0 8px; }
|
||||
.adv { border: 1px solid #1f2630; border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; }
|
||||
.adv.resolution { border-color: #b3402f; }
|
||||
.adv.advisory { border-color: #9a6700; }
|
||||
.adv .lv { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.adv.resolution .lv { color: #ff7b72; }
|
||||
.adv.advisory .lv { color: #e3b341; }
|
||||
.adv .who { color: #c9d1d9; }
|
||||
.adv .act { color: #8b949e; font-size: 12px; margin-top: 3px; }
|
||||
.empty { color: #6e7681; }
|
||||
#legend { position: absolute; left: 12px; bottom: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; vertical-align: middle; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>ECC - Agent Airspace</h1>
|
||||
<span class="sub" id="status">connecting...</span>
|
||||
</header>
|
||||
<div id="wrap">
|
||||
<div id="stage">
|
||||
<canvas id="c"></canvas>
|
||||
<div id="legend">
|
||||
<div><span class="dot" style="background:#3fb950"></span>clear</div>
|
||||
<div><span class="dot" style="background:#e3b341"></span>traffic advisory (transmit)</div>
|
||||
<div><span class="dot" style="background:#ff7b72"></span>resolution (steer)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="side">
|
||||
<h2>Advisories</h2>
|
||||
<div id="advisories"><div class="empty">No advisories - airspace clear.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var canvas = document.getElementById('c');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var state = { positions: [], links: [], advisories: [], riskByAgent: {} };
|
||||
var angle = 0;
|
||||
|
||||
function resize() {
|
||||
var r = canvas.parentElement.getBoundingClientRect();
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.max(1, Math.floor(r.width * dpr));
|
||||
canvas.height = Math.max(1, Math.floor(r.height * dpr));
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
function riskColor(risk) {
|
||||
if (risk >= 0.7) return '#ff7b72';
|
||||
if (risk >= 0.35) return '#e3b341';
|
||||
return '#3fb950';
|
||||
}
|
||||
|
||||
// 3D to 2D: rotate around Y, simple perspective.
|
||||
function project(p, w, h) {
|
||||
var x = p[0], y = p[1] || 0, z = p[2] || 0;
|
||||
var ca = Math.cos(angle), sa = Math.sin(angle);
|
||||
var rx = x * ca - z * sa;
|
||||
var rz = x * sa + z * ca;
|
||||
var scale = 2.4 / (3.2 + rz); // perspective
|
||||
return [w / 2 + rx * scale * (Math.min(w, h) * 0.32), h / 2 + y * scale * (Math.min(w, h) * 0.32), scale];
|
||||
}
|
||||
|
||||
function draw() {
|
||||
var w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
var pos = {};
|
||||
for (var i = 0; i < state.positions.length; i++) {
|
||||
var a = state.positions[i];
|
||||
pos[a.agentId] = project(a.position || [0, 0, 0], w, h);
|
||||
}
|
||||
// links first (under the points)
|
||||
for (var l = 0; l < state.links.length; l++) {
|
||||
var link = state.links[l];
|
||||
if (link.risk < 0.2) continue;
|
||||
var pa = pos[link.a], pb = pos[link.b];
|
||||
if (!pa || !pb) continue;
|
||||
ctx.strokeStyle = riskColor(link.risk);
|
||||
ctx.globalAlpha = Math.min(1, 0.25 + link.risk * 0.7);
|
||||
ctx.lineWidth = 1 + link.risk * 3;
|
||||
ctx.beginPath(); ctx.moveTo(pa[0], pa[1]); ctx.lineTo(pb[0], pb[1]); ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
// points
|
||||
for (var k = 0; k < state.positions.length; k++) {
|
||||
var ag = state.positions[k];
|
||||
var p = pos[ag.agentId];
|
||||
var radius = (6 + Math.sqrt(ag.fileCount || 1) * 3) * p[2];
|
||||
ctx.fillStyle = riskColor(state.riskByAgent[ag.agentId] || 0);
|
||||
ctx.beginPath(); ctx.arc(p[0], p[1], radius, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = '#c9d1d9';
|
||||
ctx.font = '11px -apple-system, system-ui, sans-serif';
|
||||
ctx.fillText(String(ag.agentId).slice(0, 18), p[0] + radius + 4, p[1] + 3);
|
||||
}
|
||||
angle += 0.0035;
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
function renderAdvisories() {
|
||||
var box = document.getElementById('advisories');
|
||||
box.textContent = '';
|
||||
if (!state.advisories.length) {
|
||||
var e = document.createElement('div'); e.className = 'empty';
|
||||
e.textContent = 'No advisories - airspace clear.'; box.appendChild(e); return;
|
||||
}
|
||||
state.advisories.forEach(function (adv) {
|
||||
var el = document.createElement('div');
|
||||
el.className = 'adv ' + (adv.level === 'resolution' ? 'resolution' : 'advisory');
|
||||
var lv = document.createElement('div'); lv.className = 'lv';
|
||||
lv.textContent = Math.round(adv.risk * 100) + '% - ' + adv.level; el.appendChild(lv);
|
||||
var who = document.createElement('div'); who.className = 'who';
|
||||
who.textContent = (adv.aLabel || adv.a) + ' <-> ' + (adv.bLabel || adv.b); el.appendChild(who);
|
||||
var act = document.createElement('div'); act.className = 'act';
|
||||
act.textContent = adv.level === 'resolution'
|
||||
? (adv.steer + ' steers - ' + adv.hold + ' holds')
|
||||
: 'both transmit intent';
|
||||
el.appendChild(act);
|
||||
box.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function applySnapshot(prox) {
|
||||
state.positions = prox.positions || [];
|
||||
state.links = prox.links || [];
|
||||
state.advisories = prox.advisories || [];
|
||||
var risk = {};
|
||||
state.links.forEach(function (l) {
|
||||
risk[l.a] = Math.max(risk[l.a] || 0, l.risk);
|
||||
risk[l.b] = Math.max(risk[l.b] || 0, l.risk);
|
||||
});
|
||||
state.riskByAgent = risk;
|
||||
renderAdvisories();
|
||||
var c = prox.counts || {};
|
||||
document.getElementById('status').textContent =
|
||||
(c.agents || 0) + ' agents - ' + (c.advisories || 0) + ' advisories - ' + (c.resolutions || 0) + ' steering';
|
||||
}
|
||||
|
||||
function poll() {
|
||||
fetch('/api/proximity').then(function (r) { return r.json(); }).then(function (data) {
|
||||
applySnapshot(data && data.enabled ? data : (data || {}));
|
||||
}).catch(function () {
|
||||
document.getElementById('status').textContent = 'offline';
|
||||
});
|
||||
}
|
||||
|
||||
resize();
|
||||
poll();
|
||||
setInterval(poll, 5000);
|
||||
requestAnimationFrame(draw);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
module.exports = { renderProximityVizHtml };
|
||||
@@ -0,0 +1,262 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Control-pane integration for the agent-space proximity metric.
|
||||
*
|
||||
* Turns live sessions into agent working sets (the files each session's worktree
|
||||
* has changed), builds the dependency graph over those files, and runs the
|
||||
* TCAS-style airspace scan — so the board can surface "two agents are converging"
|
||||
* advisories and a 3D position per agent. See docs/design/agent-proximity.md.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const { scanAirspace, buildProximityTriggers } = require('../agent-proximity');
|
||||
const { buildDependencyGraph } = require('../agent-proximity/graph');
|
||||
|
||||
/**
|
||||
* Parse `git diff --unified=0` output into per-file NEW-side line ranges. Hunk
|
||||
* headers look like `@@ -a,b +c,d @@`; we keep the +c,d (new) side so the overlap
|
||||
* channel can tell that two agents touch the *same file* but *different line
|
||||
* ranges* (different functions) and not flag a false collision.
|
||||
*
|
||||
* @param {string} diff
|
||||
* @returns {Map<string, Array<[number,number]>>}
|
||||
*/
|
||||
function parseDiffRanges(diff) {
|
||||
const byFile = new Map();
|
||||
let current = null;
|
||||
for (const line of String(diff || '').split('\n')) {
|
||||
const fileMatch = line.match(/^\+\+\+ b\/(.+)$/);
|
||||
if (fileMatch) {
|
||||
const name = fileMatch[1].trim();
|
||||
current = name === '/dev/null' ? null : name;
|
||||
if (current && !byFile.has(current)) byFile.set(current, []);
|
||||
continue;
|
||||
}
|
||||
const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
|
||||
if (hunk && current) {
|
||||
const start = parseInt(hunk[1], 10);
|
||||
const count = hunk[2] === undefined ? 1 : parseInt(hunk[2], 10);
|
||||
if (count > 0) byFile.get(current).push([start, start + count - 1]);
|
||||
}
|
||||
}
|
||||
return byFile;
|
||||
}
|
||||
|
||||
function runGitDiff(worktreePath, base, extraArgs) {
|
||||
return execFileSync('git', ['-C', worktreePath, 'diff', ...extraArgs, `${base}...HEAD`], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Default working-set source: a session's worktree diff against its base, with
|
||||
* per-file changed line ranges. Returns [{ path, lines? }], or [] on failure so
|
||||
* proximity degrades gracefully (never throws into the snapshot path).
|
||||
*/
|
||||
function defaultWorkingSetFor(session) {
|
||||
const wt = session && session.worktree;
|
||||
if (!wt || !wt.path) return [];
|
||||
const base = wt.base || 'HEAD';
|
||||
try {
|
||||
const ranges = parseDiffRanges(runGitDiff(wt.path, base, ['--unified=0']));
|
||||
return [...ranges.entries()].map(([path, lines]) => (lines.length > 0 ? { path, lines } : { path }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-compat file-name-only source (no line ranges).
|
||||
*/
|
||||
function defaultChangedFilesFor(session) {
|
||||
const wt = session && session.worktree;
|
||||
if (!wt || !wt.path) return [];
|
||||
const base = wt.base || 'HEAD';
|
||||
try {
|
||||
return runGitDiff(wt.path, base, ['--name-only'])
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map sessions to agent working sets. Only sessions with a worktree and at least
|
||||
* one changed file participate (an agent with no edits cannot collide).
|
||||
* Inject `workingSetFor` (returns [{path,lines?}]) or `changedFilesFor`
|
||||
* (returns file-name strings) for tests.
|
||||
*/
|
||||
function sessionsToAgents(sessions, deps = {}) {
|
||||
const workingSetFor = deps.workingSetFor || (deps.changedFilesFor ? session => deps.changedFilesFor(session).map(p => ({ path: p })) : defaultWorkingSetFor);
|
||||
const agents = [];
|
||||
for (const session of sessions || []) {
|
||||
const files = workingSetFor(session).map(f => ({ weight: 1, ...f }));
|
||||
if (files.length === 0) continue;
|
||||
agents.push({
|
||||
agentId: session.id,
|
||||
label: session.task || session.id,
|
||||
startedAt: session.createdAt || null,
|
||||
files
|
||||
});
|
||||
}
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the proximity snapshot from the control-pane sessions.
|
||||
*
|
||||
* @param {Array} sessions normalized control-pane sessions
|
||||
* @param {object} [options] { repoRoot, changedFilesFor, ...scanOptions }
|
||||
* @returns {{ enabled, advisories, positions, links, counts }}
|
||||
*/
|
||||
function buildProximitySnapshot(sessions, options = {}) {
|
||||
const repoRoot = path.resolve(options.repoRoot || path.join(__dirname, '..', '..', '..'));
|
||||
const agents = sessionsToAgents(sessions, options);
|
||||
|
||||
// Need at least two participating agents for a collision to be possible.
|
||||
if (agents.length < 2) {
|
||||
return {
|
||||
enabled: true,
|
||||
advisories: [],
|
||||
positions: agents.map(a => ({ agentId: a.agentId, position: [0, 0, 0], fileCount: a.files.length })),
|
||||
links: [],
|
||||
counts: { agents: agents.length, advisories: 0, resolutions: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
const touched = [...new Set(agents.flatMap(a => a.files.map(f => f.path)))];
|
||||
let graph = { adjacency: {}, files: [] };
|
||||
try {
|
||||
graph = options.graph || buildDependencyGraph(repoRoot, touched, options.graphDeps || {});
|
||||
} catch {
|
||||
graph = { adjacency: {}, files: [] };
|
||||
}
|
||||
|
||||
const scan = scanAirspace(agents, graph, options);
|
||||
const labels = new Map(agents.map(a => [a.agentId, a.label]));
|
||||
const advisories = scan.advisories.map(adv => ({
|
||||
...adv,
|
||||
aLabel: labels.get(adv.a) || adv.a,
|
||||
bLabel: labels.get(adv.b) || adv.b
|
||||
}));
|
||||
return {
|
||||
enabled: true,
|
||||
advisories,
|
||||
triggers: buildProximityTriggers(scan.advisories),
|
||||
positions: scan.positions,
|
||||
links: scan.links,
|
||||
counts: scan.counts
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver proximity triggers via an injected message sink. The sink is
|
||||
* `sendMessage({ fromSession, toSession, content, msgType })` — e.g. a writer
|
||||
* for the ECC `messages` table the control pane already reads. Best-effort:
|
||||
* a failing send is skipped, never thrown. Returns the dispatched count.
|
||||
*/
|
||||
function dispatchProximityTriggers(triggers, deps = {}) {
|
||||
const send = deps.sendMessage;
|
||||
if (typeof send !== 'function') return { dispatched: 0, skipped: (triggers || []).length };
|
||||
let dispatched = 0;
|
||||
let skipped = 0;
|
||||
for (const t of triggers || []) {
|
||||
try {
|
||||
send({ fromSession: t.from, toSession: t.to, content: t.content, msgType: t.type });
|
||||
dispatched += 1;
|
||||
} catch {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
return { dispatched, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful dispatcher with per-trigger cooldown, so a collision that persists
|
||||
* across many ticks fires once and then stays quiet until it clears or the
|
||||
* cooldown lapses — agents get steered, not spammed. Inject `sendMessage`
|
||||
* (e.g. createEccMessageSink) and optionally `now`/`cooldownMs` for tests.
|
||||
*/
|
||||
function createProximityDispatcher(deps = {}) {
|
||||
const send = deps.sendMessage;
|
||||
const cooldownMs = Number.isFinite(deps.cooldownMs) ? deps.cooldownMs : 5 * 60 * 1000;
|
||||
const now = typeof deps.now === 'function' ? deps.now : () => Date.now();
|
||||
const lastFired = new Map();
|
||||
const keyOf = t => `${t.to}<-${t.from}:${t.type}`;
|
||||
|
||||
return {
|
||||
dispatch(triggers) {
|
||||
let dispatched = 0;
|
||||
let suppressed = 0;
|
||||
let skipped = 0;
|
||||
for (const t of triggers || []) {
|
||||
const key = keyOf(t);
|
||||
const last = lastFired.get(key);
|
||||
const ts = now();
|
||||
if (last !== undefined && ts - last < cooldownMs) {
|
||||
suppressed += 1;
|
||||
continue;
|
||||
}
|
||||
if (typeof send !== 'function') {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
send({ fromSession: t.from, toSession: t.to, content: t.content, msgType: t.type });
|
||||
lastFired.set(key, ts);
|
||||
dispatched += 1;
|
||||
} catch {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
return { dispatched, suppressed, skipped };
|
||||
},
|
||||
reset() {
|
||||
lastFired.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One proximity tick: build the snapshot, then dispatch its triggers (steer the
|
||||
* agents). `buildSnapshot()` returns a control-pane snapshot with a `proximity`
|
||||
* field; `dispatcher` is a createProximityDispatcher. `dryRun` reports what
|
||||
* would fire without sending. Both are injected so the CLI stays a thin wrapper
|
||||
* and the logic is unit-testable.
|
||||
*/
|
||||
async function runProximityTick(deps = {}) {
|
||||
const snapshot = await deps.buildSnapshot();
|
||||
const prox = (snapshot && snapshot.proximity) || { advisories: [], triggers: [], counts: {} };
|
||||
const triggers = prox.triggers || [];
|
||||
let result;
|
||||
if (deps.dryRun || !deps.dispatcher) {
|
||||
result = { dispatched: 0, suppressed: 0, skipped: triggers.length, dryRun: Boolean(deps.dryRun) };
|
||||
} else {
|
||||
result = deps.dispatcher.dispatch(triggers);
|
||||
}
|
||||
return {
|
||||
counts: prox.counts || {},
|
||||
advisories: prox.advisories || [],
|
||||
triggers,
|
||||
result
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildProximitySnapshot,
|
||||
sessionsToAgents,
|
||||
defaultWorkingSetFor,
|
||||
defaultChangedFilesFor,
|
||||
parseDiffRanges,
|
||||
dispatchProximityTriggers,
|
||||
createProximityDispatcher,
|
||||
runProximityTick
|
||||
};
|
||||
@@ -0,0 +1,371 @@
|
||||
'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');
|
||||
const { renderProximityVizHtml } = require('./proximity-viz');
|
||||
const { claimWorkItem, moveWorkItem } = require('./work-item-mutations');
|
||||
|
||||
// Run a single write against the local work-item store, then close it. Kept
|
||||
// thin so the loopback-only server can mutate the JIT board without holding a
|
||||
// long-lived handle.
|
||||
async function withStateStore(stateDbPath, fn) {
|
||||
const { createStateStore } = require('../state-store');
|
||||
const store = await createStateStore({ dbPath: stateDbPath });
|
||||
try {
|
||||
return await fn(store);
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Host/Origin gating lives in scripts/lib/loopback-guard.js so every ECC
|
||||
// loopback server shares one hardened implementation; re-exported below to
|
||||
// keep this module's public API stable.
|
||||
const {
|
||||
buildAllowedHostnames,
|
||||
isAllowedHostHeader,
|
||||
isAllowedOrigin
|
||||
} = require('../loopback-guard');
|
||||
|
||||
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 allowedHostnames = buildAllowedHostnames(host);
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
if (!isAllowedHostHeader(req.headers.host, allowedHostnames)) {
|
||||
sendJson(res, 421, { ok: false, error: 'Misdirected request' });
|
||||
return;
|
||||
}
|
||||
if (!isAllowedOrigin(req.headers.origin, allowedHostnames)) {
|
||||
sendJson(res, 403, { ok: false, error: 'Forbidden origin' });
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 3D agent-airspace visualization (Layer 4 observability).
|
||||
if (req.method === 'GET' && requestUrl.pathname === '/proximity') {
|
||||
sendText(res, 200, renderProximityVizHtml(), 'text/html; charset=utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && requestUrl.pathname === '/api/proximity') {
|
||||
const snapshot = await buildControlPaneSnapshot({
|
||||
repoRoot,
|
||||
dbPath: resolvedConfig.dbPath,
|
||||
stateDbPath: resolvedConfig.stateDbPath,
|
||||
config: resolvedConfig,
|
||||
allowActions,
|
||||
includeProximity: true
|
||||
});
|
||||
sendJson(res, 200, snapshot.proximity || { enabled: true, advisories: [], positions: [], links: [], counts: {} });
|
||||
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;
|
||||
}
|
||||
|
||||
// Interactive JIT board: claim / move a work item from the browser.
|
||||
const claimMatch = requestUrl.pathname.match(/^\/api\/work-items\/([^/]+)\/claim$/);
|
||||
const moveMatch = requestUrl.pathname.match(/^\/api\/work-items\/([^/]+)\/move$/);
|
||||
if (req.method === 'POST' && (claimMatch || moveMatch)) {
|
||||
if (!allowActions) {
|
||||
sendJson(res, 403, {
|
||||
ok: false,
|
||||
error: 'Board edits are disabled by --read-only.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const id = decodeURIComponent((claimMatch || moveMatch)[1]);
|
||||
const body = await readRequestJson(req);
|
||||
try {
|
||||
const result = await withStateStore(resolvedConfig.stateDbPath, store =>
|
||||
claimMatch
|
||||
? claimWorkItem(store, {
|
||||
id,
|
||||
owner: body.owner,
|
||||
assigneeKind: body.as || body.assigneeKind,
|
||||
sessionId: body.sessionId
|
||||
})
|
||||
: moveWorkItem(store, { id, lane: body.lane })
|
||||
);
|
||||
sendJson(res, 200, { ok: true, ...result });
|
||||
} catch (mutationError) {
|
||||
sendJson(res, 400, { ok: false, error: mutationError.message });
|
||||
}
|
||||
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,
|
||||
isAllowedHostHeader,
|
||||
isAllowedOrigin,
|
||||
buildAllowedHostnames,
|
||||
usage
|
||||
};
|
||||
@@ -0,0 +1,675 @@
|
||||
'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';
|
||||
}
|
||||
|
||||
// Heuristics for whether a work item's owner is an autonomous agent or a human.
|
||||
// Agent signals win when present so the board reflects who is *actively* on a card.
|
||||
const AGENT_OWNER_RE = /(agent|claude|codex|hermes|gemini|opencode|qwen|joycode|codebuddy|\bbot\b|gpt|sonnet|opus|haiku|fable)/i;
|
||||
const SESSION_ID_RE = /^(sid-|tx-|proj-|sess|session|run-|wt-)/i;
|
||||
|
||||
/**
|
||||
* Classify the assignment of a work item for agent+human JIT team workflows.
|
||||
* Returns the assignee kind ('agent' | 'human' | 'unassigned') and the resolved
|
||||
* assignee label, so the board can show who owns each card and which cards are
|
||||
* waiting for a just-in-time pickup.
|
||||
*/
|
||||
function classifyAssignee({ owner, sessionId, metadata = {} }) {
|
||||
const explicitKind = String(metadata.assigneeKind || metadata.ownerKind || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (explicitKind === 'agent' || explicitKind === 'human') {
|
||||
return { assigneeKind: explicitKind, assignee: owner || sessionId || metadata.assignee || null };
|
||||
}
|
||||
const ownerStr = owner ? String(owner) : '';
|
||||
if (sessionId || (ownerStr && (AGENT_OWNER_RE.test(ownerStr) || SESSION_ID_RE.test(ownerStr)))) {
|
||||
return { assigneeKind: 'agent', assignee: ownerStr || String(sessionId) };
|
||||
}
|
||||
if (ownerStr) {
|
||||
return { assigneeKind: 'human', assignee: ownerStr };
|
||||
}
|
||||
return { assigneeKind: 'unassigned', assignee: null };
|
||||
}
|
||||
|
||||
function normalizeWorkItem(row) {
|
||||
const parsedMetadata = parseJson(row.metadata, {});
|
||||
const metadata = isPlainObject(parsedMetadata) ? normalizeObjectKeys(parsedMetadata) : {};
|
||||
const kanbanState = normalizeWorkItemStatus(row.status);
|
||||
const owner = row.owner ? String(row.owner) : null;
|
||||
const sessionId = row.session_id ? String(row.session_id) : null;
|
||||
const { assigneeKind, assignee } = classifyAssignee({ owner, sessionId, metadata });
|
||||
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,
|
||||
assigneeKind,
|
||||
assignee,
|
||||
repoRoot: row.repo_root ? String(row.repo_root) : null,
|
||||
sessionId,
|
||||
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
|
||||
},
|
||||
// Agent + human JIT team-workflow view: who owns the open work, and which
|
||||
// open cards are waiting for a just-in-time pickup.
|
||||
assignment: {
|
||||
agent: 0,
|
||||
human: 0,
|
||||
unassigned: 0
|
||||
},
|
||||
needsAssignment: [],
|
||||
items
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const kanbanState = normalizeWorkItemStatus(item.kanbanState || item.status);
|
||||
summary.kanban[kanbanState] += 1;
|
||||
const isOpen = kanbanState !== 'done';
|
||||
if (kanbanState === 'done') {
|
||||
summary.doneCount += 1;
|
||||
} else {
|
||||
summary.openCount += 1;
|
||||
}
|
||||
if (kanbanState === 'blocked') summary.blockedCount += 1;
|
||||
|
||||
// Assignment is only meaningful for open work; done cards don't need an owner.
|
||||
if (isOpen) {
|
||||
const kind = item.assigneeKind || classifyAssignee(item).assigneeKind;
|
||||
summary.assignment[kind] = (summary.assignment[kind] || 0) + 1;
|
||||
if (kind === 'unassigned') {
|
||||
summary.needsAssignment.push({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
kanbanState,
|
||||
priority: item.priority || null,
|
||||
url: item.url || null
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Surface the highest-priority unclaimed work first for JIT pickup.
|
||||
const priorityRank = { critical: 0, high: 1, urgent: 1, medium: 2, normal: 2, low: 3 };
|
||||
summary.needsAssignment.sort((a, b) => {
|
||||
const ra = priorityRank[String(a.priority || '').toLowerCase()] ?? 2;
|
||||
const rb = priorityRank[String(b.priority || '').toLowerCase()] ?? 2;
|
||||
return ra - rb;
|
||||
});
|
||||
|
||||
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);
|
||||
// Proximity (agent-space collision avoidance) is opt-in: it shells `git diff`
|
||||
// per worktree, so we only compute it when explicitly requested to keep the
|
||||
// default snapshot fast.
|
||||
let proximity = null;
|
||||
if (options.includeProximity) {
|
||||
const { buildProximitySnapshot } = require('./proximity');
|
||||
proximity = buildProximitySnapshot(sessions, { repoRoot, ...(options.proximityOptions || {}) });
|
||||
}
|
||||
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),
|
||||
proximity
|
||||
};
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SNAPSHOT_SCHEMA_VERSION,
|
||||
buildControlPaneSnapshot,
|
||||
defaultConfigPaths,
|
||||
defaultStateDbPath,
|
||||
recallKnowledgeEntries,
|
||||
resolveControlPaneConfig
|
||||
};
|
||||
@@ -0,0 +1,696 @@
|
||||
'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 || {};
|
||||
const needsAssignment = Array.isArray(summary.needsAssignment) ? summary.needsAssignment : [];
|
||||
const assignment = summary.assignment || { agent: 0, human: 0, unassigned: 0 };
|
||||
$('#work-item-count').textContent = summary.openCount + ' open / ' + summary.blockedCount + ' blocked'
|
||||
+ ' / ' + (assignment.agent || 0) + ' agent / ' + (assignment.human || 0) + ' human'
|
||||
+ (needsAssignment.length ? ' / ' + needsAssignment.length + ' need owner' : '');
|
||||
|
||||
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 assigneeKind = item.assigneeKind || 'unassigned';
|
||||
const owner = item.assignee || item.owner || (assigneeKind === 'unassigned' ? 'unassigned (JIT)' : item.source) || 'unassigned';
|
||||
// Ids/lanes go into HTML-escaped data-* attributes and are read back via
|
||||
// dataset in delegated listeners below — never concatenated into inline
|
||||
// JS handlers — so a crafted work-item id cannot inject script (XSS).
|
||||
const idAttr = escapeHtml(item.id);
|
||||
const moveButtons = ['ready', 'running', 'blocked', 'done'].map(lane =>
|
||||
'<button type="button" data-wi-action="move" data-wi-id="' + idAttr + '" data-wi-lane="' + lane + '">' + escapeHtml(lane) + '</button>'
|
||||
).join('');
|
||||
const controls = state.allowActions
|
||||
? '<div class="row">'
|
||||
+ (assigneeKind === 'unassigned' ? '<button type="button" data-wi-action="claim" data-wi-id="' + idAttr + '">Claim</button>' : '')
|
||||
+ moveButtons
|
||||
+ '</div>'
|
||||
: '';
|
||||
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(assigneeKind) + '] ' + 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>' : '') +
|
||||
controls +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
document.querySelectorAll('#work-items [data-wi-action]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const id = button.getAttribute('data-wi-id');
|
||||
if (button.getAttribute('data-wi-action') === 'claim') {
|
||||
eccClaimItem(id);
|
||||
} else {
|
||||
eccMoveItem(id, button.getAttribute('data-wi-lane'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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';
|
||||
state.allowActions = Boolean(snapshot.execution.allowActions);
|
||||
$('#action-status').textContent = state.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));
|
||||
});
|
||||
|
||||
async function postWorkItem(pathSuffix, payload) {
|
||||
const response = await fetch('/api/work-items/' + pathSuffix, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload || {})
|
||||
});
|
||||
const result = await readJsonResponse(response);
|
||||
if (!result.ok) throw new Error(result.error || 'request failed');
|
||||
await load();
|
||||
}
|
||||
|
||||
window.eccClaimItem = function (id) {
|
||||
if (!state.allowActions) return;
|
||||
const owner = window.prompt('Claim "' + id + '" as (owner name):');
|
||||
if (!owner) return;
|
||||
const as = (window.prompt("Owner kind: 'agent' or 'human'", 'human') || '').trim().toLowerCase();
|
||||
postWorkItem(encodeURIComponent(id) + '/claim', { owner: owner.trim(), as: as === 'agent' ? 'agent' : 'human' })
|
||||
.catch(error => showError('#app', error));
|
||||
};
|
||||
window.eccMoveItem = function (id, lane) {
|
||||
if (!state.allowActions) return;
|
||||
postWorkItem(encodeURIComponent(id) + '/move', { lane })
|
||||
.catch(error => showError('#app', error));
|
||||
};
|
||||
|
||||
// Live board: refresh on a gentle interval; pause while a prompt/tab is hidden.
|
||||
setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
load().catch(() => {});
|
||||
}, 15000);
|
||||
|
||||
load().catch(error => showError('#app', error));
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
renderControlPaneHtml
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Shared work-item mutation helpers for the agent+human JIT board.
|
||||
*
|
||||
* Used by both the `work-items.js` CLI (`claim`) and the control-pane local
|
||||
* server (interactive claim / move), so the two surfaces never diverge.
|
||||
*/
|
||||
|
||||
const DONE_STATUSES = new Set(['done', 'closed', 'resolved', 'merged', 'cancelled']);
|
||||
const PRIORITY_RANK = { critical: 0, high: 1, urgent: 1, medium: 2, normal: 2, low: 3 };
|
||||
|
||||
// Kanban lanes the board renders, and the canonical status each maps to on a move.
|
||||
const LANE_TO_STATUS = {
|
||||
ready: 'open',
|
||||
running: 'running',
|
||||
blocked: 'blocked',
|
||||
done: 'done'
|
||||
};
|
||||
const VALID_LANES = new Set(Object.keys(LANE_TO_STATUS));
|
||||
const VALID_ASSIGNEE_KINDS = new Set(['agent', 'human']);
|
||||
|
||||
function isOpenStatus(status) {
|
||||
return !DONE_STATUSES.has(
|
||||
String(status || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
function priorityRank(priority) {
|
||||
return PRIORITY_RANK[String(priority || '').toLowerCase()] ?? 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which work item a claim targets: an explicit id, otherwise the
|
||||
* highest-priority unassigned open item (the JIT pickup queue).
|
||||
*/
|
||||
function selectClaimTarget(store, { id } = {}) {
|
||||
if (id) {
|
||||
const item = store.getWorkItemById(id);
|
||||
if (!item) {
|
||||
throw new Error(`Work item not found: ${id}`);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
const { items } = store.listWorkItems({ limit: 100 });
|
||||
return items.filter(item => !item.owner && isOpenStatus(item.status)).sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority))[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim an unassigned work item for an agent or human. Sets the owner (and
|
||||
* optional assigneeKind) and moves the card to running unless an explicit
|
||||
* status is supplied. Returns { claimed, item } or { claimed: false, reason }.
|
||||
*/
|
||||
function claimWorkItem(store, { id, owner, assigneeKind, sessionId, status } = {}) {
|
||||
if (!owner) {
|
||||
throw new Error('claim requires an owner.');
|
||||
}
|
||||
const kind = assigneeKind ? String(assigneeKind).toLowerCase() : null;
|
||||
if (kind && !VALID_ASSIGNEE_KINDS.has(kind)) {
|
||||
throw new Error("assigneeKind must be 'agent' or 'human'.");
|
||||
}
|
||||
const target = selectClaimTarget(store, { id });
|
||||
if (!target) {
|
||||
return { claimed: false, reason: 'no-unassigned-open-items' };
|
||||
}
|
||||
if (!isOpenStatus(target.status)) {
|
||||
throw new Error(`Work item ${target.id} is already done; cannot claim.`);
|
||||
}
|
||||
const metadata = { ...(target.metadata || {}) };
|
||||
if (kind) {
|
||||
metadata.assigneeKind = kind;
|
||||
}
|
||||
const item = store.upsertWorkItem({
|
||||
...target,
|
||||
owner,
|
||||
sessionId: sessionId ?? target.sessionId ?? null,
|
||||
status: status ?? 'running',
|
||||
metadata,
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
return { claimed: true, item };
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a work item to a kanban lane (ready | running | blocked | done).
|
||||
*/
|
||||
function moveWorkItem(store, { id, lane } = {}) {
|
||||
if (!id) {
|
||||
throw new Error('move requires a work item id.');
|
||||
}
|
||||
const laneKey = String(lane || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!VALID_LANES.has(laneKey)) {
|
||||
throw new Error(`Invalid lane '${lane}'. Expected one of ${[...VALID_LANES].join(', ')}.`);
|
||||
}
|
||||
const target = store.getWorkItemById(id);
|
||||
if (!target) {
|
||||
throw new Error(`Work item not found: ${id}`);
|
||||
}
|
||||
const item = store.upsertWorkItem({
|
||||
...target,
|
||||
status: LANE_TO_STATUS[laneKey],
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
return { moved: true, item };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DONE_STATUSES,
|
||||
LANE_TO_STATUS,
|
||||
VALID_LANES,
|
||||
VALID_ASSIGNEE_KINDS,
|
||||
isOpenStatus,
|
||||
priorityRank,
|
||||
selectClaimTarget,
|
||||
claimWorkItem,
|
||||
moveWorkItem
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Shared cost estimation for ECC hooks.
|
||||
*
|
||||
* Approximate per-1M-token blended rates (conservative defaults).
|
||||
*/
|
||||
|
||||
const RATE_TABLE = {
|
||||
haiku: { in: 0.8, out: 4.0 },
|
||||
sonnet: { in: 3.0, out: 15.0 },
|
||||
opus: { in: 15.0, out: 75.0 }
|
||||
};
|
||||
|
||||
/**
|
||||
* Estimate USD cost from token counts.
|
||||
* @param {string} model - Model name (may contain "haiku", "sonnet", or "opus")
|
||||
* @param {number} inputTokens
|
||||
* @param {number} outputTokens
|
||||
* @returns {number} Estimated cost in USD (rounded to 6 decimal places)
|
||||
*/
|
||||
function estimateCost(model, inputTokens, outputTokens) {
|
||||
const normalized = String(model || '').toLowerCase();
|
||||
let rates = RATE_TABLE.sonnet;
|
||||
if (normalized.includes('haiku')) rates = RATE_TABLE.haiku;
|
||||
if (normalized.includes('opus')) rates = RATE_TABLE.opus;
|
||||
|
||||
const cost = (inputTokens / 1_000_000) * rates.in + (outputTokens / 1_000_000) * rates.out;
|
||||
return Math.round(cost * 1e6) / 1e6;
|
||||
}
|
||||
|
||||
module.exports = { estimateCost, RATE_TABLE };
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
function toCursorAgentFileName(fileName) {
|
||||
if (!fileName || fileName.startsWith('ecc-')) {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
return `ecc-${fileName}`;
|
||||
}
|
||||
|
||||
function toCursorAgentRelativePath(relativePath) {
|
||||
const segments = String(relativePath || '').split(/[\\/]+/).filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
const fileName = segments.pop();
|
||||
return path.join(...segments, toCursorAgentFileName(fileName));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
toCursorAgentFileName,
|
||||
toCursorAgentRelativePath,
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Runtime helpers for ecc_dashboard.py that do not depend on tkinter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
from typing import Optional, Tuple, Dict, List
|
||||
|
||||
|
||||
def maximize_window(window) -> None:
|
||||
"""Maximize the dashboard window using the safest supported method."""
|
||||
try:
|
||||
window.state('zoomed')
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
system_name = platform.system()
|
||||
if system_name == 'Linux':
|
||||
try:
|
||||
window.attributes('-zoomed', True)
|
||||
except Exception:
|
||||
pass
|
||||
elif system_name == 'Darwin':
|
||||
try:
|
||||
window.attributes('-fullscreen', True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def build_terminal_launch(
|
||||
path: str,
|
||||
*,
|
||||
os_name: Optional[str] = None,
|
||||
system_name: Optional[str] = None,
|
||||
) -> Tuple[List[str], Dict[str, object]]:
|
||||
"""Return safe argv/kwargs for opening a terminal rooted at the requested path."""
|
||||
resolved_os_name = os_name or os.name
|
||||
resolved_system_name = system_name or platform.system()
|
||||
|
||||
if resolved_os_name == 'nt':
|
||||
creationflags = getattr(subprocess, 'CREATE_NEW_CONSOLE', 0)
|
||||
return (
|
||||
['cmd.exe'],
|
||||
{
|
||||
'cwd': path,
|
||||
'creationflags': creationflags,
|
||||
},
|
||||
)
|
||||
|
||||
if resolved_system_name == 'Darwin':
|
||||
return (['open', '-a', 'Terminal', path], {})
|
||||
|
||||
return (
|
||||
['x-terminal-emulator', '-e', 'bash', '-lc', 'cd -- "$1"; exec bash', 'bash', path],
|
||||
{},
|
||||
)
|
||||
|
||||
|
||||
def launch_terminal(path: str) -> None:
|
||||
"""Open a terminal at the given path after validating the target directory."""
|
||||
canonical = os.path.realpath(path)
|
||||
if not os.path.isdir(canonical):
|
||||
raise ValueError(f"Path is not a valid directory: {canonical!r}")
|
||||
argv, kwargs = build_terminal_launch(canonical)
|
||||
subprocess.Popen(argv, **kwargs) # noqa: S603 - list argv, no shell=True, path validated above
|
||||
@@ -0,0 +1,57 @@
|
||||
'use strict';
|
||||
|
||||
const policy = require('./github-coordination/policy');
|
||||
const parsing = require('./github-coordination/parsing');
|
||||
const ghApi = require('./github-coordination/gh-api');
|
||||
const state = require('./github-coordination/state');
|
||||
const actions = require('./github-coordination/actions');
|
||||
const store = require('./github-coordination/store');
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_CONFIG_FILE: policy.DEFAULT_CONFIG_FILE,
|
||||
DEFAULT_CONFIG_PATH: policy.DEFAULT_CONFIG_PATH,
|
||||
DEFAULT_POLICY: policy.DEFAULT_POLICY,
|
||||
DEFAULT_SCHEMA_VERSION: policy.DEFAULT_SCHEMA_VERSION,
|
||||
loadPolicy: policy.loadPolicy,
|
||||
|
||||
extractCoordinationState: parsing.extractCoordinationState,
|
||||
extractIssueReferences: parsing.extractIssueReferences,
|
||||
extractTasks: parsing.extractTasks,
|
||||
mergeIssueBody: parsing.mergeIssueBody,
|
||||
renderCoordinationState: parsing.renderCoordinationState,
|
||||
|
||||
commentIssue: ghApi.commentIssue,
|
||||
editIssue: ghApi.editIssue,
|
||||
getIssue: ghApi.getIssue,
|
||||
listIssues: ghApi.listIssues,
|
||||
normalizeIssueNumber: ghApi.normalizeIssueNumber,
|
||||
normalizeLabels: ghApi.normalizeLabels,
|
||||
normalizeRepo: ghApi.normalizeRepo,
|
||||
runGh: ghApi.runGh,
|
||||
runGhJson: ghApi.runGhJson,
|
||||
|
||||
buildIssueComment: state.buildIssueComment,
|
||||
buildIssueStateFromAction: state.buildIssueStateFromAction,
|
||||
defaultCoordinationState: state.defaultCoordinationState,
|
||||
desiredLabelsForState: state.desiredLabelsForState,
|
||||
getCoordinationState: state.getCoordinationState,
|
||||
mapStateToWorkItemStatus: state.mapStateToWorkItemStatus,
|
||||
slugifySegment: state.slugifySegment,
|
||||
summarizeStateForOutput: state.summarizeStateForOutput,
|
||||
syncIssueLabels: state.syncIssueLabels,
|
||||
verifyDependenciesClosed: state.verifyDependenciesClosed,
|
||||
|
||||
applyClaim: actions.applyClaim,
|
||||
applyDecompose: actions.applyDecompose,
|
||||
applyPublish: actions.applyPublish,
|
||||
applyReview: actions.applyReview,
|
||||
applySync: actions.applySync,
|
||||
applyUnblock: actions.applyUnblock,
|
||||
applyValidate: actions.applyValidate,
|
||||
formatCollection: actions.formatCollection,
|
||||
formatSummary: actions.formatSummary,
|
||||
|
||||
epicWorkItemId: store.epicWorkItemId,
|
||||
openStore: store.openStore,
|
||||
upsertCoordinationWorkItem: store.upsertCoordinationWorkItem,
|
||||
};
|
||||
@@ -0,0 +1,385 @@
|
||||
'use strict';
|
||||
|
||||
const { loadPolicy } = require('./policy');
|
||||
const { mergeIssueBody, normalizeBodyForComparison } = require('./parsing');
|
||||
const { getIssue, listIssues, editIssue, commentIssue, normalizeLabels } = require('./gh-api');
|
||||
const {
|
||||
assertIssueClaimable,
|
||||
buildIssueComment,
|
||||
buildIssueStateFromAction,
|
||||
desiredLabelsForState,
|
||||
getCoordinationState,
|
||||
summarizeStateForOutput,
|
||||
syncIssueLabels,
|
||||
verifyDependenciesClosed,
|
||||
} = require('./state');
|
||||
const { upsertCoordinationWorkItem } = require('./store');
|
||||
const { extractIssueReferences, extractTasks } = require('./parsing');
|
||||
|
||||
function assertValidRepo(repo) {
|
||||
if (typeof repo !== 'string' || !repo.trim()) {
|
||||
throw new Error(`invalid repo: expected non-empty string, got ${JSON.stringify(repo)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidIssueNumber(issueNumber) {
|
||||
if (!Number.isFinite(issueNumber) || issueNumber <= 0 || !Number.isInteger(issueNumber)) {
|
||||
throw new Error(`invalid issueNumber: expected positive integer, got ${JSON.stringify(issueNumber)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function staleCoordinationLabels(issue, nextLabels, policy) {
|
||||
const epicLabel = policy.labels && policy.labels.epic;
|
||||
return normalizeLabels(issue.labels).filter(l =>
|
||||
(l.startsWith('coordination:') || l === epicLabel) && !nextLabels.includes(l)
|
||||
);
|
||||
}
|
||||
|
||||
// applyClaim performs a read (getIssue) → check (assertIssueClaimable) → write
|
||||
// (editIssue) sequence that is NOT atomic. Two concurrent callers can both read
|
||||
// an unclaimed issue, pass the check, and both succeed — resulting in a
|
||||
// double-claim. A code-review finding suggested fixing this via
|
||||
// context.store.acquireLock(repo, issueNumber), but that API does not exist in
|
||||
// store.js; adding a call to it would throw at runtime. Left as-is until a
|
||||
// locking primitive is available — callers should prevent races via external
|
||||
// serialization (e.g. a serialized job queue or GitHub branch-protection rule).
|
||||
function applyClaim(repo, issueNumber, options = {}, context = {}) {
|
||||
assertValidRepo(repo);
|
||||
assertValidIssueNumber(issueNumber);
|
||||
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
|
||||
const store = context.store || null;
|
||||
const issue = getIssue(repo, issueNumber, options);
|
||||
const currentState = getCoordinationState(issue, policy);
|
||||
|
||||
assertIssueClaimable(issue, currentState);
|
||||
|
||||
const nextState = buildIssueStateFromAction(issue, currentState, 'claim', {
|
||||
owner: options.actor || options.owner || currentState.owner || issue.author?.login || null,
|
||||
branch: options.branch || currentState.branch || null,
|
||||
status: options.status || 'claimed',
|
||||
validation: options.validation || currentState.validation || 'pending',
|
||||
review: options.review || currentState.review || (policy.review.required ? 'requested' : 'not-requested'),
|
||||
projectState: options.projectState || 'in-progress',
|
||||
}, policy);
|
||||
|
||||
const trackedIssue = {
|
||||
...issue,
|
||||
labels: desiredLabelsForState(nextState, policy),
|
||||
};
|
||||
const body = mergeIssueBody(issue, nextState, policy);
|
||||
if (!options.dryRun) {
|
||||
editIssue(repo, issueNumber, {
|
||||
body,
|
||||
addLabels: trackedIssue.labels,
|
||||
removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy),
|
||||
}, options);
|
||||
commentIssue(repo, issueNumber, buildIssueComment('claimed', repo, issueNumber, nextState), options);
|
||||
upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'claim', { ...context, policy });
|
||||
}
|
||||
|
||||
return summarizeStateForOutput(repo, trackedIssue, nextState, 'claim', policy);
|
||||
}
|
||||
|
||||
function applySync(repo, options = {}, context = {}) {
|
||||
assertValidRepo(repo);
|
||||
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
|
||||
const store = context.store || null;
|
||||
const issues = listIssues(repo, { ...options, state: options.state || 'all', limit: options.limit || 100 });
|
||||
const syncedAt = new Date().toISOString();
|
||||
const results = [];
|
||||
|
||||
for (const issue of issues) {
|
||||
const currentState = getCoordinationState(issue, policy);
|
||||
const nextState = buildIssueStateFromAction(issue, currentState, 'sync', {
|
||||
status: currentState.status,
|
||||
validation: currentState.validation,
|
||||
review: currentState.review,
|
||||
projectState: currentState.project && currentState.project.state ? currentState.project.state : 'backlog',
|
||||
}, policy);
|
||||
|
||||
const trackedIssue = {
|
||||
...issue,
|
||||
labels: desiredLabelsForState(nextState, policy),
|
||||
};
|
||||
const body = mergeIssueBody(issue, nextState, policy);
|
||||
const labelPlan = syncIssueLabels(repo, issue, nextState, policy, options);
|
||||
|
||||
let snapshot = null;
|
||||
if (!options.dryRun) {
|
||||
if (normalizeBodyForComparison(body) !== normalizeBodyForComparison(issue.body)) {
|
||||
editIssue(repo, issue.number, { body }, options);
|
||||
}
|
||||
snapshot = upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'sync', { ...context, policy });
|
||||
}
|
||||
results.push({
|
||||
...summarizeStateForOutput(repo, trackedIssue, nextState, 'sync', policy),
|
||||
syncedAt,
|
||||
labelPlan,
|
||||
snapshot: snapshot || null,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
repo,
|
||||
syncedAt,
|
||||
count: results.length,
|
||||
items: results,
|
||||
};
|
||||
}
|
||||
|
||||
function applyValidate(repo, issueNumber, options = {}, context = {}, existingIssue = null) {
|
||||
assertValidRepo(repo);
|
||||
assertValidIssueNumber(issueNumber);
|
||||
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
|
||||
const issue = existingIssue || getIssue(repo, issueNumber, options);
|
||||
const state = getCoordinationState(issue, policy);
|
||||
const dependencyNumbers = Array.isArray(state.dependencies) ? state.dependencies : [];
|
||||
const closedDependencies = verifyDependenciesClosed(repo, dependencyNumbers, options);
|
||||
const missingDependencies = dependencyNumbers.filter(number => !closedDependencies.includes(number));
|
||||
const validations = [];
|
||||
|
||||
if (missingDependencies.length > 0) {
|
||||
validations.push({ check: 'dependencies', ok: false, detail: missingDependencies.join(',') });
|
||||
} else {
|
||||
validations.push({ check: 'dependencies', ok: true, detail: 'closed' });
|
||||
}
|
||||
|
||||
const ok = validations.every(entry => entry.ok);
|
||||
const nextState = buildIssueStateFromAction(issue, state, 'validate', {
|
||||
status: ok ? 'validated' : state.status,
|
||||
validation: ok ? 'passed' : 'failed',
|
||||
projectState: ok ? 'ready' : (state.project && state.project.state) || 'backlog',
|
||||
}, policy);
|
||||
const trackedIssue = {
|
||||
...issue,
|
||||
labels: desiredLabelsForState(nextState, policy),
|
||||
};
|
||||
|
||||
if (!options.dryRun) {
|
||||
const body = mergeIssueBody(issue, nextState, policy);
|
||||
editIssue(repo, issueNumber, {
|
||||
body,
|
||||
addLabels: trackedIssue.labels,
|
||||
removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy),
|
||||
}, options);
|
||||
upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'validate', { ...context, policy });
|
||||
}
|
||||
|
||||
return {
|
||||
...summarizeStateForOutput(repo, trackedIssue, nextState, 'validate', policy),
|
||||
ok,
|
||||
validations,
|
||||
missingDependencies,
|
||||
};
|
||||
}
|
||||
|
||||
function applyPublish(repo, issueNumber, options = {}, context = {}) {
|
||||
assertValidRepo(repo);
|
||||
assertValidIssueNumber(issueNumber);
|
||||
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
|
||||
const issue = getIssue(repo, issueNumber, options);
|
||||
const state = getCoordinationState(issue, policy);
|
||||
const validation = applyValidate(repo, issueNumber, { ...options, dryRun: true }, context, issue);
|
||||
|
||||
if (!validation.ok) {
|
||||
throw new Error(`Issue #${issueNumber} is not ready to publish: ${validation.validations.map(entry => `${entry.check}=${entry.ok}`).join(', ')}`);
|
||||
}
|
||||
|
||||
if (policy.review && policy.review.required && state.review !== 'approved') {
|
||||
throw new Error(`Issue #${issueNumber} cannot be published: review approval required (current: ${state.review})`);
|
||||
}
|
||||
|
||||
const nextState = buildIssueStateFromAction(issue, state, 'publish', {
|
||||
status: 'published',
|
||||
validation: 'passed',
|
||||
review: state.review === 'changes-requested' ? state.review : 'approved',
|
||||
projectState: 'done',
|
||||
}, policy);
|
||||
const trackedIssue = {
|
||||
...issue,
|
||||
labels: desiredLabelsForState(nextState, policy),
|
||||
};
|
||||
|
||||
if (!options.dryRun) {
|
||||
const body = mergeIssueBody(issue, nextState, policy);
|
||||
editIssue(repo, issueNumber, {
|
||||
body,
|
||||
addLabels: trackedIssue.labels,
|
||||
removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy),
|
||||
}, options);
|
||||
commentIssue(repo, issueNumber, buildIssueComment('published', repo, issueNumber, nextState, {
|
||||
validation: 'passed',
|
||||
}), options);
|
||||
upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'publish', { ...context, policy });
|
||||
}
|
||||
|
||||
return summarizeStateForOutput(repo, trackedIssue, nextState, 'publish', policy);
|
||||
}
|
||||
|
||||
function applyReview(repo, issueNumber, options = {}, context = {}) {
|
||||
assertValidRepo(repo);
|
||||
assertValidIssueNumber(issueNumber);
|
||||
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
|
||||
const issue = getIssue(repo, issueNumber, options);
|
||||
const state = getCoordinationState(issue, policy);
|
||||
const reviewState = options.review || 'approved';
|
||||
const nextState = buildIssueStateFromAction(issue, state, 'review', {
|
||||
status: reviewState === 'approved' ? 'ready' : reviewState === 'requested' ? 'claimed' : 'blocked',
|
||||
review: reviewState,
|
||||
projectState: reviewState === 'approved' ? 'ready' : 'blocked',
|
||||
}, policy);
|
||||
const trackedIssue = {
|
||||
...issue,
|
||||
labels: desiredLabelsForState(nextState, policy),
|
||||
};
|
||||
|
||||
if (!options.dryRun) {
|
||||
const body = mergeIssueBody(issue, nextState, policy);
|
||||
editIssue(repo, issueNumber, {
|
||||
body,
|
||||
addLabels: trackedIssue.labels,
|
||||
removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy),
|
||||
}, options);
|
||||
commentIssue(repo, issueNumber, buildIssueComment('reviewed', repo, issueNumber, nextState, {
|
||||
review: reviewState,
|
||||
}), options);
|
||||
upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'review', { ...context, policy });
|
||||
}
|
||||
|
||||
return summarizeStateForOutput(repo, trackedIssue, nextState, 'review', policy);
|
||||
}
|
||||
|
||||
function applyDecompose(repo, issueNumber, options = {}, context = {}) {
|
||||
assertValidRepo(repo);
|
||||
assertValidIssueNumber(issueNumber);
|
||||
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
|
||||
const issue = getIssue(repo, issueNumber, options);
|
||||
const state = getCoordinationState(issue, policy);
|
||||
const tasks = extractTasks(issue.body);
|
||||
const dependencies = extractIssueReferences(issue.body);
|
||||
const nextState = buildIssueStateFromAction(issue, state, 'decompose', {
|
||||
tasks,
|
||||
dependencies,
|
||||
status: tasks.some(task => !task.done) ? 'claimed' : state.status,
|
||||
projectState: tasks.some(task => !task.done) ? 'in-progress' : (state.project && state.project.state) || 'backlog',
|
||||
}, policy);
|
||||
|
||||
const trackedIssue = {
|
||||
...issue,
|
||||
labels: desiredLabelsForState(nextState, policy),
|
||||
};
|
||||
|
||||
if (!options.dryRun) {
|
||||
const body = mergeIssueBody(issue, nextState, policy);
|
||||
editIssue(repo, issueNumber, {
|
||||
body,
|
||||
addLabels: trackedIssue.labels,
|
||||
removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy),
|
||||
}, options);
|
||||
commentIssue(repo, issueNumber, buildIssueComment('decomposed', repo, issueNumber, nextState, {
|
||||
taskCount: String(tasks.length),
|
||||
dependencyCount: String(dependencies.length),
|
||||
}), options);
|
||||
upsertCoordinationWorkItem(context.store || null, repo, trackedIssue, nextState, 'decompose', { ...context, policy });
|
||||
}
|
||||
|
||||
return {
|
||||
...summarizeStateForOutput(repo, trackedIssue, nextState, 'decompose', policy),
|
||||
tasks,
|
||||
dependencyCount: dependencies.length,
|
||||
};
|
||||
}
|
||||
|
||||
function applyUnblock(repo, options = {}, context = {}) {
|
||||
assertValidRepo(repo);
|
||||
const policy = context.policy || loadPolicy(context.rootDir || process.cwd(), options.configPath);
|
||||
const store = context.store || null;
|
||||
const issues = listIssues(repo, { ...options, state: 'all', limit: options.limit || 100 });
|
||||
const results = [];
|
||||
|
||||
for (const issue of issues) {
|
||||
const state = getCoordinationState(issue, policy);
|
||||
if (state.status !== 'blocked') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dependencyNumbers = Array.isArray(state.dependencies) ? state.dependencies : [];
|
||||
const closedDependencies = verifyDependenciesClosed(repo, dependencyNumbers, options, issues);
|
||||
if (dependencyNumbers.length > 0 && closedDependencies.length !== dependencyNumbers.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextState = buildIssueStateFromAction(issue, state, 'unblock', {
|
||||
status: 'ready',
|
||||
projectState: 'ready',
|
||||
validation: state.validation === 'failed' ? 'pending' : state.validation,
|
||||
}, policy);
|
||||
const trackedIssue = {
|
||||
...issue,
|
||||
labels: desiredLabelsForState(nextState, policy),
|
||||
};
|
||||
|
||||
if (!options.dryRun) {
|
||||
const body = mergeIssueBody(issue, nextState, policy);
|
||||
editIssue(repo, issue.number, {
|
||||
body,
|
||||
addLabels: trackedIssue.labels,
|
||||
removeLabels: staleCoordinationLabels(issue, trackedIssue.labels, policy),
|
||||
}, options);
|
||||
commentIssue(repo, issue.number, buildIssueComment('unblocked', repo, issue.number, nextState, {
|
||||
dependencies: dependencyNumbers.length > 0 ? dependencyNumbers.join(',') : 'none',
|
||||
}), options);
|
||||
upsertCoordinationWorkItem(store, repo, trackedIssue, nextState, 'unblock', { ...context, policy });
|
||||
}
|
||||
|
||||
results.push(summarizeStateForOutput(repo, trackedIssue, nextState, 'unblock', policy));
|
||||
}
|
||||
|
||||
return {
|
||||
repo,
|
||||
count: results.length,
|
||||
items: results,
|
||||
};
|
||||
}
|
||||
|
||||
function formatSummary(payload) {
|
||||
const lines = [
|
||||
`${payload.action || 'sync'} epic #${payload.issueNumber}: ${payload.issueTitle}`,
|
||||
`Repo: ${payload.repo}`,
|
||||
`Status: ${payload.status}`,
|
||||
`Owner: ${payload.owner || '(unassigned)'}`,
|
||||
`Branch: ${payload.branch || '(none)'}`,
|
||||
`Validation: ${payload.validation || 'pending'}`,
|
||||
`Review: ${payload.review || 'not-requested'}`,
|
||||
];
|
||||
if (payload.tasks && payload.tasks.length > 0) {
|
||||
lines.push(`Tasks: ${payload.tasks.length}`);
|
||||
}
|
||||
if (payload.dependencies && payload.dependencies.length > 0) {
|
||||
lines.push(`Dependencies: ${payload.dependencies.join(', ')}`);
|
||||
}
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function formatCollection(payload) {
|
||||
const lines = [
|
||||
`Repo: ${payload.repo}`,
|
||||
`Items: ${payload.count}`,
|
||||
];
|
||||
for (const item of payload.items || []) {
|
||||
lines.push(`- #${item.issueNumber} ${item.status}: ${item.issueTitle}`);
|
||||
}
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyClaim,
|
||||
applyDecompose,
|
||||
applyPublish,
|
||||
applyReview,
|
||||
applySync,
|
||||
applyUnblock,
|
||||
applyValidate,
|
||||
formatCollection,
|
||||
formatSummary,
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
'use strict';
|
||||
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
function normalizeRepo(repo) {
|
||||
const parts = String(repo || '').split('/').filter(Boolean);
|
||||
if (parts.length !== 2) {
|
||||
throw new Error(`Invalid repo format: "${repo}". Expected "owner/repo".`);
|
||||
}
|
||||
const [owner, name] = parts;
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
function normalizeIssueNumber(value) {
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`Invalid issue number: ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeLabelValue(label) {
|
||||
if (typeof label === 'string') {
|
||||
return label.trim();
|
||||
}
|
||||
if (label && typeof label === 'object') {
|
||||
return String(label.name || label.label || '').trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeLabels(labels) {
|
||||
return Array.from(new Set((Array.isArray(labels) ? labels : []).map(normalizeLabelValue).filter(Boolean))).sort();
|
||||
}
|
||||
|
||||
function runCommand(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env || process.env,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(`${command} ${args.join(' ')} failed: ${result.error.message}`);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`);
|
||||
}
|
||||
|
||||
return result.stdout || '';
|
||||
}
|
||||
|
||||
// ECC_GH_SHIM creates a trust boundary: when set, shimPath replaces the real
|
||||
// `gh` binary and command/commandArgs execute an arbitrary script via
|
||||
// process.execPath. This variable MUST only be set in trusted, isolated test
|
||||
// environments (e.g., a test's own temp directory). Never set ECC_GH_SHIM in
|
||||
// production — doing so allows arbitrary script execution under the caller's
|
||||
// privileges.
|
||||
function runGh(args, options = {}) {
|
||||
const shimPath = process.env.ECC_GH_SHIM;
|
||||
const command = shimPath ? process.execPath : 'gh';
|
||||
const commandArgs = shimPath ? [shimPath, ...args] : args;
|
||||
const env = { ...process.env };
|
||||
|
||||
if (options.stripGithubToken) {
|
||||
delete env.GITHUB_TOKEN;
|
||||
}
|
||||
|
||||
return runCommand(command, commandArgs, { cwd: options.cwd, env });
|
||||
}
|
||||
|
||||
function runGhJson(args, options = {}) {
|
||||
try {
|
||||
return JSON.parse(runGh(args, options) || 'null');
|
||||
} catch (error) {
|
||||
throw new Error(`gh ${args.join(' ')} returned invalid JSON: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getIssue(repo, issueNumber, options = {}) {
|
||||
const { owner, name } = normalizeRepo(repo);
|
||||
const json = runGhJson([
|
||||
'issue',
|
||||
'view',
|
||||
String(issueNumber),
|
||||
'--repo',
|
||||
`${owner}/${name}`,
|
||||
'--json',
|
||||
'number,title,body,url,state,labels,author,updatedAt,assignees',
|
||||
], options);
|
||||
|
||||
if (!json) {
|
||||
throw new Error(`Unable to load issue #${issueNumber} from ${repo}`);
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
function listIssues(repo, options = {}) {
|
||||
const { owner, name } = normalizeRepo(repo);
|
||||
const limit = Number.isFinite(options.limit) ? options.limit : 100;
|
||||
const state = options.state || 'all';
|
||||
return runGhJson([
|
||||
'issue',
|
||||
'list',
|
||||
'--repo',
|
||||
`${owner}/${name}`,
|
||||
'--state',
|
||||
state,
|
||||
'--limit',
|
||||
String(limit),
|
||||
'--json',
|
||||
'number,title,body,url,state,labels,author,updatedAt,assignees',
|
||||
], options) || [];
|
||||
}
|
||||
|
||||
function editIssue(repo, issueNumber, options = {}) {
|
||||
const { owner, name } = normalizeRepo(repo);
|
||||
const args = [
|
||||
'issue',
|
||||
'edit',
|
||||
String(issueNumber),
|
||||
'--repo',
|
||||
`${owner}/${name}`,
|
||||
];
|
||||
|
||||
if (options.body !== undefined) {
|
||||
args.push('--body', options.body);
|
||||
}
|
||||
|
||||
for (const label of options.addLabels || []) {
|
||||
args.push('--add-label', label);
|
||||
}
|
||||
|
||||
for (const label of options.removeLabels || []) {
|
||||
args.push('--remove-label', label);
|
||||
}
|
||||
|
||||
if (options.title) {
|
||||
args.push('--title', options.title);
|
||||
}
|
||||
|
||||
if (options.assignee) {
|
||||
args.push('--add-assignee', options.assignee);
|
||||
}
|
||||
|
||||
return runGh(args, options);
|
||||
}
|
||||
|
||||
function commentIssue(repo, issueNumber, body, options = {}) {
|
||||
const { owner, name } = normalizeRepo(repo);
|
||||
return runGh([
|
||||
'issue',
|
||||
'comment',
|
||||
String(issueNumber),
|
||||
'--repo',
|
||||
`${owner}/${name}`,
|
||||
'--body',
|
||||
body,
|
||||
], options);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
commentIssue,
|
||||
editIssue,
|
||||
getIssue,
|
||||
listIssues,
|
||||
normalizeIssueNumber,
|
||||
normalizeLabels,
|
||||
normalizeRepo,
|
||||
runGh,
|
||||
runGhJson,
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
'use strict';
|
||||
|
||||
const { DEFAULT_POLICY, DEFAULT_SCHEMA_VERSION, DEFAULT_SECTION_MARKER } = require('./policy');
|
||||
|
||||
function escapeRegExp(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function normalizeBodyForComparison(body) {
|
||||
return (body || '').replace(/"lastSyncAt"\s*:\s*[^,}\n]+/g, '"lastSyncAt": NORMALIZED');
|
||||
}
|
||||
|
||||
function extractCoordinationState(body, policy = DEFAULT_POLICY) {
|
||||
const marker = escapeRegExp(policy.sectionMarker || DEFAULT_SECTION_MARKER);
|
||||
const regex = new RegExp(`<!--\\s*${marker}:start\\s*-->\\s*` + '```json\\s*([\\s\\S]*?)\\s*```' + `\\s*<!--\\s*${marker}:end\\s*-->`, 'm');
|
||||
const match = String(body || '').match(regex);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(match[1]);
|
||||
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||
} catch (error) {
|
||||
throw new SyntaxError(`Malformed coordination JSON in body: ${error.message} — raw: ${match[1].slice(0, 120)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function extractIssueReferences(text) {
|
||||
const refs = new Set();
|
||||
const source = String(text || '');
|
||||
for (const match of source.matchAll(/(?:^|[^\d])#(\d+)\b/g)) {
|
||||
refs.add(Number.parseInt(match[1], 10));
|
||||
}
|
||||
return Array.from(refs)
|
||||
.filter(Number.isFinite)
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function extractTasks(body) {
|
||||
const lines = String(body || '').split(/\r?\n/);
|
||||
const tasks = [];
|
||||
let inTasks = false;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (/^#{2,3}\s+tasks\b/i.test(line) || /^#{2,3}\s+task list\b/i.test(line)) {
|
||||
inTasks = true;
|
||||
continue;
|
||||
}
|
||||
if (inTasks && /^#{2,3}\s+\S/.test(line)) {
|
||||
break;
|
||||
}
|
||||
if (inTasks) {
|
||||
const taskMatch = line.match(/^- \[( |x)\]\s+(.+)$/i);
|
||||
if (taskMatch) {
|
||||
tasks.push({
|
||||
title: taskMatch[2].trim(),
|
||||
done: taskMatch[1].toLowerCase() === 'x'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
function parseStringList(value) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
return String(value)
|
||||
.split(',')
|
||||
.map(part => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function renderCoordinationState(state, policy = DEFAULT_POLICY) {
|
||||
const marker = policy.sectionMarker || DEFAULT_SECTION_MARKER;
|
||||
const payload = {
|
||||
schemaVersion: state.schemaVersion || policy.schemaVersion || DEFAULT_SCHEMA_VERSION,
|
||||
kind: state.kind || 'epic',
|
||||
status: state.status || 'available',
|
||||
owner: state.owner || null,
|
||||
branch: state.branch || null,
|
||||
validation: state.validation || 'pending',
|
||||
review: state.review || 'not-requested',
|
||||
project: state.project || { state: 'backlog', fields: {} },
|
||||
dependencies: Array.isArray(state.dependencies) ? state.dependencies : [],
|
||||
tasks: Array.isArray(state.tasks) ? state.tasks : [],
|
||||
labels: Array.isArray(state.labels) ? state.labels : [],
|
||||
lastAction: state.lastAction || 'sync',
|
||||
lastActionAt: state.lastActionAt || new Date().toISOString(),
|
||||
lastSyncAt: state.lastSyncAt || new Date().toISOString(),
|
||||
notes: state.notes || null
|
||||
};
|
||||
|
||||
return [`<!-- ${marker}:start -->`, '```json', JSON.stringify(payload, null, 2), '```', `<!-- ${marker}:end -->`].join('\n');
|
||||
}
|
||||
|
||||
function mergeIssueBody(issue, nextState, policy = DEFAULT_POLICY) {
|
||||
const body = String(issue.body || '');
|
||||
const markerEscaped = escapeRegExp(policy.sectionMarker || DEFAULT_SECTION_MARKER);
|
||||
const rendered = renderCoordinationState(nextState, policy);
|
||||
const regex = new RegExp(`\\n?<!--\\s*${markerEscaped}:start\\s*-->[\\s\\S]*?<!--\\s*${markerEscaped}:end\\s*-->\\n?`, 'm');
|
||||
|
||||
if (regex.test(body)) {
|
||||
return body.replace(regex, `\n${rendered}\n`).trim() + '\n';
|
||||
}
|
||||
|
||||
const trimmed = body.trimEnd();
|
||||
if (!trimmed) {
|
||||
return `${rendered}\n`;
|
||||
}
|
||||
|
||||
return `${trimmed}\n\n${rendered}\n`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
escapeRegExp,
|
||||
extractCoordinationState,
|
||||
extractIssueReferences,
|
||||
extractTasks,
|
||||
mergeIssueBody,
|
||||
normalizeBodyForComparison,
|
||||
parseStringList,
|
||||
renderCoordinationState
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DEFAULT_CONFIG_FILE = 'github-native-coordination.json';
|
||||
const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', '..', '..', 'config', DEFAULT_CONFIG_FILE);
|
||||
const DEFAULT_SECTION_MARKER = 'ecc-coordination';
|
||||
const DEFAULT_SCHEMA_VERSION = 'ecc.github.coordination.v1';
|
||||
const DEFAULT_LABELS = Object.freeze({
|
||||
epic: 'epic',
|
||||
available: 'coordination:available',
|
||||
claimed: 'coordination:claimed',
|
||||
ready: 'coordination:ready',
|
||||
blocked: 'coordination:blocked',
|
||||
validated: 'coordination:validated',
|
||||
reviewRequested: 'coordination:review-requested',
|
||||
reviewApproved: 'coordination:review-approved',
|
||||
reviewChangesRequested: 'coordination:review-changes-requested',
|
||||
published: 'coordination:published',
|
||||
synced: 'coordination:synced',
|
||||
});
|
||||
const DEFAULT_POLICY = Object.freeze({
|
||||
schemaVersion: DEFAULT_SCHEMA_VERSION,
|
||||
sectionMarker: DEFAULT_SECTION_MARKER,
|
||||
labels: DEFAULT_LABELS,
|
||||
review: {
|
||||
required: true,
|
||||
defaultMode: 'required',
|
||||
},
|
||||
validation: {
|
||||
required: true,
|
||||
},
|
||||
branchModel: {
|
||||
epicOnly: true,
|
||||
taskBranches: false,
|
||||
},
|
||||
project: {
|
||||
enabled: false,
|
||||
fieldNames: {
|
||||
status: 'Status',
|
||||
owner: 'Owner',
|
||||
branch: 'Branch',
|
||||
validation: 'Validation',
|
||||
review: 'Review',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function loadPolicy(rootDir = process.cwd(), configPath = null) {
|
||||
const resolvedPath = configPath
|
||||
? path.resolve(configPath)
|
||||
: path.join(rootDir, 'config', DEFAULT_CONFIG_FILE);
|
||||
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
return {
|
||||
...DEFAULT_POLICY,
|
||||
sourcePath: null,
|
||||
};
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load policy from ${resolvedPath}: ${error.message}`);
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error(`Policy file ${resolvedPath} must contain a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`);
|
||||
}
|
||||
const labels = typeof parsed.labels === 'object' && parsed.labels !== null && !Array.isArray(parsed.labels) ? parsed.labels : {};
|
||||
const review = typeof parsed.review === 'object' && parsed.review !== null && !Array.isArray(parsed.review) ? parsed.review : {};
|
||||
const validation = typeof parsed.validation === 'object' && parsed.validation !== null && !Array.isArray(parsed.validation) ? parsed.validation : {};
|
||||
const branchModel = typeof parsed.branchModel === 'object' && parsed.branchModel !== null && !Array.isArray(parsed.branchModel) ? parsed.branchModel : {};
|
||||
const project = typeof parsed.project === 'object' && parsed.project !== null && !Array.isArray(parsed.project) ? parsed.project : {};
|
||||
const fieldNames = typeof project.fieldNames === 'object' && project.fieldNames !== null && !Array.isArray(project.fieldNames) ? project.fieldNames : {};
|
||||
return {
|
||||
...DEFAULT_POLICY,
|
||||
...parsed,
|
||||
labels: { ...DEFAULT_LABELS, ...labels },
|
||||
review: { ...DEFAULT_POLICY.review, ...review },
|
||||
validation: { ...DEFAULT_POLICY.validation, ...validation },
|
||||
branchModel: { ...DEFAULT_POLICY.branchModel, ...branchModel },
|
||||
project: {
|
||||
...DEFAULT_POLICY.project,
|
||||
...project,
|
||||
fieldNames: { ...DEFAULT_POLICY.project.fieldNames, ...fieldNames },
|
||||
},
|
||||
sourcePath: resolvedPath,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_CONFIG_FILE,
|
||||
DEFAULT_CONFIG_PATH,
|
||||
DEFAULT_LABELS,
|
||||
DEFAULT_POLICY,
|
||||
DEFAULT_SCHEMA_VERSION,
|
||||
DEFAULT_SECTION_MARKER,
|
||||
loadPolicy,
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
'use strict';
|
||||
|
||||
const { DEFAULT_POLICY, DEFAULT_SCHEMA_VERSION } = require('./policy');
|
||||
const { extractIssueReferences, extractTasks } = require('./parsing');
|
||||
const { normalizeLabels, listIssues, editIssue } = require('./gh-api');
|
||||
|
||||
function slugifySegment(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'unknown';
|
||||
}
|
||||
|
||||
function defaultCoordinationState(issue, policy = DEFAULT_POLICY) {
|
||||
return {
|
||||
schemaVersion: policy.schemaVersion || DEFAULT_SCHEMA_VERSION,
|
||||
kind: 'epic',
|
||||
status: 'available',
|
||||
owner: issue && issue.author && issue.author.login ? issue.author.login : null,
|
||||
branch: null,
|
||||
validation: 'pending',
|
||||
review: 'not-requested',
|
||||
project: {
|
||||
state: 'backlog',
|
||||
fields: {},
|
||||
},
|
||||
dependencies: extractIssueReferences(issue && issue.body ? issue.body : ''),
|
||||
tasks: extractTasks(issue && issue.body ? issue.body : ''),
|
||||
labels: normalizeLabels(issue && issue.labels),
|
||||
lastAction: 'sync',
|
||||
lastActionAt: new Date().toISOString(),
|
||||
lastSyncAt: new Date().toISOString(),
|
||||
notes: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getCoordinationState(issue, policy = DEFAULT_POLICY) {
|
||||
const { extractCoordinationState } = require('./parsing'); // lazy to avoid circular init order
|
||||
let existing;
|
||||
try {
|
||||
existing = extractCoordinationState(issue && issue.body, policy);
|
||||
} catch (error) {
|
||||
process.stderr.write(`[github-coordination] Warning: ${error.message} (issue #${issue && issue.number})\n`);
|
||||
existing = null;
|
||||
}
|
||||
if (existing) {
|
||||
return {
|
||||
...defaultCoordinationState(issue, policy),
|
||||
...existing,
|
||||
project: {
|
||||
...defaultCoordinationState(issue, policy).project,
|
||||
...(existing.project || {}),
|
||||
},
|
||||
tasks: Array.isArray(existing.tasks) ? existing.tasks : extractTasks(issue && issue.body ? issue.body : ''),
|
||||
dependencies: Array.isArray(existing.dependencies) ? existing.dependencies : extractIssueReferences(issue && issue.body ? issue.body : ''),
|
||||
labels: Array.isArray(existing.labels) ? existing.labels : normalizeLabels(issue && issue.labels),
|
||||
};
|
||||
}
|
||||
return defaultCoordinationState(issue, policy);
|
||||
}
|
||||
|
||||
function buildIssueStateFromAction(issue, currentState, action, options = {}, policy = DEFAULT_POLICY) {
|
||||
const now = new Date().toISOString();
|
||||
const next = {
|
||||
...currentState,
|
||||
schemaVersion: policy.schemaVersion || DEFAULT_SCHEMA_VERSION,
|
||||
kind: 'epic',
|
||||
lastAction: action,
|
||||
lastActionAt: now,
|
||||
lastSyncAt: now,
|
||||
labels: normalizeLabels(issue.labels),
|
||||
dependencies: Array.isArray(currentState.dependencies) ? currentState.dependencies : extractIssueReferences(issue.body),
|
||||
tasks: Array.isArray(currentState.tasks) ? currentState.tasks : extractTasks(issue.body),
|
||||
};
|
||||
|
||||
if (options.owner !== undefined) next.owner = options.owner;
|
||||
if (options.branch !== undefined) next.branch = options.branch;
|
||||
if (options.validation !== undefined) next.validation = options.validation;
|
||||
if (options.review !== undefined) next.review = options.review;
|
||||
if (options.status !== undefined) next.status = options.status;
|
||||
if (options.projectState !== undefined) {
|
||||
next.project = { ...(next.project || {}), state: options.projectState };
|
||||
}
|
||||
if (options.notes !== undefined) next.notes = options.notes;
|
||||
if (options.tasks !== undefined) next.tasks = options.tasks;
|
||||
if (options.dependencies !== undefined) next.dependencies = options.dependencies;
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function desiredLabelsForState(state, policy = DEFAULT_POLICY) {
|
||||
const labels = [];
|
||||
const known = policy.labels || DEFAULT_POLICY.labels;
|
||||
|
||||
labels.push(known.epic);
|
||||
labels.push(known.synced);
|
||||
|
||||
if (state.status === 'available') labels.push(known.available);
|
||||
if (state.status === 'claimed') labels.push(known.claimed);
|
||||
if (state.status === 'ready') labels.push(known.ready);
|
||||
if (state.status === 'blocked') labels.push(known.blocked);
|
||||
if (state.validation === 'passed') labels.push(known.validated);
|
||||
if (state.review === 'requested') labels.push(known.reviewRequested);
|
||||
if (state.review === 'approved') labels.push(known.reviewApproved);
|
||||
if (state.review === 'changes-requested') labels.push(known.reviewChangesRequested);
|
||||
if (state.status === 'published') labels.push(known.published);
|
||||
|
||||
return Array.from(new Set(labels.filter(Boolean))).sort();
|
||||
}
|
||||
|
||||
function syncIssueLabels(repo, issue, state, policy = DEFAULT_POLICY, options = {}) {
|
||||
const desired = new Set(desiredLabelsForState(state, policy));
|
||||
const current = new Set(normalizeLabels(issue.labels));
|
||||
const addLabels = Array.from(desired).filter(label => !current.has(label));
|
||||
const removeLabels = Array.from(current).filter(label => {
|
||||
if (!label.startsWith('coordination:') && label !== (policy.labels && policy.labels.epic)) {
|
||||
return false;
|
||||
}
|
||||
return !desired.has(label);
|
||||
});
|
||||
|
||||
if (options.dryRun || (addLabels.length === 0 && removeLabels.length === 0)) {
|
||||
return { addLabels, removeLabels };
|
||||
}
|
||||
|
||||
if (addLabels.length > 0 || removeLabels.length > 0) {
|
||||
editIssue(repo, issue.number, { ...options, addLabels, removeLabels });
|
||||
}
|
||||
|
||||
return { addLabels, removeLabels };
|
||||
}
|
||||
|
||||
function findIssueByNumber(issues, issueNumber) {
|
||||
return issues.find(issue => Number(issue.number) === Number(issueNumber)) || null;
|
||||
}
|
||||
|
||||
function buildIssueComment(action, repo, issueNumber, state, extra = {}) {
|
||||
const summary = [
|
||||
`ECC coordination ${action}`,
|
||||
`Repo: ${repo}`,
|
||||
`Issue: #${issueNumber}`,
|
||||
`Status: ${state.status}`,
|
||||
`Owner: ${state.owner || '(unassigned)'}`,
|
||||
`Branch: ${state.branch || '(none)'}`,
|
||||
`Validation: ${state.validation || 'pending'}`,
|
||||
`Review: ${state.review || 'not-requested'}`,
|
||||
];
|
||||
|
||||
for (const [key, value] of Object.entries(extra)) {
|
||||
summary.push(`${key}: ${value}`);
|
||||
}
|
||||
|
||||
summary.push('', 'This comment is part of the append-only coordination audit trail.');
|
||||
return summary.join('\n');
|
||||
}
|
||||
|
||||
function mapStateToWorkItemStatus(state) {
|
||||
switch (state) {
|
||||
case 'blocked':
|
||||
return 'blocked';
|
||||
case 'published':
|
||||
return 'done';
|
||||
case 'validated':
|
||||
case 'reviewing':
|
||||
case 'claimed':
|
||||
case 'ready':
|
||||
return 'in-progress';
|
||||
case 'changes-requested':
|
||||
return 'needs-review';
|
||||
case 'available':
|
||||
default:
|
||||
return 'open';
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeProjectProjection(state, policy = DEFAULT_POLICY) {
|
||||
return {
|
||||
enabled: Boolean(policy.project && policy.project.enabled),
|
||||
state: state.project && state.project.state ? state.project.state : 'backlog',
|
||||
fields: {
|
||||
...(state.project && state.project.fields ? state.project.fields : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeStateForOutput(repo, issue, state, action, policy = DEFAULT_POLICY) {
|
||||
return {
|
||||
schemaVersion: state.schemaVersion || policy.schemaVersion || DEFAULT_SCHEMA_VERSION,
|
||||
repo,
|
||||
issueNumber: issue.number,
|
||||
issueUrl: issue.url || null,
|
||||
issueTitle: issue.title,
|
||||
action,
|
||||
status: state.status,
|
||||
owner: state.owner || null,
|
||||
branch: state.branch || null,
|
||||
validation: state.validation || 'pending',
|
||||
review: state.review || 'not-requested',
|
||||
project: summarizeProjectProjection(state, policy),
|
||||
dependencies: Array.isArray(state.dependencies) ? state.dependencies : [],
|
||||
tasks: Array.isArray(state.tasks) ? state.tasks : [],
|
||||
labels: normalizeLabels(issue.labels),
|
||||
workItemId: `github-${slugifySegment(repo)}-epic-${issue.number}`,
|
||||
lastActionAt: state.lastActionAt || null,
|
||||
lastSyncAt: state.lastSyncAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
function assertIssueClaimable(issue, state) {
|
||||
if (String(issue.state || '').toLowerCase() !== 'open') {
|
||||
throw new Error(`Issue #${issue.number} is not open`);
|
||||
}
|
||||
|
||||
if (state.status === 'claimed') {
|
||||
throw new Error(`Issue #${issue.number} is already claimed by ${state.owner || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyDependenciesClosed(repo, dependencyNumbers, options = {}, allIssues = null) {
|
||||
if (!Array.isArray(dependencyNumbers) || dependencyNumbers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const issueList = allIssues || listIssues(repo, { ...options, state: 'all', limit: options.limit || 200 });
|
||||
const closed = [];
|
||||
for (const dependencyNumber of dependencyNumbers) {
|
||||
const issue = findIssueByNumber(issueList, dependencyNumber);
|
||||
if (!issue) {
|
||||
process.stderr.write(`[github-coordination] Warning: dependency issue #${dependencyNumber} not found in issue list (may be in a different repo or beyond limit)\n`);
|
||||
} else if (String(issue.state || '').toLowerCase() === 'closed') {
|
||||
closed.push(dependencyNumber);
|
||||
}
|
||||
}
|
||||
|
||||
return closed;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertIssueClaimable,
|
||||
buildIssueComment,
|
||||
buildIssueStateFromAction,
|
||||
defaultCoordinationState,
|
||||
desiredLabelsForState,
|
||||
findIssueByNumber,
|
||||
getCoordinationState,
|
||||
mapStateToWorkItemStatus,
|
||||
slugifySegment,
|
||||
summarizeProjectProjection,
|
||||
summarizeStateForOutput,
|
||||
syncIssueLabels,
|
||||
verifyDependenciesClosed,
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
'use strict';
|
||||
|
||||
const os = require('os');
|
||||
|
||||
const { createStateStore } = require('../state-store');
|
||||
const { DEFAULT_SCHEMA_VERSION, DEFAULT_POLICY } = require('./policy');
|
||||
const { normalizeLabels } = require('./gh-api');
|
||||
const { slugifySegment, mapStateToWorkItemStatus, summarizeProjectProjection } = require('./state');
|
||||
|
||||
function epicWorkItemId(repo, issueNumber) {
|
||||
return `github-${slugifySegment(repo)}-epic-${issueNumber}`;
|
||||
}
|
||||
|
||||
function upsertCoordinationWorkItem(store, repo, issue, state, action, options = {}) {
|
||||
if (!store) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const metadata = {
|
||||
schemaVersion: state.schemaVersion || DEFAULT_SCHEMA_VERSION,
|
||||
repo,
|
||||
issueNumber: issue.number,
|
||||
issueUrl: issue.url || null,
|
||||
issueTitle: issue.title || null,
|
||||
labels: normalizeLabels(issue.labels),
|
||||
coordination: state,
|
||||
projectProjection: summarizeProjectProjection(state, options.policy || DEFAULT_POLICY),
|
||||
action,
|
||||
actionAt: now,
|
||||
syncedBy: 'ecc-github-coordination',
|
||||
};
|
||||
|
||||
return store.upsertWorkItem({
|
||||
id: epicWorkItemId(repo, issue.number),
|
||||
source: 'github-epic',
|
||||
sourceId: String(issue.number),
|
||||
title: `Epic #${issue.number}: ${issue.title}`,
|
||||
status: mapStateToWorkItemStatus(state.status),
|
||||
priority: state.status === 'blocked' ? 'high' : 'normal',
|
||||
url: issue.url || null,
|
||||
owner: state.owner || (issue.author && issue.author.login) || null,
|
||||
repoRoot: options.repoRoot || process.cwd(),
|
||||
sessionId: options.sessionId || null,
|
||||
metadata,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
async function openStore(options = {}) {
|
||||
if (options.dbPath === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createStateStore({
|
||||
dbPath: options.dbPath,
|
||||
homeDir: options.homeDir || process.env.HOME || os.homedir(),
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
epicWorkItemId,
|
||||
openStore,
|
||||
upsertCoordinationWorkItem,
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
'use strict';
|
||||
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const DEFAULT_DISCUSSION_FIRST = 100;
|
||||
const MAINTAINER_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
|
||||
const DISCUSSION_ENABLED_QUERY = 'query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled } }';
|
||||
const DISCUSSION_QUERY = 'query($owner: String!, $name: String!, $first: Int!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled discussions(first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { number title url updatedAt authorAssociation category { name isAnswerable } answer { url authorAssociation } comments(first: 20) { nodes { authorAssociation } } } } } }';
|
||||
|
||||
function splitRepo(repo) {
|
||||
const [owner, name] = String(repo || '').split('/');
|
||||
if (!owner || !name) {
|
||||
throw new Error(`Invalid repo: ${repo}`);
|
||||
}
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
function runCommand(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env || process.env,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(`${command} ${args.join(' ')} failed: ${result.error.message}`);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`);
|
||||
}
|
||||
|
||||
return result.stdout || '';
|
||||
}
|
||||
|
||||
function runGhJson(args, options = {}) {
|
||||
const shimPath = process.env.ECC_GH_SHIM;
|
||||
const command = shimPath ? process.execPath : 'gh';
|
||||
const commandArgs = shimPath ? [shimPath, ...args] : args;
|
||||
const env = { ...process.env };
|
||||
|
||||
if (!options.useEnvGithubToken) {
|
||||
delete env.GITHUB_TOKEN;
|
||||
}
|
||||
|
||||
const stdout = runCommand(command, commandArgs, { env });
|
||||
try {
|
||||
return JSON.parse(stdout || 'null');
|
||||
} catch (error) {
|
||||
throw new Error(`gh ${args.join(' ')} returned invalid JSON: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function discussionNeedsMaintainerTouch(discussion) {
|
||||
if (MAINTAINER_ASSOCIATIONS.has(discussion.authorAssociation)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
discussion.answer
|
||||
&& MAINTAINER_ASSOCIATIONS.has(discussion.answer.authorAssociation)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const comments = discussion.comments && Array.isArray(discussion.comments.nodes)
|
||||
? discussion.comments.nodes
|
||||
: [];
|
||||
return !comments.some(comment => MAINTAINER_ASSOCIATIONS.has(comment.authorAssociation));
|
||||
}
|
||||
|
||||
function discussionNeedsAcceptedAnswer(discussion) {
|
||||
return Boolean(
|
||||
discussion
|
||||
&& discussion.category
|
||||
&& discussion.category.isAnswerable
|
||||
&& !discussion.answer
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeDiscussion(discussion) {
|
||||
return {
|
||||
number: discussion.number,
|
||||
title: discussion.title,
|
||||
url: discussion.url,
|
||||
updatedAt: discussion.updatedAt,
|
||||
category: discussion.category ? discussion.category.name : null,
|
||||
};
|
||||
}
|
||||
|
||||
function fetchDiscussionSummary(repo, options = {}) {
|
||||
const { owner, name } = splitRepo(repo);
|
||||
const first = Number.isFinite(options.first) ? options.first : DEFAULT_DISCUSSION_FIRST;
|
||||
const enabledPayload = runGhJson([
|
||||
'api',
|
||||
'graphql',
|
||||
'-f',
|
||||
`owner=${owner}`,
|
||||
'-f',
|
||||
`name=${name}`,
|
||||
'-f',
|
||||
`query=${DISCUSSION_ENABLED_QUERY}`,
|
||||
], options);
|
||||
const enabledRepository = enabledPayload && enabledPayload.data && enabledPayload.data.repository;
|
||||
|
||||
if (!enabledRepository || !enabledRepository.hasDiscussionsEnabled) {
|
||||
return emptyDiscussionSummary();
|
||||
}
|
||||
|
||||
const payload = runGhJson([
|
||||
'api',
|
||||
'graphql',
|
||||
'-f',
|
||||
`owner=${owner}`,
|
||||
'-f',
|
||||
`name=${name}`,
|
||||
'-F',
|
||||
`first=${first}`,
|
||||
'-f',
|
||||
`query=${DISCUSSION_QUERY}`,
|
||||
], options);
|
||||
const repository = payload && payload.data && payload.data.repository;
|
||||
const discussions = repository && repository.discussions;
|
||||
const nodes = discussions && Array.isArray(discussions.nodes) ? discussions.nodes : [];
|
||||
const needingTouch = nodes.filter(discussionNeedsMaintainerTouch);
|
||||
const missingAcceptedAnswer = nodes.filter(discussionNeedsAcceptedAnswer);
|
||||
|
||||
return {
|
||||
enabled: Boolean(repository && repository.hasDiscussionsEnabled),
|
||||
totalCount: discussions && Number.isFinite(discussions.totalCount) ? discussions.totalCount : 0,
|
||||
sampledCount: nodes.length,
|
||||
needingMaintainerTouch: needingTouch.map(summarizeDiscussion),
|
||||
answerableWithoutAcceptedAnswer: missingAcceptedAnswer.map(summarizeDiscussion),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyDiscussionSummary() {
|
||||
return {
|
||||
enabled: false,
|
||||
totalCount: 0,
|
||||
sampledCount: 0,
|
||||
needingMaintainerTouch: [],
|
||||
answerableWithoutAcceptedAnswer: [],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_DISCUSSION_FIRST,
|
||||
DISCUSSION_ENABLED_QUERY,
|
||||
DISCUSSION_QUERY,
|
||||
MAINTAINER_ASSOCIATIONS,
|
||||
discussionNeedsAcceptedAnswer,
|
||||
discussionNeedsMaintainerTouch,
|
||||
emptyDiscussionSummary,
|
||||
fetchDiscussionSummary,
|
||||
splitRepo,
|
||||
summarizeDiscussion,
|
||||
};
|
||||
@@ -0,0 +1,453 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const MATRIX_BLOCK_START = '<!-- harness-adapter-compliance:matrix-start -->';
|
||||
const MATRIX_BLOCK_END = '<!-- harness-adapter-compliance:matrix-end -->';
|
||||
|
||||
const COMPLIANCE_STATES = Object.freeze({
|
||||
Native: 'ECC can install or verify the surface directly for this harness.',
|
||||
'Adapter-backed': 'ECC has a thin adapter, plugin, or package surface, but parity differs by harness.',
|
||||
'Instruction-backed': 'ECC can provide the guidance and files, but the harness does not expose the runtime hook/session surface ECC needs for enforcement.',
|
||||
'Reference-only': 'The tool is useful as a design pressure or external runtime, but ECC does not yet ship a direct installer or adapter for it.',
|
||||
});
|
||||
|
||||
const REQUIRED_FIELDS = Object.freeze([
|
||||
'id',
|
||||
'harness',
|
||||
'state',
|
||||
'supported_assets',
|
||||
'unsupported_surfaces',
|
||||
'install_or_onramp',
|
||||
'verification_commands',
|
||||
'risk_notes',
|
||||
'last_verified_at',
|
||||
'owner',
|
||||
'source_docs',
|
||||
]);
|
||||
|
||||
function freezeRecord(record) {
|
||||
return Object.freeze({
|
||||
...record,
|
||||
supported_assets: Object.freeze(record.supported_assets.slice()),
|
||||
unsupported_surfaces: Object.freeze(record.unsupported_surfaces.slice()),
|
||||
install_or_onramp: Object.freeze(record.install_or_onramp.slice()),
|
||||
verification_commands: Object.freeze(record.verification_commands.slice()),
|
||||
risk_notes: Object.freeze(record.risk_notes.slice()),
|
||||
source_docs: Object.freeze(record.source_docs.slice()),
|
||||
});
|
||||
}
|
||||
|
||||
const ADAPTER_RECORDS = Object.freeze([
|
||||
{
|
||||
id: 'claude-code',
|
||||
harness: 'Claude Code',
|
||||
state: 'Native',
|
||||
supported_assets: [
|
||||
'Claude plugin assets',
|
||||
'skills',
|
||||
'commands',
|
||||
'hooks',
|
||||
'MCP config',
|
||||
'local rules',
|
||||
'statusline-oriented workflows',
|
||||
],
|
||||
unsupported_surfaces: ['Claude-native hooks do not imply parity in other harnesses'],
|
||||
install_or_onramp: [
|
||||
'`./install.sh --profile minimal --target claude`',
|
||||
'Claude plugin install',
|
||||
],
|
||||
verification_commands: [
|
||||
'`npm run harness:audit -- --format json`',
|
||||
'`node scripts/session-inspect.js --list-adapters`',
|
||||
],
|
||||
risk_notes: ['Avoid loading every skill by default; keep hooks opt-in and inspectable.'],
|
||||
last_verified_at: '2026-05-12',
|
||||
owner: 'ECC maintainers',
|
||||
source_docs: [
|
||||
'.claude-plugin/plugin.json',
|
||||
'docs/architecture/cross-harness.md',
|
||||
'scripts/lib/install-targets/claude-home.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
harness: 'Codex',
|
||||
state: 'Instruction-backed',
|
||||
supported_assets: [
|
||||
'`AGENTS.md`',
|
||||
'Codex plugin metadata',
|
||||
'skills',
|
||||
'MCP reference config',
|
||||
'command patterns',
|
||||
],
|
||||
unsupported_surfaces: ['Native hook enforcement and Claude slash-command semantics are not equivalent'],
|
||||
install_or_onramp: [
|
||||
'`./install.sh --profile minimal --target codex`',
|
||||
'repo-local `AGENTS.md` review',
|
||||
],
|
||||
verification_commands: ['`npm run harness:audit -- --format json`'],
|
||||
risk_notes: ['Treat hooks as policy text unless a native Codex hook surface exists.'],
|
||||
last_verified_at: '2026-05-12',
|
||||
owner: 'ECC maintainers',
|
||||
source_docs: [
|
||||
'.codex-plugin/plugin.json',
|
||||
'AGENTS.md',
|
||||
'scripts/lib/install-targets/codex-home.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'opencode',
|
||||
harness: 'OpenCode',
|
||||
state: 'Adapter-backed',
|
||||
supported_assets: [
|
||||
'OpenCode package/plugin metadata',
|
||||
'shared skills',
|
||||
'MCP config',
|
||||
'event adapter patterns',
|
||||
],
|
||||
unsupported_surfaces: ['Event names, plugin packaging, and command dispatch differ from Claude Code'],
|
||||
install_or_onramp: ['OpenCode package or plugin surface from this repo'],
|
||||
verification_commands: [
|
||||
'`node tests/scripts/build-opencode.test.js`',
|
||||
'`npm run harness:audit -- --format json`',
|
||||
],
|
||||
risk_notes: ['Keep hook logic in shared scripts and adapt only event shape at the edge.'],
|
||||
last_verified_at: '2026-05-12',
|
||||
owner: 'ECC maintainers',
|
||||
source_docs: [
|
||||
'.opencode/package.json',
|
||||
'.opencode/plugins/ecc-hooks.ts',
|
||||
'scripts/build-opencode.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
harness: 'Cursor',
|
||||
state: 'Adapter-backed',
|
||||
supported_assets: [
|
||||
'Cursor rules',
|
||||
'project-local skills',
|
||||
'hook adapter',
|
||||
'shared scripts',
|
||||
],
|
||||
unsupported_surfaces: ['Cursor hook events and rule loading differ from Claude Code'],
|
||||
install_or_onramp: ['`./install.sh --profile minimal --target cursor`'],
|
||||
verification_commands: [
|
||||
'`node tests/lib/install-targets.test.js`',
|
||||
'`npm run harness:audit -- --format json`',
|
||||
],
|
||||
risk_notes: ['Cursor adapters must preserve existing project rules and avoid silent overwrite.'],
|
||||
last_verified_at: '2026-05-12',
|
||||
owner: 'ECC maintainers',
|
||||
source_docs: [
|
||||
'.cursor/',
|
||||
'scripts/lib/install-targets/cursor-project.js',
|
||||
'tests/lib/install-targets.test.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
harness: 'Gemini',
|
||||
state: 'Instruction-backed',
|
||||
supported_assets: [
|
||||
'Gemini project-local instructions',
|
||||
'shared skills',
|
||||
'rules',
|
||||
'compatibility docs',
|
||||
],
|
||||
unsupported_surfaces: ['No full ECC hook parity; ecosystem ports must document drift from upstream ECC'],
|
||||
install_or_onramp: ['`./install.sh --profile minimal --target gemini`'],
|
||||
verification_commands: ['`node tests/lib/install-targets.test.js`'],
|
||||
risk_notes: ['Treat Gemini ports as ecosystem adapters until validated end to end inside Gemini CLI.'],
|
||||
last_verified_at: '2026-05-12',
|
||||
owner: 'ECC maintainers',
|
||||
source_docs: [
|
||||
'.gemini/',
|
||||
'scripts/lib/install-targets/gemini-project.js',
|
||||
'tests/lib/install-targets.test.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'zed',
|
||||
harness: 'Zed',
|
||||
state: 'Adapter-backed',
|
||||
supported_assets: [
|
||||
'Zed project settings',
|
||||
'flattened project rules',
|
||||
'shared skills',
|
||||
'commands',
|
||||
'agents',
|
||||
],
|
||||
unsupported_surfaces: ['Zed external agents and native Agent Panel permissions are not Claude hooks'],
|
||||
install_or_onramp: ['`./install.sh --profile minimal --target zed`'],
|
||||
verification_commands: [
|
||||
'`node tests/lib/install-targets.test.js`',
|
||||
'`npm run harness:audit -- --format json`',
|
||||
],
|
||||
risk_notes: ['Keep project settings conservative and do not copy BYOK/OpenRouter secrets into `.zed/`.'],
|
||||
last_verified_at: '2026-05-17',
|
||||
owner: 'ECC maintainers',
|
||||
source_docs: [
|
||||
'.zed/settings.json',
|
||||
'scripts/lib/install-targets/zed-project.js',
|
||||
'docs/architecture/cross-harness.md',
|
||||
'tests/lib/install-targets.test.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dmux',
|
||||
harness: 'dmux',
|
||||
state: 'Adapter-backed',
|
||||
supported_assets: [
|
||||
'session snapshots',
|
||||
'tmux/worktree orchestration status',
|
||||
'handoff exports',
|
||||
],
|
||||
unsupported_surfaces: ['dmux is an orchestration runtime, not an install target for skills/rules'],
|
||||
install_or_onramp: [
|
||||
'`node scripts/session-inspect.js --list-adapters`',
|
||||
'dmux session target inspection',
|
||||
],
|
||||
verification_commands: ['`node tests/lib/session-adapters.test.js`'],
|
||||
risk_notes: ['Treat dmux events as session/runtime signals, not as a replacement for repo validation.'],
|
||||
last_verified_at: '2026-05-12',
|
||||
owner: 'ECC maintainers',
|
||||
source_docs: [
|
||||
'scripts/lib/session-adapters/dmux-tmux.js',
|
||||
'scripts/orchestration-status.js',
|
||||
'tests/lib/session-adapters.test.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'orca',
|
||||
harness: 'Orca',
|
||||
state: 'Reference-only',
|
||||
supported_assets: [
|
||||
'worktree lifecycle',
|
||||
'review state',
|
||||
'notification',
|
||||
'provider-identity design pressure',
|
||||
],
|
||||
unsupported_surfaces: ['No ECC installer or direct adapter today'],
|
||||
install_or_onramp: ['Use as a comparison target for worktree/session state requirements'],
|
||||
verification_commands: ['`npm run observability:ready`'],
|
||||
risk_notes: ['Do not import product-specific assumptions; convert lessons into ECC event fields.'],
|
||||
last_verified_at: '2026-05-12',
|
||||
owner: 'ECC maintainers',
|
||||
source_docs: ['docs/architecture/cross-harness.md'],
|
||||
},
|
||||
{
|
||||
id: 'superset',
|
||||
harness: 'Superset',
|
||||
state: 'Reference-only',
|
||||
supported_assets: [
|
||||
'workspace presets',
|
||||
'parallel-agent review loops',
|
||||
'worktree isolation design pressure',
|
||||
],
|
||||
unsupported_surfaces: ['No ECC installer or direct adapter today'],
|
||||
install_or_onramp: ['Use as a comparison target for workspace preset taxonomy'],
|
||||
verification_commands: ['`npm run observability:ready`'],
|
||||
risk_notes: ['Keep ECC portable; do not require a desktop workspace to get basic value.'],
|
||||
last_verified_at: '2026-05-12',
|
||||
owner: 'ECC maintainers',
|
||||
source_docs: ['docs/architecture/cross-harness.md'],
|
||||
},
|
||||
{
|
||||
id: 'ghast',
|
||||
harness: 'Ghast',
|
||||
state: 'Reference-only',
|
||||
supported_assets: [
|
||||
'terminal-native pane grouping',
|
||||
'cwd grouping',
|
||||
'search',
|
||||
'notifications',
|
||||
],
|
||||
unsupported_surfaces: ['No ECC installer or direct adapter today'],
|
||||
install_or_onramp: ['Use as a comparison target for terminal-first session grouping'],
|
||||
verification_commands: ['`node scripts/session-inspect.js --list-adapters`'],
|
||||
risk_notes: ['Preserve terminal ergonomics before adding visual UI assumptions.'],
|
||||
last_verified_at: '2026-05-12',
|
||||
owner: 'ECC maintainers',
|
||||
source_docs: ['docs/architecture/cross-harness.md'],
|
||||
},
|
||||
{
|
||||
id: 'terminal-only',
|
||||
harness: 'Terminal-only',
|
||||
state: 'Native',
|
||||
supported_assets: [
|
||||
'skills',
|
||||
'rules',
|
||||
'commands',
|
||||
'scripts',
|
||||
'harness audit',
|
||||
'observability readiness',
|
||||
'handoffs',
|
||||
],
|
||||
unsupported_surfaces: ['No external UI, no automatic session control unless scripts are run explicitly'],
|
||||
install_or_onramp: [
|
||||
'Clone repo',
|
||||
'run commands directly',
|
||||
'use minimal profile for project installs',
|
||||
],
|
||||
verification_commands: [
|
||||
'`npm run harness:audit -- --format json`',
|
||||
'`npm run observability:ready`',
|
||||
],
|
||||
risk_notes: ['This is the fallback contract; every higher-level adapter should degrade to it.'],
|
||||
last_verified_at: '2026-05-12',
|
||||
owner: 'ECC maintainers',
|
||||
source_docs: [
|
||||
'scripts/harness-audit.js',
|
||||
'scripts/observability-readiness.js',
|
||||
'docs/architecture/observability-readiness.md',
|
||||
],
|
||||
},
|
||||
].map(freezeRecord));
|
||||
|
||||
function toTextList(value) {
|
||||
return Array.isArray(value) ? value.join('; ') : String(value || '');
|
||||
}
|
||||
|
||||
function escapeMarkdownCell(value) {
|
||||
return toTextList(value).replace(/\|/g, '\\|').trim();
|
||||
}
|
||||
|
||||
function renderMarkdownTable(records = ADAPTER_RECORDS) {
|
||||
const lines = [
|
||||
'| Harness or runtime | State | Supported assets | Unsupported or different surfaces | Install or onramp | Verification command | Risk notes |',
|
||||
'| --- | --- | --- | --- | --- | --- | --- |',
|
||||
];
|
||||
|
||||
for (const record of records) {
|
||||
lines.push([
|
||||
record.harness,
|
||||
record.state,
|
||||
record.supported_assets,
|
||||
record.unsupported_surfaces,
|
||||
record.install_or_onramp,
|
||||
record.verification_commands,
|
||||
record.risk_notes,
|
||||
].map(escapeMarkdownCell).join(' | ').replace(/^/, '| ').replace(/$/, ' |'));
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderStateTable() {
|
||||
const lines = [
|
||||
'| State | Meaning |',
|
||||
'| --- | --- |',
|
||||
];
|
||||
|
||||
for (const [state, meaning] of Object.entries(COMPLIANCE_STATES)) {
|
||||
lines.push(`| ${escapeMarkdownCell(state)} | ${escapeMarkdownCell(meaning)} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function validateAdapterRecords(records = ADAPTER_RECORDS) {
|
||||
const errors = [];
|
||||
const ids = new Set();
|
||||
|
||||
records.forEach((record, index) => {
|
||||
const label = record?.id || `record[${index}]`;
|
||||
|
||||
for (const field of REQUIRED_FIELDS) {
|
||||
if (!Object.prototype.hasOwnProperty.call(record, field)) {
|
||||
errors.push(`${label}: missing required field ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof record.id !== 'string' || !/^[a-z0-9-]+$/.test(record.id)) {
|
||||
errors.push(`${label}: id must be a lowercase slug`);
|
||||
} else if (ids.has(record.id)) {
|
||||
errors.push(`${label}: duplicate id`);
|
||||
} else {
|
||||
ids.add(record.id);
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(COMPLIANCE_STATES, record.state)) {
|
||||
errors.push(`${label}: unknown state ${record.state}`);
|
||||
}
|
||||
|
||||
for (const field of [
|
||||
'supported_assets',
|
||||
'unsupported_surfaces',
|
||||
'install_or_onramp',
|
||||
'verification_commands',
|
||||
'risk_notes',
|
||||
'source_docs',
|
||||
]) {
|
||||
if (!Array.isArray(record[field]) || record[field].length === 0) {
|
||||
errors.push(`${label}: ${field} must be a non-empty array`);
|
||||
continue;
|
||||
}
|
||||
|
||||
record[field].forEach((value, valueIndex) => {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
errors.push(`${label}: ${field}[${valueIndex}] must be a non-empty string`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof record.harness !== 'string' || !record.harness.trim()) {
|
||||
errors.push(`${label}: harness must be a non-empty string`);
|
||||
}
|
||||
|
||||
if (typeof record.owner !== 'string' || !record.owner.trim()) {
|
||||
errors.push(`${label}: owner must be a non-empty string`);
|
||||
}
|
||||
|
||||
if (typeof record.last_verified_at !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(record.last_verified_at)) {
|
||||
errors.push(`${label}: last_verified_at must be YYYY-MM-DD`);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function extractMatrixBlock(markdown) {
|
||||
const normalized = String(markdown).replace(/\r\n/g, '\n');
|
||||
const start = normalized.indexOf(MATRIX_BLOCK_START);
|
||||
const end = normalized.indexOf(MATRIX_BLOCK_END);
|
||||
|
||||
if (start < 0 || end < 0 || end <= start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized.slice(start + MATRIX_BLOCK_START.length, end).trim();
|
||||
}
|
||||
|
||||
function validateDocumentation(options = {}) {
|
||||
const repoRoot = options.repoRoot || path.resolve(__dirname, '..', '..');
|
||||
const docPath = options.docPath || path.join(repoRoot, 'docs', 'architecture', 'harness-adapter-compliance.md');
|
||||
const errors = [];
|
||||
const source = fs.readFileSync(docPath, 'utf8');
|
||||
const actual = extractMatrixBlock(source);
|
||||
const expected = renderMarkdownTable();
|
||||
|
||||
if (actual === null) {
|
||||
errors.push(`missing matrix block markers in ${path.relative(repoRoot, docPath)}`);
|
||||
} else if (actual !== expected) {
|
||||
errors.push(`matrix block in ${path.relative(repoRoot, docPath)} is not generated from adapter records`);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ADAPTER_RECORDS,
|
||||
COMPLIANCE_STATES,
|
||||
MATRIX_BLOCK_END,
|
||||
MATRIX_BLOCK_START,
|
||||
REQUIRED_FIELDS,
|
||||
extractMatrixBlock,
|
||||
renderMarkdownTable,
|
||||
renderStateTable,
|
||||
validateAdapterRecords,
|
||||
validateDocumentation,
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Shared hook enable/disable controls.
|
||||
*
|
||||
* Controls:
|
||||
* - ECC_HOOK_PROFILE=minimal|standard|strict (default: standard)
|
||||
* - ECC_DISABLED_HOOKS=comma,separated,hook,ids
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const VALID_PROFILES = new Set(['minimal', 'standard', 'strict']);
|
||||
|
||||
function normalizeId(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getHookProfile() {
|
||||
const raw = String(process.env.ECC_HOOK_PROFILE || 'standard').trim().toLowerCase();
|
||||
return VALID_PROFILES.has(raw) ? raw : 'standard';
|
||||
}
|
||||
|
||||
function getDisabledHookIds() {
|
||||
const raw = String(process.env.ECC_DISABLED_HOOKS || '');
|
||||
if (!raw.trim()) return new Set();
|
||||
|
||||
return new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map(v => normalizeId(v))
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
function parseProfiles(rawProfiles, fallback = ['standard', 'strict']) {
|
||||
if (!rawProfiles) return [...fallback];
|
||||
|
||||
if (Array.isArray(rawProfiles)) {
|
||||
const parsed = rawProfiles
|
||||
.map(v => String(v || '').trim().toLowerCase())
|
||||
.filter(v => VALID_PROFILES.has(v));
|
||||
return parsed.length > 0 ? parsed : [...fallback];
|
||||
}
|
||||
|
||||
const parsed = String(rawProfiles)
|
||||
.split(',')
|
||||
.map(v => v.trim().toLowerCase())
|
||||
.filter(v => VALID_PROFILES.has(v));
|
||||
|
||||
return parsed.length > 0 ? parsed : [...fallback];
|
||||
}
|
||||
|
||||
function isDryRun() {
|
||||
return process.env.ECC_DRY_RUN === '1';
|
||||
}
|
||||
|
||||
function isHookEnabled(hookId, options = {}) {
|
||||
const id = normalizeId(hookId);
|
||||
if (!id) return true;
|
||||
|
||||
const disabled = getDisabledHookIds();
|
||||
if (disabled.has(id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const profile = getHookProfile();
|
||||
const allowedProfiles = parseProfiles(options.profiles);
|
||||
return allowedProfiles.includes(profile);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
VALID_PROFILES,
|
||||
normalizeId,
|
||||
getHookProfile,
|
||||
getDisabledHookIds,
|
||||
parseProfiles,
|
||||
isHookEnabled,
|
||||
isDryRun,
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
'use strict';
|
||||
|
||||
const DEFAULT_FAILURE_THRESHOLD = 3;
|
||||
const DEFAULT_WINDOW_SIZE = 50;
|
||||
|
||||
const FAILURE_OUTCOMES = new Set(['failure', 'failed', 'error']);
|
||||
|
||||
/**
|
||||
* Normalize a failure reason string for grouping.
|
||||
* Strips timestamps, UUIDs, file paths, and numeric suffixes.
|
||||
*/
|
||||
function normalizeFailureReason(reason) {
|
||||
if (!reason || typeof reason !== 'string') {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
return reason
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
// Strip ISO timestamps (note: already lowercased, so t/z not T/Z)
|
||||
.replace(/\d{4}-\d{2}-\d{2}[t ]\d{2}:\d{2}:\d{2}[.\dz]*/g, '<timestamp>')
|
||||
// Strip UUIDs (already lowercased)
|
||||
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, '<uuid>')
|
||||
// Strip file paths
|
||||
.replace(/\/[\w./-]+/g, '<path>')
|
||||
// Collapse whitespace
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Group skill runs by skill ID and normalized failure reason.
|
||||
*
|
||||
* @param {Array} skillRuns - Array of skill run objects
|
||||
* @returns {Map<string, { skillId: string, normalizedReason: string, runs: Array }>}
|
||||
*/
|
||||
function groupFailures(skillRuns) {
|
||||
const groups = new Map();
|
||||
|
||||
for (const run of skillRuns) {
|
||||
const outcome = String(run.outcome || '').toLowerCase();
|
||||
if (!FAILURE_OUTCOMES.has(outcome)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedReason = normalizeFailureReason(run.failureReason);
|
||||
const key = `${run.skillId}::${normalizedReason}`;
|
||||
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
skillId: run.skillId,
|
||||
normalizedReason,
|
||||
runs: [],
|
||||
});
|
||||
}
|
||||
|
||||
groups.get(key).runs.push(run);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect recurring failure patterns from skill runs.
|
||||
*
|
||||
* @param {Array} skillRuns - Array of skill run objects (newest first)
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.threshold=3] - Minimum failure count to trigger pattern detection
|
||||
* @returns {Array<Object>} Array of detected patterns sorted by count descending
|
||||
*/
|
||||
function detectPatterns(skillRuns, options = {}) {
|
||||
const threshold = options.threshold ?? DEFAULT_FAILURE_THRESHOLD;
|
||||
const groups = groupFailures(skillRuns);
|
||||
const patterns = [];
|
||||
|
||||
for (const [, group] of groups) {
|
||||
if (group.runs.length < threshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sortedRuns = [...group.runs].sort(
|
||||
(a, b) => (b.createdAt || '').localeCompare(a.createdAt || '')
|
||||
);
|
||||
|
||||
const firstSeen = sortedRuns[sortedRuns.length - 1].createdAt || null;
|
||||
const lastSeen = sortedRuns[0].createdAt || null;
|
||||
const sessionIds = [...new Set(sortedRuns.map(r => r.sessionId).filter(Boolean))];
|
||||
const versions = [...new Set(sortedRuns.map(r => r.skillVersion).filter(Boolean))];
|
||||
|
||||
// Collect unique raw failure reasons for this normalized group
|
||||
const rawReasons = [...new Set(sortedRuns.map(r => r.failureReason).filter(Boolean))];
|
||||
|
||||
patterns.push({
|
||||
skillId: group.skillId,
|
||||
normalizedReason: group.normalizedReason,
|
||||
count: group.runs.length,
|
||||
firstSeen,
|
||||
lastSeen,
|
||||
sessionIds,
|
||||
versions,
|
||||
rawReasons,
|
||||
runIds: sortedRuns.map(r => r.id),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by count descending, then by lastSeen descending
|
||||
return patterns.sort((a, b) => {
|
||||
if (b.count !== a.count) return b.count - a.count;
|
||||
return (b.lastSeen || '').localeCompare(a.lastSeen || '');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an inspection report from detected patterns.
|
||||
*
|
||||
* @param {Array} patterns - Output from detectPatterns()
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.generatedAt] - ISO timestamp for the report
|
||||
* @returns {Object} Inspection report
|
||||
*/
|
||||
function generateReport(patterns, options = {}) {
|
||||
const generatedAt = options.generatedAt || new Date().toISOString();
|
||||
|
||||
if (patterns.length === 0) {
|
||||
return {
|
||||
generatedAt,
|
||||
status: 'clean',
|
||||
patternCount: 0,
|
||||
patterns: [],
|
||||
summary: 'No recurring failure patterns detected.',
|
||||
};
|
||||
}
|
||||
|
||||
const totalFailures = patterns.reduce((sum, p) => sum + p.count, 0);
|
||||
const affectedSkills = [...new Set(patterns.map(p => p.skillId))];
|
||||
|
||||
return {
|
||||
generatedAt,
|
||||
status: 'attention_needed',
|
||||
patternCount: patterns.length,
|
||||
totalFailures,
|
||||
affectedSkills,
|
||||
patterns: patterns.map(p => ({
|
||||
skillId: p.skillId,
|
||||
normalizedReason: p.normalizedReason,
|
||||
count: p.count,
|
||||
firstSeen: p.firstSeen,
|
||||
lastSeen: p.lastSeen,
|
||||
sessionIds: p.sessionIds,
|
||||
versions: p.versions,
|
||||
rawReasons: p.rawReasons.slice(0, 5),
|
||||
suggestedAction: suggestAction(p),
|
||||
})),
|
||||
summary: `Found ${patterns.length} recurring failure pattern(s) across ${affectedSkills.length} skill(s) (${totalFailures} total failures).`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest a remediation action based on pattern characteristics.
|
||||
*/
|
||||
function suggestAction(pattern) {
|
||||
const reason = pattern.normalizedReason;
|
||||
|
||||
if (reason.includes('timeout')) {
|
||||
return 'Increase timeout or optimize skill execution time.';
|
||||
}
|
||||
if (reason.includes('permission') || reason.includes('denied') || reason.includes('auth')) {
|
||||
return 'Check tool permissions and authentication configuration.';
|
||||
}
|
||||
if (reason.includes('not found') || reason.includes('missing')) {
|
||||
return 'Verify required files/dependencies exist before skill execution.';
|
||||
}
|
||||
if (reason.includes('parse') || reason.includes('syntax') || reason.includes('json')) {
|
||||
return 'Review input/output format expectations and add validation.';
|
||||
}
|
||||
if (pattern.versions.length > 1) {
|
||||
return 'Failure spans multiple versions. Consider rollback to last stable version.';
|
||||
}
|
||||
|
||||
return 'Investigate root cause and consider adding error handling.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Run full inspection pipeline: query skill runs, detect patterns, generate report.
|
||||
*
|
||||
* @param {Object} store - State store instance with listRecentSessions, getSessionDetail
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.threshold] - Minimum failure count
|
||||
* @param {number} [options.windowSize] - Number of recent skill runs to analyze
|
||||
* @returns {Object} Inspection report
|
||||
*/
|
||||
function inspect(store, options = {}) {
|
||||
const windowSize = options.windowSize ?? DEFAULT_WINDOW_SIZE;
|
||||
const threshold = options.threshold ?? DEFAULT_FAILURE_THRESHOLD;
|
||||
|
||||
const status = store.getStatus({ recentSkillRunLimit: windowSize });
|
||||
const skillRuns = status.skillRuns.recent || [];
|
||||
|
||||
const patterns = detectPatterns(skillRuns, { threshold });
|
||||
return generateReport(patterns, { generatedAt: status.generatedAt });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_FAILURE_THRESHOLD,
|
||||
DEFAULT_WINDOW_SIZE,
|
||||
detectPatterns,
|
||||
generateReport,
|
||||
groupFailures,
|
||||
inspect,
|
||||
normalizeFailureReason,
|
||||
suggestAction,
|
||||
};
|
||||
@@ -0,0 +1,812 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const { toCursorAgentRelativePath } = require('./cursor-agent-names');
|
||||
const { LEGACY_INSTALL_TARGETS, parseInstallArgs } = require('./install/request');
|
||||
const { SUPPORTED_INSTALL_TARGETS, listLegacyCompatibilityLanguages, resolveLegacyCompatibilitySelection, resolveInstallPlan } = require('./install-manifests');
|
||||
const { getInstallTargetAdapter } = require('./install-targets/registry');
|
||||
|
||||
const LANGUAGE_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
||||
const CLAUDE_ECC_NAMESPACE = 'ecc';
|
||||
const EXCLUDED_GENERATED_SOURCE_SUFFIXES = ['/ecc-install-state.json', '/ecc/install-state.json'];
|
||||
|
||||
function getSourceRoot() {
|
||||
return path.join(__dirname, '../..');
|
||||
}
|
||||
|
||||
function getPackageVersion(sourceRoot) {
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'package.json'), 'utf8'));
|
||||
return packageJson.version || null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getManifestVersion(sourceRoot) {
|
||||
try {
|
||||
const modulesManifest = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'manifests', 'install-modules.json'), 'utf8'));
|
||||
return modulesManifest.version || 1;
|
||||
} catch (_error) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function getRepoCommit(sourceRoot) {
|
||||
try {
|
||||
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: sourceRoot,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 5000
|
||||
}).trim();
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readDirectoryNames(dirPath) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(dirPath, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => entry.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function listAvailableLanguages(sourceRoot = getSourceRoot()) {
|
||||
return [...new Set([...listLegacyCompatibilityLanguages(), ...readDirectoryNames(path.join(sourceRoot, 'rules')).filter(name => name !== 'common')])].sort();
|
||||
}
|
||||
|
||||
function validateLegacyTarget(target) {
|
||||
if (LEGACY_INSTALL_TARGETS.includes(target)) {
|
||||
return;
|
||||
}
|
||||
// A target can be fully supported yet not installable via the bare-language
|
||||
// positional syntax (which is legacy-only). Guide the user to the right mode
|
||||
// instead of implying the target is unknown (#2282).
|
||||
if (SUPPORTED_INSTALL_TARGETS.includes(target)) {
|
||||
throw new Error(
|
||||
`Target '${target}' is supported, but the bare-language install syntax only accepts ${LEGACY_INSTALL_TARGETS.join(', ')}. ` +
|
||||
`Install '${target}' with a component selection instead, e.g. \`install.sh --target ${target} --profile full\` ` +
|
||||
`(or --modules <id,...> / --skills <id,...>).`
|
||||
);
|
||||
}
|
||||
throw new Error(`Unknown install target: ${target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}`);
|
||||
}
|
||||
|
||||
const IGNORED_DIRECTORY_NAMES = new Set(['node_modules', '.git']);
|
||||
|
||||
function listFilesRecursive(dirPath) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (IGNORED_DIRECTORY_NAMES.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
const childFiles = listFilesRecursive(absolutePath);
|
||||
for (const childFile of childFiles) {
|
||||
files.push(path.join(entry.name, childFile));
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
files.push(entry.name);
|
||||
}
|
||||
}
|
||||
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
function isGeneratedRuntimeSourcePath(sourceRelativePath) {
|
||||
const normalizedPath = String(sourceRelativePath || '').replace(/\\/g, '/');
|
||||
return EXCLUDED_GENERATED_SOURCE_SUFFIXES.some(suffix => normalizedPath.endsWith(suffix));
|
||||
}
|
||||
|
||||
function createStatePreview(options) {
|
||||
const { createInstallState } = require('./install-state');
|
||||
return createInstallState(options);
|
||||
}
|
||||
|
||||
function applyInstallPlan(plan) {
|
||||
const { applyInstallPlan: applyPlan } = require('./install/apply');
|
||||
return applyPlan(plan);
|
||||
}
|
||||
|
||||
function buildCopyFileOperation({ moduleId, sourcePath, sourceRelativePath, destinationPath, strategy }) {
|
||||
return {
|
||||
kind: 'copy-file',
|
||||
moduleId,
|
||||
sourcePath,
|
||||
sourceRelativePath,
|
||||
destinationPath,
|
||||
strategy,
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false
|
||||
};
|
||||
}
|
||||
|
||||
function addRecursiveCopyOperations(operations, options) {
|
||||
const sourceDir = path.join(options.sourceRoot, options.sourceRelativeDir);
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const relativeFiles = listFilesRecursive(sourceDir);
|
||||
|
||||
for (const relativeFile of relativeFiles) {
|
||||
const sourceRelativePath = path.join(options.sourceRelativeDir, relativeFile);
|
||||
const sourcePath = path.join(options.sourceRoot, sourceRelativePath);
|
||||
const destinationRelativePath = typeof options.destinationRelativePathTransform === 'function' ? options.destinationRelativePathTransform(relativeFile, sourceRelativePath) : relativeFile;
|
||||
if (!destinationRelativePath) {
|
||||
continue;
|
||||
}
|
||||
const destinationPath = path.join(options.destinationDir, destinationRelativePath);
|
||||
operations.push(
|
||||
buildCopyFileOperation({
|
||||
moduleId: options.moduleId,
|
||||
sourcePath,
|
||||
sourceRelativePath,
|
||||
destinationPath,
|
||||
strategy: options.strategy || 'preserve-relative-path'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return relativeFiles.length;
|
||||
}
|
||||
|
||||
function addFileCopyOperation(operations, options) {
|
||||
const sourcePath = path.join(options.sourceRoot, options.sourceRelativePath);
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
operations.push(
|
||||
buildCopyFileOperation({
|
||||
moduleId: options.moduleId,
|
||||
sourcePath,
|
||||
sourceRelativePath: options.sourceRelativePath,
|
||||
destinationPath: options.destinationPath,
|
||||
strategy: options.strategy || 'preserve-relative-path'
|
||||
})
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function readJsonObject(filePath, label) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse ${label} at ${filePath}: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`Invalid ${label} at ${filePath}: expected a JSON object`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function addCursorAgentDataScaffoldOperations(operations, options) {
|
||||
const scaffoldRoot = path.join(options.sourceRoot, 'scaffolds', 'cursor');
|
||||
if (!fs.existsSync(scaffoldRoot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
addFileCopyOperation(operations, {
|
||||
moduleId: options.moduleId,
|
||||
sourceRoot: options.sourceRoot,
|
||||
sourceRelativePath: path.join('scaffolds', 'cursor', 'ecc-agent-data.json'),
|
||||
destinationPath: path.join(options.targetRoot, 'ecc-agent-data.json'),
|
||||
strategy: 'preserve-relative-path'
|
||||
});
|
||||
|
||||
addFileCopyOperation(operations, {
|
||||
moduleId: options.moduleId,
|
||||
sourceRoot: options.sourceRoot,
|
||||
sourceRelativePath: path.join('scaffolds', 'cursor', 'rules', 'ecc-agent-data-home.mdc'),
|
||||
destinationPath: path.join(options.targetRoot, 'rules', 'ecc-agent-data-home.mdc'),
|
||||
strategy: 'preserve-relative-path'
|
||||
});
|
||||
|
||||
addJsonMergeOperation(operations, {
|
||||
moduleId: options.moduleId,
|
||||
sourceRoot: options.sourceRoot,
|
||||
sourceRelativePath: path.join('scaffolds', 'cursor', 'hooks.json'),
|
||||
destinationPath: path.join(options.targetRoot, 'hooks.json')
|
||||
});
|
||||
|
||||
const cursorSessionHookDeps = [path.join('scripts', 'hooks', 'cursor-session-env.js'), path.join('scripts', 'lib', 'agent-data-home.js'), path.join('scripts', 'lib', 'utils.js')];
|
||||
|
||||
for (const sourceRelativePath of cursorSessionHookDeps) {
|
||||
addFileCopyOperation(operations, {
|
||||
moduleId: options.moduleId,
|
||||
sourceRoot: options.sourceRoot,
|
||||
sourceRelativePath,
|
||||
destinationPath: path.join(options.targetRoot, sourceRelativePath),
|
||||
strategy: 'preserve-relative-path'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addJsonMergeOperation(operations, options) {
|
||||
const sourcePath = path.join(options.sourceRoot, options.sourceRelativePath);
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
operations.push({
|
||||
kind: 'merge-json',
|
||||
moduleId: options.moduleId,
|
||||
sourceRelativePath: options.sourceRelativePath,
|
||||
destinationPath: options.destinationPath,
|
||||
strategy: 'merge-json',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
mergePayload: readJsonObject(sourcePath, options.sourceRelativePath)
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function addMatchingRuleOperations(operations, options) {
|
||||
const sourceDir = path.join(options.sourceRoot, options.sourceRelativeDir);
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const files = fs
|
||||
.readdirSync(sourceDir, { withFileTypes: true })
|
||||
.filter(entry => entry.isFile() && options.matcher(entry.name))
|
||||
.map(entry => entry.name)
|
||||
.sort();
|
||||
|
||||
for (const fileName of files) {
|
||||
const sourceRelativePath = path.join(options.sourceRelativeDir, fileName);
|
||||
const sourcePath = path.join(options.sourceRoot, sourceRelativePath);
|
||||
const destinationPath = path.join(options.destinationDir, options.rename ? options.rename(fileName) : fileName);
|
||||
|
||||
operations.push(
|
||||
buildCopyFileOperation({
|
||||
moduleId: options.moduleId,
|
||||
sourcePath,
|
||||
sourceRelativePath,
|
||||
destinationPath,
|
||||
strategy: options.strategy || 'flatten-copy'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return files.length;
|
||||
}
|
||||
|
||||
function isDirectoryNonEmpty(dirPath) {
|
||||
return fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory() && fs.readdirSync(dirPath).length > 0;
|
||||
}
|
||||
|
||||
function planClaudeStyleLegacyInstall(context, { adapterId, adapterRootInput, rulesDir: rulesDirOverride }) {
|
||||
const adapter = getInstallTargetAdapter(adapterId);
|
||||
const targetRoot = adapter.resolveRoot(adapterRootInput);
|
||||
const rulesDir = rulesDirOverride || path.join(targetRoot, 'rules', CLAUDE_ECC_NAMESPACE);
|
||||
const installStatePath = adapter.getInstallStatePath(adapterRootInput);
|
||||
const operations = [];
|
||||
const warnings = [];
|
||||
|
||||
if (isDirectoryNonEmpty(rulesDir)) {
|
||||
warnings.push(`Destination ${rulesDir}/ already exists and files may be overwritten`);
|
||||
}
|
||||
|
||||
addRecursiveCopyOperations(operations, {
|
||||
moduleId: 'legacy-claude-rules',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: path.join('rules', 'common'),
|
||||
destinationDir: path.join(rulesDir, 'common')
|
||||
});
|
||||
|
||||
for (const language of context.languages) {
|
||||
if (!LANGUAGE_NAME_PATTERN.test(language)) {
|
||||
warnings.push(`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceDir = path.join(context.sourceRoot, 'rules', language);
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
warnings.push(`rules/${language}/ does not exist, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
addRecursiveCopyOperations(operations, {
|
||||
moduleId: 'legacy-claude-rules',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: path.join('rules', language),
|
||||
destinationDir: path.join(rulesDir, language)
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'legacy',
|
||||
adapter,
|
||||
target: adapterId,
|
||||
targetRoot,
|
||||
installRoot: rulesDir,
|
||||
installStatePath,
|
||||
operations,
|
||||
warnings,
|
||||
selectedModules: ['legacy-claude-rules']
|
||||
};
|
||||
}
|
||||
|
||||
function planClaudeLegacyInstall(context) {
|
||||
return planClaudeStyleLegacyInstall(context, {
|
||||
adapterId: 'claude',
|
||||
adapterRootInput: { homeDir: context.homeDir },
|
||||
rulesDir: context.claudeRulesDir || null
|
||||
});
|
||||
}
|
||||
|
||||
function planClaudeProjectLegacyInstall(context) {
|
||||
return planClaudeStyleLegacyInstall(context, {
|
||||
adapterId: 'claude-project',
|
||||
adapterRootInput: { repoRoot: context.projectRoot },
|
||||
rulesDir: null
|
||||
});
|
||||
}
|
||||
|
||||
function planCursorLegacyInstall(context) {
|
||||
const adapter = getInstallTargetAdapter('cursor');
|
||||
const targetRoot = adapter.resolveRoot({ repoRoot: context.projectRoot });
|
||||
const installStatePath = adapter.getInstallStatePath({ repoRoot: context.projectRoot });
|
||||
const operations = [];
|
||||
const warnings = [];
|
||||
|
||||
addMatchingRuleOperations(operations, {
|
||||
moduleId: 'legacy-cursor-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: path.join('.cursor', 'rules'),
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
matcher: fileName => /^common-.*\.md$/.test(fileName)
|
||||
});
|
||||
|
||||
for (const language of context.languages) {
|
||||
if (!LANGUAGE_NAME_PATTERN.test(language)) {
|
||||
warnings.push(`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const matches = addMatchingRuleOperations(operations, {
|
||||
moduleId: 'legacy-cursor-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: path.join('.cursor', 'rules'),
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
matcher: fileName => fileName.startsWith(`${language}-`) && fileName.endsWith('.md')
|
||||
});
|
||||
|
||||
if (matches === 0) {
|
||||
warnings.push(`No Cursor rules for '${language}' found, skipping`);
|
||||
}
|
||||
}
|
||||
|
||||
addRecursiveCopyOperations(operations, {
|
||||
moduleId: 'legacy-cursor-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: path.join('.cursor', 'agents'),
|
||||
destinationDir: path.join(targetRoot, 'agents'),
|
||||
destinationRelativePathTransform: toCursorAgentRelativePath
|
||||
});
|
||||
addRecursiveCopyOperations(operations, {
|
||||
moduleId: 'legacy-cursor-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: path.join('.cursor', 'skills'),
|
||||
destinationDir: path.join(targetRoot, 'skills')
|
||||
});
|
||||
addRecursiveCopyOperations(operations, {
|
||||
moduleId: 'legacy-cursor-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: path.join('.cursor', 'commands'),
|
||||
destinationDir: path.join(targetRoot, 'commands')
|
||||
});
|
||||
addRecursiveCopyOperations(operations, {
|
||||
moduleId: 'legacy-cursor-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: path.join('.cursor', 'hooks'),
|
||||
destinationDir: path.join(targetRoot, 'hooks')
|
||||
});
|
||||
|
||||
addFileCopyOperation(operations, {
|
||||
moduleId: 'legacy-cursor-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativePath: path.join('.cursor', 'hooks.json'),
|
||||
destinationPath: path.join(targetRoot, 'hooks.json')
|
||||
});
|
||||
addJsonMergeOperation(operations, {
|
||||
moduleId: 'legacy-cursor-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativePath: '.mcp.json',
|
||||
destinationPath: path.join(targetRoot, 'mcp.json')
|
||||
});
|
||||
|
||||
addCursorAgentDataScaffoldOperations(operations, {
|
||||
moduleId: 'legacy-cursor-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
targetRoot
|
||||
});
|
||||
|
||||
return {
|
||||
mode: 'legacy',
|
||||
adapter,
|
||||
target: 'cursor',
|
||||
targetRoot,
|
||||
installRoot: targetRoot,
|
||||
installStatePath,
|
||||
operations,
|
||||
warnings,
|
||||
selectedModules: ['legacy-cursor-install']
|
||||
};
|
||||
}
|
||||
|
||||
function planAntigravityLegacyInstall(context) {
|
||||
const adapter = getInstallTargetAdapter('antigravity');
|
||||
const targetRoot = adapter.resolveRoot({ repoRoot: context.projectRoot });
|
||||
const installStatePath = adapter.getInstallStatePath({ repoRoot: context.projectRoot });
|
||||
const operations = [];
|
||||
const warnings = [];
|
||||
|
||||
if (isDirectoryNonEmpty(path.join(targetRoot, 'rules'))) {
|
||||
warnings.push(`Destination ${path.join(targetRoot, 'rules')}/ already exists and files may be overwritten`);
|
||||
}
|
||||
|
||||
addMatchingRuleOperations(operations, {
|
||||
moduleId: 'legacy-antigravity-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: path.join('rules', 'common'),
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
matcher: fileName => fileName.endsWith('.md'),
|
||||
rename: fileName => `common-${fileName}`
|
||||
});
|
||||
|
||||
for (const language of context.languages) {
|
||||
if (!LANGUAGE_NAME_PATTERN.test(language)) {
|
||||
warnings.push(`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceDir = path.join(context.sourceRoot, 'rules', language);
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
warnings.push(`rules/${language}/ does not exist, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
addMatchingRuleOperations(operations, {
|
||||
moduleId: 'legacy-antigravity-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: path.join('rules', language),
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
matcher: fileName => fileName.endsWith('.md'),
|
||||
rename: fileName => `${language}-${fileName}`
|
||||
});
|
||||
}
|
||||
|
||||
addRecursiveCopyOperations(operations, {
|
||||
moduleId: 'legacy-antigravity-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: 'commands',
|
||||
destinationDir: path.join(targetRoot, 'workflows')
|
||||
});
|
||||
addRecursiveCopyOperations(operations, {
|
||||
moduleId: 'legacy-antigravity-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: 'agents',
|
||||
destinationDir: path.join(targetRoot, 'skills')
|
||||
});
|
||||
addRecursiveCopyOperations(operations, {
|
||||
moduleId: 'legacy-antigravity-install',
|
||||
sourceRoot: context.sourceRoot,
|
||||
sourceRelativeDir: 'skills',
|
||||
destinationDir: path.join(targetRoot, 'skills')
|
||||
});
|
||||
|
||||
return {
|
||||
mode: 'legacy',
|
||||
adapter,
|
||||
target: 'antigravity',
|
||||
targetRoot,
|
||||
installRoot: targetRoot,
|
||||
installStatePath,
|
||||
operations,
|
||||
warnings,
|
||||
selectedModules: ['legacy-antigravity-install']
|
||||
};
|
||||
}
|
||||
|
||||
function createLegacyInstallPlan(options = {}) {
|
||||
const sourceRoot = options.sourceRoot || getSourceRoot();
|
||||
const projectRoot = options.projectRoot || process.cwd();
|
||||
const homeDir = options.homeDir || process.env.HOME || os.homedir();
|
||||
const target = options.target || 'claude';
|
||||
|
||||
validateLegacyTarget(target);
|
||||
|
||||
const context = {
|
||||
sourceRoot,
|
||||
projectRoot,
|
||||
homeDir,
|
||||
languages: Array.isArray(options.languages) ? options.languages : [],
|
||||
claudeRulesDir: options.claudeRulesDir || process.env.CLAUDE_RULES_DIR || null
|
||||
};
|
||||
|
||||
let plan;
|
||||
if (target === 'claude') {
|
||||
plan = planClaudeLegacyInstall(context);
|
||||
} else if (target === 'claude-project') {
|
||||
plan = planClaudeProjectLegacyInstall(context);
|
||||
} else if (target === 'cursor') {
|
||||
plan = planCursorLegacyInstall(context);
|
||||
} else {
|
||||
plan = planAntigravityLegacyInstall(context);
|
||||
}
|
||||
|
||||
const source = {
|
||||
repoVersion: getPackageVersion(sourceRoot),
|
||||
repoCommit: getRepoCommit(sourceRoot),
|
||||
manifestVersion: getManifestVersion(sourceRoot)
|
||||
};
|
||||
|
||||
const statePreview = createStatePreview({
|
||||
adapter: plan.adapter,
|
||||
targetRoot: plan.targetRoot,
|
||||
installStatePath: plan.installStatePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: [],
|
||||
legacyLanguages: context.languages,
|
||||
legacyMode: true
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: plan.selectedModules,
|
||||
skippedModules: []
|
||||
},
|
||||
operations: plan.operations,
|
||||
source
|
||||
});
|
||||
|
||||
return {
|
||||
mode: 'legacy',
|
||||
target: plan.target,
|
||||
adapter: {
|
||||
id: plan.adapter.id,
|
||||
target: plan.adapter.target,
|
||||
kind: plan.adapter.kind
|
||||
},
|
||||
targetRoot: plan.targetRoot,
|
||||
installRoot: plan.installRoot,
|
||||
installStatePath: plan.installStatePath,
|
||||
warnings: plan.warnings,
|
||||
languages: context.languages,
|
||||
operations: plan.operations,
|
||||
statePreview
|
||||
};
|
||||
}
|
||||
|
||||
function createLegacyCompatInstallPlan(options = {}) {
|
||||
const sourceRoot = options.sourceRoot || getSourceRoot();
|
||||
const projectRoot = options.projectRoot || process.cwd();
|
||||
const target = options.target || 'claude';
|
||||
const includeComponentIds = Array.isArray(options.includeComponentIds) ? [...options.includeComponentIds] : [];
|
||||
const excludeComponentIds = Array.isArray(options.excludeComponentIds) ? [...options.excludeComponentIds] : [];
|
||||
|
||||
validateLegacyTarget(target);
|
||||
|
||||
const selection = resolveLegacyCompatibilitySelection({
|
||||
repoRoot: sourceRoot,
|
||||
target,
|
||||
legacyLanguages: options.legacyLanguages || []
|
||||
});
|
||||
|
||||
return createManifestInstallPlan({
|
||||
sourceRoot,
|
||||
projectRoot,
|
||||
homeDir: options.homeDir,
|
||||
target,
|
||||
profileId: null,
|
||||
moduleIds: selection.moduleIds,
|
||||
includeComponentIds,
|
||||
excludeComponentIds,
|
||||
legacyLanguages: selection.legacyLanguages,
|
||||
legacyMode: true,
|
||||
requestProfileId: null,
|
||||
requestModuleIds: [],
|
||||
requestIncludeComponentIds: includeComponentIds,
|
||||
requestExcludeComponentIds: excludeComponentIds,
|
||||
mode: 'legacy-compat'
|
||||
});
|
||||
}
|
||||
|
||||
function materializeScaffoldOperation(sourceRoot, operation) {
|
||||
if (operation.kind === 'merge-json') {
|
||||
return [
|
||||
{
|
||||
kind: 'merge-json',
|
||||
moduleId: operation.moduleId,
|
||||
sourceRelativePath: operation.sourceRelativePath,
|
||||
destinationPath: operation.destinationPath,
|
||||
strategy: operation.strategy || 'merge-json',
|
||||
ownership: operation.ownership || 'managed',
|
||||
scaffoldOnly: Object.hasOwn(operation, 'scaffoldOnly') ? operation.scaffoldOnly : false,
|
||||
mergePayload: readJsonObject(path.join(sourceRoot, operation.sourceRelativePath), operation.sourceRelativePath)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
const sourcePath = path.join(sourceRoot, operation.sourceRelativePath);
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isGeneratedRuntimeSourcePath(operation.sourceRelativePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stat = fs.statSync(sourcePath);
|
||||
if (stat.isFile()) {
|
||||
return [
|
||||
buildCopyFileOperation({
|
||||
moduleId: operation.moduleId,
|
||||
sourcePath,
|
||||
sourceRelativePath: operation.sourceRelativePath,
|
||||
destinationPath: operation.destinationPath,
|
||||
strategy: operation.strategy
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
const relativeFiles = listFilesRecursive(sourcePath).filter(relativeFile => {
|
||||
const sourceRelativePath = path.join(operation.sourceRelativePath, relativeFile);
|
||||
return !isGeneratedRuntimeSourcePath(sourceRelativePath);
|
||||
});
|
||||
return relativeFiles.map(relativeFile => {
|
||||
const sourceRelativePath = path.join(operation.sourceRelativePath, relativeFile);
|
||||
return buildCopyFileOperation({
|
||||
moduleId: operation.moduleId,
|
||||
sourcePath: path.join(sourcePath, relativeFile),
|
||||
sourceRelativePath,
|
||||
destinationPath: path.join(operation.destinationPath, relativeFile),
|
||||
strategy: operation.strategy
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function dedupeCopyFileOperations(operations) {
|
||||
// A `copy-file` operation fully overwrites its destination, so when several
|
||||
// of them target the same path (e.g. a generic `commands/<name>.md` shadowed
|
||||
// by an OpenCode `.opencode/commands/<name>.md` override) only the last one
|
||||
// actually determines the installed content. Recording the shadowed earlier
|
||||
// writes in install-state makes `doctor` report perpetual drift and drives
|
||||
// `repair` to clobber the override with the generic source (issue #2414).
|
||||
// Keep only the last `copy-file` per destination — matching the sequential
|
||||
// apply order in applyInstallPlan — and leave every other operation kind
|
||||
// (e.g. accumulating `merge-json` writes into a shared config) untouched and
|
||||
// in order.
|
||||
const lastCopyIndexByDestination = new Map();
|
||||
operations.forEach((operation, index) => {
|
||||
if (operation.kind === 'copy-file' && operation.destinationPath) {
|
||||
lastCopyIndexByDestination.set(operation.destinationPath, index);
|
||||
}
|
||||
});
|
||||
|
||||
return operations.filter((operation, index) => {
|
||||
if (operation.kind !== 'copy-file' || !operation.destinationPath) {
|
||||
return true;
|
||||
}
|
||||
return lastCopyIndexByDestination.get(operation.destinationPath) === index;
|
||||
});
|
||||
}
|
||||
|
||||
function createManifestInstallPlan(options = {}) {
|
||||
const sourceRoot = options.sourceRoot || getSourceRoot();
|
||||
const projectRoot = options.projectRoot || process.cwd();
|
||||
const target = options.target || 'claude';
|
||||
const legacyLanguages = Array.isArray(options.legacyLanguages) ? [...options.legacyLanguages] : [];
|
||||
const requestProfileId = Object.hasOwn(options, 'requestProfileId') ? options.requestProfileId : options.profileId || null;
|
||||
const requestModuleIds = Object.hasOwn(options, 'requestModuleIds') ? [...options.requestModuleIds] : Array.isArray(options.moduleIds) ? [...options.moduleIds] : [];
|
||||
const requestIncludeComponentIds = Object.hasOwn(options, 'requestIncludeComponentIds')
|
||||
? [...options.requestIncludeComponentIds]
|
||||
: Array.isArray(options.includeComponentIds)
|
||||
? [...options.includeComponentIds]
|
||||
: [];
|
||||
const requestExcludeComponentIds = Object.hasOwn(options, 'requestExcludeComponentIds')
|
||||
? [...options.requestExcludeComponentIds]
|
||||
: Array.isArray(options.excludeComponentIds)
|
||||
? [...options.excludeComponentIds]
|
||||
: [];
|
||||
const plan = resolveInstallPlan({
|
||||
repoRoot: sourceRoot,
|
||||
projectRoot,
|
||||
homeDir: options.homeDir,
|
||||
profileId: options.profileId || null,
|
||||
moduleIds: options.moduleIds || [],
|
||||
includeComponentIds: options.includeComponentIds || [],
|
||||
excludeComponentIds: options.excludeComponentIds || [],
|
||||
target,
|
||||
exemptValidationCodes: options.exemptValidationCodes || [],
|
||||
});
|
||||
const adapter = getInstallTargetAdapter(target);
|
||||
const operations = dedupeCopyFileOperations(
|
||||
plan.operations.flatMap(operation => materializeScaffoldOperation(sourceRoot, operation))
|
||||
);
|
||||
const source = {
|
||||
repoVersion: getPackageVersion(sourceRoot),
|
||||
repoCommit: getRepoCommit(sourceRoot),
|
||||
manifestVersion: getManifestVersion(sourceRoot)
|
||||
};
|
||||
const statePreview = createStatePreview({
|
||||
adapter,
|
||||
targetRoot: plan.targetRoot,
|
||||
installStatePath: plan.installStatePath,
|
||||
request: {
|
||||
profile: requestProfileId,
|
||||
modules: requestModuleIds,
|
||||
includeComponents: requestIncludeComponentIds,
|
||||
excludeComponents: requestExcludeComponentIds,
|
||||
legacyLanguages,
|
||||
legacyMode: Boolean(options.legacyMode)
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: plan.selectedModuleIds,
|
||||
skippedModules: plan.skippedModuleIds
|
||||
},
|
||||
operations,
|
||||
source
|
||||
});
|
||||
|
||||
return {
|
||||
mode: options.mode || 'manifest',
|
||||
target,
|
||||
adapter: {
|
||||
id: adapter.id,
|
||||
target: adapter.target,
|
||||
kind: adapter.kind
|
||||
},
|
||||
targetRoot: plan.targetRoot,
|
||||
installRoot: plan.targetRoot,
|
||||
installStatePath: plan.installStatePath,
|
||||
warnings: Array.isArray(options.warnings) ? [...options.warnings] : [],
|
||||
languages: legacyLanguages,
|
||||
legacyLanguages,
|
||||
profileId: plan.profileId,
|
||||
requestedModuleIds: plan.requestedModuleIds,
|
||||
explicitModuleIds: plan.explicitModuleIds,
|
||||
includedComponentIds: plan.includedComponentIds,
|
||||
excludedComponentIds: plan.excludedComponentIds,
|
||||
selectedModuleIds: plan.selectedModuleIds,
|
||||
skippedModuleIds: plan.skippedModuleIds,
|
||||
excludedModuleIds: plan.excludedModuleIds,
|
||||
operations,
|
||||
statePreview
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SUPPORTED_INSTALL_TARGETS,
|
||||
LEGACY_INSTALL_TARGETS,
|
||||
applyInstallPlan,
|
||||
createLegacyCompatInstallPlan,
|
||||
createManifestInstallPlan,
|
||||
createLegacyInstallPlan,
|
||||
dedupeCopyFileOperations,
|
||||
getSourceRoot,
|
||||
listAvailableLanguages,
|
||||
parseInstallArgs
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,730 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { getInstallTargetAdapter, planInstallTargetScaffold } = require('./install-targets/registry');
|
||||
|
||||
const DEFAULT_REPO_ROOT = path.join(__dirname, '../..');
|
||||
const SUPPORTED_INSTALL_TARGETS = ['claude', 'claude-project', 'cursor', 'antigravity', 'codex', 'gemini', 'opencode', 'codebuddy', 'joycode', 'qwen', 'zed', 'hermes', 'openclaw', 'kimi'];
|
||||
const COMPONENT_FAMILY_PREFIXES = {
|
||||
baseline: 'baseline:',
|
||||
language: 'lang:',
|
||||
framework: 'framework:',
|
||||
capability: 'capability:',
|
||||
agent: 'agent:',
|
||||
skill: 'skill:',
|
||||
locale: 'locale:',
|
||||
};
|
||||
const SUPPORTED_LOCALES = Object.freeze(['ja', 'zh-CN', 'ko-KR', 'pt-BR', 'ru', 'tr', 'vi-VN', 'zh-TW', 'de-DE']);
|
||||
const LOCALE_ALIAS_TO_COMPONENT_ID = Object.freeze({
|
||||
'ja': 'locale:ja',
|
||||
'ja-JP': 'locale:ja',
|
||||
'zh-CN': 'locale:zh-cn',
|
||||
'zh': 'locale:zh-cn',
|
||||
'ko-KR': 'locale:ko-kr',
|
||||
'ko': 'locale:ko-kr',
|
||||
'pt-BR': 'locale:pt-br',
|
||||
'pt': 'locale:pt-br',
|
||||
'ru': 'locale:ru',
|
||||
'tr': 'locale:tr',
|
||||
'vi-VN': 'locale:vi-vn',
|
||||
'vi': 'locale:vi-vn',
|
||||
'zh-TW': 'locale:zh-tw',
|
||||
'de-DE': 'locale:de-de',
|
||||
'de': 'locale:de-de',
|
||||
});
|
||||
|
||||
function listSupportedLocales() {
|
||||
return [...SUPPORTED_LOCALES];
|
||||
}
|
||||
const LEGACY_COMPAT_BASE_MODULE_IDS_BY_TARGET = Object.freeze({
|
||||
claude: [
|
||||
'rules-core',
|
||||
'agents-core',
|
||||
'commands-core',
|
||||
'hooks-runtime',
|
||||
'platform-configs',
|
||||
'workflow-quality',
|
||||
],
|
||||
'claude-project': [
|
||||
'rules-core',
|
||||
'agents-core',
|
||||
'commands-core',
|
||||
'hooks-runtime',
|
||||
'platform-configs',
|
||||
'workflow-quality',
|
||||
],
|
||||
cursor: [
|
||||
'rules-core',
|
||||
'agents-core',
|
||||
'commands-core',
|
||||
'hooks-runtime',
|
||||
'platform-configs',
|
||||
'workflow-quality',
|
||||
],
|
||||
antigravity: [
|
||||
'rules-core',
|
||||
'agents-core',
|
||||
'commands-core',
|
||||
],
|
||||
zed: [
|
||||
'rules-core',
|
||||
'agents-core',
|
||||
'commands-core',
|
||||
'platform-configs',
|
||||
'workflow-quality',
|
||||
],
|
||||
hermes: [
|
||||
'rules-core',
|
||||
'agents-core',
|
||||
'commands-core',
|
||||
'platform-configs',
|
||||
'workflow-quality',
|
||||
],
|
||||
openclaw: [
|
||||
'rules-core',
|
||||
'agents-core',
|
||||
'commands-core',
|
||||
'platform-configs',
|
||||
'workflow-quality',
|
||||
],
|
||||
kimi: [
|
||||
'rules-core',
|
||||
'agents-core',
|
||||
'commands-core',
|
||||
'platform-configs',
|
||||
'workflow-quality',
|
||||
],
|
||||
});
|
||||
const LEGACY_LANGUAGE_ALIAS_TO_CANONICAL = Object.freeze({
|
||||
c: 'c',
|
||||
cpp: 'cpp',
|
||||
csharp: 'csharp',
|
||||
fsharp: 'fsharp',
|
||||
go: 'go',
|
||||
golang: 'go',
|
||||
arkts: 'arkts',
|
||||
harmonyos: 'arkts',
|
||||
java: 'java',
|
||||
javascript: 'typescript',
|
||||
kotlin: 'java',
|
||||
perl: 'perl',
|
||||
php: 'php',
|
||||
python: 'python',
|
||||
rails: 'ruby',
|
||||
ruby: 'ruby',
|
||||
rust: 'rust',
|
||||
swift: 'swift',
|
||||
typescript: 'typescript',
|
||||
});
|
||||
const LEGACY_LANGUAGE_EXTRA_MODULE_IDS = Object.freeze({
|
||||
c: ['framework-language'],
|
||||
cpp: ['framework-language'],
|
||||
csharp: ['framework-language'],
|
||||
fsharp: ['framework-language'],
|
||||
go: ['framework-language'],
|
||||
arkts: ['framework-language'],
|
||||
java: ['framework-language'],
|
||||
perl: [],
|
||||
php: [],
|
||||
python: ['framework-language'],
|
||||
ruby: ['framework-language', 'security'],
|
||||
rust: ['framework-language'],
|
||||
swift: [],
|
||||
typescript: ['framework-language'],
|
||||
});
|
||||
const TARGET_DEFAULT_PROFILE_IDS = Object.freeze({
|
||||
opencode: 'opencode',
|
||||
});
|
||||
const TARGET_DEFAULT_EXCLUSIONS = Object.freeze({
|
||||
opencode: [
|
||||
{
|
||||
moduleId: 'hooks-runtime',
|
||||
reason: 'OpenCode defaults intentionally exclude hooks-runtime until users opt in.',
|
||||
optInCommand: './install.sh --target opencode --modules hooks-runtime',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
function readJson(filePath, label) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read ${label}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeStrings(values) {
|
||||
return [...new Set((Array.isArray(values) ? values : []).map(value => String(value).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function listSkillDirectoryIds(repoRoot) {
|
||||
const skillsRoot = path.join(repoRoot, 'skills');
|
||||
if (!fs.existsSync(skillsRoot) || !fs.statSync(skillsRoot).isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(skillsRoot, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => entry.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function addSyntheticSkillComponents({ repoRoot, modules, components }) {
|
||||
const moduleIds = new Set(modules.map(module => module.id));
|
||||
const componentIds = new Set(components.map(component => component.id));
|
||||
|
||||
for (const skillId of listSkillDirectoryIds(repoRoot)) {
|
||||
const componentId = `skill:${skillId}`;
|
||||
if (componentIds.has(componentId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const moduleId = `skill-${skillId}`;
|
||||
if (!moduleIds.has(moduleId)) {
|
||||
modules.push({
|
||||
id: moduleId,
|
||||
kind: 'skills',
|
||||
description: `Single-skill install surface for ${skillId}.`,
|
||||
paths: [`skills/${skillId}`],
|
||||
targets: SUPPORTED_INSTALL_TARGETS.slice(),
|
||||
dependencies: [],
|
||||
defaultInstall: false,
|
||||
cost: 'light',
|
||||
stability: 'stable',
|
||||
synthetic: true,
|
||||
});
|
||||
moduleIds.add(moduleId);
|
||||
}
|
||||
|
||||
components.push({
|
||||
id: componentId,
|
||||
family: 'skill',
|
||||
description: `Install only the ${skillId} skill directory.`,
|
||||
modules: [moduleId],
|
||||
synthetic: true,
|
||||
});
|
||||
componentIds.add(componentId);
|
||||
}
|
||||
}
|
||||
|
||||
function readOptionalStringOption(options, key) {
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(options, key)
|
||||
|| options[key] === null
|
||||
|| options[key] === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof options[key] !== 'string' || options[key].trim() === '') {
|
||||
throw new Error(`${key} must be a non-empty string when provided`);
|
||||
}
|
||||
|
||||
return options[key];
|
||||
}
|
||||
|
||||
function readModuleTargetsOrThrow(module) {
|
||||
const moduleId = module && module.id ? module.id : '<unknown>';
|
||||
const targets = module && module.targets;
|
||||
|
||||
if (!Array.isArray(targets)) {
|
||||
throw new Error(`Install module ${moduleId} has invalid targets; expected an array of supported target ids`);
|
||||
}
|
||||
|
||||
const normalizedTargets = targets.map(target => (
|
||||
typeof target === 'string' ? target.trim() : ''
|
||||
));
|
||||
|
||||
if (normalizedTargets.some(target => target.length === 0)) {
|
||||
throw new Error(`Install module ${moduleId} has invalid targets; expected an array of supported target ids`);
|
||||
}
|
||||
|
||||
const unsupportedTargets = normalizedTargets.filter(target => !SUPPORTED_INSTALL_TARGETS.includes(target));
|
||||
if (unsupportedTargets.length > 0) {
|
||||
throw new Error(
|
||||
`Install module ${moduleId} has unsupported targets: ${unsupportedTargets.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return normalizedTargets;
|
||||
}
|
||||
|
||||
function assertKnownModuleIds(moduleIds, manifests) {
|
||||
const unknownModuleIds = dedupeStrings(moduleIds)
|
||||
.filter(moduleId => !manifests.modulesById.has(moduleId));
|
||||
|
||||
if (unknownModuleIds.length === 1) {
|
||||
throw new Error(`Unknown install module: ${unknownModuleIds[0]}`);
|
||||
}
|
||||
|
||||
if (unknownModuleIds.length > 1) {
|
||||
throw new Error(`Unknown install modules: ${unknownModuleIds.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function intersectTargets(modules) {
|
||||
if (!Array.isArray(modules) || modules.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return SUPPORTED_INSTALL_TARGETS.filter(target => (
|
||||
modules.every(module => Array.isArray(module.targets) && module.targets.includes(target))
|
||||
));
|
||||
}
|
||||
|
||||
function getManifestPaths(repoRoot = DEFAULT_REPO_ROOT) {
|
||||
return {
|
||||
modulesPath: path.join(repoRoot, 'manifests', 'install-modules.json'),
|
||||
profilesPath: path.join(repoRoot, 'manifests', 'install-profiles.json'),
|
||||
componentsPath: path.join(repoRoot, 'manifests', 'install-components.json'),
|
||||
};
|
||||
}
|
||||
|
||||
function loadInstallManifests(options = {}) {
|
||||
const repoRoot = options.repoRoot || DEFAULT_REPO_ROOT;
|
||||
const { modulesPath, profilesPath, componentsPath } = getManifestPaths(repoRoot);
|
||||
|
||||
if (!fs.existsSync(modulesPath) || !fs.existsSync(profilesPath)) {
|
||||
throw new Error(`Install manifests not found under ${repoRoot}`);
|
||||
}
|
||||
|
||||
const modulesData = readJson(modulesPath, 'install-modules.json');
|
||||
const profilesData = readJson(profilesPath, 'install-profiles.json');
|
||||
const componentsData = fs.existsSync(componentsPath)
|
||||
? readJson(componentsPath, 'install-components.json')
|
||||
: { version: null, components: [] };
|
||||
const modules = Array.isArray(modulesData.modules) ? modulesData.modules.slice() : [];
|
||||
const profiles = profilesData && typeof profilesData.profiles === 'object'
|
||||
? profilesData.profiles
|
||||
: {};
|
||||
const components = Array.isArray(componentsData.components) ? componentsData.components.slice() : [];
|
||||
|
||||
addSyntheticSkillComponents({ repoRoot, modules, components });
|
||||
|
||||
for (const module of modules) {
|
||||
readModuleTargetsOrThrow(module);
|
||||
}
|
||||
|
||||
const modulesById = new Map(modules.map(module => [module.id, module]));
|
||||
const componentsById = new Map(components.map(component => [component.id, component]));
|
||||
|
||||
return {
|
||||
repoRoot,
|
||||
modulesPath,
|
||||
profilesPath,
|
||||
componentsPath,
|
||||
modules,
|
||||
profiles,
|
||||
components,
|
||||
modulesById,
|
||||
componentsById,
|
||||
modulesVersion: modulesData.version,
|
||||
profilesVersion: profilesData.version,
|
||||
componentsVersion: componentsData.version,
|
||||
};
|
||||
}
|
||||
|
||||
function listInstallProfiles(options = {}) {
|
||||
const manifests = loadInstallManifests(options);
|
||||
return Object.entries(manifests.profiles).map(([id, profile]) => ({
|
||||
id,
|
||||
description: profile.description,
|
||||
moduleCount: Array.isArray(profile.modules) ? profile.modules.length : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function listInstallModules(options = {}) {
|
||||
const manifests = loadInstallManifests(options);
|
||||
return manifests.modules.map(module => ({
|
||||
id: module.id,
|
||||
kind: module.kind,
|
||||
description: module.description,
|
||||
targets: module.targets,
|
||||
defaultInstall: module.defaultInstall,
|
||||
cost: module.cost,
|
||||
stability: module.stability,
|
||||
dependencyCount: Array.isArray(module.dependencies) ? module.dependencies.length : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function listLegacyCompatibilityLanguages() {
|
||||
return Object.keys(LEGACY_LANGUAGE_ALIAS_TO_CANONICAL).sort();
|
||||
}
|
||||
|
||||
function validateInstallModuleIds(moduleIds, options = {}) {
|
||||
const manifests = loadInstallManifests(options);
|
||||
const normalizedModuleIds = dedupeStrings(moduleIds);
|
||||
assertKnownModuleIds(normalizedModuleIds, manifests);
|
||||
return normalizedModuleIds;
|
||||
}
|
||||
|
||||
function listInstallComponents(options = {}) {
|
||||
const manifests = loadInstallManifests(options);
|
||||
const family = options.family || null;
|
||||
const target = options.target || null;
|
||||
|
||||
if (family && !Object.hasOwn(COMPONENT_FAMILY_PREFIXES, family)) {
|
||||
throw new Error(
|
||||
`Unknown component family: ${family}. Expected one of ${Object.keys(COMPONENT_FAMILY_PREFIXES).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (target && !SUPPORTED_INSTALL_TARGETS.includes(target)) {
|
||||
throw new Error(
|
||||
`Unknown install target: ${target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return manifests.components
|
||||
.filter(component => !family || component.family === family)
|
||||
.map(component => {
|
||||
const moduleIds = dedupeStrings(component.modules);
|
||||
const modules = moduleIds
|
||||
.map(moduleId => manifests.modulesById.get(moduleId))
|
||||
.filter(Boolean);
|
||||
const targets = intersectTargets(modules);
|
||||
|
||||
return {
|
||||
id: component.id,
|
||||
family: component.family,
|
||||
description: component.description,
|
||||
moduleIds,
|
||||
moduleCount: moduleIds.length,
|
||||
targets,
|
||||
};
|
||||
})
|
||||
.filter(component => !target || component.targets.includes(target));
|
||||
}
|
||||
|
||||
function getInstallComponent(componentId, options = {}) {
|
||||
const manifests = loadInstallManifests(options);
|
||||
const normalizedComponentId = String(componentId || '').trim();
|
||||
|
||||
if (!normalizedComponentId) {
|
||||
throw new Error('An install component ID is required');
|
||||
}
|
||||
|
||||
const component = manifests.componentsById.get(normalizedComponentId);
|
||||
if (!component) {
|
||||
throw new Error(`Unknown install component: ${normalizedComponentId}`);
|
||||
}
|
||||
|
||||
const moduleIds = dedupeStrings(component.modules);
|
||||
const modules = moduleIds
|
||||
.map(moduleId => manifests.modulesById.get(moduleId))
|
||||
.filter(Boolean)
|
||||
.map(module => ({
|
||||
id: module.id,
|
||||
kind: module.kind,
|
||||
description: module.description,
|
||||
targets: module.targets,
|
||||
defaultInstall: module.defaultInstall,
|
||||
cost: module.cost,
|
||||
stability: module.stability,
|
||||
dependencies: dedupeStrings(module.dependencies),
|
||||
}));
|
||||
|
||||
return {
|
||||
id: component.id,
|
||||
family: component.family,
|
||||
description: component.description,
|
||||
moduleIds,
|
||||
moduleCount: moduleIds.length,
|
||||
targets: intersectTargets(modules),
|
||||
modules,
|
||||
};
|
||||
}
|
||||
|
||||
function expandComponentIdsToModuleIds(componentIds, manifests) {
|
||||
const expandedModuleIds = [];
|
||||
|
||||
for (const componentId of dedupeStrings(componentIds)) {
|
||||
const component = manifests.componentsById.get(componentId);
|
||||
if (!component) {
|
||||
throw new Error(`Unknown install component: ${componentId}`);
|
||||
}
|
||||
expandedModuleIds.push(...component.modules);
|
||||
}
|
||||
|
||||
return dedupeStrings(expandedModuleIds);
|
||||
}
|
||||
|
||||
function getTargetDefaultProfileId(target, manifests) {
|
||||
const profileId = target ? TARGET_DEFAULT_PROFILE_IDS[target] : null;
|
||||
return profileId && manifests.profiles[profileId] ? profileId : null;
|
||||
}
|
||||
|
||||
function getTargetDefaultExclusions(target, manifests) {
|
||||
const exclusions = target ? TARGET_DEFAULT_EXCLUSIONS[target] : null;
|
||||
if (!Array.isArray(exclusions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return exclusions
|
||||
.filter(exclusion => manifests.modulesById.has(exclusion.moduleId))
|
||||
.map(exclusion => ({ ...exclusion }));
|
||||
}
|
||||
|
||||
function resolveLegacyCompatibilitySelection(options = {}) {
|
||||
const manifests = loadInstallManifests(options);
|
||||
const target = options.target || null;
|
||||
|
||||
if (target && !SUPPORTED_INSTALL_TARGETS.includes(target)) {
|
||||
throw new Error(
|
||||
`Unknown install target: ${target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const legacyLanguages = dedupeStrings(options.legacyLanguages)
|
||||
.map(language => language.toLowerCase());
|
||||
const normalizedLegacyLanguages = dedupeStrings(legacyLanguages);
|
||||
|
||||
if (normalizedLegacyLanguages.length === 0) {
|
||||
throw new Error('No legacy languages were provided');
|
||||
}
|
||||
|
||||
const unknownLegacyLanguages = normalizedLegacyLanguages
|
||||
.filter(language => !Object.hasOwn(LEGACY_LANGUAGE_ALIAS_TO_CANONICAL, language));
|
||||
|
||||
if (unknownLegacyLanguages.length === 1) {
|
||||
throw new Error(
|
||||
`Unknown legacy language: ${unknownLegacyLanguages[0]}. Expected one of ${listLegacyCompatibilityLanguages().join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (unknownLegacyLanguages.length > 1) {
|
||||
throw new Error(
|
||||
`Unknown legacy languages: ${unknownLegacyLanguages.join(', ')}. Expected one of ${listLegacyCompatibilityLanguages().join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const canonicalLegacyLanguages = normalizedLegacyLanguages
|
||||
.map(language => LEGACY_LANGUAGE_ALIAS_TO_CANONICAL[language]);
|
||||
const baseModuleIds = LEGACY_COMPAT_BASE_MODULE_IDS_BY_TARGET[target || 'claude']
|
||||
|| LEGACY_COMPAT_BASE_MODULE_IDS_BY_TARGET.claude;
|
||||
const moduleIds = dedupeStrings([
|
||||
...baseModuleIds,
|
||||
...(target === 'antigravity'
|
||||
? []
|
||||
: canonicalLegacyLanguages.flatMap(language => LEGACY_LANGUAGE_EXTRA_MODULE_IDS[language] || [])),
|
||||
]);
|
||||
|
||||
assertKnownModuleIds(moduleIds, manifests);
|
||||
|
||||
return {
|
||||
legacyLanguages: normalizedLegacyLanguages,
|
||||
canonicalLegacyLanguages,
|
||||
moduleIds,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveInstallPlan(options = {}) {
|
||||
const manifests = loadInstallManifests(options);
|
||||
const requestedProfileId = options.profileId || null;
|
||||
const explicitModuleIds = dedupeStrings(options.moduleIds);
|
||||
const includedComponentIds = dedupeStrings(options.includeComponentIds);
|
||||
const excludedComponentIds = dedupeStrings(options.excludeComponentIds);
|
||||
const requestedModuleIds = [];
|
||||
const target = options.target || null;
|
||||
|
||||
if (target && !SUPPORTED_INSTALL_TARGETS.includes(target)) {
|
||||
throw new Error(
|
||||
`Unknown install target: ${target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const shouldUseTargetDefaultProfile = !requestedProfileId
|
||||
&& explicitModuleIds.length === 0
|
||||
&& includedComponentIds.length === 0;
|
||||
const targetDefaultProfileId = shouldUseTargetDefaultProfile
|
||||
? getTargetDefaultProfileId(target, manifests)
|
||||
: null;
|
||||
const profileId = requestedProfileId || targetDefaultProfileId;
|
||||
const targetDefaultExclusions = targetDefaultProfileId
|
||||
? getTargetDefaultExclusions(target, manifests)
|
||||
: [];
|
||||
|
||||
if (profileId) {
|
||||
const profile = manifests.profiles[profileId];
|
||||
if (!profile) {
|
||||
throw new Error(`Unknown install profile: ${profileId}`);
|
||||
}
|
||||
requestedModuleIds.push(...profile.modules);
|
||||
}
|
||||
|
||||
requestedModuleIds.push(...explicitModuleIds);
|
||||
requestedModuleIds.push(...expandComponentIdsToModuleIds(includedComponentIds, manifests));
|
||||
|
||||
const excludedModuleIds = expandComponentIdsToModuleIds(excludedComponentIds, manifests);
|
||||
const excludedModuleOwners = new Map();
|
||||
for (const componentId of excludedComponentIds) {
|
||||
const component = manifests.componentsById.get(componentId);
|
||||
if (!component) {
|
||||
throw new Error(`Unknown install component: ${componentId}`);
|
||||
}
|
||||
for (const moduleId of component.modules) {
|
||||
const owners = excludedModuleOwners.get(moduleId) || [];
|
||||
owners.push(componentId);
|
||||
excludedModuleOwners.set(moduleId, owners);
|
||||
}
|
||||
}
|
||||
for (const exclusion of targetDefaultExclusions) {
|
||||
const owners = excludedModuleOwners.get(exclusion.moduleId) || [];
|
||||
owners.push(`${target} default`);
|
||||
excludedModuleOwners.set(exclusion.moduleId, owners);
|
||||
}
|
||||
|
||||
const validatedProjectRoot = readOptionalStringOption(options, 'projectRoot');
|
||||
const validatedHomeDir = readOptionalStringOption(options, 'homeDir');
|
||||
const targetPlanningInput = target
|
||||
? {
|
||||
repoRoot: manifests.repoRoot,
|
||||
projectRoot: validatedProjectRoot || manifests.repoRoot,
|
||||
homeDir: validatedHomeDir || os.homedir(),
|
||||
}
|
||||
: null;
|
||||
const targetAdapter = target ? getInstallTargetAdapter(target) : null;
|
||||
|
||||
const effectiveRequestedIds = dedupeStrings(
|
||||
requestedModuleIds.filter(moduleId => !excludedModuleOwners.has(moduleId))
|
||||
);
|
||||
|
||||
if (requestedModuleIds.length === 0) {
|
||||
throw new Error('No install profile, module IDs, or included component IDs were provided');
|
||||
}
|
||||
|
||||
if (effectiveRequestedIds.length === 0) {
|
||||
throw new Error('Selection excludes every requested install module');
|
||||
}
|
||||
|
||||
const selectedIds = new Set();
|
||||
const skippedTargetIds = new Set();
|
||||
const excludedIds = new Set([
|
||||
...excludedModuleIds,
|
||||
...targetDefaultExclusions.map(exclusion => exclusion.moduleId),
|
||||
]);
|
||||
const visitingIds = new Set();
|
||||
const resolvedIds = new Set();
|
||||
|
||||
function resolveModule(moduleId, dependencyOf, rootRequesterId) {
|
||||
const module = manifests.modulesById.get(moduleId);
|
||||
if (!module) {
|
||||
throw new Error(`Unknown install module: ${moduleId}`);
|
||||
}
|
||||
|
||||
if (excludedModuleOwners.has(moduleId)) {
|
||||
if (dependencyOf) {
|
||||
const owners = excludedModuleOwners.get(moduleId) || [];
|
||||
throw new Error(
|
||||
`Module ${dependencyOf} depends on excluded module ${moduleId}${owners.length > 0 ? ` (excluded by ${owners.join(', ')})` : ''}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const supportsTarget = !target
|
||||
|| (
|
||||
readModuleTargetsOrThrow(module).includes(target)
|
||||
&& (!targetAdapter || targetAdapter.supportsModule(module, targetPlanningInput))
|
||||
);
|
||||
|
||||
if (!supportsTarget) {
|
||||
if (dependencyOf) {
|
||||
skippedTargetIds.add(rootRequesterId || dependencyOf);
|
||||
return false;
|
||||
}
|
||||
skippedTargetIds.add(moduleId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (resolvedIds.has(moduleId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (visitingIds.has(moduleId)) {
|
||||
throw new Error(`Circular install dependency detected at ${moduleId}`);
|
||||
}
|
||||
|
||||
visitingIds.add(moduleId);
|
||||
for (const dependencyId of module.dependencies) {
|
||||
const dependencyResolved = resolveModule(
|
||||
dependencyId,
|
||||
moduleId,
|
||||
rootRequesterId || moduleId
|
||||
);
|
||||
if (!dependencyResolved) {
|
||||
visitingIds.delete(moduleId);
|
||||
if (!dependencyOf) {
|
||||
skippedTargetIds.add(moduleId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
visitingIds.delete(moduleId);
|
||||
resolvedIds.add(moduleId);
|
||||
selectedIds.add(moduleId);
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const moduleId of effectiveRequestedIds) {
|
||||
resolveModule(moduleId, null, moduleId);
|
||||
}
|
||||
|
||||
const selectedModules = manifests.modules.filter(module => selectedIds.has(module.id));
|
||||
const skippedModules = manifests.modules.filter(module => skippedTargetIds.has(module.id));
|
||||
const excludedModules = manifests.modules.filter(module => excludedIds.has(module.id));
|
||||
const scaffoldPlan = target
|
||||
? planInstallTargetScaffold({
|
||||
target,
|
||||
repoRoot: targetPlanningInput.repoRoot,
|
||||
projectRoot: targetPlanningInput.projectRoot,
|
||||
homeDir: targetPlanningInput.homeDir,
|
||||
modules: selectedModules,
|
||||
exemptValidationCodes: options.exemptValidationCodes || [],
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
repoRoot: manifests.repoRoot,
|
||||
profileId,
|
||||
target,
|
||||
requestedModuleIds: effectiveRequestedIds,
|
||||
explicitModuleIds,
|
||||
includedComponentIds,
|
||||
excludedComponentIds,
|
||||
targetDefaultProfileId,
|
||||
targetDefaultExclusions,
|
||||
warnings: targetDefaultExclusions.map(exclusion => (
|
||||
`${exclusion.moduleId} is intentionally excluded from the OpenCode default. `
|
||||
+ `Opt in with: ${exclusion.optInCommand}`
|
||||
)),
|
||||
selectedModuleIds: selectedModules.map(module => module.id),
|
||||
skippedModuleIds: skippedModules.map(module => module.id),
|
||||
excludedModuleIds: excludedModules.map(module => module.id),
|
||||
selectedModules,
|
||||
skippedModules,
|
||||
excludedModules,
|
||||
targetAdapterId: scaffoldPlan ? scaffoldPlan.adapter.id : null,
|
||||
targetRoot: scaffoldPlan ? scaffoldPlan.targetRoot : null,
|
||||
installStatePath: scaffoldPlan ? scaffoldPlan.installStatePath : null,
|
||||
operations: scaffoldPlan ? scaffoldPlan.operations : [],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_REPO_ROOT,
|
||||
SUPPORTED_INSTALL_TARGETS,
|
||||
SUPPORTED_LOCALES,
|
||||
LOCALE_ALIAS_TO_COMPONENT_ID,
|
||||
getManifestPaths,
|
||||
loadInstallManifests,
|
||||
getInstallComponent,
|
||||
listInstallComponents,
|
||||
listLegacyCompatibilityLanguages,
|
||||
listSupportedLocales,
|
||||
listInstallModules,
|
||||
listInstallProfiles,
|
||||
resolveInstallPlan,
|
||||
resolveLegacyCompatibilitySelection,
|
||||
validateInstallModuleIds,
|
||||
};
|
||||
@@ -0,0 +1,313 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let Ajv = null;
|
||||
try {
|
||||
// Prefer schema-backed validation when dependencies are installed.
|
||||
// The fallback validator below keeps source checkouts usable in bare environments.
|
||||
const ajvModule = require('ajv');
|
||||
Ajv = ajvModule.default || ajvModule;
|
||||
} catch (_error) {
|
||||
Ajv = null;
|
||||
}
|
||||
|
||||
const SCHEMA_PATH = path.join(__dirname, '..', '..', 'schemas', 'install-state.schema.json');
|
||||
|
||||
let cachedValidator = null;
|
||||
|
||||
function cloneJsonValue(value) {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function readJson(filePath, label) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read ${label}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getValidator() {
|
||||
if (cachedValidator) {
|
||||
return cachedValidator;
|
||||
}
|
||||
|
||||
if (Ajv) {
|
||||
const schema = readJson(SCHEMA_PATH, 'install-state schema');
|
||||
const ajv = new Ajv({ allErrors: true });
|
||||
cachedValidator = ajv.compile(schema);
|
||||
return cachedValidator;
|
||||
}
|
||||
|
||||
cachedValidator = createFallbackValidator();
|
||||
return cachedValidator;
|
||||
}
|
||||
|
||||
function createFallbackValidator() {
|
||||
const validate = state => {
|
||||
const errors = [];
|
||||
validate.errors = errors;
|
||||
|
||||
function pushError(instancePath, message) {
|
||||
errors.push({
|
||||
instancePath,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
function isNonEmptyString(value) {
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
}
|
||||
|
||||
function validateNoAdditionalProperties(value, instancePath, allowedKeys) {
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowedKeys.includes(key)) {
|
||||
pushError(`${instancePath}/${key}`, 'must NOT have additional properties');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateStringArray(value, instancePath) {
|
||||
if (!Array.isArray(value)) {
|
||||
pushError(instancePath, 'must be array');
|
||||
return;
|
||||
}
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (!isNonEmptyString(value[index])) {
|
||||
pushError(`${instancePath}/${index}`, 'must be non-empty string');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionalString(value, instancePath) {
|
||||
if (value !== undefined && value !== null && !isNonEmptyString(value)) {
|
||||
pushError(instancePath, 'must be string or null');
|
||||
}
|
||||
}
|
||||
|
||||
if (!state || typeof state !== 'object' || Array.isArray(state)) {
|
||||
pushError('/', 'must be object');
|
||||
return false;
|
||||
}
|
||||
|
||||
validateNoAdditionalProperties(
|
||||
state,
|
||||
'',
|
||||
['schemaVersion', 'installedAt', 'lastValidatedAt', 'target', 'request', 'resolution', 'source', 'operations']
|
||||
);
|
||||
|
||||
if (state.schemaVersion !== 'ecc.install.v1') {
|
||||
pushError('/schemaVersion', 'must equal ecc.install.v1');
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(state.installedAt)) {
|
||||
pushError('/installedAt', 'must be non-empty string');
|
||||
}
|
||||
|
||||
if (state.lastValidatedAt !== undefined && !isNonEmptyString(state.lastValidatedAt)) {
|
||||
pushError('/lastValidatedAt', 'must be non-empty string');
|
||||
}
|
||||
|
||||
const target = state.target;
|
||||
if (!target || typeof target !== 'object' || Array.isArray(target)) {
|
||||
pushError('/target', 'must be object');
|
||||
} else {
|
||||
validateNoAdditionalProperties(target, '/target', ['id', 'target', 'kind', 'root', 'installStatePath']);
|
||||
if (!isNonEmptyString(target.id)) {
|
||||
pushError('/target/id', 'must be non-empty string');
|
||||
}
|
||||
validateOptionalString(target.target, '/target/target');
|
||||
if (target.kind !== undefined && !['home', 'project'].includes(target.kind)) {
|
||||
pushError('/target/kind', 'must be equal to one of the allowed values');
|
||||
}
|
||||
if (!isNonEmptyString(target.root)) {
|
||||
pushError('/target/root', 'must be non-empty string');
|
||||
}
|
||||
if (!isNonEmptyString(target.installStatePath)) {
|
||||
pushError('/target/installStatePath', 'must be non-empty string');
|
||||
}
|
||||
}
|
||||
|
||||
const request = state.request;
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
pushError('/request', 'must be object');
|
||||
} else {
|
||||
validateNoAdditionalProperties(
|
||||
request,
|
||||
'/request',
|
||||
['profile', 'modules', 'includeComponents', 'excludeComponents', 'legacyLanguages', 'legacyMode']
|
||||
);
|
||||
if (!(Object.prototype.hasOwnProperty.call(request, 'profile') && (request.profile === null || typeof request.profile === 'string'))) {
|
||||
pushError('/request/profile', 'must be string or null');
|
||||
}
|
||||
validateStringArray(request.modules, '/request/modules');
|
||||
validateStringArray(request.includeComponents, '/request/includeComponents');
|
||||
validateStringArray(request.excludeComponents, '/request/excludeComponents');
|
||||
validateStringArray(request.legacyLanguages, '/request/legacyLanguages');
|
||||
if (typeof request.legacyMode !== 'boolean') {
|
||||
pushError('/request/legacyMode', 'must be boolean');
|
||||
}
|
||||
}
|
||||
|
||||
const resolution = state.resolution;
|
||||
if (!resolution || typeof resolution !== 'object' || Array.isArray(resolution)) {
|
||||
pushError('/resolution', 'must be object');
|
||||
} else {
|
||||
validateNoAdditionalProperties(resolution, '/resolution', ['selectedModules', 'skippedModules']);
|
||||
validateStringArray(resolution.selectedModules, '/resolution/selectedModules');
|
||||
validateStringArray(resolution.skippedModules, '/resolution/skippedModules');
|
||||
}
|
||||
|
||||
const source = state.source;
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
||||
pushError('/source', 'must be object');
|
||||
} else {
|
||||
validateNoAdditionalProperties(source, '/source', ['repoVersion', 'repoCommit', 'manifestVersion']);
|
||||
validateOptionalString(source.repoVersion, '/source/repoVersion');
|
||||
validateOptionalString(source.repoCommit, '/source/repoCommit');
|
||||
if (!Number.isInteger(source.manifestVersion) || source.manifestVersion < 1) {
|
||||
pushError('/source/manifestVersion', 'must be integer >= 1');
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(state.operations)) {
|
||||
pushError('/operations', 'must be array');
|
||||
} else {
|
||||
for (let index = 0; index < state.operations.length; index += 1) {
|
||||
const operation = state.operations[index];
|
||||
const instancePath = `/operations/${index}`;
|
||||
|
||||
if (!operation || typeof operation !== 'object' || Array.isArray(operation)) {
|
||||
pushError(instancePath, 'must be object');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(operation.kind)) {
|
||||
pushError(`${instancePath}/kind`, 'must be non-empty string');
|
||||
}
|
||||
if (!isNonEmptyString(operation.moduleId)) {
|
||||
pushError(`${instancePath}/moduleId`, 'must be non-empty string');
|
||||
}
|
||||
if (!isNonEmptyString(operation.sourceRelativePath)) {
|
||||
pushError(`${instancePath}/sourceRelativePath`, 'must be non-empty string');
|
||||
}
|
||||
if (!isNonEmptyString(operation.destinationPath)) {
|
||||
pushError(`${instancePath}/destinationPath`, 'must be non-empty string');
|
||||
}
|
||||
if (!isNonEmptyString(operation.strategy)) {
|
||||
pushError(`${instancePath}/strategy`, 'must be non-empty string');
|
||||
}
|
||||
if (!isNonEmptyString(operation.ownership)) {
|
||||
pushError(`${instancePath}/ownership`, 'must be non-empty string');
|
||||
}
|
||||
if (typeof operation.scaffoldOnly !== 'boolean') {
|
||||
pushError(`${instancePath}/scaffoldOnly`, 'must be boolean');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
validate.errors = [];
|
||||
return validate;
|
||||
}
|
||||
|
||||
function formatValidationErrors(errors = []) {
|
||||
return errors
|
||||
.map(error => `${error.instancePath || '/'} ${error.message}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function validateInstallState(state) {
|
||||
const validator = getValidator();
|
||||
const valid = validator(state);
|
||||
return {
|
||||
valid,
|
||||
errors: validator.errors || [],
|
||||
};
|
||||
}
|
||||
|
||||
function assertValidInstallState(state, label) {
|
||||
const result = validateInstallState(state);
|
||||
if (!result.valid) {
|
||||
throw new Error(`Invalid install-state${label ? ` (${label})` : ''}: ${formatValidationErrors(result.errors)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function createInstallState(options) {
|
||||
const installedAt = options.installedAt || new Date().toISOString();
|
||||
const state = {
|
||||
schemaVersion: 'ecc.install.v1',
|
||||
installedAt,
|
||||
target: {
|
||||
id: options.adapter.id,
|
||||
target: options.adapter.target || undefined,
|
||||
kind: options.adapter.kind || undefined,
|
||||
root: options.targetRoot,
|
||||
installStatePath: options.installStatePath,
|
||||
},
|
||||
request: {
|
||||
profile: options.request.profile || null,
|
||||
modules: Array.isArray(options.request.modules) ? [...options.request.modules] : [],
|
||||
includeComponents: Array.isArray(options.request.includeComponents)
|
||||
? [...options.request.includeComponents]
|
||||
: [],
|
||||
excludeComponents: Array.isArray(options.request.excludeComponents)
|
||||
? [...options.request.excludeComponents]
|
||||
: [],
|
||||
legacyLanguages: Array.isArray(options.request.legacyLanguages)
|
||||
? [...options.request.legacyLanguages]
|
||||
: [],
|
||||
legacyMode: Boolean(options.request.legacyMode),
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: Array.isArray(options.resolution.selectedModules)
|
||||
? [...options.resolution.selectedModules]
|
||||
: [],
|
||||
skippedModules: Array.isArray(options.resolution.skippedModules)
|
||||
? [...options.resolution.skippedModules]
|
||||
: [],
|
||||
},
|
||||
source: {
|
||||
repoVersion: options.source.repoVersion || null,
|
||||
repoCommit: options.source.repoCommit || null,
|
||||
manifestVersion: options.source.manifestVersion,
|
||||
},
|
||||
operations: Array.isArray(options.operations)
|
||||
? options.operations.map(operation => cloneJsonValue(operation))
|
||||
: [],
|
||||
};
|
||||
|
||||
if (options.lastValidatedAt) {
|
||||
state.lastValidatedAt = options.lastValidatedAt;
|
||||
}
|
||||
|
||||
assertValidInstallState(state, 'create');
|
||||
return state;
|
||||
}
|
||||
|
||||
function readInstallState(filePath) {
|
||||
const state = readJson(filePath, 'install-state');
|
||||
assertValidInstallState(state, filePath);
|
||||
return state;
|
||||
}
|
||||
|
||||
function writeInstallState(filePath, state) {
|
||||
assertValidInstallState(state, filePath);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`);
|
||||
return state;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createInstallState,
|
||||
readInstallState,
|
||||
validateInstallState,
|
||||
writeInstallState,
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
createFlatRuleOperations,
|
||||
createInstallTargetAdapter,
|
||||
createManagedScaffoldOperation,
|
||||
normalizeRelativePath,
|
||||
} = require('./helpers');
|
||||
|
||||
const SUPPORTED_SOURCE_PREFIXES = ['rules', 'commands', 'agents', 'skills', '.agents', 'AGENTS.md'];
|
||||
|
||||
function supportsAntigravitySourcePath(sourceRelativePath) {
|
||||
const normalizedPath = normalizeRelativePath(sourceRelativePath);
|
||||
return SUPPORTED_SOURCE_PREFIXES.some(prefix => (
|
||||
normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`)
|
||||
));
|
||||
}
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'antigravity-project',
|
||||
target: 'antigravity',
|
||||
kind: 'project',
|
||||
rootSegments: ['.agent'],
|
||||
installStatePathSegments: ['ecc-install-state.json'],
|
||||
supportsModule(module) {
|
||||
const paths = Array.isArray(module && module.paths) ? module.paths : [];
|
||||
return paths.length > 0;
|
||||
},
|
||||
planOperations(input, adapter) {
|
||||
const modules = Array.isArray(input.modules)
|
||||
? input.modules
|
||||
: (input.module ? [input.module] : []);
|
||||
const {
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
homeDir,
|
||||
} = input;
|
||||
const planningInput = {
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
homeDir,
|
||||
};
|
||||
const targetRoot = adapter.resolveRoot(planningInput);
|
||||
|
||||
return modules.flatMap(module => {
|
||||
const paths = Array.isArray(module.paths) ? module.paths : [];
|
||||
return paths
|
||||
.filter(supportsAntigravitySourcePath)
|
||||
.flatMap(sourceRelativePath => {
|
||||
if (sourceRelativePath === 'rules') {
|
||||
return createFlatRuleOperations({
|
||||
moduleId: module.id,
|
||||
repoRoot,
|
||||
sourceRelativePath,
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
});
|
||||
}
|
||||
|
||||
if (sourceRelativePath === 'commands') {
|
||||
return [
|
||||
createManagedScaffoldOperation(
|
||||
module.id,
|
||||
sourceRelativePath,
|
||||
path.join(targetRoot, 'workflows'),
|
||||
'preserve-relative-path'
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (sourceRelativePath === 'agents') {
|
||||
return [
|
||||
createManagedScaffoldOperation(
|
||||
module.id,
|
||||
sourceRelativePath,
|
||||
path.join(targetRoot, 'skills'),
|
||||
'preserve-relative-path'
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
createInstallTargetAdapter,
|
||||
createRemappedOperation,
|
||||
isForeignPlatformPath,
|
||||
normalizeRelativePath,
|
||||
} = require('./helpers');
|
||||
|
||||
const CLAUDE_ECC_NAMESPACE = 'ecc';
|
||||
|
||||
function getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) {
|
||||
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
|
||||
const targetRoot = adapter.resolveRoot(input);
|
||||
|
||||
if (normalizedSourcePath === 'rules') {
|
||||
return path.join(targetRoot, 'rules', CLAUDE_ECC_NAMESPACE);
|
||||
}
|
||||
|
||||
if (normalizedSourcePath.startsWith('rules/')) {
|
||||
return path.join(
|
||||
targetRoot,
|
||||
'rules',
|
||||
CLAUDE_ECC_NAMESPACE,
|
||||
normalizedSourcePath.slice('rules/'.length)
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedSourcePath === 'skills') {
|
||||
return path.join(targetRoot, 'skills', CLAUDE_ECC_NAMESPACE);
|
||||
}
|
||||
|
||||
if (normalizedSourcePath.startsWith('skills/')) {
|
||||
return path.join(
|
||||
targetRoot,
|
||||
'skills',
|
||||
CLAUDE_ECC_NAMESPACE,
|
||||
normalizedSourcePath.slice('skills/'.length)
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedSourcePath === 'docs' || normalizedSourcePath.startsWith('docs/')) {
|
||||
return path.join(targetRoot, normalizedSourcePath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'claude-home',
|
||||
target: 'claude',
|
||||
kind: 'home',
|
||||
rootSegments: ['.claude'],
|
||||
installStatePathSegments: ['ecc', 'install-state.json'],
|
||||
nativeRootRelativePath: '.claude-plugin',
|
||||
planOperations(input, adapter) {
|
||||
const modules = Array.isArray(input.modules)
|
||||
? input.modules
|
||||
: (input.module ? [input.module] : []);
|
||||
const planningInput = {
|
||||
repoRoot: input.repoRoot,
|
||||
projectRoot: input.projectRoot,
|
||||
homeDir: input.homeDir,
|
||||
};
|
||||
|
||||
return modules.flatMap(module => {
|
||||
const paths = Array.isArray(module.paths) ? module.paths : [];
|
||||
return paths
|
||||
.filter(p => !isForeignPlatformPath(p, adapter.target))
|
||||
.map(sourceRelativePath => {
|
||||
const managedDestinationPath = getClaudeManagedDestinationPath(
|
||||
adapter,
|
||||
sourceRelativePath,
|
||||
planningInput
|
||||
);
|
||||
|
||||
if (managedDestinationPath) {
|
||||
return createRemappedOperation(
|
||||
adapter,
|
||||
module.id,
|
||||
sourceRelativePath,
|
||||
managedDestinationPath,
|
||||
{ strategy: 'preserve-relative-path' }
|
||||
);
|
||||
}
|
||||
|
||||
return adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput);
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
createInstallTargetAdapter,
|
||||
createRemappedOperation,
|
||||
isForeignPlatformPath,
|
||||
normalizeRelativePath,
|
||||
} = require('./helpers');
|
||||
|
||||
const CLAUDE_ECC_NAMESPACE = 'ecc';
|
||||
|
||||
function getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) {
|
||||
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
|
||||
const targetRoot = adapter.resolveRoot(input);
|
||||
|
||||
if (normalizedSourcePath === 'rules') {
|
||||
return path.join(targetRoot, 'rules', CLAUDE_ECC_NAMESPACE);
|
||||
}
|
||||
|
||||
if (normalizedSourcePath.startsWith('rules/')) {
|
||||
return path.join(
|
||||
targetRoot,
|
||||
'rules',
|
||||
CLAUDE_ECC_NAMESPACE,
|
||||
normalizedSourcePath.slice('rules/'.length)
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedSourcePath === 'skills') {
|
||||
return path.join(targetRoot, 'skills', CLAUDE_ECC_NAMESPACE);
|
||||
}
|
||||
|
||||
if (normalizedSourcePath.startsWith('skills/')) {
|
||||
return path.join(
|
||||
targetRoot,
|
||||
'skills',
|
||||
CLAUDE_ECC_NAMESPACE,
|
||||
normalizedSourcePath.slice('skills/'.length)
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedSourcePath === 'docs' || normalizedSourcePath.startsWith('docs/')) {
|
||||
return path.join(targetRoot, normalizedSourcePath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'claude-project',
|
||||
target: 'claude-project',
|
||||
kind: 'project',
|
||||
rootSegments: ['.claude'],
|
||||
installStatePathSegments: ['ecc', 'install-state.json'],
|
||||
nativeRootRelativePath: '.claude-plugin',
|
||||
planOperations(input, adapter) {
|
||||
const modules = Array.isArray(input.modules)
|
||||
? input.modules
|
||||
: (input.module ? [input.module] : []);
|
||||
const planningInput = {
|
||||
repoRoot: input.repoRoot,
|
||||
projectRoot: input.projectRoot,
|
||||
homeDir: input.homeDir,
|
||||
};
|
||||
|
||||
return modules.flatMap(module => {
|
||||
const paths = Array.isArray(module.paths) ? module.paths : [];
|
||||
return paths
|
||||
.filter(p => !isForeignPlatformPath(p, 'claude'))
|
||||
.map(sourceRelativePath => {
|
||||
const managedDestinationPath = getClaudeManagedDestinationPath(
|
||||
adapter,
|
||||
sourceRelativePath,
|
||||
planningInput
|
||||
);
|
||||
|
||||
if (managedDestinationPath) {
|
||||
return createRemappedOperation(
|
||||
adapter,
|
||||
module.id,
|
||||
sourceRelativePath,
|
||||
managedDestinationPath,
|
||||
{ strategy: 'preserve-relative-path' }
|
||||
);
|
||||
}
|
||||
|
||||
return adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput);
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
createFlatRuleOperations,
|
||||
createInstallTargetAdapter,
|
||||
isForeignPlatformPath,
|
||||
} = require('./helpers');
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'codebuddy-project',
|
||||
target: 'codebuddy',
|
||||
kind: 'project',
|
||||
rootSegments: ['.codebuddy'],
|
||||
installStatePathSegments: ['ecc-install-state.json'],
|
||||
nativeRootRelativePath: '.codebuddy',
|
||||
planOperations(input, adapter) {
|
||||
const modules = Array.isArray(input.modules)
|
||||
? input.modules
|
||||
: (input.module ? [input.module] : []);
|
||||
const {
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
homeDir,
|
||||
} = input;
|
||||
const planningInput = {
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
homeDir,
|
||||
};
|
||||
const targetRoot = adapter.resolveRoot(planningInput);
|
||||
|
||||
return modules.flatMap(module => {
|
||||
const paths = Array.isArray(module.paths) ? module.paths : [];
|
||||
return paths
|
||||
.filter(p => !isForeignPlatformPath(p, adapter.target))
|
||||
.flatMap(sourceRelativePath => {
|
||||
if (sourceRelativePath === 'rules') {
|
||||
return createFlatRuleOperations({
|
||||
moduleId: module.id,
|
||||
repoRoot,
|
||||
sourceRelativePath,
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
});
|
||||
}
|
||||
|
||||
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
const { createInstallTargetAdapter } = require('./helpers');
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'codex-home',
|
||||
target: 'codex',
|
||||
kind: 'home',
|
||||
rootSegments: ['.codex'],
|
||||
installStatePathSegments: ['ecc-install-state.json'],
|
||||
nativeRootRelativePath: '.codex',
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { toCursorAgentFileName } = require('../cursor-agent-names');
|
||||
const {
|
||||
createFlatFileOperations,
|
||||
createFlatRuleOperations,
|
||||
createInstallTargetAdapter,
|
||||
createManagedOperation,
|
||||
isForeignPlatformPath,
|
||||
} = require('./helpers');
|
||||
|
||||
function toCursorRuleFileName(fileName, sourceRelativeFile) {
|
||||
if (path.basename(sourceRelativeFile).toLowerCase() === 'readme.md') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fileName.endsWith('.md')
|
||||
? `${fileName.slice(0, -3)}.mdc`
|
||||
: fileName;
|
||||
}
|
||||
|
||||
function readJsonObject(filePath, label) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse ${label} at ${filePath}: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`Invalid ${label} at ${filePath}: expected a JSON object`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function createJsonMergeOperation({ moduleId, repoRoot, sourceRelativePath, destinationPath }) {
|
||||
const sourcePath = path.join(repoRoot, sourceRelativePath);
|
||||
if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createManagedOperation({
|
||||
kind: 'merge-json',
|
||||
moduleId,
|
||||
sourceRelativePath,
|
||||
destinationPath,
|
||||
strategy: 'merge-json',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
mergePayload: readJsonObject(sourcePath, sourceRelativePath),
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'cursor-project',
|
||||
target: 'cursor',
|
||||
kind: 'project',
|
||||
rootSegments: ['.cursor'],
|
||||
installStatePathSegments: ['ecc-install-state.json'],
|
||||
nativeRootRelativePath: '.cursor',
|
||||
planOperations(input, adapter) {
|
||||
const modules = Array.isArray(input.modules)
|
||||
? input.modules
|
||||
: (input.module ? [input.module] : []);
|
||||
const seenDestinationPaths = new Set();
|
||||
const {
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
homeDir,
|
||||
} = input;
|
||||
const planningInput = {
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
homeDir,
|
||||
};
|
||||
const targetRoot = adapter.resolveRoot(planningInput);
|
||||
const entries = modules.flatMap((module, moduleIndex) => {
|
||||
const paths = Array.isArray(module.paths) ? module.paths : [];
|
||||
return paths
|
||||
.filter(p => !isForeignPlatformPath(p, adapter.target))
|
||||
.map((sourceRelativePath, pathIndex) => ({
|
||||
module,
|
||||
sourceRelativePath,
|
||||
moduleIndex,
|
||||
pathIndex,
|
||||
}));
|
||||
}).sort((left, right) => {
|
||||
const getPriority = value => {
|
||||
if (value === '.cursor') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (value === 'rules') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 2;
|
||||
};
|
||||
|
||||
const leftPriority = getPriority(left.sourceRelativePath);
|
||||
const rightPriority = getPriority(right.sourceRelativePath);
|
||||
if (leftPriority !== rightPriority) {
|
||||
return leftPriority - rightPriority;
|
||||
}
|
||||
|
||||
if (left.moduleIndex !== right.moduleIndex) {
|
||||
return left.moduleIndex - right.moduleIndex;
|
||||
}
|
||||
|
||||
return left.pathIndex - right.pathIndex;
|
||||
});
|
||||
|
||||
function takeUniqueOperations(operations) {
|
||||
return operations.filter(operation => {
|
||||
if (!operation || !operation.destinationPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (seenDestinationPaths.has(operation.destinationPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenDestinationPaths.add(operation.destinationPath);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return entries.flatMap(({ module, sourceRelativePath }) => {
|
||||
const cursorMcpOperation = createJsonMergeOperation({
|
||||
moduleId: module.id,
|
||||
repoRoot,
|
||||
sourceRelativePath: '.mcp.json',
|
||||
destinationPath: path.join(targetRoot, 'mcp.json'),
|
||||
});
|
||||
|
||||
if (sourceRelativePath === 'AGENTS.md') {
|
||||
// Cursor treats nested AGENTS.md files as directory context; do not
|
||||
// install ECC's root project identity into a host project's .cursor/.
|
||||
return [];
|
||||
}
|
||||
|
||||
if (sourceRelativePath === 'rules') {
|
||||
return takeUniqueOperations(createFlatRuleOperations({
|
||||
moduleId: module.id,
|
||||
repoRoot,
|
||||
sourceRelativePath,
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
destinationNameTransform: toCursorRuleFileName,
|
||||
}));
|
||||
}
|
||||
|
||||
if (sourceRelativePath === 'agents') {
|
||||
return takeUniqueOperations(createFlatFileOperations({
|
||||
moduleId: module.id,
|
||||
repoRoot,
|
||||
sourceRelativePath,
|
||||
destinationDir: path.join(targetRoot, 'agents'),
|
||||
destinationNameTransform: toCursorAgentFileName,
|
||||
}));
|
||||
}
|
||||
|
||||
if (sourceRelativePath === '.cursor') {
|
||||
const cursorRoot = path.join(repoRoot, '.cursor');
|
||||
if (!fs.existsSync(cursorRoot) || !fs.statSync(cursorRoot).isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const childOperations = fs.readdirSync(cursorRoot, { withFileTypes: true })
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.filter(entry => entry.name !== 'rules')
|
||||
.map(entry => createManagedOperation({
|
||||
moduleId: module.id,
|
||||
sourceRelativePath: path.join('.cursor', entry.name),
|
||||
destinationPath: path.join(targetRoot, entry.name),
|
||||
strategy: 'preserve-relative-path',
|
||||
}));
|
||||
|
||||
const ruleOperations = createFlatRuleOperations({
|
||||
moduleId: module.id,
|
||||
repoRoot,
|
||||
sourceRelativePath: '.cursor/rules',
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
destinationNameTransform: toCursorRuleFileName,
|
||||
});
|
||||
|
||||
return takeUniqueOperations([
|
||||
...childOperations,
|
||||
...(cursorMcpOperation ? [cursorMcpOperation] : []),
|
||||
...ruleOperations,
|
||||
]);
|
||||
}
|
||||
|
||||
if (sourceRelativePath === 'mcp-configs') {
|
||||
const operations = [
|
||||
adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput),
|
||||
];
|
||||
if (cursorMcpOperation) {
|
||||
operations.push(cursorMcpOperation);
|
||||
}
|
||||
return takeUniqueOperations(operations);
|
||||
}
|
||||
|
||||
return takeUniqueOperations([
|
||||
adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput),
|
||||
]);
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
const { createInstallTargetAdapter } = require('./helpers');
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'gemini-project',
|
||||
target: 'gemini',
|
||||
kind: 'project',
|
||||
rootSegments: ['.gemini'],
|
||||
installStatePathSegments: ['ecc-install-state.json'],
|
||||
nativeRootRelativePath: '.gemini',
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const PLATFORM_SOURCE_PATH_OWNERS = Object.freeze({
|
||||
'.claude-plugin': 'claude',
|
||||
'.codex': 'codex',
|
||||
'.cursor': 'cursor',
|
||||
'.gemini': 'gemini',
|
||||
'.hermes': 'hermes',
|
||||
'.kimi': 'kimi',
|
||||
'.joycode': 'joycode',
|
||||
'.opencode': 'opencode',
|
||||
'.openclaw': 'openclaw',
|
||||
'.codebuddy': 'codebuddy',
|
||||
'.qwen': 'qwen',
|
||||
'.zed': 'zed',
|
||||
});
|
||||
|
||||
function normalizeRelativePath(relativePath) {
|
||||
return String(relativePath || '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\.\/+/, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function isForeignPlatformPath(sourceRelativePath, adapterTarget) {
|
||||
const normalizedPath = normalizeRelativePath(sourceRelativePath);
|
||||
|
||||
for (const [prefix, ownerTarget] of Object.entries(PLATFORM_SOURCE_PATH_OWNERS)) {
|
||||
if (normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`)) {
|
||||
return ownerTarget !== adapterTarget;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveBaseRoot(scope, input = {}) {
|
||||
if (scope === 'home') {
|
||||
return input.homeDir || os.homedir();
|
||||
}
|
||||
|
||||
if (scope === 'project') {
|
||||
const projectRoot = input.projectRoot || input.repoRoot;
|
||||
if (!projectRoot) {
|
||||
throw new Error('projectRoot or repoRoot is required for project install targets');
|
||||
}
|
||||
return projectRoot;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported install target scope: ${scope}`);
|
||||
}
|
||||
|
||||
function buildValidationIssue(severity, code, message, extra = {}) {
|
||||
return {
|
||||
severity,
|
||||
code,
|
||||
message,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function listRelativeFiles(dirPath, prefix = '') {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true }).sort((left, right) => (
|
||||
left.name.localeCompare(right.name)
|
||||
));
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPrefix = prefix ? path.join(prefix, entry.name) : entry.name;
|
||||
const absolutePath = path.join(dirPath, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...listRelativeFiles(absolutePath, entryPrefix));
|
||||
} else if (entry.isFile()) {
|
||||
files.push(normalizeRelativePath(entryPrefix));
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function createManagedOperation({
|
||||
kind = 'copy-path',
|
||||
moduleId,
|
||||
sourceRelativePath,
|
||||
destinationPath,
|
||||
strategy = 'preserve-relative-path',
|
||||
ownership = 'managed',
|
||||
scaffoldOnly = true,
|
||||
...rest
|
||||
}) {
|
||||
return {
|
||||
kind,
|
||||
moduleId,
|
||||
sourceRelativePath: normalizeRelativePath(sourceRelativePath),
|
||||
destinationPath,
|
||||
strategy,
|
||||
ownership,
|
||||
scaffoldOnly,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultValidateAdapterInput(config, input = {}) {
|
||||
if (config.kind === 'project' && !input.projectRoot && !input.repoRoot) {
|
||||
return [
|
||||
buildValidationIssue(
|
||||
'error',
|
||||
'missing-project-root',
|
||||
'projectRoot or repoRoot is required for project install targets'
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (config.kind === 'home' && !input.homeDir && !os.homedir()) {
|
||||
return [
|
||||
buildValidationIssue(
|
||||
'error',
|
||||
'missing-home-dir',
|
||||
'homeDir is required for home install targets'
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function createRemappedOperation(adapter, moduleId, sourceRelativePath, destinationPath, options = {}) {
|
||||
return createManagedOperation({
|
||||
kind: options.kind || 'copy-path',
|
||||
moduleId,
|
||||
sourceRelativePath,
|
||||
destinationPath,
|
||||
strategy: options.strategy || 'preserve-relative-path',
|
||||
ownership: options.ownership || 'managed',
|
||||
scaffoldOnly: Object.hasOwn(options, 'scaffoldOnly') ? options.scaffoldOnly : true,
|
||||
...options.extra,
|
||||
});
|
||||
}
|
||||
|
||||
function createNamespacedFlatRuleOperations(adapter, moduleId, sourceRelativePath, input = {}) {
|
||||
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
|
||||
const sourceRoot = path.join(input.repoRoot || '', normalizedSourcePath);
|
||||
|
||||
if (!input.repoRoot || !fs.existsSync(sourceRoot) || !fs.statSync(sourceRoot).isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const targetRulesDir = path.join(adapter.resolveRoot(input), 'rules');
|
||||
const operations = [];
|
||||
const entries = fs.readdirSync(sourceRoot, { withFileTypes: true }).sort((left, right) => (
|
||||
left.name.localeCompare(right.name)
|
||||
));
|
||||
|
||||
for (const entry of entries) {
|
||||
const namespace = entry.name;
|
||||
const entryPath = path.join(sourceRoot, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const relativeFiles = listRelativeFiles(entryPath);
|
||||
for (const relativeFile of relativeFiles) {
|
||||
const flattenedFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`;
|
||||
const sourceRelativeFile = path.join(normalizedSourcePath, namespace, relativeFile);
|
||||
operations.push(createManagedOperation({
|
||||
moduleId,
|
||||
sourceRelativePath: sourceRelativeFile,
|
||||
destinationPath: path.join(targetRulesDir, flattenedFileName),
|
||||
strategy: 'flatten-copy',
|
||||
}));
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
operations.push(createManagedOperation({
|
||||
moduleId,
|
||||
sourceRelativePath: path.join(normalizedSourcePath, entry.name),
|
||||
destinationPath: path.join(targetRulesDir, entry.name),
|
||||
strategy: 'flatten-copy',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return operations;
|
||||
}
|
||||
|
||||
function createFlatFileOperations({
|
||||
moduleId,
|
||||
repoRoot,
|
||||
sourceRelativePath,
|
||||
destinationDir,
|
||||
destinationNameTransform,
|
||||
}) {
|
||||
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
|
||||
const sourceRoot = path.join(repoRoot || '', normalizedSourcePath);
|
||||
|
||||
if (!repoRoot || !fs.existsSync(sourceRoot) || !fs.statSync(sourceRoot).isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const operations = [];
|
||||
const entries = fs.readdirSync(sourceRoot, { withFileTypes: true }).sort((left, right) => (
|
||||
left.name.localeCompare(right.name)
|
||||
));
|
||||
|
||||
for (const entry of entries) {
|
||||
const namespace = entry.name;
|
||||
const entryPath = path.join(sourceRoot, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const relativeFiles = listRelativeFiles(entryPath);
|
||||
for (const relativeFile of relativeFiles) {
|
||||
const defaultFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`;
|
||||
const sourceRelativeFile = path.join(normalizedSourcePath, namespace, relativeFile);
|
||||
const flattenedFileName = typeof destinationNameTransform === 'function'
|
||||
? destinationNameTransform(defaultFileName, sourceRelativeFile)
|
||||
: defaultFileName;
|
||||
if (!flattenedFileName) {
|
||||
continue;
|
||||
}
|
||||
operations.push(createManagedOperation({
|
||||
moduleId,
|
||||
sourceRelativePath: sourceRelativeFile,
|
||||
destinationPath: path.join(destinationDir, flattenedFileName),
|
||||
strategy: 'flatten-copy',
|
||||
}));
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
const sourceRelativeFile = path.join(normalizedSourcePath, entry.name);
|
||||
const destinationFileName = typeof destinationNameTransform === 'function'
|
||||
? destinationNameTransform(entry.name, sourceRelativeFile)
|
||||
: entry.name;
|
||||
if (!destinationFileName) {
|
||||
continue;
|
||||
}
|
||||
operations.push(createManagedOperation({
|
||||
moduleId,
|
||||
sourceRelativePath: sourceRelativeFile,
|
||||
destinationPath: path.join(destinationDir, destinationFileName),
|
||||
strategy: 'flatten-copy',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return operations;
|
||||
}
|
||||
|
||||
function createFlatRuleOperations(options) {
|
||||
return createFlatFileOperations(options);
|
||||
}
|
||||
|
||||
function createInstallTargetAdapter(config) {
|
||||
const adapter = {
|
||||
id: config.id,
|
||||
target: config.target,
|
||||
kind: config.kind,
|
||||
nativeRootRelativePath: config.nativeRootRelativePath || null,
|
||||
supports(target) {
|
||||
return target === config.target || target === config.id;
|
||||
},
|
||||
resolveRoot(input = {}) {
|
||||
const baseRoot = resolveBaseRoot(config.kind, input);
|
||||
return path.join(baseRoot, ...config.rootSegments);
|
||||
},
|
||||
getInstallStatePath(input = {}) {
|
||||
const root = adapter.resolveRoot(input);
|
||||
return path.join(root, ...config.installStatePathSegments);
|
||||
},
|
||||
resolveDestinationPath(sourceRelativePath, input = {}) {
|
||||
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
|
||||
const targetRoot = adapter.resolveRoot(input);
|
||||
|
||||
if (
|
||||
config.nativeRootRelativePath
|
||||
&& normalizedSourcePath === normalizeRelativePath(config.nativeRootRelativePath)
|
||||
) {
|
||||
return targetRoot;
|
||||
}
|
||||
|
||||
return path.join(targetRoot, normalizedSourcePath);
|
||||
},
|
||||
determineStrategy(sourceRelativePath) {
|
||||
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
|
||||
|
||||
if (
|
||||
config.nativeRootRelativePath
|
||||
&& normalizedSourcePath === normalizeRelativePath(config.nativeRootRelativePath)
|
||||
) {
|
||||
return 'sync-root-children';
|
||||
}
|
||||
|
||||
return 'preserve-relative-path';
|
||||
},
|
||||
createScaffoldOperation(moduleId, sourceRelativePath, input = {}) {
|
||||
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
|
||||
return createManagedOperation({
|
||||
moduleId,
|
||||
sourceRelativePath: normalizedSourcePath,
|
||||
destinationPath: adapter.resolveDestinationPath(normalizedSourcePath, input),
|
||||
strategy: adapter.determineStrategy(normalizedSourcePath),
|
||||
});
|
||||
},
|
||||
planOperations(input = {}) {
|
||||
if (typeof config.planOperations === 'function') {
|
||||
return config.planOperations(input, adapter);
|
||||
}
|
||||
|
||||
if (Array.isArray(input.modules)) {
|
||||
return input.modules.flatMap(module => {
|
||||
const paths = Array.isArray(module.paths) ? module.paths : [];
|
||||
return paths
|
||||
.filter(p => !isForeignPlatformPath(p, config.target))
|
||||
.map(sourceRelativePath => adapter.createScaffoldOperation(
|
||||
module.id,
|
||||
sourceRelativePath,
|
||||
input
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
const module = input.module || {};
|
||||
const paths = Array.isArray(module.paths) ? module.paths : [];
|
||||
return paths
|
||||
.filter(p => !isForeignPlatformPath(p, config.target))
|
||||
.map(sourceRelativePath => adapter.createScaffoldOperation(
|
||||
module.id,
|
||||
sourceRelativePath,
|
||||
input
|
||||
));
|
||||
},
|
||||
supportsModule(module, input = {}) {
|
||||
if (typeof config.supportsModule === 'function') {
|
||||
return config.supportsModule(module, input, adapter);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
validate(input = {}) {
|
||||
if (typeof config.validate === 'function') {
|
||||
return config.validate(input, adapter);
|
||||
}
|
||||
|
||||
return defaultValidateAdapterInput(config, input);
|
||||
},
|
||||
};
|
||||
|
||||
return Object.freeze(adapter);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildValidationIssue,
|
||||
createFlatFileOperations,
|
||||
createFlatRuleOperations,
|
||||
createInstallTargetAdapter,
|
||||
createManagedOperation,
|
||||
createManagedScaffoldOperation: (moduleId, sourceRelativePath, destinationPath, strategy) => (
|
||||
createManagedOperation({
|
||||
moduleId,
|
||||
sourceRelativePath,
|
||||
destinationPath,
|
||||
strategy,
|
||||
})
|
||||
),
|
||||
createNamespacedFlatRuleOperations,
|
||||
createRemappedOperation,
|
||||
isForeignPlatformPath,
|
||||
normalizeRelativePath,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
const { createInstallTargetAdapter } = require('./helpers');
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'hermes-home',
|
||||
target: 'hermes',
|
||||
kind: 'home',
|
||||
rootSegments: ['.hermes'],
|
||||
installStatePathSegments: ['ecc-install-state.json'],
|
||||
nativeRootRelativePath: '.hermes',
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
createFlatRuleOperations,
|
||||
createInstallTargetAdapter,
|
||||
isForeignPlatformPath,
|
||||
} = require('./helpers');
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'joycode-project',
|
||||
target: 'joycode',
|
||||
kind: 'project',
|
||||
rootSegments: ['.joycode'],
|
||||
installStatePathSegments: ['ecc-install-state.json'],
|
||||
nativeRootRelativePath: '.joycode',
|
||||
planOperations(input, adapter) {
|
||||
const modules = Array.isArray(input.modules)
|
||||
? input.modules
|
||||
: (input.module ? [input.module] : []);
|
||||
const {
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
homeDir,
|
||||
} = input;
|
||||
const planningInput = {
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
homeDir,
|
||||
};
|
||||
const targetRoot = adapter.resolveRoot(planningInput);
|
||||
|
||||
return modules.flatMap(module => {
|
||||
const paths = Array.isArray(module.paths) ? module.paths : [];
|
||||
return paths
|
||||
.filter(p => !isForeignPlatformPath(p, adapter.target))
|
||||
.flatMap(sourceRelativePath => {
|
||||
if (sourceRelativePath === 'rules') {
|
||||
return createFlatRuleOperations({
|
||||
moduleId: module.id,
|
||||
repoRoot,
|
||||
sourceRelativePath,
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
});
|
||||
}
|
||||
|
||||
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
const { createInstallTargetAdapter } = require('./helpers');
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'kimi-project',
|
||||
target: 'kimi',
|
||||
kind: 'project',
|
||||
rootSegments: ['.kimi'],
|
||||
installStatePathSegments: ['ecc-install-state.json'],
|
||||
nativeRootRelativePath: '.kimi',
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
const { createInstallTargetAdapter } = require('./helpers');
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'openclaw-home',
|
||||
target: 'openclaw',
|
||||
kind: 'home',
|
||||
rootSegments: ['.openclaw'],
|
||||
installStatePathSegments: ['ecc-install-state.json'],
|
||||
nativeRootRelativePath: '.openclaw',
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
buildValidationIssue,
|
||||
createInstallTargetAdapter,
|
||||
} = require('./helpers');
|
||||
|
||||
const COMPILED_PLUGIN_DIST_DIR = path.join('.opencode', 'dist');
|
||||
const REQUIRED_COMPILED_ARTEFACTS = Object.freeze([
|
||||
{ relativePath: path.join(COMPILED_PLUGIN_DIST_DIR, 'index.js'), expectedType: 'file' },
|
||||
{ relativePath: path.join(COMPILED_PLUGIN_DIST_DIR, 'plugins'), expectedType: 'directory' },
|
||||
{ relativePath: path.join(COMPILED_PLUGIN_DIST_DIR, 'tools'), expectedType: 'directory' },
|
||||
]);
|
||||
const BUILD_COMMAND_HINT = 'node scripts/build-opencode.js (or: npm run build:opencode)';
|
||||
|
||||
// Errors that mean "this artefact does not exist at the expected path / type".
|
||||
// Anything else (EACCES, EIO, ...) is a genuine system fault we surface to the
|
||||
// caller rather than masking as a missing artefact.
|
||||
const MISSING_ARTEFACT_ERROR_CODES = new Set(['ENOENT', 'ENOTDIR']);
|
||||
|
||||
function isExpectedType(absolutePath, expectedType) {
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(absolutePath);
|
||||
} catch (error) {
|
||||
if (error && MISSING_ARTEFACT_ERROR_CODES.has(error.code)) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return expectedType === 'file' ? stat.isFile() : stat.isDirectory();
|
||||
}
|
||||
|
||||
function defaultValidateOpencodeHome(input = {}) {
|
||||
if (!input.homeDir && !os.homedir()) {
|
||||
return [
|
||||
buildValidationIssue(
|
||||
'error',
|
||||
'missing-home-dir',
|
||||
'homeDir is required for home install targets'
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (!input.repoRoot) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const missingPaths = REQUIRED_COMPILED_ARTEFACTS
|
||||
.map(artefact => ({
|
||||
relativePath: artefact.relativePath,
|
||||
absolutePath: path.join(input.repoRoot, artefact.relativePath),
|
||||
expectedType: artefact.expectedType,
|
||||
}))
|
||||
.filter(entry => !isExpectedType(entry.absolutePath, entry.expectedType));
|
||||
|
||||
if (missingPaths.length > 0) {
|
||||
const missingList = missingPaths.map(entry => entry.relativePath).join(', ');
|
||||
return [
|
||||
buildValidationIssue(
|
||||
'error',
|
||||
'opencode-plugin-not-built',
|
||||
'OpenCode install requires the compiled plugin payload under '
|
||||
+ `${COMPILED_PLUGIN_DIST_DIR}, but the following artefact(s) were `
|
||||
+ `missing or had the wrong type: ${missingList}. Run `
|
||||
+ `${BUILD_COMMAND_HINT} from the repo root before re-running the `
|
||||
+ 'installer.',
|
||||
{
|
||||
missingPaths: missingPaths.map(entry => entry.absolutePath),
|
||||
missingRelativePaths: missingPaths.map(entry => entry.relativePath),
|
||||
expectedTypes: missingPaths.map(entry => entry.expectedType),
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'opencode-home',
|
||||
target: 'opencode',
|
||||
kind: 'home',
|
||||
rootSegments: ['.opencode'],
|
||||
installStatePathSegments: ['ecc-install-state.json'],
|
||||
nativeRootRelativePath: '.opencode',
|
||||
validate: defaultValidateOpencodeHome,
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
const { createInstallTargetAdapter } = require('./helpers');
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'qwen-home',
|
||||
target: 'qwen',
|
||||
kind: 'home',
|
||||
rootSegments: ['.qwen'],
|
||||
installStatePathSegments: ['ecc-install-state.json'],
|
||||
nativeRootRelativePath: '.qwen',
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
const antigravityProject = require('./antigravity-project');
|
||||
const claudeHome = require('./claude-home');
|
||||
const claudeProject = require('./claude-project');
|
||||
const codebuddyProject = require('./codebuddy-project');
|
||||
const codexHome = require('./codex-home');
|
||||
const cursorProject = require('./cursor-project');
|
||||
const geminiProject = require('./gemini-project');
|
||||
const hermesHome = require('./hermes-home');
|
||||
const joycodeProject = require('./joycode-project');
|
||||
const kimiProject = require('./kimi-project');
|
||||
const openclawHome = require('./openclaw-home');
|
||||
const opencodeHome = require('./opencode-home');
|
||||
const qwenHome = require('./qwen-home');
|
||||
const zedProject = require('./zed-project');
|
||||
|
||||
const ADAPTERS = Object.freeze([
|
||||
claudeHome,
|
||||
claudeProject,
|
||||
cursorProject,
|
||||
antigravityProject,
|
||||
codexHome,
|
||||
geminiProject,
|
||||
hermesHome,
|
||||
opencodeHome,
|
||||
openclawHome,
|
||||
codebuddyProject,
|
||||
joycodeProject,
|
||||
kimiProject,
|
||||
qwenHome,
|
||||
zedProject,
|
||||
]);
|
||||
|
||||
function listInstallTargetAdapters() {
|
||||
return ADAPTERS.slice();
|
||||
}
|
||||
|
||||
function getInstallTargetAdapter(targetOrAdapterId) {
|
||||
const adapter = ADAPTERS.find(candidate => candidate.supports(targetOrAdapterId));
|
||||
|
||||
if (!adapter) {
|
||||
throw new Error(`Unknown install target adapter: ${targetOrAdapterId}`);
|
||||
}
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
function planInstallTargetScaffold(options = {}) {
|
||||
const adapter = getInstallTargetAdapter(options.target);
|
||||
const modules = Array.isArray(options.modules) ? options.modules : [];
|
||||
const exemptValidationCodes = new Set(Array.isArray(options.exemptValidationCodes) ? options.exemptValidationCodes : []);
|
||||
const planningInput = {
|
||||
repoRoot: options.repoRoot,
|
||||
projectRoot: options.projectRoot || options.repoRoot,
|
||||
homeDir: options.homeDir,
|
||||
};
|
||||
const validationIssues = adapter.validate(planningInput);
|
||||
const blockingIssues = validationIssues.filter(issue => (
|
||||
issue.severity === 'error' && !exemptValidationCodes.has(issue.code)
|
||||
));
|
||||
if (blockingIssues.length > 0) {
|
||||
throw new Error(blockingIssues.map(issue => issue.message).join('; '));
|
||||
}
|
||||
const targetRoot = adapter.resolveRoot(planningInput);
|
||||
const installStatePath = adapter.getInstallStatePath(planningInput);
|
||||
const operations = adapter.planOperations({
|
||||
...planningInput,
|
||||
modules,
|
||||
});
|
||||
|
||||
return {
|
||||
adapter: {
|
||||
id: adapter.id,
|
||||
target: adapter.target,
|
||||
kind: adapter.kind,
|
||||
},
|
||||
targetRoot,
|
||||
installStatePath,
|
||||
validationIssues,
|
||||
operations,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getInstallTargetAdapter,
|
||||
listInstallTargetAdapters,
|
||||
planInstallTargetScaffold,
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
createFlatRuleOperations,
|
||||
createInstallTargetAdapter,
|
||||
isForeignPlatformPath,
|
||||
} = require('./helpers');
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'zed-project',
|
||||
target: 'zed',
|
||||
kind: 'project',
|
||||
rootSegments: ['.zed'],
|
||||
installStatePathSegments: ['ecc-install-state.json'],
|
||||
nativeRootRelativePath: '.zed',
|
||||
planOperations(input, adapter) {
|
||||
const modules = Array.isArray(input.modules)
|
||||
? input.modules
|
||||
: (input.module ? [input.module] : []);
|
||||
const {
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
homeDir,
|
||||
} = input;
|
||||
const planningInput = {
|
||||
repoRoot,
|
||||
projectRoot,
|
||||
homeDir,
|
||||
};
|
||||
const targetRoot = adapter.resolveRoot(planningInput);
|
||||
|
||||
return modules.flatMap(module => {
|
||||
const paths = Array.isArray(module.paths) ? module.paths : [];
|
||||
return paths
|
||||
.filter(p => !isForeignPlatformPath(p, adapter.target))
|
||||
.flatMap(sourceRelativePath => {
|
||||
if (sourceRelativePath === 'rules') {
|
||||
return createFlatRuleOperations({
|
||||
moduleId: module.id,
|
||||
repoRoot,
|
||||
sourceRelativePath,
|
||||
destinationDir: path.join(targetRoot, 'rules'),
|
||||
});
|
||||
}
|
||||
|
||||
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { writeInstallState } = require('../install-state');
|
||||
const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config');
|
||||
const { buildInstallIndex, isNamespacedSource, rewriteRelativeLinks } = require('./link-rewrite');
|
||||
|
||||
function isMarkdownPath(filePath) {
|
||||
return /\.(md|mdx|markdown)$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
// Map every copy-file operation to { sourceRel, destRel } so relative links in
|
||||
// namespaced markdown can be rewritten to the file's actual installed location
|
||||
// (issue #2340). Returns null when the plan lacks the data needed to do so.
|
||||
function buildLinkIndexForPlan(plan) {
|
||||
if (!plan || !plan.targetRoot || !Array.isArray(plan.operations)) {
|
||||
return null;
|
||||
}
|
||||
const mappings = [];
|
||||
for (const operation of plan.operations) {
|
||||
if (operation.kind === 'copy-file' && operation.sourceRelativePath) {
|
||||
mappings.push({
|
||||
sourceRel: operation.sourceRelativePath,
|
||||
destRel: path.relative(plan.targetRoot, operation.destinationPath),
|
||||
});
|
||||
}
|
||||
}
|
||||
return buildInstallIndex(mappings);
|
||||
}
|
||||
|
||||
function readJsonObject(filePath, label) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse ${label} at ${filePath}: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`Invalid ${label} at ${filePath}: expected a JSON object`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function cloneJsonValue(value) {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function deepMergeJson(baseValue, patchValue) {
|
||||
if (!isPlainObject(baseValue) || !isPlainObject(patchValue)) {
|
||||
return cloneJsonValue(patchValue);
|
||||
}
|
||||
|
||||
const merged = { ...baseValue };
|
||||
for (const [key, value] of Object.entries(patchValue)) {
|
||||
if (isPlainObject(value) && isPlainObject(merged[key])) {
|
||||
merged[key] = deepMergeJson(merged[key], value);
|
||||
} else {
|
||||
merged[key] = cloneJsonValue(value);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function formatJson(value) {
|
||||
return `${JSON.stringify(value, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function replacePluginRootPlaceholders(value, pluginRoot) {
|
||||
if (!pluginRoot) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value.split('${CLAUDE_PLUGIN_ROOT}').join(pluginRoot);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => replacePluginRootPlaceholders(item, pluginRoot));
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, nestedValue]) => [
|
||||
key,
|
||||
replacePluginRootPlaceholders(nestedValue, pluginRoot),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function findHooksSourcePath(plan, hooksDestinationPath) {
|
||||
const operation = plan.operations.find(item => item.destinationPath === hooksDestinationPath);
|
||||
return operation ? operation.sourcePath : null;
|
||||
}
|
||||
|
||||
function isMcpConfigPath(filePath) {
|
||||
const basename = path.basename(String(filePath || ''));
|
||||
return basename === '.mcp.json' || basename === 'mcp.json';
|
||||
}
|
||||
|
||||
function buildResolvedClaudeHooks(plan) {
|
||||
if (!plan.adapter || (plan.adapter.target !== 'claude' && plan.adapter.target !== 'claude-project')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pluginRoot = plan.targetRoot;
|
||||
const hooksDestinationPath = path.join(plan.targetRoot, 'hooks', 'hooks.json');
|
||||
const hooksSourcePath = findHooksSourcePath(plan, hooksDestinationPath) || hooksDestinationPath;
|
||||
if (!fs.existsSync(hooksSourcePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hooksConfig = readJsonObject(hooksSourcePath, 'hooks config');
|
||||
const resolvedHooks = replacePluginRootPlaceholders(hooksConfig.hooks, pluginRoot);
|
||||
if (!resolvedHooks || typeof resolvedHooks !== 'object' || Array.isArray(resolvedHooks)) {
|
||||
throw new Error(`Invalid hooks config at ${hooksSourcePath}: expected "hooks" to be a JSON object`);
|
||||
}
|
||||
|
||||
return {
|
||||
hooksDestinationPath,
|
||||
resolvedHooksConfig: {
|
||||
...hooksConfig,
|
||||
hooks: resolvedHooks,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyInstallPlan(plan) {
|
||||
const resolvedClaudeHooksPlan = buildResolvedClaudeHooks(plan);
|
||||
const disabledServers = parseDisabledMcpServers(process.env.ECC_DISABLED_MCPS);
|
||||
const linkIndex = buildLinkIndexForPlan(plan);
|
||||
|
||||
for (const operation of plan.operations) {
|
||||
fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true });
|
||||
|
||||
if (operation.kind === 'merge-json') {
|
||||
const payload = cloneJsonValue(operation.mergePayload);
|
||||
if (payload === undefined) {
|
||||
throw new Error(`Missing merge payload for ${operation.destinationPath}`);
|
||||
}
|
||||
|
||||
const filteredPayload = (
|
||||
isMcpConfigPath(operation.destinationPath) && disabledServers.length > 0
|
||||
)
|
||||
? filterMcpConfig(payload, disabledServers).config
|
||||
: payload;
|
||||
|
||||
const currentValue = fs.existsSync(operation.destinationPath)
|
||||
? readJsonObject(operation.destinationPath, 'existing JSON config')
|
||||
: {};
|
||||
const mergedValue = deepMergeJson(currentValue, filteredPayload);
|
||||
fs.writeFileSync(operation.destinationPath, formatJson(mergedValue), 'utf8');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (operation.kind === 'copy-file' && isMcpConfigPath(operation.destinationPath) && disabledServers.length > 0) {
|
||||
const sourceConfig = readJsonObject(operation.sourcePath, 'MCP config');
|
||||
const filteredConfig = filterMcpConfig(sourceConfig, disabledServers).config;
|
||||
fs.writeFileSync(operation.destinationPath, formatJson(filteredConfig), 'utf8');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Namespaced markdown (e.g. skills/<id> -> skills/ecc/<id>) needs its
|
||||
// relative cross-directory links rewritten so they resolve after install
|
||||
// (issue #2340). Files whose install path is unchanged (no namespace
|
||||
// injected) and all non-markdown files stay on the byte-for-byte copy path.
|
||||
if (
|
||||
linkIndex
|
||||
&& operation.kind === 'copy-file'
|
||||
&& operation.sourceRelativePath
|
||||
&& isMarkdownPath(operation.destinationPath)
|
||||
&& isNamespacedSource(operation.sourceRelativePath, linkIndex)
|
||||
) {
|
||||
const rewritten = rewriteRelativeLinks(
|
||||
fs.readFileSync(operation.sourcePath, 'utf8'),
|
||||
{ sourceRel: operation.sourceRelativePath, index: linkIndex }
|
||||
);
|
||||
fs.writeFileSync(operation.destinationPath, rewritten, 'utf8');
|
||||
continue;
|
||||
}
|
||||
|
||||
fs.copyFileSync(operation.sourcePath, operation.destinationPath);
|
||||
}
|
||||
|
||||
if (resolvedClaudeHooksPlan) {
|
||||
fs.mkdirSync(path.dirname(resolvedClaudeHooksPlan.hooksDestinationPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
resolvedClaudeHooksPlan.hooksDestinationPath,
|
||||
JSON.stringify(resolvedClaudeHooksPlan.resolvedHooksConfig, null, 2) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
writeInstallState(plan.installStatePath, plan.statePreview);
|
||||
|
||||
return {
|
||||
...plan,
|
||||
applied: true,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyInstallPlan,
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Ajv = require('ajv');
|
||||
|
||||
const DEFAULT_INSTALL_CONFIG = 'ecc-install.json';
|
||||
const CONFIG_SCHEMA_PATH = path.join(__dirname, '..', '..', '..', 'schemas', 'ecc-install-config.schema.json');
|
||||
|
||||
let cachedValidator = null;
|
||||
|
||||
function readJson(filePath, label) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid JSON in ${label}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getValidator() {
|
||||
if (cachedValidator) {
|
||||
return cachedValidator;
|
||||
}
|
||||
|
||||
const schema = readJson(CONFIG_SCHEMA_PATH, 'ecc-install-config.schema.json');
|
||||
const ajv = new Ajv({ allErrors: true });
|
||||
cachedValidator = ajv.compile(schema);
|
||||
return cachedValidator;
|
||||
}
|
||||
|
||||
function dedupeStrings(values) {
|
||||
return [...new Set((Array.isArray(values) ? values : []).map(value => String(value).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function formatValidationErrors(errors = []) {
|
||||
return errors.map(error => `${error.instancePath || '/'} ${error.message}`).join('; ');
|
||||
}
|
||||
|
||||
function resolveInstallConfigPath(configPath, options = {}) {
|
||||
if (!configPath) {
|
||||
throw new Error('An install config path is required');
|
||||
}
|
||||
|
||||
const cwd = options.cwd || process.cwd();
|
||||
return path.isAbsolute(configPath)
|
||||
? configPath
|
||||
: path.normalize(path.join(cwd, configPath));
|
||||
}
|
||||
|
||||
function findDefaultInstallConfigPath(options = {}) {
|
||||
const cwd = options.cwd || process.cwd();
|
||||
const candidatePath = path.join(cwd, DEFAULT_INSTALL_CONFIG);
|
||||
return fs.existsSync(candidatePath) ? candidatePath : null;
|
||||
}
|
||||
|
||||
function loadInstallConfig(configPath, options = {}) {
|
||||
const resolvedPath = resolveInstallConfigPath(configPath, options);
|
||||
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
throw new Error(`Install config not found: ${resolvedPath}`);
|
||||
}
|
||||
|
||||
const raw = readJson(resolvedPath, path.basename(resolvedPath));
|
||||
const validator = getValidator();
|
||||
|
||||
if (!validator(raw)) {
|
||||
throw new Error(
|
||||
`Invalid install config ${resolvedPath}: ${formatValidationErrors(validator.errors)}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
path: resolvedPath,
|
||||
version: raw.version,
|
||||
target: raw.target || null,
|
||||
profileId: raw.profile || null,
|
||||
moduleIds: dedupeStrings(raw.modules),
|
||||
includeComponentIds: dedupeStrings(raw.include),
|
||||
excludeComponentIds: dedupeStrings(raw.exclude),
|
||||
options: raw.options && typeof raw.options === 'object' ? { ...raw.options } : {},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_INSTALL_CONFIG,
|
||||
findDefaultInstallConfigPath,
|
||||
loadInstallConfig,
|
||||
resolveInstallConfigPath,
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
const posix = path.posix;
|
||||
|
||||
// Matches inline markdown links and images: `](target)` / `](target "title")`.
|
||||
// We deliberately scope to the inline form because that is what skill/rule docs
|
||||
// use for cross-directory references. Reference-style and autolinks are left
|
||||
// untouched (they are rare in these files and carry higher false-positive risk).
|
||||
const INLINE_LINK_PATTERN = /(!?\]\()([^()\s]+)(\s+"[^"]*")?(\))/g;
|
||||
|
||||
function toPosix(relativePath) {
|
||||
return String(relativePath || '').replace(/\\/g, '/').replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
function stripTrailingSlash(value) {
|
||||
return value.length > 1 ? value.replace(/\/+$/, '') : value;
|
||||
}
|
||||
|
||||
// Build file + directory lookup maps from the plan's own file placements.
|
||||
// `fileMappings` is a list of { sourceRel, destRel } where both are paths
|
||||
// relative to the repo root and the install root respectively. The directory
|
||||
// map is derived by walking shared ancestors of each source/dest pair, which is
|
||||
// exact for prefix-insertion namespacing (e.g. `skills/x` -> `skills/ecc/x`):
|
||||
// the path suffix below the inserted segment is preserved, so ancestor `k`
|
||||
// of the source maps to the dest with the matching number of trailing
|
||||
// segments removed.
|
||||
function buildInstallIndex(fileMappings) {
|
||||
const byFile = new Map();
|
||||
const byDir = new Map();
|
||||
|
||||
for (const mapping of fileMappings || []) {
|
||||
const sourceRel = toPosix(mapping.sourceRel);
|
||||
const destRel = toPosix(mapping.destRel);
|
||||
if (!sourceRel || !destRel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
byFile.set(sourceRel, destRel);
|
||||
|
||||
const sourceParts = sourceRel.split('/');
|
||||
const destParts = destRel.split('/');
|
||||
// Map every source ancestor directory to its installed counterpart by
|
||||
// removing the same count of trailing segments from the dest path.
|
||||
for (let depth = 1; depth < sourceParts.length; depth += 1) {
|
||||
const trailing = sourceParts.length - depth;
|
||||
const destDepth = destParts.length - trailing;
|
||||
if (destDepth < 1) {
|
||||
continue;
|
||||
}
|
||||
const sourceDir = sourceParts.slice(0, depth).join('/');
|
||||
const destDir = destParts.slice(0, destDepth).join('/');
|
||||
// Only record real prefix-insertion mappings (suffix preserved). If a
|
||||
// directory resolves to itself (no namespace change) we skip it so the
|
||||
// rewriter leaves those links alone.
|
||||
if (sourceDir !== destDir) {
|
||||
byDir.set(sourceDir, destDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { byFile, byDir };
|
||||
}
|
||||
|
||||
function isExternalOrAnchor(target) {
|
||||
return (
|
||||
target === ''
|
||||
|| target.startsWith('#')
|
||||
|| target.startsWith('/')
|
||||
|| target.startsWith('mailto:')
|
||||
|| /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(target) // has a URL scheme (http:, https:, file:, ...)
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve `target` (a relative link from `sourceDir`) to its repo-relative
|
||||
// path, then return the install-relative path it should point to, or null when
|
||||
// the target is not installed by this plan (leave such links untouched).
|
||||
function resolveInstalledTarget(target, sourceDir, index) {
|
||||
const hadTrailingSlash = target.endsWith('/');
|
||||
const resolved = stripTrailingSlash(toPosix(posix.normalize(posix.join(sourceDir, target))));
|
||||
|
||||
// Escapes the repo root (starts with `..`) -> not something we placed.
|
||||
if (resolved === '' || resolved === '.' || resolved.startsWith('..')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hadTrailingSlash && index.byFile.has(resolved)) {
|
||||
return { installed: index.byFile.get(resolved), trailingSlash: false };
|
||||
}
|
||||
if (index.byDir.has(resolved)) {
|
||||
return { installed: index.byDir.get(resolved), trailingSlash: hadTrailingSlash };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// True when the plan installs `sourceRel` at a different relative path than the
|
||||
// source (i.e. a namespace segment was injected, e.g. skills/x -> skills/ecc/x).
|
||||
// Callers use this to keep non-namespaced files on the byte-for-byte copy path.
|
||||
function isNamespacedSource(sourceRel, index) {
|
||||
const normalizedSource = toPosix(sourceRel);
|
||||
const installedSource = index && index.byFile.get(normalizedSource);
|
||||
return Boolean(installedSource) && installedSource !== normalizedSource;
|
||||
}
|
||||
|
||||
// Rewrite relative links in a single namespaced markdown file so they resolve
|
||||
// to the file's installed location. Returns the content unchanged when the
|
||||
// file itself was not namespaced or when no link needs adjustment. Pure: no IO.
|
||||
function rewriteRelativeLinks(content, options) {
|
||||
const { sourceRel, index } = options || {};
|
||||
const normalizedSource = toPosix(sourceRel);
|
||||
const installedSource = index && index.byFile.get(normalizedSource);
|
||||
|
||||
// Only rewrite when the file's own install path gained/changed a namespace
|
||||
// segment. If it lands at the same relative path, every link recomputes to
|
||||
// itself, so there is nothing to do.
|
||||
if (!installedSource || installedSource === normalizedSource) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const installedSourceDir = posix.dirname(installedSource);
|
||||
const sourceDir = posix.dirname(normalizedSource);
|
||||
const lines = String(content).split('\n');
|
||||
let inFence = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const fenceToggle = /^\s*(```|~~~)/.test(lines[i]);
|
||||
if (fenceToggle) {
|
||||
inFence = !inFence;
|
||||
continue;
|
||||
}
|
||||
if (inFence) {
|
||||
continue; // never rewrite inside fenced code blocks
|
||||
}
|
||||
|
||||
lines[i] = lines[i].replace(
|
||||
INLINE_LINK_PATTERN,
|
||||
(match, open, target, title, close) => {
|
||||
// Preserve any `#fragment` so anchors survive the rewrite.
|
||||
const hashIdx = target.indexOf('#');
|
||||
const pathPart = hashIdx === -1 ? target : target.slice(0, hashIdx);
|
||||
const fragment = hashIdx === -1 ? '' : target.slice(hashIdx);
|
||||
|
||||
if (isExternalOrAnchor(pathPart)) {
|
||||
return match;
|
||||
}
|
||||
|
||||
const resolution = resolveInstalledTarget(pathPart, sourceDir, index);
|
||||
if (!resolution) {
|
||||
return match;
|
||||
}
|
||||
|
||||
let rewritten = posix.relative(installedSourceDir, resolution.installed);
|
||||
if (rewritten === '') {
|
||||
rewritten = '.';
|
||||
}
|
||||
if (resolution.trailingSlash && !rewritten.endsWith('/')) {
|
||||
rewritten += '/';
|
||||
}
|
||||
// If the recomputed link points to the same place as the original
|
||||
// (e.g. an intra-namespace `./sibling.md` whose endpoints both shift by
|
||||
// the same prefix), keep the original text verbatim — including any
|
||||
// leading `./` — so the rewrite stays a strict no-op where it must.
|
||||
if (posix.normalize(rewritten) === posix.normalize(pathPart)) {
|
||||
return match;
|
||||
}
|
||||
return `${open}${rewritten}${fragment}${title || ''}${close}`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildInstallIndex,
|
||||
isNamespacedSource,
|
||||
rewriteRelativeLinks,
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
'use strict';
|
||||
|
||||
const { validateInstallModuleIds, LOCALE_ALIAS_TO_COMPONENT_ID, listSupportedLocales } = require('../install-manifests');
|
||||
|
||||
const LEGACY_INSTALL_TARGETS = ['claude', 'claude-project', 'cursor', 'antigravity'];
|
||||
|
||||
function dedupeStrings(values) {
|
||||
return [...new Set((Array.isArray(values) ? values : []).map(value => String(value).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeSkillComponentIds(rawValue) {
|
||||
return dedupeStrings(String(rawValue || '').split(',')).map(value => (
|
||||
value.startsWith('skill:') ? value : `skill:${value}`
|
||||
));
|
||||
}
|
||||
|
||||
function parseInstallArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
const parsed = {
|
||||
target: null,
|
||||
dryRun: false,
|
||||
json: false,
|
||||
help: false,
|
||||
configPath: null,
|
||||
profileId: null,
|
||||
moduleIds: [],
|
||||
includeComponentIds: [],
|
||||
excludeComponentIds: [],
|
||||
languages: [],
|
||||
locale: null,
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
|
||||
if (arg === '--target') {
|
||||
parsed.target = args[index + 1] || null;
|
||||
index += 1;
|
||||
} else if (arg === '--config') {
|
||||
parsed.configPath = args[index + 1] || null;
|
||||
index += 1;
|
||||
} else if (arg === '--profile') {
|
||||
parsed.profileId = args[index + 1] || null;
|
||||
index += 1;
|
||||
} else if (arg === '--modules') {
|
||||
const raw = args[index + 1] || '';
|
||||
parsed.moduleIds = dedupeStrings(raw.split(','));
|
||||
index += 1;
|
||||
} else if (arg === '--with') {
|
||||
const componentId = args[index + 1] || '';
|
||||
if (componentId.trim()) {
|
||||
parsed.includeComponentIds.push(componentId.trim());
|
||||
}
|
||||
index += 1;
|
||||
} else if (arg === '--skill' || arg === '--skills') {
|
||||
parsed.includeComponentIds.push(...normalizeSkillComponentIds(args[index + 1] || ''));
|
||||
index += 1;
|
||||
} else if (arg === '--without') {
|
||||
const componentId = args[index + 1] || '';
|
||||
if (componentId.trim()) {
|
||||
parsed.excludeComponentIds.push(componentId.trim());
|
||||
}
|
||||
index += 1;
|
||||
} else if (arg === '--locale') {
|
||||
const locale = args[index + 1] || '';
|
||||
if (!locale || locale.startsWith('--')) {
|
||||
throw new Error('Missing value for --locale');
|
||||
}
|
||||
parsed.locale = locale;
|
||||
index += 1;
|
||||
} else if (arg === '--dry-run') {
|
||||
parsed.dryRun = true;
|
||||
} else if (arg === '--json') {
|
||||
parsed.json = true;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
parsed.help = true;
|
||||
} else if (arg.startsWith('--')) {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
} else {
|
||||
parsed.languages.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeInstallRequest(options = {}) {
|
||||
const config = options.config && typeof options.config === 'object'
|
||||
? options.config
|
||||
: null;
|
||||
const profileId = options.profileId || config?.profileId || null;
|
||||
const target = options.target || config?.target || 'claude';
|
||||
const moduleIds = validateInstallModuleIds(
|
||||
dedupeStrings([...(config?.moduleIds || []), ...(options.moduleIds || [])])
|
||||
);
|
||||
const locale = options.locale || config?.locale || null;
|
||||
const localeComponentId = locale ? LOCALE_ALIAS_TO_COMPONENT_ID[locale] : null;
|
||||
if (locale && !localeComponentId) {
|
||||
throw new Error(
|
||||
`Unsupported locale: "${locale}". Supported locales: ${listSupportedLocales().join(', ')}`
|
||||
);
|
||||
}
|
||||
if (locale && target !== 'claude' && target !== 'claude-project') {
|
||||
throw new Error('--locale can only be used with --target claude or --target claude-project');
|
||||
}
|
||||
const requestedIncludeComponentIds = dedupeStrings([
|
||||
...(config?.includeComponentIds || []),
|
||||
...(options.includeComponentIds || []),
|
||||
]);
|
||||
const includeComponentIds = dedupeStrings([
|
||||
...requestedIncludeComponentIds,
|
||||
...(localeComponentId ? [localeComponentId] : []),
|
||||
]);
|
||||
const excludeComponentIds = dedupeStrings([
|
||||
...(config?.excludeComponentIds || []),
|
||||
...(options.excludeComponentIds || []),
|
||||
]);
|
||||
const legacyLanguages = dedupeStrings(dedupeStrings([
|
||||
...(Array.isArray(options.legacyLanguages) ? options.legacyLanguages : []),
|
||||
...(Array.isArray(options.languages) ? options.languages : []),
|
||||
]).map(language => language.toLowerCase()));
|
||||
const hasManifestBaseSelection = Boolean(profileId) || moduleIds.length > 0 || includeComponentIds.length > 0;
|
||||
const hasNonLocaleManifestSelection = Boolean(profileId)
|
||||
|| moduleIds.length > 0
|
||||
|| requestedIncludeComponentIds.length > 0
|
||||
|| excludeComponentIds.length > 0;
|
||||
const usingManifestMode = hasManifestBaseSelection || excludeComponentIds.length > 0;
|
||||
|
||||
if (hasNonLocaleManifestSelection && legacyLanguages.length > 0) {
|
||||
throw new Error(
|
||||
'Legacy language arguments cannot be combined with --profile, --modules, --with, --without, or manifest config selections'
|
||||
);
|
||||
}
|
||||
|
||||
if (!options.help && !hasManifestBaseSelection && legacyLanguages.length === 0) {
|
||||
throw new Error('No install profile, module IDs, included components, or legacy languages were provided');
|
||||
}
|
||||
|
||||
return {
|
||||
mode: legacyLanguages.length > 0
|
||||
? 'legacy-compat'
|
||||
: (usingManifestMode ? 'manifest' : 'legacy-compat'),
|
||||
target,
|
||||
profileId,
|
||||
moduleIds,
|
||||
includeComponentIds,
|
||||
excludeComponentIds,
|
||||
legacyLanguages,
|
||||
configPath: config?.path || options.configPath || null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LEGACY_INSTALL_TARGETS,
|
||||
normalizeInstallRequest,
|
||||
parseInstallArgs,
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
createLegacyCompatInstallPlan,
|
||||
createLegacyInstallPlan,
|
||||
createManifestInstallPlan,
|
||||
} = require('../install-executor');
|
||||
|
||||
function createInstallPlanFromRequest(request, options = {}) {
|
||||
if (!request || typeof request !== 'object') {
|
||||
throw new Error('A normalized install request is required');
|
||||
}
|
||||
|
||||
if (request.mode === 'manifest') {
|
||||
return createManifestInstallPlan({
|
||||
target: request.target,
|
||||
profileId: request.profileId,
|
||||
moduleIds: request.moduleIds,
|
||||
includeComponentIds: request.includeComponentIds,
|
||||
excludeComponentIds: request.excludeComponentIds,
|
||||
projectRoot: options.projectRoot,
|
||||
homeDir: options.homeDir,
|
||||
sourceRoot: options.sourceRoot,
|
||||
});
|
||||
}
|
||||
|
||||
if (request.mode === 'legacy-compat') {
|
||||
return createLegacyCompatInstallPlan({
|
||||
target: request.target,
|
||||
legacyLanguages: request.legacyLanguages,
|
||||
includeComponentIds: request.includeComponentIds,
|
||||
excludeComponentIds: request.excludeComponentIds,
|
||||
projectRoot: options.projectRoot,
|
||||
homeDir: options.homeDir,
|
||||
claudeRulesDir: options.claudeRulesDir,
|
||||
sourceRoot: options.sourceRoot,
|
||||
});
|
||||
}
|
||||
|
||||
if (request.mode === 'legacy') {
|
||||
return createLegacyInstallPlan({
|
||||
target: request.target,
|
||||
languages: request.languages,
|
||||
projectRoot: options.projectRoot,
|
||||
homeDir: options.homeDir,
|
||||
claudeRulesDir: options.claudeRulesDir,
|
||||
sourceRoot: options.sourceRoot,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported install request mode: ${request.mode}`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createInstallPlanFromRequest,
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* LLM-powered session summary generator
|
||||
*
|
||||
* Uses `claude -p` (Claude Code CLI) to generate rich, contextual session
|
||||
* summaries from JSONL transcripts. Requires no API key — reuses Claude Code's
|
||||
* own authentication.
|
||||
*
|
||||
* Recursion guard: sets ECC_SKIP_LLM_SUMMARY=1 in subprocess env so any Stop
|
||||
* hooks fired by the subprocess do NOT re-enter LLM summarization.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { spawnSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
|
||||
const MAX_TRANSCRIPT_CHARS = 7000;
|
||||
const MAX_TURNS = 25;
|
||||
const LLM_TIMEOUT_MS = 90000;
|
||||
|
||||
function getLLMModel() {
|
||||
return process.env.ECC_LLM_SUMMARY_MODEL || 'haiku';
|
||||
}
|
||||
|
||||
function getContextThreshold() {
|
||||
const raw = parseInt(process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD || '20', 10);
|
||||
return Number.isFinite(raw) && raw > 0 && raw <= 100 ? raw : 20;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the last MAX_TURNS user+assistant turns from a JSONL transcript.
|
||||
* Returns null when the transcript is missing or has no parseable turns.
|
||||
*/
|
||||
function extractConversationText(transcriptPath) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(transcriptPath, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = content.split('\n').filter(Boolean);
|
||||
const turns = [];
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
const isUser = entry.type === 'user' || entry.message?.role === 'user';
|
||||
const isAssistant = entry.type === 'assistant';
|
||||
|
||||
if (isUser) {
|
||||
const rawContent = entry.message?.content ?? entry.content;
|
||||
const text =
|
||||
typeof rawContent === 'string'
|
||||
? rawContent
|
||||
: Array.isArray(rawContent)
|
||||
? rawContent
|
||||
.filter(c => c?.type === 'text')
|
||||
.map(c => c.text)
|
||||
.join(' ')
|
||||
: '';
|
||||
const cleaned = text.replace(/\n+/g, ' ').trim();
|
||||
if (cleaned) {
|
||||
turns.push({ role: 'User', text: cleaned.slice(0, 400) });
|
||||
}
|
||||
}
|
||||
|
||||
if (isAssistant && Array.isArray(entry.message?.content)) {
|
||||
const textParts = entry.message.content
|
||||
.filter(b => b?.type === 'text')
|
||||
.map(b => b.text)
|
||||
.join(' ')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim();
|
||||
if (textParts) {
|
||||
turns.push({ role: 'Claude', text: textParts.slice(0, 600) });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
|
||||
if (turns.length === 0) return null;
|
||||
|
||||
const recent = turns.slice(-MAX_TURNS);
|
||||
const formatted = recent.map(t => `**${t.role}:** ${t.text}`).join('\n\n');
|
||||
return formatted.length > MAX_TRANSCRIPT_CHARS ? '...(前略)\n\n' + formatted.slice(-MAX_TRANSCRIPT_CHARS) : formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the context remaining percentage from a transcript's latest usage record.
|
||||
* Returns null when unavailable.
|
||||
*/
|
||||
function getContextRemainingPct(transcriptPath) {
|
||||
try {
|
||||
const { readLatestContextTokens, resolveContextWindowTokens } = require('./transcript-context');
|
||||
const usage = readLatestContextTokens(transcriptPath);
|
||||
if (!usage) return null;
|
||||
const windowTokens = resolveContextWindowTokens(usage.tokens, usage.model);
|
||||
return Math.round((1 - usage.tokens / windowTokens) * 100);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a session summary using `claude -p`.
|
||||
* Returns the summary string, or null on failure or when recursion guard is active.
|
||||
*/
|
||||
function generateSessionSummary(transcriptPath) {
|
||||
if (process.env.ECC_SKIP_LLM_SUMMARY) return null;
|
||||
|
||||
const conversation = extractConversationText(transcriptPath);
|
||||
if (!conversation) return null;
|
||||
|
||||
const prompt = [
|
||||
'Below is a conversation log from a Claude Code coding session.',
|
||||
'Create a summary to help the next session quickly understand the context.',
|
||||
'',
|
||||
'## Prioritize including',
|
||||
'- Design decisions and technology choices made this session',
|
||||
'- Bugs and problems solved',
|
||||
'- Files changed or created, with a brief description of changes',
|
||||
'- Unfinished tasks and work to continue in the next session',
|
||||
'- Important context the next session needs to know',
|
||||
'',
|
||||
'## Conversation log',
|
||||
conversation,
|
||||
'',
|
||||
'## Output format (Markdown only, no preamble)',
|
||||
'',
|
||||
'## Session Summary',
|
||||
'',
|
||||
'### Tasks',
|
||||
'(main tasks worked on this session)',
|
||||
'',
|
||||
'### Decisions Made',
|
||||
'(design decisions and technology choices)',
|
||||
'',
|
||||
'### Files Modified',
|
||||
'(files changed or created)',
|
||||
'',
|
||||
'### Unresolved Issues',
|
||||
'(unfinished tasks and work to continue)',
|
||||
'',
|
||||
'### Next Session Context',
|
||||
'(important context for the next session)'
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
const result = spawnSync('claude', ['--model', getLLMModel(), '-p'], {
|
||||
input: prompt,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDECODE: '',
|
||||
ECC_SKIP_LLM_SUMMARY: '1'
|
||||
},
|
||||
timeout: LLM_TIMEOUT_MS,
|
||||
shell: process.platform === 'win32'
|
||||
});
|
||||
|
||||
if (result.error || result.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output = (result.stdout || '').trim();
|
||||
return output || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { generateSessionSummary, extractConversationText, getContextRemainingPct, getContextThreshold, getLLMModel };
|
||||
@@ -0,0 +1,53 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Host/Origin gating for ECC's loopback HTTP servers (control pane, plan
|
||||
* canvas). DNS rebinding can point an attacker-controlled hostname at
|
||||
* 127.0.0.1, so every request must present a Host header from this
|
||||
* allowlist before the server does any work.
|
||||
*/
|
||||
|
||||
const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
|
||||
|
||||
// Extract the hostname portion of an HTTP Host header value, stripping any
|
||||
// port. Returns null when the header is missing or malformed.
|
||||
function parseHostHeader(value) {
|
||||
if (!value || typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const match = trimmed.match(/^(\[[^\]]+\]|[^:]+)(?::\d+)?$/);
|
||||
if (!match) return null;
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function buildAllowedHostnames(configuredHost) {
|
||||
const set = new Set(LOOPBACK_HOSTNAMES);
|
||||
if (configuredHost) set.add(String(configuredHost).toLowerCase());
|
||||
return set;
|
||||
}
|
||||
|
||||
function isAllowedHostHeader(hostHeader, allowedHostnames) {
|
||||
const hostname = parseHostHeader(hostHeader);
|
||||
if (!hostname) return false;
|
||||
return allowedHostnames.has(hostname);
|
||||
}
|
||||
|
||||
// Origin is absent on same-origin navigations and CLI clients; when present
|
||||
// it must resolve to an allowed hostname.
|
||||
function isAllowedOrigin(originHeader, allowedHostnames) {
|
||||
if (!originHeader || typeof originHeader !== 'string') return true;
|
||||
try {
|
||||
const url = new URL(originHeader);
|
||||
return allowedHostnames.has(url.hostname.toLowerCase());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LOOPBACK_HOSTNAMES,
|
||||
buildAllowedHostnames,
|
||||
isAllowedHostHeader,
|
||||
isAllowedOrigin,
|
||||
parseHostHeader
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
'use strict';
|
||||
|
||||
function parseDisabledMcpServers(value) {
|
||||
return [...new Set(
|
||||
String(value || '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
)];
|
||||
}
|
||||
|
||||
function filterMcpConfig(config, disabledServerNames = []) {
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
||||
throw new Error('MCP config must be a JSON object');
|
||||
}
|
||||
|
||||
const servers = config.mcpServers;
|
||||
if (!servers || typeof servers !== 'object' || Array.isArray(servers)) {
|
||||
throw new Error('MCP config must include an mcpServers object');
|
||||
}
|
||||
|
||||
const disabled = new Set(parseDisabledMcpServers(disabledServerNames));
|
||||
if (disabled.size === 0) {
|
||||
return {
|
||||
config: {
|
||||
...config,
|
||||
mcpServers: { ...servers },
|
||||
},
|
||||
removed: [],
|
||||
};
|
||||
}
|
||||
|
||||
const nextServers = {};
|
||||
const removed = [];
|
||||
|
||||
for (const [name, serverConfig] of Object.entries(servers)) {
|
||||
if (disabled.has(name)) {
|
||||
removed.push(name);
|
||||
continue;
|
||||
}
|
||||
nextServers[name] = serverConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
config: {
|
||||
...config,
|
||||
mcpServers: nextServers,
|
||||
},
|
||||
removed,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
filterMcpConfig,
|
||||
parseDisabledMcpServers,
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
'use strict';
|
||||
|
||||
const MCP_SCHEMA_VERSION = 'ecc.mcp.v1';
|
||||
|
||||
// Env keys whose values are almost always secrets. Used only to flag a server
|
||||
// as carrying credentials; values are NEVER copied into the canonical record.
|
||||
const SECRET_KEY_PATTERN = /(token|secret|key|password|passwd|auth|credential|api[_-]?key|access[_-]?key|private)/i;
|
||||
|
||||
const REDACTED = '***';
|
||||
|
||||
// Known secret value prefixes (provider API keys) plus a high-entropy fallback.
|
||||
const SECRET_VALUE_PATTERNS = [
|
||||
/^sk-[A-Za-z0-9_-]{16,}$/i, // OpenAI / Anthropic (sk-ant-...)
|
||||
/^ghp_[A-Za-z0-9]{16,}$/, // GitHub PAT (classic)
|
||||
/^github_pat_[A-Za-z0-9_]{16,}$/, // GitHub PAT (fine-grained)
|
||||
/^gh[oprs]_[A-Za-z0-9]{16,}$/, // other GitHub tokens
|
||||
/^sm_[A-Za-z0-9_-]{16,}$/, // Supermemory
|
||||
/^AIza[A-Za-z0-9_-]{16,}$/, // Google API key
|
||||
/^xox[baprs]-[A-Za-z0-9-]{10,}$/, // Slack
|
||||
/^(pb|sk|pk|rk)_(live|test)_[A-Za-z0-9]{12,}$/i // Stripe / PostBridge-style
|
||||
];
|
||||
|
||||
// A CLI flag whose following value is a secret (e.g. --modelApiKey sk-...).
|
||||
const SECRET_FLAG_PATTERN = /(^|[-_])(api[-_]?key|apikey|token|secret|password|passwd|auth|credential|access[-_]?key|private[-_]?key)$/i;
|
||||
|
||||
function looksLikeSecretValue(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (SECRET_VALUE_PATTERNS.some(pattern => pattern.test(value))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// High-entropy fallback: a long opaque token (letters AND digits, no path or
|
||||
// package separators) is almost certainly a credential, not a flag value.
|
||||
return value.length >= 32
|
||||
&& /^[A-Za-z0-9_+/=.-]+$/.test(value)
|
||||
&& /[A-Za-z]/.test(value)
|
||||
&& /[0-9]/.test(value)
|
||||
&& !value.includes('/')
|
||||
&& !value.includes('@');
|
||||
}
|
||||
|
||||
// Redact secret values from a command arg vector: any token that looks like a
|
||||
// credential, or any token that immediately follows a secret-named flag. The
|
||||
// flag names themselves are preserved so the command shape stays legible.
|
||||
function redactArgs(args) {
|
||||
const list = Array.isArray(args) ? args : [];
|
||||
const result = [];
|
||||
|
||||
for (let index = 0; index < list.length; index += 1) {
|
||||
const current = list[index];
|
||||
if (typeof current !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inline form: --flag=secret
|
||||
const inlineMatch = current.match(/^(--?[A-Za-z0-9_-]+)=(.+)$/);
|
||||
if (inlineMatch && (SECRET_FLAG_PATTERN.test(inlineMatch[1].replace(/^--?/, '')) || looksLikeSecretValue(inlineMatch[2]))) {
|
||||
result.push(`${inlineMatch[1]}=${REDACTED}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const previous = index > 0 ? list[index - 1] : null;
|
||||
const followsSecretFlag = typeof previous === 'string'
|
||||
&& /^--?[A-Za-z0-9_-]+$/.test(previous)
|
||||
&& SECRET_FLAG_PATTERN.test(previous.replace(/^--?/, ''));
|
||||
|
||||
if (followsSecretFlag || looksLikeSecretValue(current)) {
|
||||
result.push(REDACTED);
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(current);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Redact embedded credentials in a server URL (userinfo + token query params).
|
||||
function redactUrl(url) {
|
||||
if (typeof url !== 'string' || url.length === 0) {
|
||||
return url;
|
||||
}
|
||||
|
||||
let safe = url.replace(/\/\/[^/@]+@/, `//${REDACTED}@`);
|
||||
safe = safe.replace(/([?&](?:token|key|api[_-]?key|access[_-]?token|secret)=)[^&]+/gi, `$1${REDACTED}`);
|
||||
return safe;
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asNonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function asStringArray(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.filter(item => typeof item === 'string');
|
||||
}
|
||||
|
||||
// Normalize a transport label across harnesses:
|
||||
// Claude: type "stdio" | "http" | "sse"
|
||||
// OpenCode: type "local" (stdio) | "remote" (http/sse)
|
||||
// Codex: no type; presence of url => http, else stdio
|
||||
function normalizeTransport(rawType, { url } = {}) {
|
||||
const type = typeof rawType === 'string' ? rawType.toLowerCase() : '';
|
||||
|
||||
if (type === 'http' || type === 'streamable-http' || type === 'streamable_http') {
|
||||
return 'http';
|
||||
}
|
||||
|
||||
if (type === 'sse') {
|
||||
return 'sse';
|
||||
}
|
||||
|
||||
if (type === 'stdio' || type === 'local') {
|
||||
return 'stdio';
|
||||
}
|
||||
|
||||
if (type === 'remote') {
|
||||
return url ? 'http' : 'stdio';
|
||||
}
|
||||
|
||||
return url ? 'http' : 'stdio';
|
||||
}
|
||||
|
||||
// Extract env KEY names only (never values). Flags whether any key looks secret.
|
||||
function summarizeEnv(env) {
|
||||
if (!isObject(env)) {
|
||||
return { envKeys: [], hasSecrets: false };
|
||||
}
|
||||
|
||||
const envKeys = Object.keys(env).sort();
|
||||
const hasSecrets = envKeys.some(key => SECRET_KEY_PATTERN.test(key));
|
||||
return { envKeys, hasSecrets };
|
||||
}
|
||||
|
||||
// A stable identity for de-duplication across harnesses. Two server configs
|
||||
// with the same transport + command + args + url collapse to one logical
|
||||
// server even if their names differ slightly.
|
||||
function buildSignature({ transport, command, args, url }) {
|
||||
if (transport === 'http' || transport === 'sse') {
|
||||
return `${transport}:${url || ''}`;
|
||||
}
|
||||
|
||||
const argString = asStringArray(args).join(' ');
|
||||
return `stdio:${[command, argString].filter(Boolean).join(' ')}`.trim();
|
||||
}
|
||||
|
||||
// Normalize a single raw server entry (from any reader) to ecc.mcp.v1 shape.
|
||||
// rawServer fields the readers already pre-split: name, type, command, args,
|
||||
// url, env, enabled, source { harness, scope, configPath }.
|
||||
function normalizeServerEntry(rawServer) {
|
||||
const name = asNonEmptyString(rawServer.name) || 'unknown';
|
||||
const command = asNonEmptyString(rawServer.command);
|
||||
const rawUrl = asNonEmptyString(rawServer.url);
|
||||
const rawArgs = asStringArray(rawServer.args);
|
||||
const transport = normalizeTransport(rawServer.type, { url: rawUrl });
|
||||
const { envKeys, hasSecrets } = summarizeEnv(rawServer.env);
|
||||
|
||||
// Secrets can hide in args (e.g. --modelApiKey sk-...) and URLs, not just
|
||||
// env. Redact before anything is stored or hashed into the signature.
|
||||
const args = redactArgs(rawArgs);
|
||||
const url = redactUrl(rawUrl);
|
||||
const argsCarrySecret = rawArgs.length !== args.length
|
||||
|| rawArgs.some((value, index) => value !== args[index]);
|
||||
const urlCarriesSecret = rawUrl !== url;
|
||||
|
||||
const source = isObject(rawServer.source) ? rawServer.source : {};
|
||||
|
||||
return {
|
||||
name,
|
||||
transport,
|
||||
command: transport === 'stdio' ? command : null,
|
||||
args: transport === 'stdio' ? args : [],
|
||||
url: transport === 'stdio' ? null : url,
|
||||
envKeys,
|
||||
hasSecrets: hasSecrets || argsCarrySecret || urlCarriesSecret,
|
||||
enabled: rawServer.enabled === false ? false : true,
|
||||
signature: buildSignature({ transport, command, args, url }),
|
||||
sources: [{
|
||||
harness: asNonEmptyString(source.harness) || 'unknown',
|
||||
scope: asNonEmptyString(source.scope) || 'user',
|
||||
configPath: asNonEmptyString(source.configPath) || null
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// Merge many per-harness server records into a deduplicated inventory keyed by
|
||||
// logical server name. Records that share a name are merged; their sources are
|
||||
// concatenated and their signatures compared for drift.
|
||||
function mergeServers(serverRecords) {
|
||||
const byName = new Map();
|
||||
|
||||
for (const record of serverRecords) {
|
||||
const existing = byName.get(record.name);
|
||||
if (!existing) {
|
||||
byName.set(record.name, {
|
||||
...record,
|
||||
signatures: [record.signature],
|
||||
sources: [...record.sources]
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.sources.push(...record.sources);
|
||||
existing.signatures.push(record.signature);
|
||||
existing.hasSecrets = existing.hasSecrets || record.hasSecrets;
|
||||
// Union of env keys observed across harnesses.
|
||||
existing.envKeys = Array.from(new Set([...existing.envKeys, ...record.envKeys])).sort();
|
||||
}
|
||||
|
||||
return Array.from(byName.values()).map(server => {
|
||||
const uniqueSignatures = Array.from(new Set(server.signatures));
|
||||
const { signatures: _signatures, ...rest } = server;
|
||||
return {
|
||||
...rest,
|
||||
harnessCount: server.sources.length,
|
||||
consistent: uniqueSignatures.length <= 1
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildFragmentation(mergedServers) {
|
||||
return mergedServers
|
||||
.filter(server => server.harnessCount > 1)
|
||||
.map(server => ({
|
||||
name: server.name,
|
||||
harnessCount: server.harnessCount,
|
||||
harnesses: server.sources.map(source => source.harness),
|
||||
consistent: server.consistent
|
||||
}))
|
||||
.sort((a, b) => b.harnessCount - a.harnessCount || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function buildInventory(serverRecords) {
|
||||
const merged = mergeServers(serverRecords).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const fragmentation = buildFragmentation(merged);
|
||||
const harnesses = new Set();
|
||||
let serversWithSecrets = 0;
|
||||
|
||||
for (const server of merged) {
|
||||
server.sources.forEach(source => harnesses.add(source.harness));
|
||||
if (server.hasSecrets) {
|
||||
serversWithSecrets += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: MCP_SCHEMA_VERSION,
|
||||
servers: merged,
|
||||
fragmentation,
|
||||
aggregates: {
|
||||
serverCount: merged.length,
|
||||
harnessCount: harnesses.size,
|
||||
duplicateServerCount: fragmentation.length,
|
||||
inconsistentServerCount: fragmentation.filter(item => !item.consistent).length,
|
||||
serversWithSecrets
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MCP_SCHEMA_VERSION,
|
||||
SECRET_KEY_PATTERN,
|
||||
REDACTED,
|
||||
looksLikeSecretValue,
|
||||
redactArgs,
|
||||
redactUrl,
|
||||
normalizeTransport,
|
||||
summarizeEnv,
|
||||
buildSignature,
|
||||
normalizeServerEntry,
|
||||
mergeServers,
|
||||
buildFragmentation,
|
||||
buildInventory
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
|
||||
const { normalizeServerEntry, buildInventory } = require('./canonical-mcp');
|
||||
const { readClaudeCodeMcp } = require('./readers/claude-code');
|
||||
const { readCodexMcp } = require('./readers/codex');
|
||||
const { readOpencodeMcp } = require('./readers/opencode');
|
||||
|
||||
const DEFAULT_READERS = Object.freeze({
|
||||
'claude-code': readClaudeCodeMcp,
|
||||
codex: readCodexMcp,
|
||||
opencode: readOpencodeMcp
|
||||
});
|
||||
|
||||
// Collect MCP server configs from every harness reader, normalize each raw
|
||||
// entry to ecc.mcp.v1, then merge into a single deduplicated inventory with a
|
||||
// fragmentation report. Secrets are stripped during normalization (only env
|
||||
// key names survive), so the returned inventory is safe to print or persist.
|
||||
function collectMcpInventory(options = {}) {
|
||||
const readers = options.readers || DEFAULT_READERS;
|
||||
const readerOptions = options.readerOptions || {};
|
||||
|
||||
const rawRecords = [];
|
||||
for (const [harness, reader] of Object.entries(readers)) {
|
||||
if (typeof reader !== 'function') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = reader(readerOptions[harness] || readerOptions.shared || {});
|
||||
} catch {
|
||||
entries = [];
|
||||
}
|
||||
|
||||
if (Array.isArray(entries)) {
|
||||
rawRecords.push(...entries);
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = rawRecords.map(normalizeServerEntry);
|
||||
return buildInventory(normalized);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectMcpInventory,
|
||||
DEFAULT_READERS
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
// Claude Code stores MCP servers under "mcpServers" in ~/.claude.json (user
|
||||
// scope) and in project-local .mcp.json files (project scope). Each entry:
|
||||
// { type: "stdio"|"http"|"sse", command, args[], env{}, url }
|
||||
function mapClaudeServer(name, raw, source) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
type: typeof raw.type === 'string' ? raw.type : (raw.url ? 'http' : 'stdio'),
|
||||
command: raw.command || null,
|
||||
args: Array.isArray(raw.args) ? raw.args : [],
|
||||
url: raw.url || null,
|
||||
env: raw.env && typeof raw.env === 'object' ? raw.env : {},
|
||||
enabled: raw.disabled === true ? false : true,
|
||||
source
|
||||
};
|
||||
}
|
||||
|
||||
function readMcpServersBlock(filePath, scope) {
|
||||
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const block = parsed && typeof parsed.mcpServers === 'object' && parsed.mcpServers
|
||||
? parsed.mcpServers
|
||||
: {};
|
||||
|
||||
return Object.entries(block)
|
||||
.map(([name, raw]) => mapClaudeServer(name, raw, {
|
||||
harness: 'claude-code',
|
||||
scope,
|
||||
configPath: filePath
|
||||
}))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function readClaudeCodeMcp(options = {}) {
|
||||
const homeDir = options.homeDir || os.homedir();
|
||||
const userConfig = options.userConfigPath || path.join(homeDir, '.claude.json');
|
||||
const projectConfigPaths = Array.isArray(options.projectConfigPaths)
|
||||
? options.projectConfigPaths
|
||||
: [];
|
||||
|
||||
const records = [
|
||||
...readMcpServersBlock(userConfig, 'user')
|
||||
];
|
||||
|
||||
for (const projectPath of projectConfigPaths) {
|
||||
records.push(...readMcpServersBlock(projectPath, 'project'));
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readClaudeCodeMcp,
|
||||
mapClaudeServer
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
// Codex stores MCP servers in ~/.codex/config.toml as TOML tables:
|
||||
// [mcp_servers.NAME]
|
||||
// command = "npx"
|
||||
// args = ["-y", "pkg"]
|
||||
// url = "https://..." # http transport
|
||||
// [mcp_servers.NAME.env] # secret values live here
|
||||
// [mcp_servers.NAME.http_headers]
|
||||
// We parse with @iarna/toml when available and fall back to a minimal
|
||||
// section parser so the reader degrades gracefully without the dependency.
|
||||
function loadTomlParser(parseTomlImpl) {
|
||||
if (typeof parseTomlImpl === 'function') {
|
||||
return parseTomlImpl;
|
||||
}
|
||||
|
||||
try {
|
||||
return require('@iarna/toml').parse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mapCodexServer(name, raw, configPath) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = raw.url ? 'http' : 'stdio';
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
command: typeof raw.command === 'string' ? raw.command : null,
|
||||
args: Array.isArray(raw.args) ? raw.args : [],
|
||||
url: typeof raw.url === 'string' ? raw.url : null,
|
||||
env: raw.env && typeof raw.env === 'object' ? raw.env : {},
|
||||
enabled: raw.enabled === false ? false : true,
|
||||
source: {
|
||||
harness: 'codex',
|
||||
scope: 'user',
|
||||
configPath
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function readCodexMcp(options = {}) {
|
||||
const homeDir = options.homeDir || os.homedir();
|
||||
const configPath = options.configPath || path.join(homeDir, '.codex', 'config.toml');
|
||||
|
||||
if (!fs.existsSync(configPath) || !fs.statSync(configPath).isFile()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parseToml = loadTomlParser(options.parseTomlImpl);
|
||||
if (!parseToml) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseToml(fs.readFileSync(configPath, 'utf8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const block = parsed && typeof parsed.mcp_servers === 'object' && parsed.mcp_servers
|
||||
? parsed.mcp_servers
|
||||
: {};
|
||||
|
||||
return Object.entries(block)
|
||||
.map(([name, raw]) => mapCodexServer(name, raw, configPath))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readCodexMcp,
|
||||
mapCodexServer
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
// OpenCode stores MCP servers under "mcp" in ~/.config/opencode/opencode.json.
|
||||
// Shape differs from Claude/Codex:
|
||||
// { type: "local"|"remote", command: ["npx","-y","pkg"], environment: {},
|
||||
// enabled: bool, url: "https://..." }
|
||||
// command is an ARRAY (binary + args combined); environment (not env) holds
|
||||
// secrets; type "local" => stdio, "remote" => http/sse.
|
||||
function mapOpencodeServer(name, raw, configPath) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const commandArray = Array.isArray(raw.command) ? raw.command.filter(item => typeof item === 'string') : [];
|
||||
const [command, ...args] = commandArray;
|
||||
|
||||
return {
|
||||
name,
|
||||
type: typeof raw.type === 'string' ? raw.type : (raw.url ? 'remote' : 'local'),
|
||||
command: command || (typeof raw.command === 'string' ? raw.command : null),
|
||||
args: command ? args : (Array.isArray(raw.args) ? raw.args : []),
|
||||
url: typeof raw.url === 'string' ? raw.url : null,
|
||||
env: raw.environment && typeof raw.environment === 'object'
|
||||
? raw.environment
|
||||
: (raw.env && typeof raw.env === 'object' ? raw.env : {}),
|
||||
enabled: raw.enabled === false ? false : true,
|
||||
source: {
|
||||
harness: 'opencode',
|
||||
scope: 'user',
|
||||
configPath
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function readOpencodeMcp(options = {}) {
|
||||
const homeDir = options.homeDir || os.homedir();
|
||||
const candidatePaths = options.configPath
|
||||
? [options.configPath]
|
||||
: [
|
||||
path.join(homeDir, '.config', 'opencode', 'opencode.json'),
|
||||
path.join(homeDir, '.config', 'opencode', 'config.json'),
|
||||
path.join(homeDir, '.opencode.json')
|
||||
];
|
||||
|
||||
const configPath = candidatePaths.find(candidate => fs.existsSync(candidate) && fs.statSync(candidate).isFile());
|
||||
if (!configPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const block = parsed && typeof parsed.mcp === 'object' && parsed.mcp
|
||||
? parsed.mcp
|
||||
: (parsed && typeof parsed.mcpServers === 'object' && parsed.mcpServers ? parsed.mcpServers : {});
|
||||
|
||||
return Object.entries(block)
|
||||
.map(([name, raw]) => mapOpencodeServer(name, raw, configPath))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readOpencodeMcp,
|
||||
mapOpencodeServer
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { ensureDir, sanitizeSessionId } = require('./utils');
|
||||
|
||||
function getHomunculusDir() {
|
||||
const override = process.env.CLV2_HOMUNCULUS_DIR;
|
||||
if (override) {
|
||||
if (path.isAbsolute(override)) {
|
||||
return override;
|
||||
}
|
||||
process.stderr.write(`[ecc] CLV2_HOMUNCULUS_DIR=${override} is not absolute; ignoring\n`);
|
||||
}
|
||||
|
||||
const xdgDataHome = process.env.XDG_DATA_HOME;
|
||||
if (xdgDataHome) {
|
||||
if (path.isAbsolute(xdgDataHome)) {
|
||||
return path.join(xdgDataHome, 'ecc-homunculus');
|
||||
}
|
||||
process.stderr.write(`[ecc] XDG_DATA_HOME=${xdgDataHome} is not absolute; ignoring\n`);
|
||||
}
|
||||
|
||||
return path.join(os.homedir(), '.local', 'share', 'ecc-homunculus');
|
||||
}
|
||||
|
||||
function getProjectsDir() {
|
||||
return path.join(getHomunculusDir(), 'projects');
|
||||
}
|
||||
|
||||
function getProjectRegistryPath() {
|
||||
return path.join(getHomunculusDir(), 'projects.json');
|
||||
}
|
||||
|
||||
function readProjectRegistry() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(getProjectRegistryPath(), 'utf8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function runGit(args, cwd) {
|
||||
const result = spawnSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
});
|
||||
if (result.status !== 0) return '';
|
||||
return (result.stdout || '').trim();
|
||||
}
|
||||
|
||||
function stripRemoteCredentials(remoteUrl) {
|
||||
if (!remoteUrl) return '';
|
||||
return String(remoteUrl).replace(/:\/\/[^@]+@/, '://');
|
||||
}
|
||||
|
||||
function normalizeRemoteUrl(remoteUrl) {
|
||||
if (!remoteUrl) return '';
|
||||
const raw = String(remoteUrl);
|
||||
const isNetwork = !raw.startsWith('file://') && (raw.includes('://') || /^[^@/:]+@[^:/]+:/.test(raw));
|
||||
let normalized = stripRemoteCredentials(raw)
|
||||
.replace(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//, '')
|
||||
.replace(/^[^@/:]+@([^:/]+):/, '$1/')
|
||||
.replace(/\.git\/?$/, '')
|
||||
.replace(/\/+$/, '');
|
||||
|
||||
if (isNetwork) {
|
||||
normalized = normalized.toLowerCase();
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveProjectRoot(cwd = process.cwd()) {
|
||||
const envRoot = process.env.CLAUDE_PROJECT_DIR;
|
||||
if (envRoot && fs.existsSync(envRoot)) {
|
||||
return path.resolve(envRoot);
|
||||
}
|
||||
|
||||
const gitRoot = runGit(['rev-parse', '--show-toplevel'], cwd);
|
||||
if (gitRoot) return path.resolve(gitRoot);
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function computeProjectId(projectRoot) {
|
||||
const remoteUrl = stripRemoteCredentials(runGit(['remote', 'get-url', 'origin'], projectRoot));
|
||||
const hashInput = normalizeRemoteUrl(remoteUrl) || remoteUrl || projectRoot;
|
||||
return crypto.createHash('sha256').update(hashInput).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
function resolveProjectContext(cwd = process.cwd()) {
|
||||
const projectRoot = resolveProjectRoot(cwd);
|
||||
if (!projectRoot) {
|
||||
const projectDir = getHomunculusDir();
|
||||
ensureDir(projectDir);
|
||||
return { projectId: 'global', projectRoot: '', projectDir, isGlobal: true };
|
||||
}
|
||||
|
||||
const registry = readProjectRegistry();
|
||||
const registryEntry = Object.values(registry).find(entry => entry && path.resolve(entry.root || '') === projectRoot);
|
||||
const projectId = registryEntry?.id || computeProjectId(projectRoot);
|
||||
const projectDir = path.join(getProjectsDir(), projectId);
|
||||
ensureDir(projectDir);
|
||||
|
||||
return { projectId, projectRoot, projectDir, isGlobal: false };
|
||||
}
|
||||
|
||||
function getObserverPidFile(context) {
|
||||
return path.join(context.projectDir, '.observer.pid');
|
||||
}
|
||||
|
||||
function getObserverSignalCounterFile(context) {
|
||||
return path.join(context.projectDir, '.observer-signal-counter');
|
||||
}
|
||||
|
||||
function getObserverActivityFile(context) {
|
||||
return path.join(context.projectDir, '.observer-last-activity');
|
||||
}
|
||||
|
||||
function getSessionLeaseDir(context) {
|
||||
return path.join(context.projectDir, '.observer-sessions');
|
||||
}
|
||||
|
||||
function resolveSessionId(rawSessionId = process.env.CLAUDE_SESSION_ID) {
|
||||
return sanitizeSessionId(rawSessionId || '') || '';
|
||||
}
|
||||
|
||||
function getSessionLeaseFile(context, rawSessionId = process.env.CLAUDE_SESSION_ID) {
|
||||
const sessionId = resolveSessionId(rawSessionId);
|
||||
if (!sessionId) return '';
|
||||
return path.join(getSessionLeaseDir(context), `${sessionId}.json`);
|
||||
}
|
||||
|
||||
function writeSessionLease(context, rawSessionId = process.env.CLAUDE_SESSION_ID, extra = {}) {
|
||||
const leaseFile = getSessionLeaseFile(context, rawSessionId);
|
||||
if (!leaseFile) return '';
|
||||
|
||||
ensureDir(getSessionLeaseDir(context));
|
||||
const payload = {
|
||||
sessionId: resolveSessionId(rawSessionId),
|
||||
cwd: process.cwd(),
|
||||
pid: process.pid,
|
||||
updatedAt: new Date().toISOString(),
|
||||
...extra
|
||||
};
|
||||
fs.writeFileSync(leaseFile, JSON.stringify(payload, null, 2) + '\n');
|
||||
return leaseFile;
|
||||
}
|
||||
|
||||
function removeSessionLease(context, rawSessionId = process.env.CLAUDE_SESSION_ID) {
|
||||
const leaseFile = getSessionLeaseFile(context, rawSessionId);
|
||||
if (!leaseFile) return false;
|
||||
try {
|
||||
fs.rmSync(leaseFile, { force: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function listSessionLeases(context) {
|
||||
const leaseDir = getSessionLeaseDir(context);
|
||||
if (!fs.existsSync(leaseDir)) return [];
|
||||
return fs.readdirSync(leaseDir)
|
||||
.filter(name => name.endsWith('.json'))
|
||||
.map(name => path.join(leaseDir, name));
|
||||
}
|
||||
|
||||
function stopObserverForContext(context) {
|
||||
const pidFile = getObserverPidFile(context);
|
||||
if (!fs.existsSync(pidFile)) return false;
|
||||
|
||||
const pid = (fs.readFileSync(pidFile, 'utf8') || '').trim();
|
||||
if (!/^[0-9]+$/.test(pid) || pid === '0' || pid === '1') {
|
||||
fs.rmSync(pidFile, { force: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(Number(pid), 0);
|
||||
} catch {
|
||||
fs.rmSync(pidFile, { force: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(Number(pid), 'SIGTERM');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
fs.rmSync(pidFile, { force: true });
|
||||
fs.rmSync(getObserverSignalCounterFile(context), { force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getHomunculusDir,
|
||||
normalizeRemoteUrl,
|
||||
resolveProjectContext,
|
||||
getObserverActivityFile,
|
||||
getObserverPidFile,
|
||||
getSessionLeaseDir,
|
||||
writeSessionLease,
|
||||
removeSessionLease,
|
||||
listSessionLeases,
|
||||
stopObserverForContext,
|
||||
resolveSessionId
|
||||
};
|
||||
@@ -0,0 +1,299 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
function stripCodeTicks(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith('`') && trimmed.endsWith('`') && trimmed.length >= 2) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parseSection(content, heading) {
|
||||
if (typeof content !== 'string' || content.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
const headingLines = new Set([`## ${heading}`, `**${heading}**`]);
|
||||
const startIndex = lines.findIndex(line => headingLines.has(line.trim()));
|
||||
|
||||
if (startIndex === -1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const collected = [];
|
||||
for (let index = startIndex + 1; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('## ') || (/^\*\*.+\*\*$/.test(trimmed) && !headingLines.has(trimmed))) {
|
||||
break;
|
||||
}
|
||||
collected.push(line);
|
||||
}
|
||||
|
||||
return collected.join('\n').trim();
|
||||
}
|
||||
|
||||
function parseBullets(section) {
|
||||
if (!section) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return section
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.startsWith('- '))
|
||||
.map(line => stripCodeTicks(line.replace(/^- /, '').trim()));
|
||||
}
|
||||
|
||||
function parseWorkerStatus(content) {
|
||||
const status = {
|
||||
state: null,
|
||||
updated: null,
|
||||
branch: null,
|
||||
worktree: null,
|
||||
taskFile: null,
|
||||
handoffFile: null
|
||||
};
|
||||
|
||||
if (typeof content !== 'string' || content.length === 0) {
|
||||
return status;
|
||||
}
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
const match = line.match(/^- ([A-Za-z ]+):\s*(.+)$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = match[1].trim().toLowerCase().replace(/\s+/g, '');
|
||||
const value = stripCodeTicks(match[2]);
|
||||
|
||||
if (key === 'state') status.state = value;
|
||||
if (key === 'updated') status.updated = value;
|
||||
if (key === 'branch') status.branch = value;
|
||||
if (key === 'worktree') status.worktree = value;
|
||||
if (key === 'taskfile') status.taskFile = value;
|
||||
if (key === 'handofffile') status.handoffFile = value;
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
function parseWorkerTask(content) {
|
||||
return {
|
||||
objective: parseSection(content, 'Objective'),
|
||||
seedPaths: parseBullets(parseSection(content, 'Seeded Local Overlays'))
|
||||
};
|
||||
}
|
||||
|
||||
function parseWorkerHandoff(content) {
|
||||
return {
|
||||
summary: parseBullets(parseSection(content, 'Summary')),
|
||||
validation: parseBullets(parseSection(content, 'Validation')),
|
||||
remainingRisks: parseBullets(parseSection(content, 'Remaining Risks'))
|
||||
};
|
||||
}
|
||||
|
||||
function readTextIfExists(filePath) {
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
function listWorkerDirectories(coordinationDir) {
|
||||
if (!coordinationDir || !fs.existsSync(coordinationDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(coordinationDir, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.filter(entry => {
|
||||
const workerDir = path.join(coordinationDir, entry.name);
|
||||
return ['status.md', 'task.md', 'handoff.md']
|
||||
.some(filename => fs.existsSync(path.join(workerDir, filename)));
|
||||
})
|
||||
.map(entry => entry.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function loadWorkerSnapshots(coordinationDir) {
|
||||
return listWorkerDirectories(coordinationDir).map(workerSlug => {
|
||||
const workerDir = path.join(coordinationDir, workerSlug);
|
||||
const statusPath = path.join(workerDir, 'status.md');
|
||||
const taskPath = path.join(workerDir, 'task.md');
|
||||
const handoffPath = path.join(workerDir, 'handoff.md');
|
||||
|
||||
const status = parseWorkerStatus(readTextIfExists(statusPath));
|
||||
const task = parseWorkerTask(readTextIfExists(taskPath));
|
||||
const handoff = parseWorkerHandoff(readTextIfExists(handoffPath));
|
||||
|
||||
return {
|
||||
workerSlug,
|
||||
workerDir,
|
||||
status,
|
||||
task,
|
||||
handoff,
|
||||
files: {
|
||||
status: statusPath,
|
||||
task: taskPath,
|
||||
handoff: handoffPath
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function listTmuxPanes(sessionName, options = {}) {
|
||||
const { spawnSyncImpl = spawnSync } = options;
|
||||
const format = [
|
||||
'#{pane_id}',
|
||||
'#{window_index}',
|
||||
'#{pane_index}',
|
||||
'#{pane_title}',
|
||||
'#{pane_current_command}',
|
||||
'#{pane_current_path}',
|
||||
'#{pane_active}',
|
||||
'#{pane_dead}',
|
||||
'#{pane_pid}'
|
||||
].join('\t');
|
||||
|
||||
const result = spawnSyncImpl('tmux', ['list-panes', '-t', sessionName, '-F', format], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
if (result.error.code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (result.stdout || '')
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.map(line => {
|
||||
const [
|
||||
paneId,
|
||||
windowIndex,
|
||||
paneIndex,
|
||||
title,
|
||||
currentCommand,
|
||||
currentPath,
|
||||
active,
|
||||
dead,
|
||||
pid
|
||||
] = line.split('\t');
|
||||
|
||||
return {
|
||||
paneId,
|
||||
windowIndex: Number(windowIndex),
|
||||
paneIndex: Number(paneIndex),
|
||||
title,
|
||||
currentCommand,
|
||||
currentPath,
|
||||
active: active === '1',
|
||||
dead: dead === '1',
|
||||
pid: pid ? Number(pid) : null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeWorkerStates(workers) {
|
||||
return workers.reduce((counts, worker) => {
|
||||
const state = worker.status.state || 'unknown';
|
||||
counts[state] = (counts[state] || 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function buildSessionSnapshot({ sessionName, coordinationDir, panes }) {
|
||||
const workerSnapshots = loadWorkerSnapshots(coordinationDir);
|
||||
const paneMap = new Map(panes.map(pane => [pane.title, pane]));
|
||||
|
||||
const workers = workerSnapshots.map(worker => ({
|
||||
...worker,
|
||||
pane: paneMap.get(worker.workerSlug) || null
|
||||
}));
|
||||
|
||||
return {
|
||||
sessionName,
|
||||
coordinationDir,
|
||||
sessionActive: panes.length > 0,
|
||||
paneCount: panes.length,
|
||||
workerCount: workers.length,
|
||||
workerStates: summarizeWorkerStates(workers),
|
||||
panes,
|
||||
workers
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSnapshotTarget(targetPath, cwd = process.cwd()) {
|
||||
const absoluteTarget = path.resolve(cwd, targetPath);
|
||||
|
||||
if (fs.existsSync(absoluteTarget) && fs.statSync(absoluteTarget).isFile()) {
|
||||
const config = JSON.parse(fs.readFileSync(absoluteTarget, 'utf8'));
|
||||
const repoRoot = path.resolve(config.repoRoot || cwd);
|
||||
const coordinationRoot = path.resolve(
|
||||
config.coordinationRoot || path.join(repoRoot, '.orchestration')
|
||||
);
|
||||
|
||||
return {
|
||||
sessionName: config.sessionName,
|
||||
coordinationDir: path.join(coordinationRoot, config.sessionName),
|
||||
repoRoot,
|
||||
targetType: 'plan'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sessionName: targetPath,
|
||||
coordinationDir: path.join(cwd, '.claude', 'orchestration', targetPath),
|
||||
repoRoot: cwd,
|
||||
targetType: 'session'
|
||||
};
|
||||
}
|
||||
|
||||
function collectSessionSnapshot(targetPath, cwd = process.cwd()) {
|
||||
const target = resolveSnapshotTarget(targetPath, cwd);
|
||||
const panes = listTmuxPanes(target.sessionName);
|
||||
const snapshot = buildSessionSnapshot({
|
||||
sessionName: target.sessionName,
|
||||
coordinationDir: target.coordinationDir,
|
||||
panes
|
||||
});
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
repoRoot: target.repoRoot,
|
||||
targetType: target.targetType
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildSessionSnapshot,
|
||||
collectSessionSnapshot,
|
||||
listTmuxPanes,
|
||||
loadWorkerSnapshots,
|
||||
normalizeText: stripCodeTicks,
|
||||
parseWorkerHandoff,
|
||||
parseWorkerStatus,
|
||||
parseWorkerTask,
|
||||
resolveSnapshotTarget
|
||||
};
|
||||
Vendored
+119
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Package Manager Detection and Selection.
|
||||
* Supports: npm, pnpm, yarn, bun.
|
||||
*/
|
||||
|
||||
/** Supported package manager names */
|
||||
export type PackageManagerName = 'npm' | 'pnpm' | 'yarn' | 'bun';
|
||||
|
||||
/** Configuration for a single package manager */
|
||||
export interface PackageManagerConfig {
|
||||
name: PackageManagerName;
|
||||
/** Lock file name (e.g., "package-lock.json", "pnpm-lock.yaml") */
|
||||
lockFile: string;
|
||||
/** Install command (e.g., "npm install") */
|
||||
installCmd: string;
|
||||
/** Run script command prefix (e.g., "npm run", "pnpm") */
|
||||
runCmd: string;
|
||||
/** Execute binary command (e.g., "npx", "pnpm dlx") */
|
||||
execCmd: string;
|
||||
/** Test command (e.g., "npm test") */
|
||||
testCmd: string;
|
||||
/** Build command (e.g., "npm run build") */
|
||||
buildCmd: string;
|
||||
/** Dev server command (e.g., "npm run dev") */
|
||||
devCmd: string;
|
||||
}
|
||||
|
||||
/** How the package manager was detected */
|
||||
export type DetectionSource =
|
||||
| 'environment'
|
||||
| 'project-config'
|
||||
| 'package.json'
|
||||
| 'lock-file'
|
||||
| 'global-config'
|
||||
| 'default';
|
||||
|
||||
/** Result from getPackageManager() */
|
||||
export interface PackageManagerResult {
|
||||
name: PackageManagerName;
|
||||
config: PackageManagerConfig;
|
||||
source: DetectionSource;
|
||||
}
|
||||
|
||||
/** Map of all supported package managers keyed by name */
|
||||
export const PACKAGE_MANAGERS: Record<PackageManagerName, PackageManagerConfig>;
|
||||
|
||||
/** Priority order for lock file detection */
|
||||
export const DETECTION_PRIORITY: PackageManagerName[];
|
||||
|
||||
export interface GetPackageManagerOptions {
|
||||
/** Project directory to detect from (default: process.cwd()) */
|
||||
projectDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the package manager to use for the current project.
|
||||
*
|
||||
* Detection priority:
|
||||
* 1. CLAUDE_PACKAGE_MANAGER environment variable
|
||||
* 2. Project-specific config (.claude/package-manager.json)
|
||||
* 3. package.json `packageManager` field
|
||||
* 4. Lock file detection
|
||||
* 5. Global user preference (~/.claude/package-manager.json)
|
||||
* 6. Default to npm (no child processes spawned)
|
||||
*/
|
||||
export function getPackageManager(options?: GetPackageManagerOptions): PackageManagerResult;
|
||||
|
||||
/**
|
||||
* Set the user's globally preferred package manager.
|
||||
* Saves to ~/.claude/package-manager.json.
|
||||
* @throws If pmName is not a known package manager or if save fails
|
||||
*/
|
||||
export function setPreferredPackageManager(pmName: PackageManagerName): { packageManager: string; setAt: string };
|
||||
|
||||
/**
|
||||
* Set a project-specific preferred package manager.
|
||||
* Saves to <projectDir>/.claude/package-manager.json.
|
||||
* @throws If pmName is not a known package manager
|
||||
*/
|
||||
export function setProjectPackageManager(pmName: PackageManagerName, projectDir?: string): { packageManager: string; setAt: string };
|
||||
|
||||
/**
|
||||
* Get package managers installed on the system.
|
||||
* WARNING: Spawns child processes for each PM check.
|
||||
* Do NOT call during session startup hooks.
|
||||
*/
|
||||
export function getAvailablePackageManagers(): PackageManagerName[];
|
||||
|
||||
/** Detect package manager from lock file in the given directory */
|
||||
export function detectFromLockFile(projectDir?: string): PackageManagerName | null;
|
||||
|
||||
/** Detect package manager from package.json `packageManager` field */
|
||||
export function detectFromPackageJson(projectDir?: string): PackageManagerName | null;
|
||||
|
||||
/**
|
||||
* Get the full command string to run a script.
|
||||
* @param script - Script name: "install", "test", "build", "dev", or custom
|
||||
*/
|
||||
export function getRunCommand(script: string, options?: GetPackageManagerOptions): string;
|
||||
|
||||
/**
|
||||
* Get the full command string to execute a package binary.
|
||||
* @param binary - Binary name (e.g., "prettier", "eslint")
|
||||
* @param args - Arguments to pass to the binary
|
||||
*/
|
||||
export function getExecCommand(binary: string, args?: string, options?: GetPackageManagerOptions): string;
|
||||
|
||||
/**
|
||||
* Get a message prompting the user to configure their package manager.
|
||||
* Does NOT spawn child processes.
|
||||
*/
|
||||
export function getSelectionPrompt(): string;
|
||||
|
||||
/**
|
||||
* Generate a regex pattern string that matches commands for all package managers.
|
||||
* @param action - Action like "dev", "install", "test", "build", or custom
|
||||
* @returns Parenthesized alternation regex string, e.g., "(npm run dev|pnpm( run)? dev|...)"
|
||||
*/
|
||||
export function getCommandPattern(action: string): string;
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Package Manager Detection and Selection
|
||||
* Automatically detects the preferred package manager or lets user choose
|
||||
*
|
||||
* Supports: npm, pnpm, yarn, bun
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { commandExists, getClaudeDir, readFile, writeFile } = require('./utils');
|
||||
|
||||
// Package manager definitions
|
||||
const PACKAGE_MANAGERS = {
|
||||
npm: {
|
||||
name: 'npm',
|
||||
lockFile: 'package-lock.json',
|
||||
installCmd: 'npm install',
|
||||
runCmd: 'npm run',
|
||||
execCmd: 'npx',
|
||||
testCmd: 'npm test',
|
||||
buildCmd: 'npm run build',
|
||||
devCmd: 'npm run dev'
|
||||
},
|
||||
pnpm: {
|
||||
name: 'pnpm',
|
||||
lockFile: 'pnpm-lock.yaml',
|
||||
installCmd: 'pnpm install',
|
||||
runCmd: 'pnpm',
|
||||
execCmd: 'pnpm dlx',
|
||||
testCmd: 'pnpm test',
|
||||
buildCmd: 'pnpm build',
|
||||
devCmd: 'pnpm dev'
|
||||
},
|
||||
yarn: {
|
||||
name: 'yarn',
|
||||
lockFile: 'yarn.lock',
|
||||
installCmd: 'yarn',
|
||||
runCmd: 'yarn',
|
||||
execCmd: 'yarn dlx',
|
||||
testCmd: 'yarn test',
|
||||
buildCmd: 'yarn build',
|
||||
devCmd: 'yarn dev'
|
||||
},
|
||||
bun: {
|
||||
name: 'bun',
|
||||
lockFile: 'bun.lockb',
|
||||
installCmd: 'bun install',
|
||||
runCmd: 'bun run',
|
||||
execCmd: 'bunx',
|
||||
testCmd: 'bun test',
|
||||
buildCmd: 'bun run build',
|
||||
devCmd: 'bun run dev'
|
||||
}
|
||||
};
|
||||
|
||||
// Priority order for detection
|
||||
const DETECTION_PRIORITY = ['pnpm', 'bun', 'yarn', 'npm'];
|
||||
|
||||
// Config file path
|
||||
function getConfigPath() {
|
||||
return path.join(getClaudeDir(), 'package-manager.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load saved package manager configuration
|
||||
*/
|
||||
function loadConfig() {
|
||||
const configPath = getConfigPath();
|
||||
const content = readFile(configPath);
|
||||
|
||||
if (content) {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save package manager configuration
|
||||
*/
|
||||
function saveConfig(config) {
|
||||
const configPath = getConfigPath();
|
||||
writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect package manager from lock file in project directory
|
||||
*/
|
||||
function detectFromLockFile(projectDir = process.cwd()) {
|
||||
for (const pmName of DETECTION_PRIORITY) {
|
||||
const pm = PACKAGE_MANAGERS[pmName];
|
||||
const lockFilePath = path.join(projectDir, pm.lockFile);
|
||||
|
||||
if (fs.existsSync(lockFilePath)) {
|
||||
return pmName;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect package manager from package.json packageManager field
|
||||
*/
|
||||
function detectFromPackageJson(projectDir = process.cwd()) {
|
||||
const packageJsonPath = path.join(projectDir, 'package.json');
|
||||
const content = readFile(packageJsonPath);
|
||||
|
||||
if (content) {
|
||||
try {
|
||||
const pkg = JSON.parse(content);
|
||||
if (pkg.packageManager) {
|
||||
// Format: "pnpm@8.6.0" or just "pnpm"
|
||||
const pmName = pkg.packageManager.split('@')[0];
|
||||
if (PACKAGE_MANAGERS[pmName]) {
|
||||
return pmName;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid package.json
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available package managers (installed on system)
|
||||
*
|
||||
* WARNING: This spawns child processes (where.exe on Windows, which on Unix)
|
||||
* for each package manager. Do NOT call this during session startup hooks —
|
||||
* it can exceed Bun's spawn limit on Windows and freeze the plugin.
|
||||
* Use detectFromLockFile() or detectFromPackageJson() for hot paths.
|
||||
*/
|
||||
function getAvailablePackageManagers() {
|
||||
const available = [];
|
||||
|
||||
for (const pmName of Object.keys(PACKAGE_MANAGERS)) {
|
||||
if (commandExists(pmName)) {
|
||||
available.push(pmName);
|
||||
}
|
||||
}
|
||||
|
||||
return available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the package manager to use for current project
|
||||
*
|
||||
* Detection priority:
|
||||
* 1. Environment variable CLAUDE_PACKAGE_MANAGER
|
||||
* 2. Project-specific config (in .claude/package-manager.json)
|
||||
* 3. package.json packageManager field
|
||||
* 4. Lock file detection
|
||||
* 5. Global user preference (in ~/.claude/package-manager.json)
|
||||
* 6. Default to npm (no child processes spawned)
|
||||
*
|
||||
* @param {object} options - Options
|
||||
* @param {string} options.projectDir - Project directory to detect from (default: cwd)
|
||||
* @returns {object} - { name, config, source }
|
||||
*/
|
||||
function getPackageManager(options = {}) {
|
||||
const { projectDir = process.cwd() } = options;
|
||||
|
||||
// 1. Check environment variable
|
||||
const envPm = process.env.CLAUDE_PACKAGE_MANAGER;
|
||||
if (envPm && PACKAGE_MANAGERS[envPm]) {
|
||||
return {
|
||||
name: envPm,
|
||||
config: PACKAGE_MANAGERS[envPm],
|
||||
source: 'environment'
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Check project-specific config
|
||||
const projectConfigPath = path.join(projectDir, '.claude', 'package-manager.json');
|
||||
const projectConfig = readFile(projectConfigPath);
|
||||
if (projectConfig) {
|
||||
try {
|
||||
const config = JSON.parse(projectConfig);
|
||||
if (config.packageManager && PACKAGE_MANAGERS[config.packageManager]) {
|
||||
return {
|
||||
name: config.packageManager,
|
||||
config: PACKAGE_MANAGERS[config.packageManager],
|
||||
source: 'project-config'
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Invalid config
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check package.json packageManager field
|
||||
const fromPackageJson = detectFromPackageJson(projectDir);
|
||||
if (fromPackageJson) {
|
||||
return {
|
||||
name: fromPackageJson,
|
||||
config: PACKAGE_MANAGERS[fromPackageJson],
|
||||
source: 'package.json'
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Check lock file
|
||||
const fromLockFile = detectFromLockFile(projectDir);
|
||||
if (fromLockFile) {
|
||||
return {
|
||||
name: fromLockFile,
|
||||
config: PACKAGE_MANAGERS[fromLockFile],
|
||||
source: 'lock-file'
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Check global user preference
|
||||
const globalConfig = loadConfig();
|
||||
if (globalConfig && globalConfig.packageManager && PACKAGE_MANAGERS[globalConfig.packageManager]) {
|
||||
return {
|
||||
name: globalConfig.packageManager,
|
||||
config: PACKAGE_MANAGERS[globalConfig.packageManager],
|
||||
source: 'global-config'
|
||||
};
|
||||
}
|
||||
|
||||
// 6. Default to npm (always available with Node.js)
|
||||
// NOTE: Previously this called getAvailablePackageManagers() which spawns
|
||||
// child processes (where.exe/which) for each PM. This caused plugin freezes
|
||||
// on Windows (see #162) because session-start hooks run during Bun init,
|
||||
// and the spawned processes exceed Bun's spawn limit.
|
||||
// Steps 1-5 already cover all config-based and file-based detection.
|
||||
// If none matched, npm is the safe default.
|
||||
return {
|
||||
name: 'npm',
|
||||
config: PACKAGE_MANAGERS.npm,
|
||||
source: 'default'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user's preferred package manager (global)
|
||||
*/
|
||||
function setPreferredPackageManager(pmName) {
|
||||
if (!PACKAGE_MANAGERS[pmName]) {
|
||||
throw new Error(`Unknown package manager: ${pmName}`);
|
||||
}
|
||||
|
||||
const config = loadConfig() || {};
|
||||
config.packageManager = pmName;
|
||||
config.setAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
saveConfig(config);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to save package manager preference: ${err.message}`);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set project's preferred package manager
|
||||
*/
|
||||
function setProjectPackageManager(pmName, projectDir = process.cwd()) {
|
||||
if (!PACKAGE_MANAGERS[pmName]) {
|
||||
throw new Error(`Unknown package manager: ${pmName}`);
|
||||
}
|
||||
|
||||
const configDir = path.join(projectDir, '.claude');
|
||||
const configPath = path.join(configDir, 'package-manager.json');
|
||||
|
||||
const config = {
|
||||
packageManager: pmName,
|
||||
setAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
try {
|
||||
writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to save package manager config to ${configPath}: ${err.message}`);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
// Allowed characters in script/binary names: alphanumeric, dash, underscore, dot, slash, @
|
||||
// This prevents shell metacharacter injection while allowing scoped packages (e.g., @scope/pkg)
|
||||
const SAFE_NAME_REGEX = /^[@a-zA-Z0-9_./-]+$/;
|
||||
|
||||
/**
|
||||
* Get the command to run a script
|
||||
* @param {string} script - Script name (e.g., "dev", "build", "test")
|
||||
* @param {object} options - { projectDir }
|
||||
* @throws {Error} If script name contains unsafe characters
|
||||
*/
|
||||
function getRunCommand(script, options = {}) {
|
||||
if (!script || typeof script !== 'string') {
|
||||
throw new Error('Script name must be a non-empty string');
|
||||
}
|
||||
if (!SAFE_NAME_REGEX.test(script)) {
|
||||
throw new Error(`Script name contains unsafe characters: ${script}`);
|
||||
}
|
||||
|
||||
const pm = getPackageManager(options);
|
||||
|
||||
switch (script) {
|
||||
case 'install':
|
||||
return pm.config.installCmd;
|
||||
case 'test':
|
||||
return pm.config.testCmd;
|
||||
case 'build':
|
||||
return pm.config.buildCmd;
|
||||
case 'dev':
|
||||
return pm.config.devCmd;
|
||||
default:
|
||||
return `${pm.config.runCmd} ${script}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Allowed characters in arguments: alphanumeric, whitespace, dashes, dots, slashes,
|
||||
// equals, colons, commas, quotes, @. Rejects shell metacharacters like ; | & ` $ ( ) { } < > !
|
||||
const SAFE_ARGS_REGEX = /^[@a-zA-Z0-9\s_./:=,'"*+-]+$/;
|
||||
|
||||
/**
|
||||
* Get the command to execute a package binary
|
||||
* @param {string} binary - Binary name (e.g., "prettier", "eslint")
|
||||
* @param {string} args - Arguments to pass
|
||||
* @throws {Error} If binary name or args contain unsafe characters
|
||||
*/
|
||||
function getExecCommand(binary, args = '', options = {}) {
|
||||
if (!binary || typeof binary !== 'string') {
|
||||
throw new Error('Binary name must be a non-empty string');
|
||||
}
|
||||
if (!SAFE_NAME_REGEX.test(binary)) {
|
||||
throw new Error(`Binary name contains unsafe characters: ${binary}`);
|
||||
}
|
||||
if (args && typeof args === 'string' && !SAFE_ARGS_REGEX.test(args)) {
|
||||
throw new Error(`Arguments contain unsafe characters: ${args}`);
|
||||
}
|
||||
|
||||
const pm = getPackageManager(options);
|
||||
return `${pm.config.execCmd} ${binary}${args ? ' ' + args : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive prompt for package manager selection
|
||||
* Returns a message for Claude to show to user
|
||||
*
|
||||
* NOTE: Does NOT spawn child processes to check availability.
|
||||
* Lists all supported PMs and shows how to configure preference.
|
||||
*/
|
||||
function getSelectionPrompt() {
|
||||
let message = '[PackageManager] No package manager preference detected.\n';
|
||||
message += 'Supported package managers: ' + Object.keys(PACKAGE_MANAGERS).join(', ') + '\n';
|
||||
message += '\nTo set your preferred package manager:\n';
|
||||
message += ' - Global: Set CLAUDE_PACKAGE_MANAGER environment variable\n';
|
||||
message += ' - Or add to ~/.claude/package-manager.json: {"packageManager": "pnpm"}\n';
|
||||
message += ' - Or add to package.json: {"packageManager": "pnpm@8"}\n';
|
||||
message += ' - Or add a lock file to your project (e.g., pnpm-lock.yaml)\n';
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
// Escape regex metacharacters in a string before interpolating into a pattern
|
||||
function escapeRegex(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a regex pattern that matches commands for all package managers
|
||||
* @param {string} action - Action pattern (e.g., "run dev", "install", "test")
|
||||
*/
|
||||
function getCommandPattern(action) {
|
||||
const patterns = [];
|
||||
|
||||
// Trim spaces from action to handle leading/trailing whitespace gracefully
|
||||
const trimmedAction = action.trim();
|
||||
|
||||
if (trimmedAction === 'dev') {
|
||||
patterns.push(
|
||||
'npm run dev',
|
||||
'pnpm( run)? dev',
|
||||
'yarn dev',
|
||||
'bun run dev'
|
||||
);
|
||||
} else if (trimmedAction === 'install') {
|
||||
patterns.push(
|
||||
'npm install',
|
||||
'pnpm install',
|
||||
'yarn( install)?',
|
||||
'bun install'
|
||||
);
|
||||
} else if (trimmedAction === 'test') {
|
||||
patterns.push(
|
||||
'npm test',
|
||||
'pnpm test',
|
||||
'yarn test',
|
||||
'bun test'
|
||||
);
|
||||
} else if (trimmedAction === 'build') {
|
||||
patterns.push(
|
||||
'npm run build',
|
||||
'pnpm( run)? build',
|
||||
'yarn build',
|
||||
'bun run build'
|
||||
);
|
||||
} else {
|
||||
// Generic run command — escape regex metacharacters in action
|
||||
const escaped = escapeRegex(trimmedAction);
|
||||
patterns.push(
|
||||
`npm run ${escaped}`,
|
||||
`pnpm( run)? ${escaped}`,
|
||||
`yarn ${escaped}`,
|
||||
`bun run ${escaped}`
|
||||
);
|
||||
}
|
||||
|
||||
return `(${patterns.join('|')})`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PACKAGE_MANAGERS,
|
||||
DETECTION_PRIORITY,
|
||||
getPackageManager,
|
||||
setPreferredPackageManager,
|
||||
setProjectPackageManager,
|
||||
getAvailablePackageManagers,
|
||||
detectFromLockFile,
|
||||
detectFromPackageJson,
|
||||
getRunCommand,
|
||||
getExecCommand,
|
||||
getSelectionPrompt,
|
||||
getCommandPattern
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Path containment helpers for install-state-driven file operations.
|
||||
*
|
||||
* Install-state files are project-local and therefore attacker-controllable
|
||||
* (a cloned/forked repo can ship a crafted `.cursor/ecc-install-state.json`).
|
||||
* `repair`/`uninstall`/`auto-update` replay recorded operations, so every
|
||||
* write/delete destination MUST be confined to the adapter-derived trusted
|
||||
* root — never trusted from the state file itself (GHSA-hfpv-w6mp-5g95).
|
||||
*/
|
||||
|
||||
function safeRealpath(target) {
|
||||
try {
|
||||
return fs.realpathSync(path.resolve(target));
|
||||
} catch {
|
||||
return path.resolve(target);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a path that may not exist yet: realpath its nearest existing
|
||||
* ancestor, then re-append the missing tail. This defeats symlink escapes
|
||||
* where an intermediate directory is a symlink pointing out of the root.
|
||||
*/
|
||||
function realpathNearestExisting(target) {
|
||||
let current = path.resolve(target);
|
||||
const tail = [];
|
||||
while (!fs.existsSync(current)) {
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
tail.unshift(path.basename(current));
|
||||
current = parent;
|
||||
}
|
||||
const real = safeRealpath(current);
|
||||
return tail.length > 0 ? path.join(real, ...tail) : real;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `target` resolves to `root` itself or a path beneath it, with
|
||||
* symlinks resolved on both sides.
|
||||
*/
|
||||
function isWithinRoot(target, root) {
|
||||
if (!root) {
|
||||
return false;
|
||||
}
|
||||
const realRoot = safeRealpath(root);
|
||||
const realTarget = realpathNearestExisting(target);
|
||||
if (realTarget === realRoot) {
|
||||
return true;
|
||||
}
|
||||
const rel = path.relative(realRoot, realTarget);
|
||||
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-closed guard: throw unless `target` is contained within `root`.
|
||||
* Returns the canonicalized target path on success.
|
||||
*/
|
||||
function assertWithinTrustedRoot(target, root, action = 'write') {
|
||||
if (!target || typeof target !== 'string') {
|
||||
throw new Error(`Refusing to ${action}: missing destination path.`);
|
||||
}
|
||||
if (!root) {
|
||||
throw new Error(`Refusing to ${action} '${target}': no trusted install root resolved.`);
|
||||
}
|
||||
if (!isWithinRoot(target, root)) {
|
||||
throw new Error(`Refusing to ${action} outside the install root: '${target}' is not within '${root}'.`);
|
||||
}
|
||||
return realpathNearestExisting(target);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
realpathNearestExisting,
|
||||
isWithinRoot,
|
||||
assertWithinTrustedRoot
|
||||
};
|
||||
@@ -0,0 +1,277 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Minimal GitHub-flavored-markdown subset renderer for Plan Canvas.
|
||||
* Renders .claude/plans/*.plan.md artifacts to HTML body content.
|
||||
*
|
||||
* Security model: the entire source line is HTML-escaped before any inline
|
||||
* rule runs, so raw HTML in the markdown always displays as text. Link and
|
||||
* image URLs are validated against an allowlist of protocols.
|
||||
*/
|
||||
|
||||
// Placeholders live in the Unicode private-use area so escaped output can
|
||||
// never collide with them. Pre-existing occurrences are stripped from input.
|
||||
const TOKEN_OPEN = '\uE000';
|
||||
const TOKEN_CLOSE = '\uE001';
|
||||
const TOKEN_RE = new RegExp(TOKEN_OPEN + '(\\d+)' + TOKEN_CLOSE, 'g');
|
||||
const STRIP_RE = new RegExp('[' + TOKEN_OPEN + TOKEN_CLOSE + ']', 'g');
|
||||
|
||||
const LIST_ITEM_RE = /^(\s*)([-*]|\d+\.)\s+(.*)$/;
|
||||
const HR_RE = /^ {0,3}(-{3,}|\*{3,})\s*$/;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function slugify(text) {
|
||||
return String(text ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/[\s-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
// Strip whitespace/control characters so "Ja vaScript:" style tricks cannot
|
||||
// hide a scheme, then classify against the allowlist.
|
||||
function classifyUrl(rawUrl) {
|
||||
const compact = String(rawUrl)
|
||||
.split('')
|
||||
.filter((ch) => ch.charCodeAt(0) > 32)
|
||||
.join('')
|
||||
.toLowerCase();
|
||||
if (compact.startsWith('#')) return 'anchor';
|
||||
if (compact.startsWith('//')) return 'blocked';
|
||||
const scheme = compact.match(/^[a-z][a-z0-9+.-]*:/);
|
||||
if (!scheme) return 'relative';
|
||||
if (scheme[0] === 'http:' || scheme[0] === 'https:') return 'http';
|
||||
if (scheme[0] === 'mailto:') return 'mailto';
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
function applyEmphasis(s) {
|
||||
return s
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/~~([^~]+)~~/g, '<del>$1</del>')
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
.replace(/(^|[^\w])_([^_]+)_(?!\w)/g, '$1<em>$2</em>');
|
||||
}
|
||||
|
||||
function renderInline(rawText) {
|
||||
const tokens = [];
|
||||
const stash = (html) => {
|
||||
tokens.push(html);
|
||||
return TOKEN_OPEN + (tokens.length - 1) + TOKEN_CLOSE;
|
||||
};
|
||||
|
||||
let s = escapeHtml(rawText);
|
||||
|
||||
// Code spans first: contents stay escaped and opt out of all other rules.
|
||||
s = s.replace(/`([^`]+)`/g, (_m, code) => stash('<code>' + code + '</code>'));
|
||||
|
||||
s = s.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, (_m, alt, src) => {
|
||||
const kind = classifyUrl(src);
|
||||
if (kind !== 'http' && kind !== 'relative') return alt;
|
||||
return stash('<img src="' + src.trim() + '" alt="' + alt + '">');
|
||||
});
|
||||
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)]*)\)/g, (_m, label, url) => {
|
||||
const kind = classifyUrl(url);
|
||||
const text = applyEmphasis(label);
|
||||
if (kind === 'blocked') return text;
|
||||
const extra = kind === 'http' ? ' target="_blank" rel="noopener"' : '';
|
||||
return stash('<a href="' + url.trim() + '"' + extra + '>' + text + '</a>');
|
||||
});
|
||||
|
||||
s = applyEmphasis(s);
|
||||
|
||||
// Stashed anchors may hold code-span tokens, so resolve until none remain.
|
||||
while (s.includes(TOKEN_OPEN)) {
|
||||
s = s.replace(TOKEN_RE, (_m, idx) => tokens[Number(idx)]);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function splitTableRow(line) {
|
||||
let s = line.trim();
|
||||
if (s.startsWith('|')) s = s.slice(1);
|
||||
if (s.endsWith('|') && !s.endsWith('\\|')) s = s.slice(0, -1);
|
||||
return s
|
||||
.replace(/\\\|/g, TOKEN_OPEN)
|
||||
.split('|')
|
||||
.map((cell) => cell.split(TOKEN_OPEN).join('|').trim());
|
||||
}
|
||||
|
||||
function isAlignmentRow(line) {
|
||||
if (!line || !line.includes('|')) return false;
|
||||
const cells = splitTableRow(line);
|
||||
return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell));
|
||||
}
|
||||
|
||||
function cellAlign(spec) {
|
||||
const left = spec.startsWith(':');
|
||||
const right = spec.endsWith(':');
|
||||
if (left && right) return 'center';
|
||||
if (right) return 'right';
|
||||
if (left) return 'left';
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderListItem(text) {
|
||||
const task = text.match(/^\[([ xX])\]\s+(.*)$/);
|
||||
if (task) {
|
||||
const checked = task[1].trim() ? ' checked' : '';
|
||||
return '<li class="task"><input type="checkbox" disabled' + checked + '> ' +
|
||||
renderInline(task[2]) + '</li>';
|
||||
}
|
||||
return '<li>' + renderInline(text) + '</li>';
|
||||
}
|
||||
|
||||
function buildList(items, start, indent) {
|
||||
const tag = /^\d/.test(items[start].marker) ? 'ol' : 'ul';
|
||||
const parts = [];
|
||||
let i = start;
|
||||
while (i < items.length && items[i].indent >= indent) {
|
||||
if (items[i].indent > indent) {
|
||||
// Deeper item: nest a sublist inside the previous <li>
|
||||
const nested = buildList(items, i, items[i].indent);
|
||||
if (parts.length > 0) {
|
||||
const last = parts.pop();
|
||||
parts.push(last.replace(/<\/li>$/, '\n' + nested.html + '\n</li>'));
|
||||
} else {
|
||||
parts.push('<li>\n' + nested.html + '\n</li>');
|
||||
}
|
||||
i = nested.end;
|
||||
} else {
|
||||
parts.push(renderListItem(items[i].text));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return { html: '<' + tag + '>\n' + parts.join('\n') + '\n</' + tag + '>', end: i };
|
||||
}
|
||||
|
||||
function startsBlock(line, nextLine) {
|
||||
return /^```/.test(line) ||
|
||||
/^#{1,6}\s/.test(line) ||
|
||||
HR_RE.test(line) ||
|
||||
/^ {0,3}>/.test(line) ||
|
||||
LIST_ITEM_RE.test(line) ||
|
||||
(line.includes('|') && isAlignmentRow(nextLine || ''));
|
||||
}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
if (!text) return '';
|
||||
const lines = String(text)
|
||||
.replace(STRIP_RE, '')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split('\n');
|
||||
const out = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
if (!line.trim()) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fence = line.match(/^```(.*)$/);
|
||||
if (fence) {
|
||||
const lang = fence[1].trim().split(/\s+/)[0].toLowerCase().replace(/[^a-z0-9-]/g, '');
|
||||
const body = [];
|
||||
i += 1;
|
||||
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
|
||||
body.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
i += 1; // skip closing fence (or run off EOF)
|
||||
if (lang === 'mermaid') {
|
||||
// Mermaid reads the element's textContent, and the browser decodes
|
||||
// character references there — so escaping keeps `-->`/`<` intact for
|
||||
// the renderer while preventing HTML injection or a </pre> breakout.
|
||||
out.push('<pre class="mermaid">' + escapeHtml(body.join('\n')) + '</pre>');
|
||||
continue;
|
||||
}
|
||||
const cls = lang ? ' class="language-' + lang + '"' : '';
|
||||
out.push('<pre><code' + cls + '>' + escapeHtml(body.join('\n')) + '</code></pre>');
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = line.match(/^(#{1,6})\s+(.+?)\s*$/);
|
||||
if (heading) {
|
||||
const level = heading[1].length;
|
||||
out.push('<h' + level + ' id="' + slugify(heading[2]) + '">' +
|
||||
renderInline(heading[2]) + '</h' + level + '>');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule (alignment rows never reach here: tables consume them)
|
||||
if (HR_RE.test(line)) {
|
||||
out.push('<hr>');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote: strip one `>` level and recurse, which handles nesting
|
||||
if (/^ {0,3}>/.test(line)) {
|
||||
const inner = [];
|
||||
while (i < lines.length && /^ {0,3}>/.test(lines[i])) {
|
||||
inner.push(lines[i].replace(/^ {0,3}> ?/, ''));
|
||||
i += 1;
|
||||
}
|
||||
out.push('<blockquote>\n' + renderMarkdown(inner.join('\n')) + '\n</blockquote>');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table: header row followed by an alignment row
|
||||
if (line.includes('|') && isAlignmentRow(lines[i + 1] || '')) {
|
||||
const aligns = splitTableRow(lines[i + 1]).map(cellAlign);
|
||||
const row = (tag, cells) => '<tr>' + cells.map((cell, idx) => {
|
||||
const style = aligns[idx] ? ' style="text-align:' + aligns[idx] + '"' : '';
|
||||
return '<' + tag + style + '>' + renderInline(cell) + '</' + tag + '>';
|
||||
}).join('') + '</tr>';
|
||||
const head = row('th', splitTableRow(line));
|
||||
const body = [];
|
||||
i += 2;
|
||||
while (i < lines.length && lines[i].trim() && lines[i].includes('|')) {
|
||||
body.push(row('td', splitTableRow(lines[i])));
|
||||
i += 1;
|
||||
}
|
||||
out.push('<table>\n<thead>\n' + head + '\n</thead>\n<tbody>\n' +
|
||||
body.join('\n') + '\n</tbody>\n</table>');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (LIST_ITEM_RE.test(line)) {
|
||||
const items = [];
|
||||
while (i < lines.length) {
|
||||
const m = lines[i].match(LIST_ITEM_RE);
|
||||
if (!m) break;
|
||||
items.push({ indent: m[1].length, marker: m[2], text: m[3] });
|
||||
i += 1;
|
||||
}
|
||||
out.push(buildList(items, 0, items[0].indent).html);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph: run of plain lines up to a blank line or block start
|
||||
const para = [line.trim()];
|
||||
i += 1;
|
||||
while (i < lines.length && lines[i].trim() && !startsBlock(lines[i], lines[i + 1])) {
|
||||
para.push(lines[i].trim());
|
||||
i += 1;
|
||||
}
|
||||
out.push('<p>' + renderInline(para.join('\n')) + '</p>');
|
||||
}
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
module.exports = { renderMarkdown, escapeHtml, slugify };
|
||||
@@ -0,0 +1,237 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Plan Canvas artifact SDK — the script injected into the reviewed artifact.
|
||||
*
|
||||
* The artifact runs in a sandboxed iframe without allow-same-origin, so this
|
||||
* script can only talk to the chrome via postMessage. It renders all of its
|
||||
* own UI inside a shadow root so it never annotates itself and never leaks
|
||||
* styles into the artifact.
|
||||
*/
|
||||
|
||||
function artifactSdkJs() {
|
||||
return `'use strict';
|
||||
(() => {
|
||||
if (window.parent === window) return; // only meaningful inside the canvas
|
||||
if (window.__eccPlanCanvasSdk) return;
|
||||
window.__eccPlanCanvasSdk = true;
|
||||
|
||||
let annotate = true;
|
||||
let card = null;
|
||||
|
||||
const post = msg => window.parent.postMessage(msg, '*');
|
||||
|
||||
// --- shadow-root UI host --------------------------------------------
|
||||
const host = document.createElement('div');
|
||||
host.setAttribute('data-ecc-plan-canvas', 'ui');
|
||||
host.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;z-index:2147483647';
|
||||
const root = host.attachShadow({ mode: 'open' });
|
||||
root.innerHTML = \`
|
||||
<style>
|
||||
:host{all:initial}
|
||||
.hl{position:fixed;pointer-events:none;border:1.5px solid #6885e8;background:rgba(104,133,232,0.12);border-radius:4px;display:none;z-index:2147483646;transition:all .06s ease-out}
|
||||
.selhint{position:absolute;display:none;z-index:2147483647;background:#101218;color:#dfe2e9;border:1px solid #272c3e;border-radius:6px;padding:4px 10px;font:600 11.5px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;cursor:pointer;box-shadow:0 8px 32px rgba(0,0,0,0.6)}
|
||||
.selhint:hover{border-color:#6885e8}
|
||||
.card{position:absolute;display:none;z-index:2147483647;width:300px;background:#101218;border:1px solid #272c3e;border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.6);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#dfe2e9}
|
||||
.card h4{margin:0;padding:10px 12px 0;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:#80859a}
|
||||
.card .snippet{padding:4px 12px 0;font:10.5px 'SF Mono','Fira Code',monospace;color:#4acbbe;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.card textarea{display:block;width:calc(100% - 24px);margin:8px 12px;min-height:56px;resize:vertical;background:#13161e;border:1px solid #1d2130;border-radius:6px;color:#dfe2e9;font:12.5px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:7px 9px;outline:none;box-sizing:border-box}
|
||||
.card textarea:focus{border-color:#6885e8}
|
||||
.card .row{display:flex;justify-content:flex-end;gap:8px;padding:0 12px 12px}
|
||||
.card button{border-radius:6px;font:600 11.5px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:5px 12px;cursor:pointer}
|
||||
.card .cancel{background:none;border:1px solid #1d2130;color:#80859a}
|
||||
.card .cancel:hover{color:#dfe2e9;border-color:#272c3e}
|
||||
.card .queue{background:#6885e8;border:1px solid #6885e8;color:#fff}
|
||||
.card .queue:hover{background:#3d5ab8}
|
||||
.card .keys{padding:0 12px 10px;font-size:9.5px;color:#4c5168}
|
||||
</style>
|
||||
<div class="hl"></div>
|
||||
<button class="selhint" type="button">Annotate selection</button>
|
||||
<div class="card">
|
||||
<h4></h4>
|
||||
<div class="snippet"></div>
|
||||
<textarea placeholder="What should change here?"></textarea>
|
||||
<div class="row">
|
||||
<button class="cancel" type="button">Cancel</button>
|
||||
<button class="queue" type="button">Queue</button>
|
||||
</div>
|
||||
<div class="keys">Enter to queue · Cmd/Ctrl+Enter to queue & send</div>
|
||||
</div>\`;
|
||||
const attach = () => document.body ? document.body.appendChild(host) : null;
|
||||
if (document.body) attach();
|
||||
else document.addEventListener('DOMContentLoaded', attach);
|
||||
|
||||
const hl = root.querySelector('.hl');
|
||||
const selhint = root.querySelector('.selhint');
|
||||
const cardEl = root.querySelector('.card');
|
||||
const cardTitle = cardEl.querySelector('h4');
|
||||
const cardSnippet = cardEl.querySelector('.snippet');
|
||||
const cardText = cardEl.querySelector('textarea');
|
||||
|
||||
// --- selectors & context ---------------------------------------------
|
||||
const esc = v => (window.CSS && CSS.escape) ? CSS.escape(v) : v.replace(/[^a-zA-Z0-9_-]/g, '\\\\$&');
|
||||
function selectorFor(el) {
|
||||
const parts = [];
|
||||
let node = el;
|
||||
for (let depth = 0; node && node.nodeType === 1 && depth < 6; depth++) {
|
||||
if (node.id) { parts.unshift('#' + esc(node.id)); return parts.join(' > '); }
|
||||
const tag = node.tagName.toLowerCase();
|
||||
if (tag === 'body' || tag === 'html') { parts.unshift(tag); break; }
|
||||
let nth = 1;
|
||||
let sib = node;
|
||||
while ((sib = sib.previousElementSibling)) if (sib.tagName === node.tagName) nth++;
|
||||
parts.unshift(tag + ':nth-of-type(' + nth + ')');
|
||||
node = node.parentElement;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
}
|
||||
function snippetFor(el) {
|
||||
return (el.innerText || el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 200);
|
||||
}
|
||||
const INTERACTIVE = new Set(['button', 'input', 'select', 'textarea', 'option', 'label', 'summary', 'a']);
|
||||
function isInteractive(el) {
|
||||
let node = el;
|
||||
while (node && node.nodeType === 1) {
|
||||
if (INTERACTIVE.has(node.tagName.toLowerCase()) || node.isContentEditable) return true;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const isOurs = el => el === host || host.contains(el);
|
||||
|
||||
// --- annotation card ---------------------------------------------------
|
||||
function openCard(target) {
|
||||
card = target;
|
||||
cardTitle.textContent = target.kindLabel;
|
||||
cardSnippet.textContent = target.anchor.snippet || target.anchor.selector;
|
||||
cardText.value = '';
|
||||
cardEl.style.display = 'block';
|
||||
const x = Math.min(target.x, window.innerWidth - 320) + window.scrollX;
|
||||
const y = target.y + 12 + window.scrollY;
|
||||
cardEl.style.left = Math.max(8, x) + 'px';
|
||||
cardEl.style.top = y + 'px';
|
||||
cardText.focus();
|
||||
}
|
||||
function closeCard() {
|
||||
card = null;
|
||||
cardEl.style.display = 'none';
|
||||
}
|
||||
function queueCard(sendNow) {
|
||||
if (!card) return;
|
||||
const text = cardText.value.trim();
|
||||
if (!text) { cardText.focus(); return; }
|
||||
post({
|
||||
type: sendNow ? 'pc:queue-and-send' : 'pc:queue',
|
||||
item: { kind: 'annotation', text, anchor: card.anchor }
|
||||
});
|
||||
closeCard();
|
||||
}
|
||||
cardEl.querySelector('.cancel').addEventListener('click', closeCard);
|
||||
cardEl.querySelector('.queue').addEventListener('click', () => queueCard(false));
|
||||
cardText.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); queueCard(true); }
|
||||
else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); queueCard(false); }
|
||||
else if (e.key === 'Escape') closeCard();
|
||||
});
|
||||
|
||||
// --- element hover / click ---------------------------------------------
|
||||
document.addEventListener('mousemove', e => {
|
||||
if (!annotate || card) { hl.style.display = 'none'; return; }
|
||||
const el = e.target;
|
||||
if (!el || isOurs(el) || el === document.body || el === document.documentElement || isInteractive(el)) {
|
||||
hl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
hl.style.display = 'block';
|
||||
hl.style.left = rect.left - 2 + 'px';
|
||||
hl.style.top = rect.top - 2 + 'px';
|
||||
hl.style.width = rect.width + 'px';
|
||||
hl.style.height = rect.height + 'px';
|
||||
}, true);
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
if (!annotate) return;
|
||||
const el = e.target;
|
||||
if (isOurs(el)) return;
|
||||
if (card) { if (!cardEl.contains(e.composedPath()[0])) closeCard(); return; }
|
||||
if (isInteractive(el)) return; // let controls behave natively
|
||||
const selection = window.getSelection();
|
||||
if (selection && !selection.isCollapsed) return; // handled by selection flow
|
||||
if (el === document.body || el === document.documentElement) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
hl.style.display = 'none';
|
||||
openCard({
|
||||
kindLabel: 'Annotate <' + el.tagName.toLowerCase() + '>',
|
||||
anchor: { selector: selectorFor(el), tag: el.tagName.toLowerCase(), snippet: snippetFor(el) },
|
||||
x: e.clientX,
|
||||
y: e.clientY
|
||||
});
|
||||
}, true);
|
||||
|
||||
// --- text selection -------------------------------------------------------
|
||||
document.addEventListener('mouseup', e => {
|
||||
if (!annotate || card || isOurs(e.target)) return;
|
||||
setTimeout(() => {
|
||||
const selection = window.getSelection();
|
||||
const text = selection ? String(selection).replace(/\\s+/g, ' ').trim() : '';
|
||||
if (!text || !selection.rangeCount) { selhint.style.display = 'none'; return; }
|
||||
const rect = selection.getRangeAt(0).getBoundingClientRect();
|
||||
selhint.style.display = 'block';
|
||||
selhint.style.left = rect.left + window.scrollX + 'px';
|
||||
selhint.style.top = rect.bottom + 6 + window.scrollY + 'px';
|
||||
selhint.onclick = () => {
|
||||
selhint.style.display = 'none';
|
||||
const anchorNode = selection.anchorNode;
|
||||
const el = anchorNode && anchorNode.nodeType === 1 ? anchorNode : anchorNode && anchorNode.parentElement;
|
||||
openCard({
|
||||
kindLabel: 'Annotate selection',
|
||||
anchor: {
|
||||
selector: el ? selectorFor(el) : 'body',
|
||||
tag: 'text',
|
||||
snippet: text.slice(0, 200),
|
||||
textRange: { text: text.slice(0, 1000) }
|
||||
},
|
||||
x: rect.left,
|
||||
y: rect.bottom
|
||||
});
|
||||
};
|
||||
}, 0);
|
||||
}, true);
|
||||
document.addEventListener('selectionchange', () => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed) selhint.style.display = 'none';
|
||||
});
|
||||
|
||||
// --- chrome bridge ---------------------------------------------------------
|
||||
window.addEventListener('message', e => {
|
||||
const msg = e.data || {};
|
||||
if (msg.type === 'pc:set-mode') {
|
||||
annotate = Boolean(msg.annotate);
|
||||
if (!annotate) { hl.style.display = 'none'; selhint.style.display = 'none'; closeCard(); }
|
||||
} else if (msg.type === 'pc:restore-scroll') {
|
||||
window.scrollTo(msg.x || 0, msg.y || 0);
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', e => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'i') {
|
||||
e.preventDefault();
|
||||
post({ type: 'pc:toggle-mode' });
|
||||
} else if (e.key === 'Escape' && card) closeCard();
|
||||
}, true);
|
||||
|
||||
let scrollTimer = null;
|
||||
window.addEventListener('scroll', () => {
|
||||
if (scrollTimer) return;
|
||||
scrollTimer = setTimeout(() => {
|
||||
scrollTimer = null;
|
||||
post({ type: 'pc:scroll', x: window.scrollX, y: window.scrollY });
|
||||
}, 150);
|
||||
}, { passive: true });
|
||||
|
||||
post({ type: 'pc:ready' });
|
||||
})();`;
|
||||
}
|
||||
|
||||
module.exports = { artifactSdkJs };
|
||||
@@ -0,0 +1,532 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Plan Canvas loopback server.
|
||||
*
|
||||
* One detached process serves every open review session: the browser chrome,
|
||||
* the rendered artifact, an SSE stream for live updates, and the long-poll
|
||||
* endpoint agents block on. Sessions are keyed by canonical artifact path
|
||||
* (see sessions.js).
|
||||
*/
|
||||
|
||||
const { EventEmitter } = require('events');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
|
||||
const { buildAllowedHostnames, isAllowedHostHeader, isAllowedOrigin } = require('../loopback-guard');
|
||||
const { renderMarkdown } = require('./markdown');
|
||||
const { artifactSdkJs } = require('./sdk');
|
||||
const {
|
||||
canvasCss,
|
||||
canvasClientJs,
|
||||
renderCanvasHtml,
|
||||
renderMarkdownArtifactHtml,
|
||||
renderSessionListHtml
|
||||
} = require('./ui');
|
||||
|
||||
const DEFAULT_PORT = 4517;
|
||||
const DEFAULT_HOST = '127.0.0.1';
|
||||
const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const MAX_BODY_BYTES = 1024 * 1024;
|
||||
|
||||
const CONTENT_TYPES = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.gif': 'image/gif',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.md': 'text/plain; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ttf': 'font/ttf',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2'
|
||||
};
|
||||
|
||||
function resolvePort(env = process.env) {
|
||||
const value = Number.parseInt(env.ECC_PLAN_CANVAS_PORT || '', 10);
|
||||
return Number.isInteger(value) && value >= 0 && value <= 65535 ? value : DEFAULT_PORT;
|
||||
}
|
||||
|
||||
function resolveIdleTimeoutMs(env = process.env) {
|
||||
const raw = String(env.ECC_PLAN_CANVAS_IDLE_MS || '').trim().toLowerCase();
|
||||
if (raw === '0' || raw === 'off') return 0;
|
||||
const value = Number.parseInt(raw, 10);
|
||||
return Number.isInteger(value) && value > 0 ? value : DEFAULT_IDLE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function readJsonBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
req.on('data', chunk => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) {
|
||||
reject(new Error('body too large'));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (chunks.length === 0) return resolve({});
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
||||
} catch {
|
||||
reject(new Error('invalid JSON body'));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, payload) {
|
||||
const body = JSON.stringify(payload);
|
||||
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function sendHtml(res, statusCode, html, { csp = true } = {}) {
|
||||
const headers = { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' };
|
||||
if (csp) {
|
||||
headers['content-security-policy'] =
|
||||
"default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self'";
|
||||
}
|
||||
res.writeHead(statusCode, headers);
|
||||
res.end(html);
|
||||
}
|
||||
|
||||
function createPlanCanvasServer({
|
||||
store,
|
||||
host = DEFAULT_HOST,
|
||||
version = '0.0.0',
|
||||
idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
||||
heartbeatMs = 15000,
|
||||
onIdleShutdown = null,
|
||||
log = () => {}
|
||||
} = {}) {
|
||||
if (!store) throw new Error('createPlanCanvasServer requires a session store');
|
||||
|
||||
const allowedHostnames = buildAllowedHostnames(host);
|
||||
const wake = new EventEmitter();
|
||||
wake.setMaxListeners(0);
|
||||
const sseClients = new Map(); // key -> Set<res>
|
||||
const awaitCounts = new Map(); // key -> active long-poll count
|
||||
const workingKeys = new Set(); // keys whose agent took feedback and is off working
|
||||
const watchers = new Map(); // key -> fs.FSWatcher
|
||||
let idleTimer = null;
|
||||
let closed = false;
|
||||
|
||||
// --- presence + SSE ---------------------------------------------------
|
||||
|
||||
function presenceFor(key) {
|
||||
const session = store.get(key);
|
||||
if (!session || session.status === 'ended') return 'ended';
|
||||
if ((awaitCounts.get(key) || 0) > 0) return 'listening';
|
||||
return workingKeys.has(key) ? 'working' : 'waiting';
|
||||
}
|
||||
|
||||
function broadcast(key, event, payload) {
|
||||
const clients = sseClients.get(key);
|
||||
if (!clients) return;
|
||||
const frameText = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
|
||||
for (const client of clients) client.write(frameText);
|
||||
}
|
||||
|
||||
function broadcastPresence(key) {
|
||||
broadcast(key, 'presence', { state: presenceFor(key) });
|
||||
}
|
||||
|
||||
function connectionCount() {
|
||||
let total = 0;
|
||||
for (const clients of sseClients.values()) total += clients.size;
|
||||
for (const count of awaitCounts.values()) total += count;
|
||||
return total;
|
||||
}
|
||||
|
||||
function armIdleTimer() {
|
||||
if (!idleTimeoutMs || closed) return;
|
||||
if (connectionCount() > 0) return;
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
if (connectionCount() === 0 && !closed) {
|
||||
log('[plan-canvas] idle timeout reached, shutting down');
|
||||
if (onIdleShutdown) onIdleShutdown();
|
||||
}
|
||||
}, idleTimeoutMs);
|
||||
if (idleTimer.unref) idleTimer.unref();
|
||||
}
|
||||
|
||||
function noteConnectionOpened() {
|
||||
clearTimeout(idleTimer);
|
||||
}
|
||||
|
||||
function noteConnectionClosed() {
|
||||
armIdleTimer();
|
||||
}
|
||||
|
||||
// --- artifact watching --------------------------------------------------
|
||||
|
||||
function watchSession(session) {
|
||||
if (watchers.has(session.key)) return;
|
||||
const dir = path.dirname(session.file);
|
||||
const base = path.basename(session.file);
|
||||
let debounce = null;
|
||||
try {
|
||||
const watcher = fs.watch(dir, (eventType, filename) => {
|
||||
if (filename && filename !== base) return;
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(() => broadcast(session.key, 'reload', {}), 150);
|
||||
});
|
||||
watcher.on('error', () => watchers.delete(session.key));
|
||||
watchers.set(session.key, watcher);
|
||||
} catch {
|
||||
// Watching is best-effort; manual reload still works.
|
||||
}
|
||||
}
|
||||
|
||||
function unwatchSession(key) {
|
||||
const watcher = watchers.get(key);
|
||||
if (watcher) {
|
||||
watcher.close();
|
||||
watchers.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// --- session actions ------------------------------------------------------
|
||||
|
||||
function endSession(key, endedBy) {
|
||||
const session = store.end(key, endedBy);
|
||||
if (!session) return null;
|
||||
wake.emit(`wake:${key}`);
|
||||
broadcast(key, 'ended', { endedBy: session.endedBy });
|
||||
broadcastPresence(key);
|
||||
unwatchSession(key);
|
||||
return session;
|
||||
}
|
||||
|
||||
// --- request handlers -------------------------------------------------------
|
||||
|
||||
async function handleApi(req, res, url) {
|
||||
const { pathname } = url;
|
||||
|
||||
if (req.method === 'POST' && pathname === '/api/sessions') {
|
||||
const body = await readJsonBody(req);
|
||||
if (!body.file || typeof body.file !== 'string') {
|
||||
return sendJson(res, 400, { error: 'file is required' });
|
||||
}
|
||||
if (!fs.existsSync(path.resolve(body.file))) {
|
||||
return sendJson(res, 404, { error: `artifact not found: ${body.file}` });
|
||||
}
|
||||
const { session, refused } = store.open(body.file, { reopen: Boolean(body.reopen) });
|
||||
if (refused) {
|
||||
return sendJson(res, 409, {
|
||||
status: 'user-ended',
|
||||
key: session.key,
|
||||
next_step: 'The user ended this review from the browser. Do not reopen it unless they ask; pass reopen:true when they do.'
|
||||
});
|
||||
}
|
||||
watchSession(session);
|
||||
broadcastPresence(session.key);
|
||||
return sendJson(res, 200, {
|
||||
status: 'open',
|
||||
key: session.key,
|
||||
file: session.file,
|
||||
url: `/canvas/${session.key}`
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && pathname === '/api/sessions') {
|
||||
return sendJson(res, 200, { sessions: store.list() });
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && pathname === '/api/await') {
|
||||
const file = url.searchParams.get('file');
|
||||
if (!file) return sendJson(res, 400, { error: 'file query parameter is required' });
|
||||
const session = store.findByFile(file);
|
||||
if (!session) return sendJson(res, 200, { status: 'missing' });
|
||||
const key = session.key;
|
||||
const timeoutRaw = url.searchParams.get('timeoutMs');
|
||||
const timeoutMs = timeoutRaw === null ? null : Math.max(0, Number.parseInt(timeoutRaw, 10) || 0);
|
||||
|
||||
const first = store.takeFeedback(key);
|
||||
if (first.status !== 'waiting') {
|
||||
if (first.status === 'feedback') workingKeys.add(key);
|
||||
broadcastPresence(key);
|
||||
return sendJson(res, 200, first);
|
||||
}
|
||||
|
||||
// Long poll: hold the request open until feedback or session end.
|
||||
noteConnectionOpened();
|
||||
awaitCounts.set(key, (awaitCounts.get(key) || 0) + 1);
|
||||
workingKeys.delete(key);
|
||||
broadcastPresence(key);
|
||||
|
||||
let settled = false;
|
||||
let heartbeat = null;
|
||||
let waitTimer = null;
|
||||
const finish = payload => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (payload) {
|
||||
if (payload.status === 'feedback') workingKeys.add(key);
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
broadcastPresence(key);
|
||||
noteConnectionClosed();
|
||||
};
|
||||
const onWake = () => {
|
||||
const result = store.takeFeedback(key);
|
||||
if (result.status !== 'waiting') finish(result);
|
||||
};
|
||||
// Settle held polls on shutdown so server.close() can complete; the
|
||||
// CLI tells agents to simply re-run await.
|
||||
const onServerClose = () =>
|
||||
finish({ status: 'waiting', note: 'canvas server is shutting down; re-run await' });
|
||||
const cleanup = () => {
|
||||
wake.removeListener(`wake:${key}`, onWake);
|
||||
wake.removeListener('server-close', onServerClose);
|
||||
clearInterval(heartbeat);
|
||||
clearTimeout(waitTimer);
|
||||
awaitCounts.set(key, Math.max(0, (awaitCounts.get(key) || 1) - 1));
|
||||
};
|
||||
|
||||
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
||||
// Leading whitespace keeps the connection visibly alive without
|
||||
// corrupting the JSON payload written at the end.
|
||||
res.write(' ');
|
||||
heartbeat = setInterval(() => {
|
||||
if (!settled) res.write(' ');
|
||||
}, heartbeatMs);
|
||||
if (timeoutMs !== null) {
|
||||
waitTimer = setTimeout(() => finish({ status: 'waiting' }), timeoutMs);
|
||||
}
|
||||
wake.on(`wake:${key}`, onWake);
|
||||
wake.once('server-close', onServerClose);
|
||||
req.on('close', () => finish(null));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && pathname === '/api/end') {
|
||||
const body = await readJsonBody(req);
|
||||
if (!body.file || typeof body.file !== 'string') {
|
||||
return sendJson(res, 400, { error: 'file is required' });
|
||||
}
|
||||
const session = store.findByFile(body.file);
|
||||
if (!session) return sendJson(res, 404, { error: 'no session for that file' });
|
||||
endSession(session.key, 'agent');
|
||||
return sendJson(res, 200, { status: 'ended', endedBy: 'agent' });
|
||||
}
|
||||
|
||||
const sessionMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/(feedback|end|reply)$/);
|
||||
if (sessionMatch && req.method === 'POST') {
|
||||
const [, key, action] = sessionMatch;
|
||||
const session = store.get(key);
|
||||
if (!session) return sendJson(res, 404, { error: 'unknown session' });
|
||||
|
||||
if (action === 'feedback') {
|
||||
const body = await readJsonBody(req);
|
||||
const result = store.queueFeedback(key, body.items, { endSession: Boolean(body.endSession) });
|
||||
if (!result) return sendJson(res, 409, { error: 'session already ended' });
|
||||
wake.emit(`wake:${key}`);
|
||||
broadcast(key, 'chat-sync', { chat: store.get(key).chat });
|
||||
if (body.endSession) broadcast(key, 'ended', { endedBy: 'user' });
|
||||
return sendJson(res, 200, { status: 'queued', accepted: result.accepted.length, pending: result.pending });
|
||||
}
|
||||
|
||||
if (action === 'end') {
|
||||
endSession(key, 'user');
|
||||
return sendJson(res, 200, { status: 'ended', endedBy: 'user' });
|
||||
}
|
||||
|
||||
if (action === 'reply') {
|
||||
const body = await readJsonBody(req);
|
||||
if (!body.text || typeof body.text !== 'string') {
|
||||
return sendJson(res, 400, { error: 'text is required' });
|
||||
}
|
||||
const entry = store.addAgentReply(key, body.text);
|
||||
broadcast(key, 'chat-sync', { chat: store.get(key).chat });
|
||||
return sendJson(res, 200, { status: 'sent', at: entry.at });
|
||||
}
|
||||
}
|
||||
|
||||
return sendJson(res, 404, { error: 'not found' });
|
||||
}
|
||||
|
||||
function handleEvents(req, res, key) {
|
||||
const session = store.get(key);
|
||||
if (!session) return sendJson(res, 404, { error: 'unknown session' });
|
||||
noteConnectionOpened();
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-store',
|
||||
connection: 'keep-alive'
|
||||
});
|
||||
res.write(`event: chat-sync\ndata: ${JSON.stringify({ chat: session.chat })}\n\n`);
|
||||
res.write(`event: presence\ndata: ${JSON.stringify({ state: presenceFor(key) })}\n\n`);
|
||||
if (!sseClients.has(key)) sseClients.set(key, new Set());
|
||||
sseClients.get(key).add(res);
|
||||
const ping = setInterval(() => res.write(': ping\n\n'), 25000);
|
||||
if (ping.unref) ping.unref();
|
||||
req.on('close', () => {
|
||||
clearInterval(ping);
|
||||
const clients = sseClients.get(key);
|
||||
if (clients) {
|
||||
clients.delete(res);
|
||||
if (clients.size === 0) sseClients.delete(key);
|
||||
}
|
||||
noteConnectionClosed();
|
||||
});
|
||||
}
|
||||
|
||||
function serveArtifact(res, key, assetPath) {
|
||||
const session = store.get(key);
|
||||
if (!session) return sendHtml(res, 404, '<h1>Unknown session</h1>');
|
||||
|
||||
if (!assetPath) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(session.file, 'utf8');
|
||||
} catch {
|
||||
return sendHtml(res, 404, `<h1>Artifact missing</h1><p>${session.file} no longer exists.</p>`, { csp: false });
|
||||
}
|
||||
const ext = path.extname(session.file).toLowerCase();
|
||||
if (ext === '.md' || ext === '.markdown') {
|
||||
const html = renderMarkdownArtifactHtml(renderMarkdown(content), {
|
||||
title: path.basename(session.file),
|
||||
sdkSrc: '/sdk.js'
|
||||
});
|
||||
return sendHtml(res, 200, html, { csp: false });
|
||||
}
|
||||
const sdkTag = '<script src="/sdk.js"></script>';
|
||||
const injected = content.includes('</body>')
|
||||
? content.replace('</body>', `${sdkTag}\n</body>`)
|
||||
: `${content}\n${sdkTag}`;
|
||||
return sendHtml(res, 200, injected, { csp: false });
|
||||
}
|
||||
|
||||
// Sibling assets resolve relative to the artifact's directory and must
|
||||
// stay confined to it.
|
||||
const baseDir = path.dirname(session.file);
|
||||
const resolved = path.resolve(baseDir, assetPath);
|
||||
if (resolved !== baseDir && !resolved.startsWith(baseDir + path.sep)) {
|
||||
return sendJson(res, 403, { error: 'asset path escapes artifact directory' });
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = fs.readFileSync(resolved);
|
||||
} catch {
|
||||
return sendJson(res, 404, { error: 'asset not found' });
|
||||
}
|
||||
const type = CONTENT_TYPES[path.extname(resolved).toLowerCase()] || 'application/octet-stream';
|
||||
res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' });
|
||||
return res.end(data);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (!isAllowedHostHeader(req.headers.host, allowedHostnames)) {
|
||||
return sendJson(res, 403, { error: 'forbidden host header' });
|
||||
}
|
||||
if (!isAllowedOrigin(req.headers.origin, allowedHostnames)) {
|
||||
return sendJson(res, 403, { error: 'forbidden origin' });
|
||||
}
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const { pathname } = url;
|
||||
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
if (req.method === 'GET' && pathname === '/health') {
|
||||
return sendJson(res, 200, { ok: true, app: 'ecc-plan-canvas', version });
|
||||
}
|
||||
if (req.method === 'POST' && pathname === '/shutdown') {
|
||||
sendJson(res, 200, { status: 'stopping' });
|
||||
setImmediate(() => {
|
||||
if (onIdleShutdown) onIdleShutdown();
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
if (req.method === 'GET' && pathname === '/') {
|
||||
return sendHtml(res, 200, renderSessionListHtml(store.list()));
|
||||
}
|
||||
if (req.method === 'GET' && pathname === '/canvas.css') {
|
||||
res.writeHead(200, { 'content-type': 'text/css; charset=utf-8', 'cache-control': 'no-store' });
|
||||
return res.end(canvasCss());
|
||||
}
|
||||
if (req.method === 'GET' && pathname === '/client.js') {
|
||||
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
|
||||
return res.end(canvasClientJs());
|
||||
}
|
||||
if (req.method === 'GET' && pathname === '/sdk.js') {
|
||||
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
|
||||
return res.end(artifactSdkJs());
|
||||
}
|
||||
const canvasMatch = pathname.match(/^\/canvas\/([a-f0-9]{12})$/);
|
||||
if (req.method === 'GET' && canvasMatch) {
|
||||
const session = store.get(canvasMatch[1]);
|
||||
if (!session) return sendHtml(res, 404, '<h1>Unknown session</h1>');
|
||||
return sendHtml(res, 200, renderCanvasHtml(session));
|
||||
}
|
||||
const eventsMatch = pathname.match(/^\/events\/([a-f0-9]{12})$/);
|
||||
if (req.method === 'GET' && eventsMatch) {
|
||||
return handleEvents(req, res, eventsMatch[1]);
|
||||
}
|
||||
const artifactMatch = pathname.match(/^\/artifact\/([a-f0-9]{12})\/(.*)$/);
|
||||
if (req.method === 'GET' && artifactMatch) {
|
||||
const assetPath = decodeURIComponent(artifactMatch[2]);
|
||||
return serveArtifact(res, artifactMatch[1], assetPath || null);
|
||||
}
|
||||
if (pathname.startsWith('/api/')) {
|
||||
return handleApi(req, res, url);
|
||||
}
|
||||
return sendJson(res, 404, { error: 'not found' });
|
||||
})
|
||||
.catch(error => {
|
||||
if (!res.headersSent) sendJson(res, 400, { error: error.message });
|
||||
else res.end();
|
||||
});
|
||||
});
|
||||
|
||||
function close() {
|
||||
closed = true;
|
||||
clearTimeout(idleTimer);
|
||||
for (const key of watchers.keys()) unwatchSession(key);
|
||||
for (const clients of sseClients.values()) {
|
||||
for (const client of clients) client.end();
|
||||
}
|
||||
sseClients.clear();
|
||||
wake.emit('server-close');
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close(error => (error ? reject(error) : resolve()));
|
||||
// Browser keep-alive sockets would otherwise hold close() open.
|
||||
if (typeof server.closeIdleConnections === 'function') server.closeIdleConnections();
|
||||
});
|
||||
}
|
||||
|
||||
function listen(port = resolvePort()) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(port, host, () => {
|
||||
armIdleTimer();
|
||||
resolve({ port: server.address().port, host });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { server, listen, close, presenceFor, watchSession };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_HOST,
|
||||
DEFAULT_PORT,
|
||||
createPlanCanvasServer,
|
||||
resolveIdleTimeoutMs,
|
||||
resolvePort
|
||||
};
|
||||
@@ -0,0 +1,269 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Plan Canvas session store.
|
||||
*
|
||||
* Sessions are keyed by the canonical artifact file path so agents never
|
||||
* juggle opaque ids. State is persisted as JSON in the Plan Canvas state
|
||||
* dir so queued human feedback survives a server restart.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const FEEDBACK_KINDS = new Set(['chat', 'annotation', 'verdict']);
|
||||
const VERDICTS = new Set(['approve', 'request-changes']);
|
||||
|
||||
function resolveStateDir(env = process.env) {
|
||||
const override = env.ECC_PLAN_CANVAS_STATE_DIR;
|
||||
if (override && String(override).trim()) return path.resolve(String(override).trim());
|
||||
return path.join(os.homedir(), '.claude', 'plan-canvas');
|
||||
}
|
||||
|
||||
// Canonicalize so `./plan.md`, symlinks, and absolute paths all land on the
|
||||
// same session.
|
||||
function canonicalizeArtifactPath(filePath) {
|
||||
const absolute = path.resolve(filePath);
|
||||
try {
|
||||
return fs.realpathSync(absolute);
|
||||
} catch {
|
||||
return absolute;
|
||||
}
|
||||
}
|
||||
|
||||
function sessionKeyFor(canonicalPath) {
|
||||
return crypto.createHash('sha256').update(canonicalPath).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function sanitizeText(value, maxLength = 4000) {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.slice(0, maxLength);
|
||||
}
|
||||
|
||||
// Normalize one browser-submitted feedback item into the shape delivered to
|
||||
// the agent. Returns null for unusable input rather than throwing so a
|
||||
// malformed item can never wedge the queue.
|
||||
function normalizeFeedbackItem(raw, counter) {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const kind = FEEDBACK_KINDS.has(raw.kind) ? raw.kind : null;
|
||||
if (!kind) return null;
|
||||
const item = {
|
||||
id: `fb-${counter}`,
|
||||
kind,
|
||||
text: sanitizeText(raw.text),
|
||||
at: nowIso()
|
||||
};
|
||||
if (kind === 'verdict') {
|
||||
if (!VERDICTS.has(raw.verdict)) return null;
|
||||
item.verdict = raw.verdict;
|
||||
}
|
||||
if (kind === 'annotation') {
|
||||
const anchor = raw.anchor && typeof raw.anchor === 'object' ? raw.anchor : null;
|
||||
if (!anchor || typeof anchor.selector !== 'string') return null;
|
||||
item.anchor = {
|
||||
selector: sanitizeText(anchor.selector, 500),
|
||||
tag: sanitizeText(anchor.tag, 60),
|
||||
snippet: sanitizeText(anchor.snippet, 400)
|
||||
};
|
||||
if (anchor.textRange && typeof anchor.textRange === 'object') {
|
||||
item.anchor.textRange = {
|
||||
text: sanitizeText(anchor.textRange.text, 1000)
|
||||
};
|
||||
}
|
||||
if (!item.text) return null;
|
||||
}
|
||||
if (kind === 'chat' && !item.text) return null;
|
||||
return item;
|
||||
}
|
||||
|
||||
function createSessionStore({ stateDir = resolveStateDir() } = {}) {
|
||||
const stateFile = path.join(stateDir, 'sessions.json');
|
||||
let state = { sessions: {}, feedbackCounter: 0 };
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
||||
if (parsed && typeof parsed === 'object' && parsed.sessions) {
|
||||
state = {
|
||||
sessions: parsed.sessions,
|
||||
feedbackCounter: Number(parsed.feedbackCounter) || 0
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Missing or corrupt state starts fresh; queued feedback loss on a
|
||||
// corrupt file beats refusing to start at all.
|
||||
}
|
||||
}
|
||||
|
||||
function persist() {
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const tmpFile = `${stateFile}.tmp`;
|
||||
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2));
|
||||
fs.renameSync(tmpFile, stateFile);
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
function get(key) {
|
||||
return state.sessions[key] || null;
|
||||
}
|
||||
|
||||
function findByFile(filePath) {
|
||||
const canonical = canonicalizeArtifactPath(filePath);
|
||||
return get(sessionKeyFor(canonical));
|
||||
}
|
||||
|
||||
// Open (or resume) a session. A session the *user* ended from the browser
|
||||
// is sticky: it refuses a plain reopen so agents do not pop the browser
|
||||
// back up uninvited. Pass reopen:true only when the human asked.
|
||||
function open(filePath, { reopen = false } = {}) {
|
||||
const canonical = canonicalizeArtifactPath(filePath);
|
||||
const key = sessionKeyFor(canonical);
|
||||
const existing = state.sessions[key];
|
||||
if (existing && existing.status === 'ended' && existing.endedBy === 'user' && !reopen) {
|
||||
return { session: existing, refused: true };
|
||||
}
|
||||
const session = existing || {
|
||||
key,
|
||||
file: canonical,
|
||||
chat: [],
|
||||
pendingFeedback: [],
|
||||
createdAt: nowIso()
|
||||
};
|
||||
session.status = 'open';
|
||||
delete session.endedBy;
|
||||
session.updatedAt = nowIso();
|
||||
state.sessions[key] = session;
|
||||
persist();
|
||||
return { session, refused: false };
|
||||
}
|
||||
|
||||
// Queue feedback from the browser. Chat-shaped items are mirrored into the
|
||||
// session transcript immediately so the conversation panel stays coherent
|
||||
// across reloads.
|
||||
function queueFeedback(key, rawItems, { endSession = false } = {}) {
|
||||
const session = get(key);
|
||||
if (!session || session.status === 'ended') return null;
|
||||
const accepted = [];
|
||||
for (const raw of Array.isArray(rawItems) ? rawItems : []) {
|
||||
state.feedbackCounter += 1;
|
||||
const item = normalizeFeedbackItem(raw, state.feedbackCounter);
|
||||
if (item) accepted.push(item);
|
||||
}
|
||||
session.pendingFeedback.push(...accepted);
|
||||
for (const item of accepted) {
|
||||
session.chat.push({ role: 'user', kind: item.kind, text: chatLineFor(item), at: item.at });
|
||||
}
|
||||
if (endSession) {
|
||||
session.status = 'ended';
|
||||
session.endedBy = 'user';
|
||||
} else if (accepted.length > 0) {
|
||||
session.status = 'feedback';
|
||||
}
|
||||
session.updatedAt = nowIso();
|
||||
persist();
|
||||
return { accepted, pending: session.pendingFeedback.length, session };
|
||||
}
|
||||
|
||||
// Deliver-and-drain: feedback is handed to exactly one await call, after
|
||||
// which the session flips back to open. An ended session keeps reporting
|
||||
// ended (with attribution) so agents know to stop polling.
|
||||
function takeFeedback(key) {
|
||||
const session = get(key);
|
||||
if (!session) return { status: 'missing' };
|
||||
if (session.pendingFeedback.length > 0) {
|
||||
const items = session.pendingFeedback;
|
||||
session.pendingFeedback = [];
|
||||
const result = { status: 'feedback', items };
|
||||
if (session.status === 'ended') {
|
||||
result.sessionEnded = true;
|
||||
result.endedBy = session.endedBy;
|
||||
} else {
|
||||
session.status = 'open';
|
||||
}
|
||||
session.updatedAt = nowIso();
|
||||
persist();
|
||||
return result;
|
||||
}
|
||||
if (session.status === 'ended') {
|
||||
return { status: 'ended', endedBy: session.endedBy };
|
||||
}
|
||||
return { status: 'waiting' };
|
||||
}
|
||||
|
||||
function addAgentReply(key, text) {
|
||||
const session = get(key);
|
||||
if (!session) return null;
|
||||
const entry = { role: 'agent', kind: 'chat', text: sanitizeText(text), at: nowIso() };
|
||||
session.chat.push(entry);
|
||||
session.updatedAt = nowIso();
|
||||
persist();
|
||||
return entry;
|
||||
}
|
||||
|
||||
function end(key, endedBy) {
|
||||
const session = get(key);
|
||||
if (!session) return null;
|
||||
session.status = 'ended';
|
||||
session.endedBy = endedBy === 'user' ? 'user' : 'agent';
|
||||
session.updatedAt = nowIso();
|
||||
persist();
|
||||
return session;
|
||||
}
|
||||
|
||||
function list() {
|
||||
return Object.values(state.sessions).map(session => ({
|
||||
key: session.key,
|
||||
file: session.file,
|
||||
status: session.status,
|
||||
endedBy: session.endedBy,
|
||||
pending: session.pendingFeedback.length,
|
||||
updatedAt: session.updatedAt
|
||||
}));
|
||||
}
|
||||
|
||||
function hasOpenSessions() {
|
||||
return Object.values(state.sessions).some(session => session.status !== 'ended');
|
||||
}
|
||||
|
||||
return {
|
||||
stateDir,
|
||||
stateFile,
|
||||
open,
|
||||
get,
|
||||
findByFile,
|
||||
queueFeedback,
|
||||
takeFeedback,
|
||||
addAgentReply,
|
||||
end,
|
||||
list,
|
||||
hasOpenSessions
|
||||
};
|
||||
}
|
||||
|
||||
// One-line rendering of a feedback item for the conversation transcript.
|
||||
function chatLineFor(item) {
|
||||
if (item.kind === 'verdict') {
|
||||
const label = item.verdict === 'approve' ? 'Approved the plan' : 'Requested changes';
|
||||
return item.text ? `${label}: ${item.text}` : label;
|
||||
}
|
||||
if (item.kind === 'annotation') {
|
||||
const where = item.anchor.snippet || item.anchor.selector;
|
||||
return `[${where}] ${item.text}`;
|
||||
}
|
||||
return item.text;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
canonicalizeArtifactPath,
|
||||
createSessionStore,
|
||||
normalizeFeedbackItem,
|
||||
resolveStateDir,
|
||||
sessionKeyFor
|
||||
};
|
||||
@@ -0,0 +1,542 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Plan Canvas browser chrome: the editor shell that frames an artifact,
|
||||
* plus the rendered-markdown artifact template.
|
||||
*
|
||||
* Visual language mirrors the ECC web dashboard (scripts/dashboard-web.js):
|
||||
* same design tokens, dark-first with a light theme, accent→pink brand
|
||||
* gradient. Everything is served inline — no CDNs, no external assets.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
|
||||
const { escapeHtml } = require('./markdown');
|
||||
|
||||
// Pinned Mermaid ESM build, loaded in the browser only when an artifact
|
||||
// actually contains a diagram. Override with a local/vendored URL (e.g. an
|
||||
// air-gapped mirror) via ECC_PLAN_CANVAS_MERMAID_URL. If the fetch fails, the
|
||||
// diagram source stays visible as a styled code block — nothing breaks.
|
||||
const DEFAULT_MERMAID_URL = 'https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs';
|
||||
|
||||
function mermaidUrl(env = process.env) {
|
||||
const override = env.ECC_PLAN_CANVAS_MERMAID_URL;
|
||||
return override && String(override).trim() ? String(override).trim() : DEFAULT_MERMAID_URL;
|
||||
}
|
||||
|
||||
// Browser module that renders `<pre class="mermaid">` blocks, themed to match
|
||||
// the ECC canvas. Kept import-only so a CDN failure degrades gracefully.
|
||||
function mermaidLoaderScript(url) {
|
||||
return `<script type="module">
|
||||
try {
|
||||
const mermaid = (await import(${JSON.stringify(url)})).default;
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
theme: 'dark',
|
||||
fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif",
|
||||
themeVariables: {
|
||||
primaryColor: '#13161e', primaryBorderColor: '#6885e8', primaryTextColor: '#dfe2e9',
|
||||
lineColor: '#80859a', secondaryColor: '#191d2a', tertiaryColor: '#101218',
|
||||
background: '#080a0e', mainBkg: '#13161e', clusterBkg: '#0d0f14'
|
||||
}
|
||||
});
|
||||
await mermaid.run({ querySelector: '.mermaid' });
|
||||
} catch (err) {
|
||||
document.querySelectorAll('.mermaid').forEach(el => el.classList.add('mermaid-unrendered'));
|
||||
console.warn('Mermaid render skipped:', err && err.message);
|
||||
}
|
||||
</script>`;
|
||||
}
|
||||
|
||||
// Design tokens shared by the chrome and the markdown artifact template.
|
||||
const TOKENS_CSS = `
|
||||
:root{
|
||||
--bg:#080a0e; --bg2:#0d0f14; --bg3:#13161e; --bg4:#191d2a;
|
||||
--surface:#101218; --surface-hover:#171a24; --border:#1d2130; --border-light:#272c3e;
|
||||
--text:#dfe2e9; --text2:#80859a; --text3:#4c5168;
|
||||
--accent:#6885e8; --accent-glow:rgba(104,133,232,0.15); --accent-dim:#3d5ab8;
|
||||
--green:#4acb8a; --green-glow:rgba(74,203,138,0.15);
|
||||
--orange:#eca85a; --orange-glow:rgba(236,168,90,0.15);
|
||||
--pink:#e26a9e; --pink-glow:rgba(226,106,158,0.15);
|
||||
--red:#e86060; --red-glow:rgba(232,96,96,0.15);
|
||||
--teal:#4acbbe; --teal-glow:rgba(74,203,190,0.15);
|
||||
--radius:8px; --radius-sm:5px;
|
||||
--font:-apple-system,BlinkMacSystemFont,'SF Pro Display','Inter','Segoe UI',Roboto,sans-serif;
|
||||
--mono:'SF Mono','Fira Code','JetBrains Mono','Cascadia Code',monospace;
|
||||
--shadow:0 1px 2px rgba(0,0,0,0.4);
|
||||
--shadow-lg:0 8px 32px rgba(0,0,0,0.6);
|
||||
}
|
||||
[data-theme="light"]{
|
||||
--bg:#f4f5f7; --bg2:#ffffff; --bg3:#eaecef; --bg4:#dfe2e6;
|
||||
--surface:#ffffff; --surface-hover:#f4f5f7; --border:#cdd1d9; --border-light:#dde1e8;
|
||||
--text:#181b23; --text2:#585e6e; --text3:#9197a8;
|
||||
--accent:#4560d0; --accent-glow:rgba(69,96,208,0.08); --accent-dim:#2f44a0;
|
||||
--green:#16a34a; --green-glow:rgba(22,163,74,0.08);
|
||||
--orange:#d97706; --orange-glow:rgba(217,119,6,0.08);
|
||||
--pink:#c73877; --pink-glow:rgba(199,56,119,0.08);
|
||||
--red:#dc2626; --red-glow:rgba(220,38,38,0.08);
|
||||
--teal:#0d9488; --teal-glow:rgba(13,148,136,0.08);
|
||||
--shadow:0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow-lg:0 8px 32px rgba(0,0,0,0.08);
|
||||
}
|
||||
`;
|
||||
|
||||
function canvasCss() {
|
||||
return `${TOKENS_CSS}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{font-family:var(--font);background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;line-height:1.4;overflow:hidden}
|
||||
::selection{background:var(--accent);color:#fff}
|
||||
::-webkit-scrollbar{width:8px;height:8px}
|
||||
::-webkit-scrollbar-track{background:transparent}
|
||||
::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}
|
||||
button{font-family:var(--font)}
|
||||
|
||||
.bar{display:flex;align-items:center;gap:12px;height:52px;padding:0 16px;background:color-mix(in srgb,var(--bg2) 88%,transparent);border-bottom:1px solid var(--border);backdrop-filter:blur(16px)}
|
||||
.brand{display:flex;align-items:center;gap:9px;min-width:0}
|
||||
.brand .logo{width:26px;height:26px;flex:none;background:linear-gradient(135deg,var(--accent),var(--pink));border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;color:#fff}
|
||||
.brand .name{font-size:13.5px;font-weight:600;white-space:nowrap}
|
||||
.brand .file{font-size:11.5px;color:var(--text2);font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:34vw}
|
||||
.bar .spacer{flex:1}
|
||||
|
||||
.presence{display:flex;align-items:center;gap:6px;font-size:11px;font-weight:500;color:var(--text2);background:var(--bg3);border:1px solid var(--border);border-radius:99px;padding:3px 10px 3px 8px;white-space:nowrap}
|
||||
.presence .dot{width:7px;height:7px;border-radius:99px;background:var(--text3)}
|
||||
.presence[data-state="listening"] .dot{background:var(--green);box-shadow:0 0 0 3px var(--green-glow);animation:pulse 2s infinite}
|
||||
.presence[data-state="working"] .dot{background:var(--orange);box-shadow:0 0 0 3px var(--orange-glow)}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
|
||||
|
||||
.toggle{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--text2);cursor:pointer;user-select:none}
|
||||
.toggle .track{width:30px;height:17px;border-radius:99px;background:var(--bg4);border:1px solid var(--border);position:relative;transition:background .15s}
|
||||
.toggle .knob{position:absolute;top:1px;left:1px;width:13px;height:13px;border-radius:99px;background:var(--text2);transition:transform .15s,background .15s}
|
||||
.toggle[aria-pressed="true"] .track{background:var(--accent);border-color:var(--accent-dim)}
|
||||
.toggle[aria-pressed="true"] .knob{transform:translateX(13px);background:#fff}
|
||||
|
||||
.icon-btn{height:28px;padding:0 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg3);color:var(--text2);cursor:pointer;font-size:11.5px;display:flex;align-items:center;gap:5px;transition:all .12s}
|
||||
.icon-btn:hover{border-color:var(--border-light);color:var(--text);background:var(--bg4)}
|
||||
.icon-btn.danger:hover{border-color:var(--red);color:var(--red);background:var(--red-glow)}
|
||||
|
||||
.layout{display:flex;height:calc(100% - 52px)}
|
||||
.frame{flex:1;min-width:0;position:relative;background:var(--bg2)}
|
||||
.frame iframe{width:100%;height:100%;border:0;background:#fff}
|
||||
[data-theme] .frame iframe{background:var(--bg2)}
|
||||
|
||||
.panel{width:340px;flex:none;display:flex;flex-direction:column;border-left:1px solid var(--border);background:var(--bg2)}
|
||||
.panel h2{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3);padding:12px 14px 8px}
|
||||
|
||||
.verdict{display:flex;gap:8px;padding:0 14px 12px;border-bottom:1px solid var(--border)}
|
||||
.verdict button{flex:1;height:30px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s}
|
||||
.verdict .approve{border:1px solid var(--green);background:var(--green-glow);color:var(--green)}
|
||||
.verdict .approve:hover{background:var(--green);color:#fff}
|
||||
.verdict .changes{border:1px solid var(--orange);background:var(--orange-glow);color:var(--orange)}
|
||||
.verdict .changes:hover{background:var(--orange);color:#fff}
|
||||
|
||||
.chat{flex:1;overflow-y:auto;padding:10px 14px;display:flex;flex-direction:column;gap:8px}
|
||||
.msg{max-width:92%;padding:7px 10px;border-radius:10px;font-size:12.5px;white-space:pre-wrap;word-break:break-word}
|
||||
.msg.user{align-self:flex-end;background:var(--accent-glow);border:1px solid color-mix(in srgb,var(--accent) 35%,transparent);color:var(--text);border-bottom-right-radius:3px}
|
||||
.msg.agent{align-self:flex-start;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-bottom-left-radius:3px}
|
||||
.msg .meta{display:block;font-size:9.5px;color:var(--text3);margin-top:3px}
|
||||
.msg.kind-annotation{border-left:2px solid var(--teal)}
|
||||
.msg.kind-verdict{border-left:2px solid var(--green)}
|
||||
.chat .empty{color:var(--text3);font-size:12px;text-align:center;margin-top:24px;line-height:1.6}
|
||||
|
||||
.queue{padding:8px 14px 0;display:flex;flex-direction:column;gap:6px;max-height:180px;overflow-y:auto}
|
||||
.pill{display:flex;align-items:flex-start;gap:8px;background:var(--bg3);border:1px solid var(--border);border-left:2px solid var(--teal);border-radius:6px;padding:6px 8px;font-size:11.5px}
|
||||
.pill.kind-chat{border-left-color:var(--accent)}
|
||||
.pill.kind-verdict{border-left-color:var(--green)}
|
||||
.pill .where{color:var(--teal);font-family:var(--mono);font-size:10px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.pill .body{flex:1;min-width:0;color:var(--text2)}
|
||||
.pill .txt{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
|
||||
.pill button{border:none;background:none;color:var(--text3);cursor:pointer;font-size:13px;line-height:1;padding:1px}
|
||||
.pill button:hover{color:var(--red)}
|
||||
|
||||
.composer{padding:10px 14px 14px;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:8px}
|
||||
.composer .hint{font-size:10px;color:var(--text3)}
|
||||
.composer textarea{width:100%;min-height:60px;max-height:160px;resize:vertical;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:8px 10px;color:var(--text);font-size:12.5px;font-family:var(--font);outline:none;transition:all .15s}
|
||||
.composer textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow)}
|
||||
.composer .row{display:flex;gap:8px;align-items:center}
|
||||
.composer .send{flex:1;height:32px;border:none;border-radius:6px;background:var(--accent);color:#fff;font-size:12.5px;font-weight:600;cursor:pointer;transition:all .12s}
|
||||
.composer .send:hover{background:var(--accent-dim)}
|
||||
.composer .send:disabled{opacity:.5;cursor:default}
|
||||
.composer .status{font-size:10.5px;color:var(--text3)}
|
||||
|
||||
.overlay{position:absolute;inset:0;display:none;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bg) 80%,transparent);backdrop-filter:blur(6px);z-index:50}
|
||||
.overlay.show{display:flex}
|
||||
.overlay .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-lg);padding:26px 32px;text-align:center;max-width:340px}
|
||||
.overlay .card h3{font-size:14px;margin-bottom:6px}
|
||||
.overlay .card p{font-size:12px;color:var(--text2);line-height:1.5}
|
||||
`;
|
||||
}
|
||||
|
||||
// Client logic for the chrome page (runs in the top window).
|
||||
function canvasClientJs() {
|
||||
return `'use strict';
|
||||
(() => {
|
||||
const boot = JSON.parse(document.getElementById('pc-session').textContent);
|
||||
const key = boot.key;
|
||||
const $ = id => document.getElementById(id);
|
||||
const frame = $('artifact');
|
||||
const chatLog = $('chatLog');
|
||||
const queueEl = $('queue');
|
||||
const input = $('chatInput');
|
||||
const sendBtn = $('send');
|
||||
const statusEl = $('sendStatus');
|
||||
const presence = $('presence');
|
||||
const QKEY = 'ecc-plan-canvas:queue:' + key;
|
||||
let queue = [];
|
||||
let lastScroll = { x: 0, y: 0 };
|
||||
let ended = boot.status === 'ended';
|
||||
let sending = false;
|
||||
|
||||
try { queue = JSON.parse(sessionStorage.getItem(QKEY) || '[]'); } catch { queue = []; }
|
||||
|
||||
// --- theme ---------------------------------------------------------
|
||||
const themeKey = 'ecc-plan-canvas:theme';
|
||||
function applyTheme(t) {
|
||||
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
||||
else document.documentElement.removeAttribute('data-theme');
|
||||
$('themeBtn').textContent = t === 'light' ? '\\u263E dark' : '\\u2600 light';
|
||||
}
|
||||
let theme = localStorage.getItem(themeKey) || 'dark';
|
||||
applyTheme(theme);
|
||||
$('themeBtn').addEventListener('click', () => {
|
||||
theme = theme === 'light' ? 'dark' : 'light';
|
||||
localStorage.setItem(themeKey, theme);
|
||||
applyTheme(theme);
|
||||
});
|
||||
|
||||
// --- annotate mode -------------------------------------------------
|
||||
let annotate = true;
|
||||
function setAnnotate(on) {
|
||||
annotate = on;
|
||||
$('annotate').setAttribute('aria-pressed', String(on));
|
||||
postToFrame({ type: 'pc:set-mode', annotate: on });
|
||||
}
|
||||
$('annotate').addEventListener('click', () => setAnnotate(!annotate));
|
||||
document.addEventListener('keydown', e => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'i') {
|
||||
e.preventDefault();
|
||||
setAnnotate(!annotate);
|
||||
}
|
||||
}, true);
|
||||
|
||||
// --- iframe bridge --------------------------------------------------
|
||||
function postToFrame(msg) {
|
||||
if (frame.contentWindow) frame.contentWindow.postMessage(msg, '*');
|
||||
}
|
||||
window.addEventListener('message', e => {
|
||||
if (e.source !== frame.contentWindow) return;
|
||||
const msg = e.data || {};
|
||||
if (msg.type === 'pc:queue' && msg.item) addToQueue(msg.item);
|
||||
else if (msg.type === 'pc:queue-and-send' && msg.item) { addToQueue(msg.item); send(); }
|
||||
else if (msg.type === 'pc:scroll') lastScroll = { x: msg.x || 0, y: msg.y || 0 };
|
||||
else if (msg.type === 'pc:toggle-mode') setAnnotate(!annotate);
|
||||
else if (msg.type === 'pc:ready') {
|
||||
postToFrame({ type: 'pc:set-mode', annotate });
|
||||
postToFrame({ type: 'pc:restore-scroll', x: lastScroll.x, y: lastScroll.y });
|
||||
}
|
||||
});
|
||||
|
||||
// --- queue ----------------------------------------------------------
|
||||
function persistQueue() { try { sessionStorage.setItem(QKEY, JSON.stringify(queue)); } catch { /* full */ } }
|
||||
function addToQueue(item) { queue.push(item); persistQueue(); renderQueue(); }
|
||||
function renderQueue() {
|
||||
queueEl.innerHTML = '';
|
||||
queue.forEach((item, i) => {
|
||||
const pill = document.createElement('div');
|
||||
pill.className = 'pill kind-' + item.kind;
|
||||
const body = document.createElement('span');
|
||||
body.className = 'body';
|
||||
if (item.anchor) {
|
||||
const where = document.createElement('span');
|
||||
where.className = 'where';
|
||||
where.textContent = item.anchor.snippet || item.anchor.selector;
|
||||
body.appendChild(where);
|
||||
}
|
||||
const txt = document.createElement('span');
|
||||
txt.className = 'txt';
|
||||
txt.textContent = item.kind === 'verdict' ? (item.verdict === 'approve' ? 'Approve plan' : 'Request changes') + (item.text ? ': ' + item.text : '') : item.text;
|
||||
body.appendChild(txt);
|
||||
const rm = document.createElement('button');
|
||||
rm.textContent = '\\u00D7';
|
||||
rm.title = 'Remove';
|
||||
rm.addEventListener('click', () => { queue.splice(i, 1); persistQueue(); renderQueue(); });
|
||||
pill.append(body, rm);
|
||||
queueEl.appendChild(pill);
|
||||
});
|
||||
}
|
||||
renderQueue();
|
||||
|
||||
// --- chat -----------------------------------------------------------
|
||||
function renderChat(entries) {
|
||||
chatLog.innerHTML = '';
|
||||
if (!entries.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'empty';
|
||||
empty.textContent = 'Click anything in the plan to annotate it, or type below. Feedback goes straight to your agent.';
|
||||
chatLog.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg ' + (entry.role === 'agent' ? 'agent' : 'user') + ' kind-' + (entry.kind || 'chat');
|
||||
div.textContent = entry.text;
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'meta';
|
||||
meta.textContent = (entry.role === 'agent' ? 'agent' : 'you') + ' \\u00B7 ' + new Date(entry.at).toLocaleTimeString();
|
||||
div.appendChild(meta);
|
||||
chatLog.appendChild(div);
|
||||
}
|
||||
chatLog.scrollTop = chatLog.scrollHeight;
|
||||
}
|
||||
renderChat(boot.chat || []);
|
||||
|
||||
// --- send -----------------------------------------------------------
|
||||
async function send(extraItems) {
|
||||
if (ended || sending) return;
|
||||
const items = queue.slice();
|
||||
if (extraItems) items.push(...extraItems);
|
||||
const text = input.value.trim();
|
||||
if (text) items.push({ kind: 'chat', text });
|
||||
if (!items.length) {
|
||||
statusEl.textContent = 'Nothing to send yet - annotate the plan or type a message.';
|
||||
return;
|
||||
}
|
||||
sending = true;
|
||||
sendBtn.disabled = true;
|
||||
statusEl.textContent = 'Sending\\u2026';
|
||||
try {
|
||||
const res = await fetch('/api/session/' + key + '/feedback', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ items })
|
||||
});
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
queue = [];
|
||||
persistQueue();
|
||||
renderQueue();
|
||||
input.value = '';
|
||||
statusEl.textContent = 'Sent. Your agent picks this up on its next check-in.';
|
||||
} catch (err) {
|
||||
statusEl.textContent = 'Send failed (' + err.message + ') - is the canvas server still running?';
|
||||
} finally {
|
||||
sending = false;
|
||||
sendBtn.disabled = ended;
|
||||
}
|
||||
}
|
||||
sendBtn.addEventListener('click', () => send());
|
||||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
|
||||
});
|
||||
$('approve').addEventListener('click', () => send([{ kind: 'verdict', verdict: 'approve' }]));
|
||||
$('changes').addEventListener('click', () => send([{ kind: 'verdict', verdict: 'request-changes' }]));
|
||||
|
||||
// --- session controls ------------------------------------------------
|
||||
$('reloadBtn').addEventListener('click', reloadArtifact);
|
||||
$('endBtn').addEventListener('click', async () => {
|
||||
if (!window.confirm('End this review session?')) return;
|
||||
try { await fetch('/api/session/' + key + '/end', { method: 'POST' }); } catch { /* server gone */ }
|
||||
});
|
||||
function reloadArtifact() {
|
||||
const base = frame.getAttribute('data-artifact-src');
|
||||
frame.src = base + '?t=' + Date.now();
|
||||
}
|
||||
function markEnded(endedBy) {
|
||||
ended = true;
|
||||
sendBtn.disabled = true;
|
||||
input.disabled = true;
|
||||
presence.setAttribute('data-state', 'ended');
|
||||
presence.querySelector('.label').textContent = 'session ended';
|
||||
$('endedOverlay').classList.add('show');
|
||||
$('endedWho').textContent = endedBy === 'agent'
|
||||
? 'Your agent closed this review.'
|
||||
: 'You ended this review. Head back to your agent session.';
|
||||
}
|
||||
if (ended) markEnded(boot.endedBy);
|
||||
|
||||
// --- server events ----------------------------------------------------
|
||||
const PRESENCE_LABELS = { waiting: 'agent not connected', listening: 'agent listening', working: 'agent working\\u2026' };
|
||||
function connectEvents() {
|
||||
const es = new EventSource('/events/' + key);
|
||||
es.addEventListener('chat-sync', e => renderChat(JSON.parse(e.data).chat || []));
|
||||
es.addEventListener('presence', e => {
|
||||
const state = JSON.parse(e.data).state;
|
||||
if (ended) return;
|
||||
presence.setAttribute('data-state', state);
|
||||
presence.querySelector('.label').textContent = PRESENCE_LABELS[state] || state;
|
||||
});
|
||||
es.addEventListener('reload', reloadArtifact);
|
||||
es.addEventListener('ended', e => { markEnded(JSON.parse(e.data).endedBy); es.close(); });
|
||||
es.onerror = () => {
|
||||
if (ended) return;
|
||||
presence.setAttribute('data-state', 'waiting');
|
||||
presence.querySelector('.label').textContent = 'canvas server offline';
|
||||
};
|
||||
}
|
||||
connectEvents();
|
||||
})();`;
|
||||
}
|
||||
|
||||
// The chrome page: header bar, artifact iframe, conversation rail.
|
||||
function renderCanvasHtml(session, { clientPath = '/client.js', cssPath = '/canvas.css' } = {}) {
|
||||
const name = path.basename(session.file);
|
||||
const bootstrap = JSON.stringify({
|
||||
key: session.key,
|
||||
file: session.file,
|
||||
status: session.status,
|
||||
endedBy: session.endedBy || null,
|
||||
chat: session.chat
|
||||
}).replace(/</g, '\\u003c');
|
||||
const artifactSrc = `/artifact/${session.key}/`;
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(name)} · Plan Canvas</title>
|
||||
<link rel="stylesheet" href="${cssPath}">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='%236885e8'/><stop offset='1' stop-color='%23e26a9e'/></linearGradient></defs><rect width='100' height='100' rx='22' fill='url(%23g)'/><text x='50' y='68' font-size='52' font-weight='700' font-family='sans-serif' fill='white' text-anchor='middle'>E</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
<script id="pc-session" type="application/json">${bootstrap}</script>
|
||||
<header class="bar">
|
||||
<div class="brand">
|
||||
<div class="logo">E</div>
|
||||
<span class="name">Plan Canvas</span>
|
||||
<span class="file" title="${escapeHtml(session.file)}">${escapeHtml(name)}</span>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<div id="presence" class="presence" data-state="waiting"><span class="dot"></span><span class="label">agent not connected</span></div>
|
||||
<div id="annotate" class="toggle" role="switch" aria-pressed="true" title="Toggle annotate mode (Cmd/Ctrl+I)">
|
||||
<span>Annotate</span><span class="track"><span class="knob"></span></span>
|
||||
</div>
|
||||
<button id="themeBtn" class="icon-btn" type="button">light</button>
|
||||
<button id="reloadBtn" class="icon-btn" type="button" title="Reload artifact">Reload</button>
|
||||
<button id="endBtn" class="icon-btn danger" type="button">End session</button>
|
||||
</header>
|
||||
<div class="layout">
|
||||
<main class="frame">
|
||||
<iframe id="artifact" title="Artifact under review" src="${artifactSrc}" data-artifact-src="${artifactSrc}" sandbox="allow-scripts allow-forms allow-popups"></iframe>
|
||||
<div id="endedOverlay" class="overlay"><div class="card"><h3>Session ended</h3><p id="endedWho"></p></div></div>
|
||||
</main>
|
||||
<aside class="panel">
|
||||
<h2>Plan verdict</h2>
|
||||
<div class="verdict">
|
||||
<button id="approve" class="approve" type="button">Approve plan</button>
|
||||
<button id="changes" class="changes" type="button">Request changes</button>
|
||||
</div>
|
||||
<h2>Conversation</h2>
|
||||
<div id="chatLog" class="chat"></div>
|
||||
<div id="queue" class="queue"></div>
|
||||
<div class="composer">
|
||||
<textarea id="chatInput" placeholder="Message your agent Enter to send · Shift+Enter for a new line"></textarea>
|
||||
<div class="row">
|
||||
<button id="send" class="send" type="button">Send to agent</button>
|
||||
</div>
|
||||
<div id="sendStatus" class="status"></div>
|
||||
<div class="hint">Annotations queue up here until you send them together.</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<script src="${clientPath}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ECC-styled document template for rendered markdown plan artifacts.
|
||||
function renderMarkdownArtifactHtml(bodyHtml, { title, sdkSrc }) {
|
||||
const hasMermaid = bodyHtml.includes('class="mermaid"');
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>
|
||||
${TOKENS_CSS}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:var(--font);background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;line-height:1.65;font-size:14.5px}
|
||||
.doc{max-width:860px;margin:0 auto;padding:44px 36px 90px}
|
||||
h1,h2,h3,h4,h5,h6{line-height:1.25;margin:1.6em 0 .55em;letter-spacing:-.01em}
|
||||
h1{font-size:26px;margin-top:.3em;padding-bottom:.45em;border-bottom:1px solid var(--border)}
|
||||
h1:after{content:'';display:block;width:56px;height:3px;margin-top:14px;border-radius:2px;background:linear-gradient(90deg,var(--accent),var(--pink))}
|
||||
h2{font-size:19px;padding-bottom:.3em;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:15.5px}
|
||||
h4,h5,h6{font-size:13.5px;color:var(--text2);text-transform:uppercase;letter-spacing:.05em}
|
||||
p,ul,ol,blockquote,table,pre{margin-bottom:.9em}
|
||||
ul,ol{padding-left:1.5em}
|
||||
li{margin:.25em 0}
|
||||
li.task{list-style:none;margin-left:-1.3em}
|
||||
li.task input{margin-right:.5em;accent-color:var(--accent)}
|
||||
a{color:var(--accent);text-decoration:none;border-bottom:1px solid var(--accent-glow)}
|
||||
a:hover{border-bottom-color:var(--accent)}
|
||||
code{font-family:var(--mono);font-size:.88em;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:.12em .38em}
|
||||
pre{background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;overflow-x:auto}
|
||||
pre code{background:none;border:none;padding:0;font-size:12.5px;line-height:1.55}
|
||||
blockquote{border-left:3px solid var(--accent);background:var(--accent-glow);border-radius:0 var(--radius-sm) var(--radius-sm) 0;padding:8px 14px;color:var(--text2)}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px;display:block;overflow-x:auto}
|
||||
th,td{text-align:left;padding:7px 12px;border:1px solid var(--border)}
|
||||
th{background:var(--bg3);font-weight:600;font-size:11.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--text2);white-space:nowrap}
|
||||
tbody tr:hover{background:var(--surface-hover)}
|
||||
hr{border:none;border-top:1px solid var(--border);margin:1.6em 0}
|
||||
img{max-width:100%;border-radius:var(--radius-sm)}
|
||||
pre.mermaid{font-family:var(--mono);font-size:12.5px;line-height:1.55;white-space:pre-wrap}
|
||||
pre.mermaid[data-processed]{background:transparent;border:none;padding:4px 0;text-align:center;overflow-x:auto}
|
||||
pre.mermaid[data-processed] svg{max-width:100%;height:auto}
|
||||
pre.mermaid.mermaid-unrendered:before{content:'diagram source (renderer unavailable)';display:block;font-family:var(--font);font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--text3);margin-bottom:6px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<article class="doc">
|
||||
${bodyHtml}
|
||||
</article>
|
||||
${hasMermaid ? mermaidLoaderScript(mermaidUrl()) : ''}
|
||||
<script src="${sdkSrc}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// Landing page listing sessions (GET /).
|
||||
function renderSessionListHtml(sessions) {
|
||||
const rows = sessions.map(s => {
|
||||
const status = s.status === 'ended' ? `ended by ${escapeHtml(s.endedBy || 'agent')}` : s.status;
|
||||
const link = s.status === 'ended'
|
||||
? escapeHtml(path.basename(s.file))
|
||||
: `<a href="/canvas/${escapeHtml(s.key)}">${escapeHtml(path.basename(s.file))}</a>`;
|
||||
return `<tr><td>${link}</td><td class="mono">${escapeHtml(s.file)}</td><td><span class="badge ${escapeHtml(s.status)}">${status}</span></td></tr>`;
|
||||
}).join('\n');
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Plan Canvas · sessions</title>
|
||||
<style>
|
||||
${TOKENS_CSS}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:var(--font);background:var(--bg);color:var(--text);padding:40px;line-height:1.5}
|
||||
.logo{width:30px;height:30px;background:linear-gradient(135deg,var(--accent),var(--pink));border-radius:7px;display:inline-flex;align-items:center;justify-content:center;font-weight:700;color:#fff;margin-right:10px;vertical-align:middle}
|
||||
h1{font-size:18px;display:inline-block;vertical-align:middle}
|
||||
table{margin-top:24px;border-collapse:collapse;width:100%;max-width:900px;font-size:13px}
|
||||
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid var(--border)}
|
||||
th{color:var(--text3);font-size:11px;text-transform:uppercase;letter-spacing:.05em}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
.mono{font-family:var(--mono);font-size:11.5px;color:var(--text2)}
|
||||
.badge{font-size:11px;padding:2px 8px;border-radius:99px;background:var(--bg3);border:1px solid var(--border);color:var(--text2)}
|
||||
.badge.open,.badge.feedback{color:var(--green);border-color:var(--green);background:var(--green-glow)}
|
||||
.empty{margin-top:24px;color:var(--text3);font-size:13px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<span class="logo">E</span><h1>Plan Canvas sessions</h1>
|
||||
${sessions.length ? `<table><thead><tr><th>Artifact</th><th>Path</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>` : '<p class="empty">No sessions yet. Ask your agent to open a plan with the plan-canvas skill.</p>'}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
canvasCss,
|
||||
canvasClientJs,
|
||||
renderCanvasHtml,
|
||||
renderMarkdownArtifactHtml,
|
||||
renderSessionListHtml
|
||||
};
|
||||
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* Project type and framework detection
|
||||
*
|
||||
* Cross-platform (Windows, macOS, Linux) project type detection
|
||||
* by inspecting files in the working directory.
|
||||
*
|
||||
* Resolves: https://github.com/affaan-m/everything-claude-code/issues/293
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Language detection rules.
|
||||
* Each rule checks for marker files or glob patterns in the project root.
|
||||
*/
|
||||
const LANGUAGE_RULES = [
|
||||
{
|
||||
type: 'python',
|
||||
markers: ['requirements.txt', 'pyproject.toml', 'setup.py', 'setup.cfg', 'Pipfile', 'poetry.lock'],
|
||||
extensions: ['.py']
|
||||
},
|
||||
{
|
||||
type: 'typescript',
|
||||
markers: ['tsconfig.json', 'tsconfig.build.json'],
|
||||
extensions: ['.ts', '.tsx']
|
||||
},
|
||||
{
|
||||
type: 'javascript',
|
||||
markers: ['package.json', 'jsconfig.json'],
|
||||
extensions: ['.js', '.jsx', '.mjs']
|
||||
},
|
||||
{
|
||||
type: 'golang',
|
||||
markers: ['go.mod', 'go.sum'],
|
||||
extensions: ['.go']
|
||||
},
|
||||
{
|
||||
type: 'rust',
|
||||
markers: ['Cargo.toml', 'Cargo.lock'],
|
||||
extensions: ['.rs']
|
||||
},
|
||||
{
|
||||
type: 'ruby',
|
||||
markers: ['Gemfile', 'Gemfile.lock', 'Rakefile'],
|
||||
extensions: ['.rb']
|
||||
},
|
||||
{
|
||||
type: 'java',
|
||||
markers: ['pom.xml', 'build.gradle', 'build.gradle.kts'],
|
||||
extensions: ['.java']
|
||||
},
|
||||
{
|
||||
type: 'c',
|
||||
markers: [],
|
||||
extensions: ['.c']
|
||||
},
|
||||
{
|
||||
type: 'csharp',
|
||||
markers: [],
|
||||
extensions: ['.cs', '.csproj', '.sln']
|
||||
},
|
||||
{
|
||||
type: 'fsharp',
|
||||
markers: [],
|
||||
extensions: ['.fs', '.fsx', '.fsproj']
|
||||
},
|
||||
{
|
||||
type: 'swift',
|
||||
markers: ['Package.swift'],
|
||||
extensions: ['.swift']
|
||||
},
|
||||
{
|
||||
type: 'kotlin',
|
||||
markers: [],
|
||||
extensions: ['.kt', '.kts']
|
||||
},
|
||||
{
|
||||
type: 'elixir',
|
||||
markers: ['mix.exs'],
|
||||
extensions: ['.ex', '.exs']
|
||||
},
|
||||
{
|
||||
type: 'php',
|
||||
markers: ['composer.json', 'composer.lock'],
|
||||
extensions: ['.php']
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Framework detection rules.
|
||||
* Checked after language detection for more specific identification.
|
||||
*/
|
||||
const FRAMEWORK_RULES = [
|
||||
// Python frameworks
|
||||
{ framework: 'django', language: 'python', markers: ['manage.py'], packageKeys: ['django'] },
|
||||
{ framework: 'fastapi', language: 'python', markers: [], packageKeys: ['fastapi'] },
|
||||
{ framework: 'flask', language: 'python', markers: [], packageKeys: ['flask'] },
|
||||
|
||||
// JavaScript/TypeScript frameworks
|
||||
{ framework: 'nextjs', language: 'typescript', markers: ['next.config.js', 'next.config.mjs', 'next.config.ts'], packageKeys: ['next'] },
|
||||
{ framework: 'react', language: 'typescript', markers: [], packageKeys: ['react'] },
|
||||
{ framework: 'vue', language: 'typescript', markers: ['vue.config.js'], packageKeys: ['vue'] },
|
||||
{ framework: 'angular', language: 'typescript', markers: ['angular.json'], packageKeys: ['@angular/core'] },
|
||||
{ framework: 'svelte', language: 'typescript', markers: ['svelte.config.js'], packageKeys: ['svelte'] },
|
||||
{ framework: 'express', language: 'javascript', markers: [], packageKeys: ['express'] },
|
||||
{ framework: 'nestjs', language: 'typescript', markers: ['nest-cli.json'], packageKeys: ['@nestjs/core'] },
|
||||
{ framework: 'remix', language: 'typescript', markers: [], packageKeys: ['@remix-run/node', '@remix-run/react'] },
|
||||
{ framework: 'astro', language: 'typescript', markers: ['astro.config.mjs', 'astro.config.ts'], packageKeys: ['astro'] },
|
||||
{ framework: 'nuxt', language: 'typescript', markers: ['nuxt.config.js', 'nuxt.config.ts'], packageKeys: ['nuxt'] },
|
||||
{ framework: 'electron', language: 'typescript', markers: [], packageKeys: ['electron'] },
|
||||
|
||||
// Ruby frameworks
|
||||
{ framework: 'rails', language: 'ruby', markers: ['config/routes.rb', 'bin/rails'], packageKeys: [] },
|
||||
|
||||
// Go frameworks
|
||||
{ framework: 'gin', language: 'golang', markers: [], packageKeys: ['github.com/gin-gonic/gin'] },
|
||||
{ framework: 'echo', language: 'golang', markers: [], packageKeys: ['github.com/labstack/echo'] },
|
||||
|
||||
// Rust frameworks
|
||||
{ framework: 'actix', language: 'rust', markers: [], packageKeys: ['actix-web'] },
|
||||
{ framework: 'axum', language: 'rust', markers: [], packageKeys: ['axum'] },
|
||||
|
||||
// Java frameworks
|
||||
{ framework: 'spring', language: 'java', markers: [], packageKeys: ['spring-boot', 'org.springframework'] },
|
||||
|
||||
// PHP frameworks
|
||||
{ framework: 'laravel', language: 'php', markers: ['artisan'], packageKeys: ['laravel/framework'] },
|
||||
{ framework: 'symfony', language: 'php', markers: ['symfony.lock'], packageKeys: ['symfony/framework-bundle'] },
|
||||
|
||||
// Elixir frameworks
|
||||
{ framework: 'phoenix', language: 'elixir', markers: [], packageKeys: ['phoenix'] }
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a file exists relative to the project directory
|
||||
* @param {string} projectDir - Project root directory
|
||||
* @param {string} filePath - Relative file path
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function fileExists(projectDir, filePath) {
|
||||
try {
|
||||
return fs.existsSync(path.join(projectDir, filePath));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any file with given extension exists in the project root (non-recursive, top-level only)
|
||||
* @param {string} projectDir - Project root directory
|
||||
* @param {string[]} extensions - File extensions to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasFileWithExtension(projectDir, extensions) {
|
||||
try {
|
||||
const entries = fs.readdirSync(projectDir, { withFileTypes: true });
|
||||
return entries.some(entry => {
|
||||
if (!entry.isFile()) return false;
|
||||
const ext = path.extname(entry.name);
|
||||
return extensions.includes(ext);
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse package.json dependencies
|
||||
* @param {string} projectDir - Project root directory
|
||||
* @returns {string[]} Array of dependency names
|
||||
*/
|
||||
function getPackageJsonDeps(projectDir) {
|
||||
try {
|
||||
const pkgPath = path.join(projectDir, 'package.json');
|
||||
if (!fs.existsSync(pkgPath)) return [];
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
return [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {})];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read requirements.txt or pyproject.toml for Python package names
|
||||
* @param {string} projectDir - Project root directory
|
||||
* @returns {string[]} Array of dependency names (lowercase)
|
||||
*/
|
||||
function getPythonDeps(projectDir) {
|
||||
const deps = [];
|
||||
|
||||
// requirements.txt
|
||||
try {
|
||||
const reqPath = path.join(projectDir, 'requirements.txt');
|
||||
if (fs.existsSync(reqPath)) {
|
||||
const content = fs.readFileSync(reqPath, 'utf8');
|
||||
content.split('\n').forEach(line => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('-')) {
|
||||
const name = trimmed
|
||||
.split(/[>=<![;]/)[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (name) deps.push(name);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// pyproject.toml — simple extraction of dependency names
|
||||
try {
|
||||
const tomlPath = path.join(projectDir, 'pyproject.toml');
|
||||
if (fs.existsSync(tomlPath)) {
|
||||
const content = fs.readFileSync(tomlPath, 'utf8');
|
||||
const depMatches = content.match(/dependencies\s*=\s*\[([\s\S]*?)\]/);
|
||||
if (depMatches) {
|
||||
const block = depMatches[1];
|
||||
block.match(/"([^"]+)"/g)?.forEach(m => {
|
||||
const name = m
|
||||
.replace(/"/g, '')
|
||||
.split(/[>=<![;]/)[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (name) deps.push(name);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
return deps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read go.mod for Go module dependencies
|
||||
* @param {string} projectDir - Project root directory
|
||||
* @returns {string[]} Array of module paths
|
||||
*/
|
||||
function getGoDeps(projectDir) {
|
||||
try {
|
||||
const modPath = path.join(projectDir, 'go.mod');
|
||||
if (!fs.existsSync(modPath)) return [];
|
||||
const content = fs.readFileSync(modPath, 'utf8');
|
||||
const deps = [];
|
||||
const requireBlock = content.match(/require\s*\(([\s\S]*?)\)/);
|
||||
if (requireBlock) {
|
||||
requireBlock[1].split('\n').forEach(line => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith('//')) {
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts[0]) deps.push(parts[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
return deps;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Cargo.toml for Rust crate dependencies
|
||||
* @param {string} projectDir - Project root directory
|
||||
* @returns {string[]} Array of crate names
|
||||
*/
|
||||
function getRustDeps(projectDir) {
|
||||
try {
|
||||
const cargoPath = path.join(projectDir, 'Cargo.toml');
|
||||
if (!fs.existsSync(cargoPath)) return [];
|
||||
const content = fs.readFileSync(cargoPath, 'utf8');
|
||||
const deps = [];
|
||||
// Match [dependencies] and [dev-dependencies] sections
|
||||
const sections = content.match(/\[(dev-)?dependencies\]([\s\S]*?)(?=\n\[|$)/g);
|
||||
if (sections) {
|
||||
sections.forEach(section => {
|
||||
section.split('\n').forEach(line => {
|
||||
const match = line.match(/^([a-zA-Z0-9_-]+)\s*=/);
|
||||
if (match && !line.startsWith('[')) {
|
||||
deps.push(match[1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
return deps;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read composer.json for PHP package dependencies
|
||||
* @param {string} projectDir - Project root directory
|
||||
* @returns {string[]} Array of package names
|
||||
*/
|
||||
function getComposerDeps(projectDir) {
|
||||
try {
|
||||
const composerPath = path.join(projectDir, 'composer.json');
|
||||
if (!fs.existsSync(composerPath)) return [];
|
||||
const composer = JSON.parse(fs.readFileSync(composerPath, 'utf8'));
|
||||
return [...Object.keys(composer.require || {}), ...Object.keys(composer['require-dev'] || {})];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read mix.exs for Elixir dependencies (simple pattern match)
|
||||
* @param {string} projectDir - Project root directory
|
||||
* @returns {string[]} Array of dependency atom names
|
||||
*/
|
||||
function getElixirDeps(projectDir) {
|
||||
try {
|
||||
const mixPath = path.join(projectDir, 'mix.exs');
|
||||
if (!fs.existsSync(mixPath)) return [];
|
||||
const content = fs.readFileSync(mixPath, 'utf8');
|
||||
const deps = [];
|
||||
const matches = content.match(/\{:(\w+)/g);
|
||||
if (matches) {
|
||||
matches.forEach(m => deps.push(m.replace('{:', '')));
|
||||
}
|
||||
return deps;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect project languages and frameworks
|
||||
* @param {string} [projectDir] - Project directory (defaults to cwd)
|
||||
* @returns {{ languages: string[], frameworks: string[], primary: string, projectDir: string }}
|
||||
*/
|
||||
function detectProjectType(projectDir) {
|
||||
projectDir = projectDir || process.cwd();
|
||||
const languages = [];
|
||||
const frameworks = [];
|
||||
|
||||
// Step 1: Detect languages
|
||||
for (const rule of LANGUAGE_RULES) {
|
||||
const hasMarker = rule.markers.some(m => fileExists(projectDir, m));
|
||||
const hasExt = rule.extensions.length > 0 && hasFileWithExtension(projectDir, rule.extensions);
|
||||
|
||||
if (hasMarker || hasExt) {
|
||||
languages.push(rule.type);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate: if both typescript and javascript detected, keep typescript
|
||||
if (languages.includes('typescript') && languages.includes('javascript')) {
|
||||
const idx = languages.indexOf('javascript');
|
||||
if (idx !== -1) languages.splice(idx, 1);
|
||||
}
|
||||
|
||||
// Step 2: Detect frameworks based on markers and dependencies
|
||||
const npmDeps = getPackageJsonDeps(projectDir);
|
||||
const pyDeps = getPythonDeps(projectDir);
|
||||
const goDeps = getGoDeps(projectDir);
|
||||
const rustDeps = getRustDeps(projectDir);
|
||||
const composerDeps = getComposerDeps(projectDir);
|
||||
const elixirDeps = getElixirDeps(projectDir);
|
||||
|
||||
for (const rule of FRAMEWORK_RULES) {
|
||||
// Check marker files
|
||||
const hasMarker = rule.markers.some(m => fileExists(projectDir, m));
|
||||
|
||||
// Check package dependencies
|
||||
let hasDep = false;
|
||||
if (rule.packageKeys.length > 0) {
|
||||
let depList = [];
|
||||
switch (rule.language) {
|
||||
case 'python':
|
||||
depList = pyDeps;
|
||||
break;
|
||||
case 'typescript':
|
||||
case 'javascript':
|
||||
depList = npmDeps;
|
||||
break;
|
||||
case 'golang':
|
||||
depList = goDeps;
|
||||
break;
|
||||
case 'rust':
|
||||
depList = rustDeps;
|
||||
break;
|
||||
case 'php':
|
||||
depList = composerDeps;
|
||||
break;
|
||||
case 'elixir':
|
||||
depList = elixirDeps;
|
||||
break;
|
||||
}
|
||||
// Boundary-aware match: a dependency matches a packageKey only when it
|
||||
// equals the key, or the key is a prefix immediately followed by a
|
||||
// delimiter (/ . _ -). Plain substring matching wrongly classified
|
||||
// `preact` / `reactive` as `react`. This still matches the real cases:
|
||||
// react-dom, @remix-run/node, spring-boot-starter, org.springframework.boot,
|
||||
// github.com/labstack/echo/v4, phoenix_live_view.
|
||||
hasDep = rule.packageKeys.some(key => {
|
||||
const k = key.toLowerCase();
|
||||
return depList.some(dep => {
|
||||
const d = dep.toLowerCase();
|
||||
if (!d.startsWith(k)) return false;
|
||||
return d.length === k.length || /[/._-]/.test(d[k.length]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (hasMarker || hasDep) {
|
||||
frameworks.push(rule.framework);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Determine primary type
|
||||
let primary = 'unknown';
|
||||
if (frameworks.length > 0) {
|
||||
primary = frameworks[0];
|
||||
} else if (languages.length > 0) {
|
||||
primary = languages[0];
|
||||
}
|
||||
|
||||
// Determine if fullstack (both frontend and backend languages)
|
||||
const frontendSignals = ['react', 'vue', 'angular', 'svelte', 'nextjs', 'nuxt', 'astro', 'remix'];
|
||||
const backendSignals = ['django', 'fastapi', 'flask', 'express', 'nestjs', 'rails', 'spring', 'laravel', 'phoenix', 'gin', 'echo', 'actix', 'axum'];
|
||||
const hasFrontend = frameworks.some(f => frontendSignals.includes(f));
|
||||
const hasBackend = frameworks.some(f => backendSignals.includes(f));
|
||||
|
||||
if (hasFrontend && hasBackend) {
|
||||
primary = 'fullstack';
|
||||
}
|
||||
|
||||
return {
|
||||
languages,
|
||||
frameworks,
|
||||
primary,
|
||||
projectDir
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
detectProjectType,
|
||||
LANGUAGE_RULES,
|
||||
FRAMEWORK_RULES,
|
||||
// Exported for testing
|
||||
getPackageJsonDeps,
|
||||
getPythonDeps,
|
||||
getGoDeps,
|
||||
getRustDeps,
|
||||
getComposerDeps,
|
||||
getElixirDeps
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const CURRENT_PLUGIN_SLUG = 'ecc';
|
||||
const LEGACY_PLUGIN_SLUG = 'everything-claude-code';
|
||||
const CURRENT_PLUGIN_HANDLE = `${CURRENT_PLUGIN_SLUG}@${CURRENT_PLUGIN_SLUG}`;
|
||||
const LEGACY_PLUGIN_HANDLE = `${LEGACY_PLUGIN_SLUG}@${LEGACY_PLUGIN_SLUG}`;
|
||||
const PLUGIN_CACHE_SLUGS = [CURRENT_PLUGIN_SLUG, LEGACY_PLUGIN_SLUG];
|
||||
const PLUGIN_ROOT_SEGMENTS = [
|
||||
[CURRENT_PLUGIN_SLUG],
|
||||
[CURRENT_PLUGIN_HANDLE],
|
||||
['marketplaces', CURRENT_PLUGIN_SLUG],
|
||||
[LEGACY_PLUGIN_SLUG],
|
||||
[LEGACY_PLUGIN_HANDLE],
|
||||
['marketplaces', LEGACY_PLUGIN_SLUG],
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolve the ECC source root directory.
|
||||
*
|
||||
* Tries, in order:
|
||||
* 1. CLAUDE_PLUGIN_ROOT env var (set by Claude Code for hooks, or by user)
|
||||
* 2. Standard install location (~/.claude/) — when scripts exist there
|
||||
* 3. Known plugin roots under ~/.claude/plugins/ (current + legacy slugs)
|
||||
* 4. Plugin cache auto-detection — scans ~/.claude/plugins/cache/{ecc,everything-claude-code}/
|
||||
* 5. Fallback to ~/.claude/ (original behaviour)
|
||||
*
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.homeDir] Override home directory (for testing)
|
||||
* @param {string} [options.envRoot] Override CLAUDE_PLUGIN_ROOT (for testing)
|
||||
* @param {string} [options.probe] Relative path used to verify a candidate root
|
||||
* contains ECC scripts. Default: 'scripts/lib/utils.js'
|
||||
* @returns {string} Resolved ECC root path
|
||||
*/
|
||||
function resolveEccRoot(options = {}) {
|
||||
const envRoot = options.envRoot !== undefined
|
||||
? options.envRoot
|
||||
: (process.env.CLAUDE_PLUGIN_ROOT || '');
|
||||
|
||||
if (envRoot && envRoot.trim()) {
|
||||
return envRoot.trim();
|
||||
}
|
||||
|
||||
const homeDir = options.homeDir || os.homedir();
|
||||
const claudeDir = path.join(homeDir, '.claude');
|
||||
const probe = options.probe || path.join('scripts', 'lib', 'utils.js');
|
||||
|
||||
// Standard install — files are copied directly into ~/.claude/
|
||||
if (fs.existsSync(path.join(claudeDir, probe))) {
|
||||
return claudeDir;
|
||||
}
|
||||
|
||||
// Exact legacy plugin install locations. These preserve backwards
|
||||
// compatibility without scanning arbitrary plugin trees.
|
||||
const legacyPluginRoots = PLUGIN_ROOT_SEGMENTS.map((segments) =>
|
||||
path.join(claudeDir, 'plugins', ...segments)
|
||||
);
|
||||
|
||||
for (const candidate of legacyPluginRoots) {
|
||||
if (fs.existsSync(path.join(candidate, probe))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin cache — Claude Code stores marketplace plugins under
|
||||
// ~/.claude/plugins/cache/<plugin-name>/<org>/<version>/
|
||||
try {
|
||||
for (const slug of PLUGIN_CACHE_SLUGS) {
|
||||
const cacheBase = path.join(claudeDir, 'plugins', 'cache', slug);
|
||||
const orgDirs = fs.readdirSync(cacheBase, { withFileTypes: true });
|
||||
|
||||
for (const orgEntry of orgDirs) {
|
||||
if (!orgEntry.isDirectory()) continue;
|
||||
const orgPath = path.join(cacheBase, orgEntry.name);
|
||||
|
||||
let versionDirs;
|
||||
try {
|
||||
versionDirs = fs.readdirSync(orgPath, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const verEntry of versionDirs) {
|
||||
if (!verEntry.isDirectory()) continue;
|
||||
const candidate = path.join(orgPath, verEntry.name);
|
||||
if (fs.existsSync(path.join(candidate, probe))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Plugin cache doesn't exist or isn't readable — continue to fallback
|
||||
}
|
||||
|
||||
return claudeDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact inline locator for embedding in hooks.json and command .md code blocks.
|
||||
*
|
||||
* Earlier revisions inlined the *entire* resolveEccRoot() search (~700 chars,
|
||||
* duplicated ~80×). That blob used a spread (`...s`) over nested array literals,
|
||||
* which broke Windows hook execution due to shell quoting (#2368).
|
||||
*
|
||||
* This minified form contains no spread, no nested array literals, and no
|
||||
* escaped double quotes, so it survives `node -e "..."` quoting on every shell.
|
||||
* When CLAUDE_PLUGIN_ROOT is set (as Claude Code does for plugin hooks and
|
||||
* commands) it is used directly. Otherwise the inline probes the same set of
|
||||
* locations resolveEccRoot() knows about — ~/.claude, the exact plugin roots
|
||||
* under ~/.claude/plugins/, and the versioned plugin cache — only far enough to
|
||||
* load the committed resolve-ecc-root module, then delegates the authoritative
|
||||
* decision to resolveEccRoot(). This keeps discovery behaviour identical to the
|
||||
* old inline while centralising the real logic in one tested module.
|
||||
*
|
||||
* Usage in commands:
|
||||
* const _r = <paste INLINE_RESOLVE>;
|
||||
* const sm = require(_r + '/scripts/lib/session-manager');
|
||||
*/
|
||||
const INLINE_RESOLVE = `(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})()`;
|
||||
|
||||
module.exports = {
|
||||
resolveEccRoot,
|
||||
INLINE_RESOLVE,
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Shared formatter resolution utilities with caching.
|
||||
*
|
||||
* Extracts project-root discovery, formatter detection, and binary
|
||||
* resolution into a single module so that post-edit-format.js and
|
||||
* quality-gate.js avoid duplicating work and filesystem lookups.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
// ── Caches (per-process, cleared on next hook invocation) ───────────
|
||||
const projectRootCache = new Map();
|
||||
const formatterCache = new Map();
|
||||
const binCache = new Map();
|
||||
|
||||
// ── Config file lists (single source of truth) ─────────────────────
|
||||
|
||||
const BIOME_CONFIGS = ['biome.json', 'biome.jsonc'];
|
||||
|
||||
const PRETTIER_CONFIGS = [
|
||||
'.prettierrc',
|
||||
'.prettierrc.json',
|
||||
'.prettierrc.js',
|
||||
'.prettierrc.cjs',
|
||||
'.prettierrc.mjs',
|
||||
'.prettierrc.yml',
|
||||
'.prettierrc.yaml',
|
||||
'.prettierrc.toml',
|
||||
'prettier.config.js',
|
||||
'prettier.config.cjs',
|
||||
'prettier.config.mjs'
|
||||
];
|
||||
|
||||
const PROJECT_ROOT_MARKERS = ['package.json', ...BIOME_CONFIGS, ...PRETTIER_CONFIGS];
|
||||
|
||||
// ── Windows .cmd shim mapping ───────────────────────────────────────
|
||||
const WIN_CMD_SHIMS = { npx: 'npx.cmd', pnpm: 'pnpm.cmd', yarn: 'yarn.cmd', bunx: 'bunx.cmd' };
|
||||
|
||||
// ── Formatter → package name mapping ────────────────────────────────
|
||||
const FORMATTER_PACKAGES = {
|
||||
biome: { binName: 'biome', pkgName: '@biomejs/biome' },
|
||||
prettier: { binName: 'prettier', pkgName: 'prettier' }
|
||||
};
|
||||
|
||||
// ── Public helpers ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Walk up from `startDir` until a directory containing a known project
|
||||
* root marker (package.json or formatter config) is found.
|
||||
* Returns `startDir` as fallback when no marker exists above it.
|
||||
*
|
||||
* @param {string} startDir - Absolute directory path to start from
|
||||
* @returns {string} Absolute path to the project root
|
||||
*/
|
||||
function findProjectRoot(startDir) {
|
||||
if (projectRootCache.has(startDir)) return projectRootCache.get(startDir);
|
||||
|
||||
const homeDir = os.homedir();
|
||||
let dir = startDir;
|
||||
while (dir !== path.dirname(dir)) {
|
||||
// Stop before checking the home directory to avoid treating global
|
||||
// dotfiles (e.g. ~/.prettierrc) as a project root marker.
|
||||
if (dir === homeDir) break;
|
||||
for (const marker of PROJECT_ROOT_MARKERS) {
|
||||
if (fs.existsSync(path.join(dir, marker))) {
|
||||
projectRootCache.set(startDir, dir);
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
|
||||
projectRootCache.set(startDir, startDir);
|
||||
return startDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the formatter configured in the project.
|
||||
* Biome takes priority over Prettier.
|
||||
*
|
||||
* @param {string} projectRoot - Absolute path to the project root
|
||||
* @returns {'biome' | 'prettier' | null}
|
||||
*/
|
||||
function detectFormatter(projectRoot) {
|
||||
if (formatterCache.has(projectRoot)) return formatterCache.get(projectRoot);
|
||||
|
||||
for (const cfg of BIOME_CONFIGS) {
|
||||
if (fs.existsSync(path.join(projectRoot, cfg))) {
|
||||
formatterCache.set(projectRoot, 'biome');
|
||||
return 'biome';
|
||||
}
|
||||
}
|
||||
|
||||
// Check package.json "prettier" key before config files
|
||||
try {
|
||||
const pkgPath = path.join(projectRoot, 'package.json');
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
if ('prettier' in pkg) {
|
||||
formatterCache.set(projectRoot, 'prettier');
|
||||
return 'prettier';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Malformed package.json — continue to file-based detection
|
||||
}
|
||||
|
||||
for (const cfg of PRETTIER_CONFIGS) {
|
||||
if (fs.existsSync(path.join(projectRoot, cfg))) {
|
||||
formatterCache.set(projectRoot, 'prettier');
|
||||
return 'prettier';
|
||||
}
|
||||
}
|
||||
|
||||
formatterCache.set(projectRoot, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the runner binary and prefix args for the configured package
|
||||
* manager (respects CLAUDE_PACKAGE_MANAGER env and project config).
|
||||
*
|
||||
* @param {string} projectRoot - Absolute path to the project root
|
||||
* @returns {{ bin: string, prefix: string[] }}
|
||||
*/
|
||||
function getRunnerFromPackageManager(projectRoot) {
|
||||
const isWin = process.platform === 'win32';
|
||||
const { getPackageManager } = require('./package-manager');
|
||||
const pm = getPackageManager({ projectDir: projectRoot });
|
||||
const execCmd = pm?.config?.execCmd || 'npx';
|
||||
const [rawBin = 'npx', ...prefix] = execCmd.split(/\s+/).filter(Boolean);
|
||||
const bin = isWin ? WIN_CMD_SHIMS[rawBin] || rawBin : rawBin;
|
||||
return { bin, prefix };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the formatter binary, preferring the local node_modules/.bin
|
||||
* installation over the package manager exec command to avoid
|
||||
* package-resolution overhead.
|
||||
*
|
||||
* @param {string} projectRoot - Absolute path to the project root
|
||||
* @param {'biome' | 'prettier'} formatter - Detected formatter name
|
||||
* @returns {{ bin: string, prefix: string[] } | null}
|
||||
* `bin` – executable path (absolute local path or runner binary)
|
||||
* `prefix` – extra args to prepend (e.g. ['@biomejs/biome'] when using npx)
|
||||
*/
|
||||
function resolveFormatterBin(projectRoot, formatter) {
|
||||
const cacheKey = `${projectRoot}:${formatter}`;
|
||||
if (binCache.has(cacheKey)) return binCache.get(cacheKey);
|
||||
|
||||
const pkg = FORMATTER_PACKAGES[formatter];
|
||||
if (!pkg) {
|
||||
binCache.set(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const isWin = process.platform === 'win32';
|
||||
const localBin = path.join(projectRoot, 'node_modules', '.bin', isWin ? `${pkg.binName}.cmd` : pkg.binName);
|
||||
|
||||
if (fs.existsSync(localBin)) {
|
||||
const result = { bin: localBin, prefix: [] };
|
||||
binCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const runner = getRunnerFromPackageManager(projectRoot);
|
||||
const result = { bin: runner.bin, prefix: [...runner.prefix, pkg.pkgName] };
|
||||
binCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all caches. Useful for testing.
|
||||
*/
|
||||
function clearCaches() {
|
||||
projectRootCache.clear();
|
||||
formatterCache.clear();
|
||||
binCache.clear();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findProjectRoot,
|
||||
detectFormatter,
|
||||
resolveFormatterBin,
|
||||
clearCaches
|
||||
};
|
||||
@@ -0,0 +1,644 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const SESSION_SCHEMA_VERSION = 'ecc.session.v1';
|
||||
const SESSION_RECORDING_SCHEMA_VERSION = 'ecc.session.recording.v1';
|
||||
const DEFAULT_RECORDING_DIR = path.join(os.tmpdir(), 'ecc-session-recordings');
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function sanitizePathSegment(value) {
|
||||
return String(value || 'unknown')
|
||||
.trim()
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '') || 'unknown';
|
||||
}
|
||||
|
||||
function parseContextSeedPaths(context) {
|
||||
if (typeof context !== 'string' || context.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return context
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function ensureString(value, fieldPath) {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new Error(`Canonical session snapshot requires ${fieldPath} to be a non-empty string`);
|
||||
}
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureBoolean(value, fieldPath) {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new Error(`Canonical session snapshot requires ${fieldPath} to be a boolean`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureArrayOfStrings(value, fieldPath) {
|
||||
if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
|
||||
throw new Error(`Canonical session snapshot requires ${fieldPath} to be an array of strings`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureInteger(value, fieldPath) {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`Canonical session snapshot requires ${fieldPath} to be a non-negative integer`);
|
||||
}
|
||||
}
|
||||
|
||||
const STALE_THRESHOLD_MS = 5 * 60 * 1000;
|
||||
|
||||
function parseUpdatedMs(updated) {
|
||||
if (typeof updated !== 'string' || updated.length === 0) return null;
|
||||
const ms = Date.parse(updated);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
|
||||
function deriveWorkerHealth(rawWorker) {
|
||||
const state = (rawWorker.status && rawWorker.status.state) || 'unknown';
|
||||
const completedStates = ['completed', 'succeeded', 'success', 'done'];
|
||||
const failedStates = ['failed', 'error'];
|
||||
|
||||
if (failedStates.includes(state)) return 'degraded';
|
||||
if (completedStates.includes(state)) return 'healthy';
|
||||
|
||||
if (state === 'running' || state === 'active') {
|
||||
const pane = rawWorker.pane;
|
||||
if (pane && pane.dead) return 'degraded';
|
||||
|
||||
const updatedMs = parseUpdatedMs(rawWorker.status && rawWorker.status.updated);
|
||||
if (updatedMs === null) return 'stale';
|
||||
if (Date.now() - updatedMs > STALE_THRESHOLD_MS) return 'stale';
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function buildAggregates(workers) {
|
||||
const states = workers.reduce((accumulator, worker) => {
|
||||
const state = worker.state || 'unknown';
|
||||
accumulator[state] = (accumulator[state] || 0) + 1;
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
const healths = workers.reduce((accumulator, worker) => {
|
||||
const health = worker.health || 'unknown';
|
||||
accumulator[health] = (accumulator[health] || 0) + 1;
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
workerCount: workers.length,
|
||||
states,
|
||||
healths
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeRawWorkerStates(snapshot) {
|
||||
if (isObject(snapshot.workerStates)) {
|
||||
return snapshot.workerStates;
|
||||
}
|
||||
|
||||
return (snapshot.workers || []).reduce((counts, worker) => {
|
||||
const state = worker && worker.status && worker.status.state
|
||||
? worker.status.state
|
||||
: 'unknown';
|
||||
counts[state] = (counts[state] || 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function deriveDmuxSessionState(snapshot) {
|
||||
const workerStates = summarizeRawWorkerStates(snapshot);
|
||||
const totalWorkers = Number.isInteger(snapshot.workerCount)
|
||||
? snapshot.workerCount
|
||||
: Object.values(workerStates).reduce((sum, count) => sum + count, 0);
|
||||
|
||||
if (snapshot.sessionActive) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
if (totalWorkers === 0) {
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
const failedCount = (workerStates.failed || 0) + (workerStates.error || 0);
|
||||
if (failedCount > 0) {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
const completedCount = (workerStates.completed || 0)
|
||||
+ (workerStates.succeeded || 0)
|
||||
+ (workerStates.success || 0)
|
||||
+ (workerStates.done || 0);
|
||||
if (completedCount === totalWorkers) {
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
function validateCanonicalSnapshot(snapshot) {
|
||||
if (!isObject(snapshot)) {
|
||||
throw new Error('Canonical session snapshot must be an object');
|
||||
}
|
||||
|
||||
ensureString(snapshot.schemaVersion, 'schemaVersion');
|
||||
if (snapshot.schemaVersion !== SESSION_SCHEMA_VERSION) {
|
||||
throw new Error(`Unsupported canonical session schema version: ${snapshot.schemaVersion}`);
|
||||
}
|
||||
|
||||
ensureString(snapshot.adapterId, 'adapterId');
|
||||
|
||||
if (!isObject(snapshot.session)) {
|
||||
throw new Error('Canonical session snapshot requires session to be an object');
|
||||
}
|
||||
|
||||
ensureString(snapshot.session.id, 'session.id');
|
||||
ensureString(snapshot.session.kind, 'session.kind');
|
||||
ensureString(snapshot.session.state, 'session.state');
|
||||
ensureOptionalString(snapshot.session.repoRoot, 'session.repoRoot');
|
||||
|
||||
if (!isObject(snapshot.session.sourceTarget)) {
|
||||
throw new Error('Canonical session snapshot requires session.sourceTarget to be an object');
|
||||
}
|
||||
|
||||
ensureString(snapshot.session.sourceTarget.type, 'session.sourceTarget.type');
|
||||
ensureString(snapshot.session.sourceTarget.value, 'session.sourceTarget.value');
|
||||
|
||||
if (!Array.isArray(snapshot.workers)) {
|
||||
throw new Error('Canonical session snapshot requires workers to be an array');
|
||||
}
|
||||
|
||||
snapshot.workers.forEach((worker, index) => {
|
||||
if (!isObject(worker)) {
|
||||
throw new Error(`Canonical session snapshot requires workers[${index}] to be an object`);
|
||||
}
|
||||
|
||||
ensureString(worker.id, `workers[${index}].id`);
|
||||
ensureString(worker.label, `workers[${index}].label`);
|
||||
ensureString(worker.state, `workers[${index}].state`);
|
||||
ensureString(worker.health, `workers[${index}].health`);
|
||||
ensureOptionalString(worker.branch, `workers[${index}].branch`);
|
||||
ensureOptionalString(worker.worktree, `workers[${index}].worktree`);
|
||||
|
||||
if (!isObject(worker.runtime)) {
|
||||
throw new Error(`Canonical session snapshot requires workers[${index}].runtime to be an object`);
|
||||
}
|
||||
|
||||
ensureString(worker.runtime.kind, `workers[${index}].runtime.kind`);
|
||||
ensureOptionalString(worker.runtime.command, `workers[${index}].runtime.command`);
|
||||
ensureBoolean(worker.runtime.active, `workers[${index}].runtime.active`);
|
||||
ensureBoolean(worker.runtime.dead, `workers[${index}].runtime.dead`);
|
||||
|
||||
if (!isObject(worker.intent)) {
|
||||
throw new Error(`Canonical session snapshot requires workers[${index}].intent to be an object`);
|
||||
}
|
||||
|
||||
ensureStringAllowEmpty(worker.intent.objective, `workers[${index}].intent.objective`);
|
||||
ensureArrayOfStrings(worker.intent.seedPaths, `workers[${index}].intent.seedPaths`);
|
||||
|
||||
if (!isObject(worker.outputs)) {
|
||||
throw new Error(`Canonical session snapshot requires workers[${index}].outputs to be an object`);
|
||||
}
|
||||
|
||||
ensureArrayOfStrings(worker.outputs.summary, `workers[${index}].outputs.summary`);
|
||||
ensureArrayOfStrings(worker.outputs.validation, `workers[${index}].outputs.validation`);
|
||||
ensureArrayOfStrings(worker.outputs.remainingRisks, `workers[${index}].outputs.remainingRisks`);
|
||||
|
||||
if (!isObject(worker.artifacts)) {
|
||||
throw new Error(`Canonical session snapshot requires workers[${index}].artifacts to be an object`);
|
||||
}
|
||||
});
|
||||
|
||||
if (!isObject(snapshot.aggregates)) {
|
||||
throw new Error('Canonical session snapshot requires aggregates to be an object');
|
||||
}
|
||||
|
||||
ensureInteger(snapshot.aggregates.workerCount, 'aggregates.workerCount');
|
||||
if (snapshot.aggregates.workerCount !== snapshot.workers.length) {
|
||||
throw new Error('Canonical session snapshot requires aggregates.workerCount to match workers.length');
|
||||
}
|
||||
|
||||
if (!isObject(snapshot.aggregates.states)) {
|
||||
throw new Error('Canonical session snapshot requires aggregates.states to be an object');
|
||||
}
|
||||
|
||||
if (!isObject(snapshot.aggregates.healths)) {
|
||||
throw new Error('Canonical session snapshot requires aggregates.healths to be an object');
|
||||
}
|
||||
|
||||
for (const [state, count] of Object.entries(snapshot.aggregates.states)) {
|
||||
ensureString(state, 'aggregates.states key');
|
||||
ensureInteger(count, `aggregates.states.${state}`);
|
||||
}
|
||||
|
||||
for (const [health, count] of Object.entries(snapshot.aggregates.healths)) {
|
||||
ensureString(health, 'aggregates.healths key');
|
||||
ensureInteger(count, `aggregates.healths.${health}`);
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function resolveRecordingDir(options = {}) {
|
||||
if (typeof options.recordingDir === 'string' && options.recordingDir.length > 0) {
|
||||
return path.resolve(options.recordingDir);
|
||||
}
|
||||
|
||||
if (typeof process.env.ECC_SESSION_RECORDING_DIR === 'string' && process.env.ECC_SESSION_RECORDING_DIR.length > 0) {
|
||||
return path.resolve(process.env.ECC_SESSION_RECORDING_DIR);
|
||||
}
|
||||
|
||||
return DEFAULT_RECORDING_DIR;
|
||||
}
|
||||
|
||||
function getFallbackSessionRecordingPath(snapshot, options = {}) {
|
||||
validateCanonicalSnapshot(snapshot);
|
||||
|
||||
return path.join(
|
||||
resolveRecordingDir(options),
|
||||
sanitizePathSegment(snapshot.adapterId),
|
||||
`${sanitizePathSegment(snapshot.session.id)}.json`
|
||||
);
|
||||
}
|
||||
|
||||
function readExistingRecording(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeFallbackSessionRecording(snapshot, options = {}) {
|
||||
const filePath = getFallbackSessionRecordingPath(snapshot, options);
|
||||
const recordedAt = new Date().toISOString();
|
||||
const existing = readExistingRecording(filePath);
|
||||
const snapshotChanged = !existing
|
||||
|| JSON.stringify(existing.latest) !== JSON.stringify(snapshot);
|
||||
|
||||
const payload = {
|
||||
schemaVersion: SESSION_RECORDING_SCHEMA_VERSION,
|
||||
adapterId: snapshot.adapterId,
|
||||
sessionId: snapshot.session.id,
|
||||
createdAt: existing && typeof existing.createdAt === 'string'
|
||||
? existing.createdAt
|
||||
: recordedAt,
|
||||
updatedAt: recordedAt,
|
||||
latest: snapshot,
|
||||
history: Array.isArray(existing && existing.history)
|
||||
? (snapshotChanged
|
||||
? existing.history.concat([{ recordedAt, snapshot }])
|
||||
: existing.history)
|
||||
: [{ recordedAt, snapshot }]
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
||||
|
||||
return {
|
||||
backend: 'json-file',
|
||||
path: filePath,
|
||||
recordedAt
|
||||
};
|
||||
}
|
||||
|
||||
function loadStateStore(options = {}) {
|
||||
if (options.stateStore) {
|
||||
return options.stateStore;
|
||||
}
|
||||
|
||||
const loadStateStoreImpl = options.loadStateStoreImpl || (() => require('../state-store'));
|
||||
|
||||
try {
|
||||
return loadStateStoreImpl();
|
||||
} catch (error) {
|
||||
const missingRequestedModule = error
|
||||
&& error.code === 'MODULE_NOT_FOUND'
|
||||
&& typeof error.message === 'string'
|
||||
&& error.message.includes('../state-store');
|
||||
|
||||
if (missingRequestedModule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStateStoreWriter(stateStore) {
|
||||
if (!stateStore) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
{ owner: stateStore, fn: stateStore.persistCanonicalSessionSnapshot },
|
||||
{ owner: stateStore, fn: stateStore.recordCanonicalSessionSnapshot },
|
||||
{ owner: stateStore, fn: stateStore.persistSessionSnapshot },
|
||||
{ owner: stateStore, fn: stateStore.recordSessionSnapshot },
|
||||
{ owner: stateStore, fn: stateStore.writeSessionSnapshot },
|
||||
{
|
||||
owner: stateStore.sessions,
|
||||
fn: stateStore.sessions && stateStore.sessions.persistCanonicalSessionSnapshot
|
||||
},
|
||||
{
|
||||
owner: stateStore.sessions,
|
||||
fn: stateStore.sessions && stateStore.sessions.recordCanonicalSessionSnapshot
|
||||
},
|
||||
{
|
||||
owner: stateStore.sessions,
|
||||
fn: stateStore.sessions && stateStore.sessions.persistSessionSnapshot
|
||||
},
|
||||
{
|
||||
owner: stateStore.sessions,
|
||||
fn: stateStore.sessions && stateStore.sessions.recordSessionSnapshot
|
||||
}
|
||||
];
|
||||
|
||||
const writer = candidates.find(candidate => typeof candidate.fn === 'function');
|
||||
return writer ? writer.fn.bind(writer.owner) : null;
|
||||
}
|
||||
|
||||
function persistCanonicalSnapshot(snapshot, options = {}) {
|
||||
validateCanonicalSnapshot(snapshot);
|
||||
|
||||
if (options.persist === false) {
|
||||
return {
|
||||
backend: 'skipped',
|
||||
path: null,
|
||||
recordedAt: null
|
||||
};
|
||||
}
|
||||
|
||||
const stateStore = loadStateStore(options);
|
||||
const writer = resolveStateStoreWriter(stateStore);
|
||||
|
||||
if (stateStore && !writer) {
|
||||
// The loaded object is a factory module (e.g. has createStateStore but no
|
||||
// writer methods). Treat it the same as a missing state store and fall
|
||||
// through to the JSON-file recording path below.
|
||||
return writeFallbackSessionRecording(snapshot, options);
|
||||
}
|
||||
|
||||
if (writer) {
|
||||
writer(snapshot, {
|
||||
adapterId: snapshot.adapterId,
|
||||
schemaVersion: snapshot.schemaVersion,
|
||||
sessionId: snapshot.session.id
|
||||
});
|
||||
|
||||
return {
|
||||
backend: 'state-store',
|
||||
path: null,
|
||||
recordedAt: null
|
||||
};
|
||||
}
|
||||
|
||||
return writeFallbackSessionRecording(snapshot, options);
|
||||
}
|
||||
|
||||
function normalizeDmuxSnapshot(snapshot, sourceTarget) {
|
||||
const workers = (snapshot.workers || []).map(worker => ({
|
||||
id: worker.workerSlug,
|
||||
label: worker.workerSlug,
|
||||
state: worker.status.state || 'unknown',
|
||||
health: deriveWorkerHealth(worker),
|
||||
branch: worker.status.branch || null,
|
||||
worktree: worker.status.worktree || null,
|
||||
runtime: {
|
||||
kind: 'tmux-pane',
|
||||
command: worker.pane ? worker.pane.currentCommand || null : null,
|
||||
pid: worker.pane ? worker.pane.pid || null : null,
|
||||
active: worker.pane ? Boolean(worker.pane.active) : false,
|
||||
dead: worker.pane ? Boolean(worker.pane.dead) : false,
|
||||
},
|
||||
intent: {
|
||||
objective: worker.task.objective || '',
|
||||
seedPaths: Array.isArray(worker.task.seedPaths) ? worker.task.seedPaths : []
|
||||
},
|
||||
outputs: {
|
||||
summary: Array.isArray(worker.handoff.summary) ? worker.handoff.summary : [],
|
||||
validation: Array.isArray(worker.handoff.validation) ? worker.handoff.validation : [],
|
||||
remainingRisks: Array.isArray(worker.handoff.remainingRisks) ? worker.handoff.remainingRisks : []
|
||||
},
|
||||
artifacts: {
|
||||
statusFile: worker.files.status,
|
||||
taskFile: worker.files.task,
|
||||
handoffFile: worker.files.handoff
|
||||
}
|
||||
}));
|
||||
|
||||
return validateCanonicalSnapshot({
|
||||
schemaVersion: SESSION_SCHEMA_VERSION,
|
||||
adapterId: 'dmux-tmux',
|
||||
session: {
|
||||
id: snapshot.sessionName,
|
||||
kind: 'orchestrated',
|
||||
state: deriveDmuxSessionState(snapshot),
|
||||
repoRoot: snapshot.repoRoot || null,
|
||||
sourceTarget
|
||||
},
|
||||
workers,
|
||||
aggregates: buildAggregates(workers)
|
||||
});
|
||||
}
|
||||
|
||||
function deriveClaudeWorkerId(session) {
|
||||
if (session.shortId && session.shortId !== 'no-id') {
|
||||
return session.shortId;
|
||||
}
|
||||
|
||||
return path.basename(session.filename || session.sessionPath || 'session', '.tmp');
|
||||
}
|
||||
|
||||
function normalizeClaudeHistorySession(session, sourceTarget) {
|
||||
const metadata = session.metadata || {};
|
||||
const workerId = deriveClaudeWorkerId(session);
|
||||
const worker = {
|
||||
id: workerId,
|
||||
label: metadata.title || session.filename || workerId,
|
||||
state: 'recorded',
|
||||
health: 'healthy',
|
||||
branch: metadata.branch || null,
|
||||
worktree: metadata.worktree || null,
|
||||
runtime: {
|
||||
kind: 'claude-session',
|
||||
command: 'claude',
|
||||
pid: null,
|
||||
active: false,
|
||||
dead: true,
|
||||
},
|
||||
intent: {
|
||||
objective: metadata.inProgress && metadata.inProgress.length > 0
|
||||
? metadata.inProgress[0]
|
||||
: (metadata.title || ''),
|
||||
seedPaths: parseContextSeedPaths(metadata.context)
|
||||
},
|
||||
outputs: {
|
||||
summary: Array.isArray(metadata.completed) ? metadata.completed : [],
|
||||
validation: [],
|
||||
remainingRisks: metadata.notes ? [metadata.notes] : []
|
||||
},
|
||||
artifacts: {
|
||||
sessionFile: session.sessionPath,
|
||||
context: metadata.context || null
|
||||
}
|
||||
};
|
||||
|
||||
return validateCanonicalSnapshot({
|
||||
schemaVersion: SESSION_SCHEMA_VERSION,
|
||||
adapterId: 'claude-history',
|
||||
session: {
|
||||
id: workerId,
|
||||
kind: 'history',
|
||||
state: 'recorded',
|
||||
repoRoot: metadata.worktree || null,
|
||||
sourceTarget
|
||||
},
|
||||
workers: [worker],
|
||||
aggregates: buildAggregates([worker])
|
||||
});
|
||||
}
|
||||
|
||||
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,160 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const sessionManager = require('../session-manager');
|
||||
const sessionAliases = require('../session-aliases');
|
||||
const { normalizeClaudeHistorySession, persistCanonicalSnapshot } = require('./canonical-session');
|
||||
|
||||
function parseClaudeTarget(target) {
|
||||
if (typeof target !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const prefix of ['claude-history:', 'claude:', 'history:']) {
|
||||
if (target.startsWith(prefix)) {
|
||||
return target.slice(prefix.length).trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSessionFileTarget(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()
|
||||
&& absoluteTarget.endsWith('.tmp');
|
||||
}
|
||||
|
||||
function hydrateSessionFromPath(sessionPath) {
|
||||
const filename = path.basename(sessionPath);
|
||||
const parsed = sessionManager.parseSessionFilename(filename);
|
||||
if (!parsed) {
|
||||
throw new Error(`Unsupported session file: ${sessionPath}`);
|
||||
}
|
||||
|
||||
const content = sessionManager.getSessionContent(sessionPath);
|
||||
const stats = fs.statSync(sessionPath);
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
sessionPath,
|
||||
content,
|
||||
metadata: sessionManager.parseSessionMetadata(content),
|
||||
stats: sessionManager.getSessionStats(content || ''),
|
||||
size: stats.size,
|
||||
modifiedTime: stats.mtime,
|
||||
createdTime: stats.birthtime || stats.ctime
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSessionRecord(target, cwd) {
|
||||
const explicitTarget = parseClaudeTarget(target);
|
||||
|
||||
if (explicitTarget) {
|
||||
if (explicitTarget === 'latest') {
|
||||
const [latest] = sessionManager.getAllSessions({ limit: 1 }).sessions;
|
||||
if (!latest) {
|
||||
throw new Error('No Claude session history found');
|
||||
}
|
||||
|
||||
return {
|
||||
session: sessionManager.getSessionById(latest.filename, true),
|
||||
sourceTarget: {
|
||||
type: 'claude-history',
|
||||
value: 'latest'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const alias = sessionAliases.resolveAlias(explicitTarget);
|
||||
if (alias) {
|
||||
return {
|
||||
session: hydrateSessionFromPath(alias.sessionPath),
|
||||
sourceTarget: {
|
||||
type: 'claude-alias',
|
||||
value: explicitTarget
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const session = sessionManager.getSessionById(explicitTarget, true);
|
||||
if (!session) {
|
||||
throw new Error(`Claude session not found: ${explicitTarget}`);
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
sourceTarget: {
|
||||
type: 'claude-history',
|
||||
value: explicitTarget
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (isSessionFileTarget(target, cwd)) {
|
||||
return {
|
||||
session: hydrateSessionFromPath(path.resolve(cwd, target)),
|
||||
sourceTarget: {
|
||||
type: 'session-file',
|
||||
value: path.resolve(cwd, target)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported Claude session target: ${target}`);
|
||||
}
|
||||
|
||||
function createClaudeHistoryAdapter(options = {}) {
|
||||
const persistCanonicalSnapshotImpl = options.persistCanonicalSnapshotImpl || persistCanonicalSnapshot;
|
||||
|
||||
return {
|
||||
id: 'claude-history',
|
||||
description: 'Claude local session history and session-file snapshots',
|
||||
targetTypes: ['claude-history', 'claude-alias', 'session-file'],
|
||||
canOpen(target, context = {}) {
|
||||
if (context.adapterId && context.adapterId !== 'claude-history') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.adapterId === 'claude-history') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cwd = context.cwd || process.cwd();
|
||||
return parseClaudeTarget(target) !== null || isSessionFileTarget(target, cwd);
|
||||
},
|
||||
open(target, context = {}) {
|
||||
const cwd = context.cwd || process.cwd();
|
||||
|
||||
return {
|
||||
adapterId: 'claude-history',
|
||||
getSnapshot() {
|
||||
const { session, sourceTarget } = resolveSessionRecord(target, cwd);
|
||||
const canonicalSnapshot = normalizeClaudeHistorySession(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 = {
|
||||
createClaudeHistoryAdapter,
|
||||
isSessionFileTarget,
|
||||
parseClaudeTarget
|
||||
};
|
||||
@@ -0,0 +1,355 @@
|
||||
'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 {
|
||||
// Strip inherited git env (GIT_DIR etc., set when running inside a git
|
||||
// hook) so the -C target is honored instead of the host repo.
|
||||
const gitEnv = { ...process.env };
|
||||
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_PREFIX']) {
|
||||
delete gitEnv[key];
|
||||
}
|
||||
const branch = execFileSync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
encoding: 'utf8',
|
||||
env: gitEnv
|
||||
}).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,90 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { collectSessionSnapshot } = require('../orchestration-session');
|
||||
const { normalizeDmuxSnapshot, persistCanonicalSnapshot } = require('./canonical-session');
|
||||
|
||||
function isPlanFileTarget(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()
|
||||
&& path.extname(absoluteTarget) === '.json';
|
||||
}
|
||||
|
||||
function isSessionNameTarget(target, cwd) {
|
||||
if (typeof target !== 'string' || target.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const coordinationDir = path.resolve(cwd, '.claude', 'orchestration', target);
|
||||
return fs.existsSync(coordinationDir) && fs.statSync(coordinationDir).isDirectory();
|
||||
}
|
||||
|
||||
function buildSourceTarget(target, cwd) {
|
||||
if (isPlanFileTarget(target, cwd)) {
|
||||
return {
|
||||
type: 'plan',
|
||||
value: path.resolve(cwd, target)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'session',
|
||||
value: target
|
||||
};
|
||||
}
|
||||
|
||||
function createDmuxTmuxAdapter(options = {}) {
|
||||
const collectSessionSnapshotImpl = options.collectSessionSnapshotImpl || collectSessionSnapshot;
|
||||
const persistCanonicalSnapshotImpl = options.persistCanonicalSnapshotImpl || persistCanonicalSnapshot;
|
||||
|
||||
return {
|
||||
id: 'dmux-tmux',
|
||||
description: 'Tmux/worktree orchestration snapshots from plan files or session names',
|
||||
targetTypes: ['plan', 'session'],
|
||||
canOpen(target, context = {}) {
|
||||
if (context.adapterId && context.adapterId !== 'dmux-tmux') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.adapterId === 'dmux-tmux') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cwd = context.cwd || process.cwd();
|
||||
return isPlanFileTarget(target, cwd) || isSessionNameTarget(target, cwd);
|
||||
},
|
||||
open(target, context = {}) {
|
||||
const cwd = context.cwd || process.cwd();
|
||||
|
||||
return {
|
||||
adapterId: 'dmux-tmux',
|
||||
getSnapshot() {
|
||||
const snapshot = collectSessionSnapshotImpl(target, cwd);
|
||||
const canonicalSnapshot = normalizeDmuxSnapshot(snapshot, buildSourceTarget(target, cwd));
|
||||
|
||||
persistCanonicalSnapshotImpl(canonicalSnapshot, {
|
||||
loadStateStoreImpl: options.loadStateStoreImpl,
|
||||
persist: context.persistSnapshots !== false && options.persistSnapshots !== false,
|
||||
recordingDir: context.recordingDir || options.recordingDir,
|
||||
stateStore: options.stateStore
|
||||
});
|
||||
|
||||
return canonicalSnapshot;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createDmuxTmuxAdapter,
|
||||
isPlanFileTarget,
|
||||
isSessionNameTarget
|
||||
};
|
||||
@@ -0,0 +1,319 @@
|
||||
'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 {
|
||||
// Strip inherited git env (GIT_DIR etc., set when running inside a git
|
||||
// hook) so the -C target is honored instead of the host repo.
|
||||
const gitEnv = { ...process.env };
|
||||
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_PREFIX']) {
|
||||
delete gitEnv[key];
|
||||
}
|
||||
const branch = execFileSync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
encoding: 'utf8',
|
||||
env: gitEnv
|
||||
}).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
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
'use strict';
|
||||
|
||||
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',
|
||||
'codex-worktree': 'codex-worktree',
|
||||
codex: 'codex-worktree',
|
||||
opencode: 'opencode'
|
||||
});
|
||||
|
||||
function buildDefaultAdapterOptions(options, adapterId) {
|
||||
const sharedOptions = {
|
||||
loadStateStoreImpl: options.loadStateStoreImpl,
|
||||
persistSnapshots: options.persistSnapshots,
|
||||
recordingDir: options.recordingDir,
|
||||
stateStore: options.stateStore
|
||||
};
|
||||
|
||||
return {
|
||||
...sharedOptions,
|
||||
...(options.adapterOptions && options.adapterOptions[adapterId]
|
||||
? options.adapterOptions[adapterId]
|
||||
: {})
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultAdapters(options = {}) {
|
||||
return [
|
||||
createClaudeHistoryAdapter(buildDefaultAdapterOptions(options, 'claude-history')),
|
||||
createDmuxTmuxAdapter(buildDefaultAdapterOptions(options, 'dmux-tmux')),
|
||||
createCodexWorktreeAdapter(buildDefaultAdapterOptions(options, 'codex-worktree')),
|
||||
createOpencodeAdapter(buildDefaultAdapterOptions(options, 'opencode'))
|
||||
];
|
||||
}
|
||||
|
||||
function coerceTargetValue(value) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error('Structured session targets require a non-empty string value');
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizeStructuredTarget(target, context = {}) {
|
||||
if (!target || typeof target !== 'object' || Array.isArray(target)) {
|
||||
return {
|
||||
target,
|
||||
context: { ...context }
|
||||
};
|
||||
}
|
||||
|
||||
const value = coerceTargetValue(target.value);
|
||||
const type = typeof target.type === 'string' ? target.type.trim() : '';
|
||||
if (type.length === 0) {
|
||||
throw new Error('Structured session targets require a non-empty type');
|
||||
}
|
||||
|
||||
const adapterId = target.adapterId || TARGET_TYPE_TO_ADAPTER_ID[type] || context.adapterId || null;
|
||||
const nextContext = {
|
||||
...context,
|
||||
adapterId
|
||||
};
|
||||
|
||||
if (type === 'claude-history' || type === 'claude-alias') {
|
||||
return {
|
||||
target: `claude:${value}`,
|
||||
context: nextContext
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
function createAdapterRegistry(options = {}) {
|
||||
const adapters = options.adapters || createDefaultAdapters(options);
|
||||
|
||||
return {
|
||||
adapters,
|
||||
getAdapter(id) {
|
||||
const adapter = adapters.find(candidate => candidate.id === id);
|
||||
if (!adapter) {
|
||||
throw new Error(`Unknown session adapter: ${id}`);
|
||||
}
|
||||
|
||||
return adapter;
|
||||
},
|
||||
listAdapters() {
|
||||
return adapters.map(adapter => ({
|
||||
id: adapter.id,
|
||||
description: adapter.description || '',
|
||||
targetTypes: Array.isArray(adapter.targetTypes) ? [...adapter.targetTypes] : []
|
||||
}));
|
||||
},
|
||||
select(target, context = {}) {
|
||||
const normalized = normalizeStructuredTarget(target, context);
|
||||
const adapter = normalized.context.adapterId
|
||||
? this.getAdapter(normalized.context.adapterId)
|
||||
: adapters.find(candidate => candidate.canOpen(normalized.target, normalized.context));
|
||||
if (!adapter) {
|
||||
throw new Error(`No session adapter matched target: ${target}`);
|
||||
}
|
||||
|
||||
return adapter;
|
||||
},
|
||||
open(target, context = {}) {
|
||||
const normalized = normalizeStructuredTarget(target, context);
|
||||
const adapter = this.select(normalized.target, normalized.context);
|
||||
return adapter.open(normalized.target, normalized.context);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function inspectSessionTarget(target, options = {}) {
|
||||
const registry = createAdapterRegistry(options);
|
||||
return registry.open(target, options).getSnapshot();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createAdapterRegistry,
|
||||
createDefaultAdapters,
|
||||
inspectSessionTarget,
|
||||
normalizeStructuredTarget
|
||||
};
|
||||
Vendored
+136
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Session Aliases Library for Claude Code.
|
||||
* Manages named aliases for session files, stored in $ECC_AGENT_DATA_HOME/session-aliases.json (default ~/.claude).
|
||||
*/
|
||||
|
||||
/** Internal alias storage entry */
|
||||
export interface AliasEntry {
|
||||
sessionPath: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
/** Alias data structure stored on disk */
|
||||
export interface AliasStore {
|
||||
version: string;
|
||||
aliases: Record<string, AliasEntry>;
|
||||
metadata: {
|
||||
totalCount: number;
|
||||
lastUpdated: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolved alias information returned by resolveAlias */
|
||||
export interface ResolvedAlias {
|
||||
alias: string;
|
||||
sessionPath: string;
|
||||
createdAt: string;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
/** Alias entry returned by listAliases */
|
||||
export interface AliasListItem {
|
||||
name: string;
|
||||
sessionPath: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
/** Result from mutation operations (set, delete, rename, update, cleanup) */
|
||||
export interface AliasResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SetAliasResult extends AliasResult {
|
||||
isNew?: boolean;
|
||||
alias?: string;
|
||||
sessionPath?: string;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
export interface DeleteAliasResult extends AliasResult {
|
||||
alias?: string;
|
||||
deletedSessionPath?: string;
|
||||
}
|
||||
|
||||
export interface RenameAliasResult extends AliasResult {
|
||||
oldAlias?: string;
|
||||
newAlias?: string;
|
||||
sessionPath?: string;
|
||||
}
|
||||
|
||||
export interface CleanupResult {
|
||||
totalChecked: number;
|
||||
removed: number;
|
||||
removedAliases: Array<{ name: string; sessionPath: string }>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ListAliasesOptions {
|
||||
/** Filter aliases by name or title (partial match, case-insensitive) */
|
||||
search?: string | null;
|
||||
/** Maximum number of aliases to return */
|
||||
limit?: number | null;
|
||||
}
|
||||
|
||||
/** Get the path to the aliases JSON file */
|
||||
export function getAliasesPath(): string;
|
||||
|
||||
/** Load all aliases from disk. Returns default structure if file doesn't exist. */
|
||||
export function loadAliases(): AliasStore;
|
||||
|
||||
/**
|
||||
* Save aliases to disk with atomic write (temp file + rename).
|
||||
* Creates backup before writing; restores on failure.
|
||||
*/
|
||||
export function saveAliases(aliases: AliasStore): boolean;
|
||||
|
||||
/**
|
||||
* Resolve an alias name to its session data.
|
||||
* @returns Alias data, or null if not found or invalid name
|
||||
*/
|
||||
export function resolveAlias(alias: string): ResolvedAlias | null;
|
||||
|
||||
/**
|
||||
* Create or update an alias for a session.
|
||||
* Alias names must be alphanumeric with dashes/underscores.
|
||||
* Reserved names (list, help, remove, delete, create, set) are rejected.
|
||||
*/
|
||||
export function setAlias(alias: string, sessionPath: string, title?: string | null): SetAliasResult;
|
||||
|
||||
/**
|
||||
* List all aliases, optionally filtered and limited.
|
||||
* Results are sorted by updated time (newest first).
|
||||
*/
|
||||
export function listAliases(options?: ListAliasesOptions): AliasListItem[];
|
||||
|
||||
/** Delete an alias by name */
|
||||
export function deleteAlias(alias: string): DeleteAliasResult;
|
||||
|
||||
/**
|
||||
* Rename an alias. Fails if old alias doesn't exist or new alias already exists.
|
||||
* New alias name must be alphanumeric with dashes/underscores.
|
||||
*/
|
||||
export function renameAlias(oldAlias: string, newAlias: string): RenameAliasResult;
|
||||
|
||||
/**
|
||||
* Resolve an alias or pass through a session path.
|
||||
* First tries to resolve as alias; if not found, returns the input as-is.
|
||||
*/
|
||||
export function resolveSessionAlias(aliasOrId: string): string;
|
||||
|
||||
/** Update the title of an existing alias. Pass null to clear. */
|
||||
export function updateAliasTitle(alias: string, title: string | null): AliasResult;
|
||||
|
||||
/** Get all aliases that point to a specific session path */
|
||||
export function getAliasesForSession(sessionPath: string): Array<{ name: string; createdAt: string; title: string | null }>;
|
||||
|
||||
/**
|
||||
* Remove aliases whose sessions no longer exist.
|
||||
* @param sessionExists - Function that returns true if a session path is valid
|
||||
*/
|
||||
export function cleanupAliases(sessionExists: (sessionPath: string) => boolean): CleanupResult;
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* Session Aliases Library for Claude Code
|
||||
* Manages session aliases stored in $ECC_AGENT_DATA_HOME/session-aliases.json (default ~/.claude).
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
getClaudeDir,
|
||||
ensureDir,
|
||||
readFile,
|
||||
log
|
||||
} = require('./utils');
|
||||
|
||||
// Aliases file path
|
||||
function getAliasesPath() {
|
||||
return path.join(getClaudeDir(), 'session-aliases.json');
|
||||
}
|
||||
|
||||
// Current alias storage format version
|
||||
const ALIAS_VERSION = '1.0';
|
||||
|
||||
/**
|
||||
* Default aliases file structure
|
||||
*/
|
||||
function getDefaultAliases() {
|
||||
return {
|
||||
version: ALIAS_VERSION,
|
||||
aliases: {},
|
||||
metadata: {
|
||||
totalCount: 0,
|
||||
lastUpdated: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load aliases from file
|
||||
* @returns {object} Aliases object
|
||||
*/
|
||||
function loadAliases() {
|
||||
const aliasesPath = getAliasesPath();
|
||||
|
||||
if (!fs.existsSync(aliasesPath)) {
|
||||
return getDefaultAliases();
|
||||
}
|
||||
|
||||
const content = readFile(aliasesPath);
|
||||
if (!content) {
|
||||
return getDefaultAliases();
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(content);
|
||||
|
||||
// Validate structure
|
||||
if (!data.aliases || typeof data.aliases !== 'object') {
|
||||
log('[Aliases] Invalid aliases file structure, resetting');
|
||||
return getDefaultAliases();
|
||||
}
|
||||
|
||||
// Ensure version field
|
||||
if (!data.version) {
|
||||
data.version = ALIAS_VERSION;
|
||||
}
|
||||
|
||||
// Ensure metadata
|
||||
if (!data.metadata) {
|
||||
data.metadata = {
|
||||
totalCount: Object.keys(data.aliases).length,
|
||||
lastUpdated: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
log(`[Aliases] Error parsing aliases file: ${err.message}`);
|
||||
return getDefaultAliases();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save aliases to file with atomic write
|
||||
* @param {object} aliases - Aliases object to save
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
function saveAliases(aliases) {
|
||||
const aliasesPath = getAliasesPath();
|
||||
const tempPath = aliasesPath + '.tmp';
|
||||
const backupPath = aliasesPath + '.bak';
|
||||
|
||||
try {
|
||||
// Update metadata
|
||||
aliases.metadata = {
|
||||
totalCount: Object.keys(aliases.aliases).length,
|
||||
lastUpdated: new Date().toISOString()
|
||||
};
|
||||
|
||||
const content = JSON.stringify(aliases, null, 2);
|
||||
|
||||
// Ensure directory exists
|
||||
ensureDir(path.dirname(aliasesPath));
|
||||
|
||||
// Create backup if file exists
|
||||
if (fs.existsSync(aliasesPath)) {
|
||||
fs.copyFileSync(aliasesPath, backupPath);
|
||||
}
|
||||
|
||||
// Atomic write: write to temp file, then rename
|
||||
fs.writeFileSync(tempPath, content, 'utf8');
|
||||
|
||||
// On Windows, rename fails with EEXIST if destination exists, so delete first.
|
||||
// On Unix/macOS, rename(2) atomically replaces the destination — skip the
|
||||
// delete to avoid an unnecessary non-atomic window between unlink and rename.
|
||||
if (process.platform === 'win32' && fs.existsSync(aliasesPath)) {
|
||||
fs.unlinkSync(aliasesPath);
|
||||
}
|
||||
fs.renameSync(tempPath, aliasesPath);
|
||||
|
||||
// Remove backup on success
|
||||
if (fs.existsSync(backupPath)) {
|
||||
fs.unlinkSync(backupPath);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
log(`[Aliases] Error saving aliases: ${err.message}`);
|
||||
|
||||
// Restore from backup if exists
|
||||
if (fs.existsSync(backupPath)) {
|
||||
try {
|
||||
fs.copyFileSync(backupPath, aliasesPath);
|
||||
log('[Aliases] Restored from backup');
|
||||
} catch (restoreErr) {
|
||||
log(`[Aliases] Failed to restore backup: ${restoreErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temp file (best-effort)
|
||||
try {
|
||||
if (fs.existsSync(tempPath)) {
|
||||
fs.unlinkSync(tempPath);
|
||||
}
|
||||
} catch {
|
||||
// Non-critical: temp file will be overwritten on next save
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an alias to get session path
|
||||
* @param {string} alias - Alias name to resolve
|
||||
* @returns {object|null} Alias data or null if not found
|
||||
*/
|
||||
function resolveAlias(alias) {
|
||||
if (!alias) return null;
|
||||
|
||||
// Validate alias name (alphanumeric, dash, underscore)
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(alias)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = loadAliases();
|
||||
const aliasData = data.aliases[alias];
|
||||
|
||||
if (!aliasData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
alias,
|
||||
sessionPath: aliasData.sessionPath,
|
||||
createdAt: aliasData.createdAt,
|
||||
title: aliasData.title || null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or update an alias for a session
|
||||
* @param {string} alias - Alias name (alphanumeric, dash, underscore)
|
||||
* @param {string} sessionPath - Session directory path
|
||||
* @param {string} title - Optional title for the alias
|
||||
* @returns {object} Result with success status and message
|
||||
*/
|
||||
function setAlias(alias, sessionPath, title = null) {
|
||||
// Validate alias name
|
||||
if (!alias || alias.length === 0) {
|
||||
return { success: false, error: 'Alias name cannot be empty' };
|
||||
}
|
||||
|
||||
// Validate session path
|
||||
if (!sessionPath || typeof sessionPath !== 'string' || sessionPath.trim().length === 0) {
|
||||
return { success: false, error: 'Session path cannot be empty' };
|
||||
}
|
||||
|
||||
if (alias.length > 128) {
|
||||
return { success: false, error: 'Alias name cannot exceed 128 characters' };
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(alias)) {
|
||||
return { success: false, error: 'Alias name must contain only letters, numbers, dashes, and underscores' };
|
||||
}
|
||||
|
||||
// Reserved alias names
|
||||
const reserved = ['list', 'help', 'remove', 'delete', 'create', 'set'];
|
||||
if (reserved.includes(alias.toLowerCase())) {
|
||||
return { success: false, error: `'${alias}' is a reserved alias name` };
|
||||
}
|
||||
|
||||
const data = loadAliases();
|
||||
const existing = data.aliases[alias];
|
||||
const isNew = !existing;
|
||||
|
||||
data.aliases[alias] = {
|
||||
sessionPath,
|
||||
createdAt: existing ? existing.createdAt : new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
title: title || null
|
||||
};
|
||||
|
||||
if (saveAliases(data)) {
|
||||
return {
|
||||
success: true,
|
||||
isNew,
|
||||
alias,
|
||||
sessionPath,
|
||||
title: data.aliases[alias].title
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: 'Failed to save alias' };
|
||||
}
|
||||
|
||||
/**
|
||||
* List all aliases
|
||||
* @param {object} options - Options object
|
||||
* @param {string} options.search - Filter aliases by name (partial match)
|
||||
* @param {number} options.limit - Maximum number of aliases to return
|
||||
* @returns {Array} Array of alias objects
|
||||
*/
|
||||
function listAliases(options = {}) {
|
||||
const { search = null, limit = null } = options;
|
||||
const data = loadAliases();
|
||||
|
||||
let aliases = Object.entries(data.aliases).map(([name, info]) => ({
|
||||
name,
|
||||
sessionPath: info.sessionPath,
|
||||
createdAt: info.createdAt,
|
||||
updatedAt: info.updatedAt,
|
||||
title: info.title
|
||||
}));
|
||||
|
||||
// Sort by updated time (newest first)
|
||||
aliases.sort((a, b) => (new Date(b.updatedAt || b.createdAt || 0).getTime() || 0) - (new Date(a.updatedAt || a.createdAt || 0).getTime() || 0));
|
||||
|
||||
// Apply search filter
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase();
|
||||
aliases = aliases.filter(a =>
|
||||
a.name.toLowerCase().includes(searchLower) ||
|
||||
(a.title && a.title.toLowerCase().includes(searchLower))
|
||||
);
|
||||
}
|
||||
|
||||
// Apply limit
|
||||
if (limit && limit > 0) {
|
||||
aliases = aliases.slice(0, limit);
|
||||
}
|
||||
|
||||
return aliases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an alias
|
||||
* @param {string} alias - Alias name to delete
|
||||
* @returns {object} Result with success status
|
||||
*/
|
||||
function deleteAlias(alias) {
|
||||
const data = loadAliases();
|
||||
|
||||
if (!data.aliases[alias]) {
|
||||
return { success: false, error: `Alias '${alias}' not found` };
|
||||
}
|
||||
|
||||
const deleted = data.aliases[alias];
|
||||
delete data.aliases[alias];
|
||||
|
||||
if (saveAliases(data)) {
|
||||
return {
|
||||
success: true,
|
||||
alias,
|
||||
deletedSessionPath: deleted.sessionPath
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: 'Failed to delete alias' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename an alias
|
||||
* @param {string} oldAlias - Current alias name
|
||||
* @param {string} newAlias - New alias name
|
||||
* @returns {object} Result with success status
|
||||
*/
|
||||
function renameAlias(oldAlias, newAlias) {
|
||||
const data = loadAliases();
|
||||
|
||||
if (!data.aliases[oldAlias]) {
|
||||
return { success: false, error: `Alias '${oldAlias}' not found` };
|
||||
}
|
||||
|
||||
// Validate new alias name (same rules as setAlias)
|
||||
if (!newAlias || newAlias.length === 0) {
|
||||
return { success: false, error: 'New alias name cannot be empty' };
|
||||
}
|
||||
|
||||
if (newAlias.length > 128) {
|
||||
return { success: false, error: 'New alias name cannot exceed 128 characters' };
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(newAlias)) {
|
||||
return { success: false, error: 'New alias name must contain only letters, numbers, dashes, and underscores' };
|
||||
}
|
||||
|
||||
const reserved = ['list', 'help', 'remove', 'delete', 'create', 'set'];
|
||||
if (reserved.includes(newAlias.toLowerCase())) {
|
||||
return { success: false, error: `'${newAlias}' is a reserved alias name` };
|
||||
}
|
||||
|
||||
if (data.aliases[newAlias]) {
|
||||
return { success: false, error: `Alias '${newAlias}' already exists` };
|
||||
}
|
||||
|
||||
const aliasData = data.aliases[oldAlias];
|
||||
delete data.aliases[oldAlias];
|
||||
|
||||
aliasData.updatedAt = new Date().toISOString();
|
||||
data.aliases[newAlias] = aliasData;
|
||||
|
||||
if (saveAliases(data)) {
|
||||
return {
|
||||
success: true,
|
||||
oldAlias,
|
||||
newAlias,
|
||||
sessionPath: aliasData.sessionPath
|
||||
};
|
||||
}
|
||||
|
||||
// Restore old alias and remove new alias on failure
|
||||
data.aliases[oldAlias] = aliasData;
|
||||
delete data.aliases[newAlias];
|
||||
// Attempt to persist the rollback
|
||||
saveAliases(data);
|
||||
return { success: false, error: 'Failed to save renamed alias — rolled back to original' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session path by alias (convenience function)
|
||||
* @param {string} aliasOrId - Alias name or session ID
|
||||
* @returns {string|null} Session path or null if not found
|
||||
*/
|
||||
function resolveSessionAlias(aliasOrId) {
|
||||
// First try to resolve as alias
|
||||
const resolved = resolveAlias(aliasOrId);
|
||||
if (resolved) {
|
||||
return resolved.sessionPath;
|
||||
}
|
||||
|
||||
// If not an alias, return as-is (might be a session path)
|
||||
return aliasOrId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update alias title
|
||||
* @param {string} alias - Alias name
|
||||
* @param {string|null} title - New title (string or null to clear)
|
||||
* @returns {object} Result with success status
|
||||
*/
|
||||
function updateAliasTitle(alias, title) {
|
||||
if (title !== null && typeof title !== 'string') {
|
||||
return { success: false, error: 'Title must be a string or null' };
|
||||
}
|
||||
|
||||
const data = loadAliases();
|
||||
|
||||
if (!data.aliases[alias]) {
|
||||
return { success: false, error: `Alias '${alias}' not found` };
|
||||
}
|
||||
|
||||
data.aliases[alias].title = title || null;
|
||||
data.aliases[alias].updatedAt = new Date().toISOString();
|
||||
|
||||
if (saveAliases(data)) {
|
||||
return {
|
||||
success: true,
|
||||
alias,
|
||||
title
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: 'Failed to update alias title' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all aliases for a specific session
|
||||
* @param {string} sessionPath - Session path to find aliases for
|
||||
* @returns {Array} Array of alias names
|
||||
*/
|
||||
function getAliasesForSession(sessionPath) {
|
||||
const data = loadAliases();
|
||||
const aliases = [];
|
||||
|
||||
for (const [name, info] of Object.entries(data.aliases)) {
|
||||
if (info.sessionPath === sessionPath) {
|
||||
aliases.push({
|
||||
name,
|
||||
createdAt: info.createdAt,
|
||||
title: info.title
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return aliases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up aliases for non-existent sessions
|
||||
* @param {Function} sessionExists - Function to check if session exists
|
||||
* @returns {object} Cleanup result
|
||||
*/
|
||||
function cleanupAliases(sessionExists) {
|
||||
if (typeof sessionExists !== 'function') {
|
||||
return { totalChecked: 0, removed: 0, removedAliases: [], error: 'sessionExists must be a function' };
|
||||
}
|
||||
|
||||
const data = loadAliases();
|
||||
const removed = [];
|
||||
|
||||
for (const [name, info] of Object.entries(data.aliases)) {
|
||||
if (!sessionExists(info.sessionPath)) {
|
||||
removed.push({ name, sessionPath: info.sessionPath });
|
||||
delete data.aliases[name];
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0 && !saveAliases(data)) {
|
||||
log('[Aliases] Failed to save after cleanup');
|
||||
return {
|
||||
success: false,
|
||||
totalChecked: Object.keys(data.aliases).length + removed.length,
|
||||
removed: removed.length,
|
||||
removedAliases: removed,
|
||||
error: 'Failed to save after cleanup'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
totalChecked: Object.keys(data.aliases).length + removed.length,
|
||||
removed: removed.length,
|
||||
removedAliases: removed
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAliasesPath,
|
||||
loadAliases,
|
||||
saveAliases,
|
||||
resolveAlias,
|
||||
setAlias,
|
||||
listAliases,
|
||||
deleteAlias,
|
||||
renameAlias,
|
||||
resolveSessionAlias,
|
||||
updateAliasTitle,
|
||||
getAliasesForSession,
|
||||
cleanupAliases
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Shared session bridge utilities for ECC hooks.
|
||||
*
|
||||
* The bridge file is a small JSON aggregate in /tmp that allows
|
||||
* statusline, metrics-bridge, and context-monitor to share state
|
||||
* without scanning large JSONL logs on every invocation.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const MAX_SESSION_ID_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* Sanitize a session ID for safe use in file paths.
|
||||
* Rejects path traversal, strips unsafe chars, limits length.
|
||||
* @param {string} raw
|
||||
* @returns {string|null} Safe session ID or null if invalid
|
||||
*/
|
||||
function sanitizeSessionId(raw) {
|
||||
if (!raw || typeof raw !== 'string') return null;
|
||||
if (/[/\\]|\.\./.test(raw)) return null;
|
||||
const safe = raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, MAX_SESSION_ID_LENGTH);
|
||||
return safe || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bridge file path for a session.
|
||||
* @param {string} sessionId - Already-sanitized session ID
|
||||
* @returns {string}
|
||||
*/
|
||||
function getBridgePath(sessionId) {
|
||||
return path.join(os.tmpdir(), `ecc-metrics-${sessionId}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read bridge data. Returns null on any error.
|
||||
* @param {string} sessionId - Already-sanitized session ID
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function readBridge(sessionId) {
|
||||
try {
|
||||
const raw = fs.readFileSync(getBridgePath(sessionId), 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write bridge data atomically (write unique-suffix tmp then rename).
|
||||
*
|
||||
* The tmp path includes `process.pid` plus a random nonce so concurrent
|
||||
* writers (e.g. PostToolUse `ecc-metrics-bridge` and the background
|
||||
* `ecc-statusline`, both writing to the same session bridge) do not
|
||||
* clobber each other's tmp file mid-write. With a fixed `.tmp` suffix
|
||||
* two writers could both call `writeFileSync` against the same path
|
||||
* before either reaches `renameSync`, causing one writer's payload to
|
||||
* silently overwrite the other and the second `renameSync` to throw
|
||||
* ENOENT once the rename consumes the file.
|
||||
*
|
||||
* Same pattern already used by `writeCostWarningIfChanged` in
|
||||
* `scripts/hooks/ecc-metrics-bridge.js` (commit 9b1d8918) for the
|
||||
* cost-warning cache; this commit applies it to the session-bridge
|
||||
* primitive too.
|
||||
*
|
||||
* @param {string} sessionId - Already-sanitized session ID
|
||||
* @param {object} data
|
||||
*/
|
||||
function writeBridgeAtomic(sessionId, data) {
|
||||
const target = getBridgePath(sessionId);
|
||||
const tmp = `${target}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(data), 'utf8');
|
||||
try {
|
||||
renameWithRetry(tmp, target);
|
||||
} catch (err) {
|
||||
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a file via rename, retrying briefly on transient OS-level errors.
|
||||
*
|
||||
* POSIX `rename(2)` is atomic between source and destination, so concurrent
|
||||
* writers each rename onto the same target without conflict. Windows
|
||||
* `MoveFileExW` is different: it fails with EPERM/EACCES/EBUSY if the
|
||||
* target is currently being renamed by *another* process — a short race
|
||||
* window that fires reliably under our PostToolUse + statusline concurrency.
|
||||
*
|
||||
* To stay portable, retry up to 5 times with exponential backoff (20 ms,
|
||||
* 40, 80, 160, 320) on the Windows-only transient codes. POSIX runs hit
|
||||
* the first try and exit immediately. Other error codes (ENOENT, ENOSPC,
|
||||
* EROFS, …) re-throw without retry — they are not transient.
|
||||
*
|
||||
* Sleep uses `Atomics.wait` on a throwaway SharedArrayBuffer so the
|
||||
* retry path does not busy-spin the CPU. This works on the main thread
|
||||
* in Node ≥ 17 (and on workers in earlier versions).
|
||||
*
|
||||
* @param {string} tmp
|
||||
* @param {string} target
|
||||
*/
|
||||
function renameWithRetry(tmp, target) {
|
||||
const RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
||||
const MAX_ATTEMPTS = 5;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
fs.renameSync(tmp, target);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (attempt + 1 >= MAX_ATTEMPTS || !RETRY_CODES.has(err.code)) {
|
||||
throw err;
|
||||
}
|
||||
const delayMs = 20 << attempt;
|
||||
try {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs);
|
||||
} catch {
|
||||
// Atomics.wait throws on the main thread in some older runtimes;
|
||||
// fall back to a brief busy-wait so the retry path still has a delay.
|
||||
const until = Date.now() + delayMs;
|
||||
while (Date.now() < until) { /* spin */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve session ID from environment variables.
|
||||
* @returns {string|null} Sanitized session ID or null
|
||||
*/
|
||||
function resolveSessionId() {
|
||||
const raw = process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || '';
|
||||
return sanitizeSessionId(raw);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sanitizeSessionId,
|
||||
getBridgePath,
|
||||
readBridge,
|
||||
writeBridgeAtomic,
|
||||
renameWithRetry,
|
||||
resolveSessionId,
|
||||
MAX_SESSION_ID_LENGTH
|
||||
};
|
||||
Vendored
+132
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Session Manager Library for Claude Code.
|
||||
* Provides CRUD operations for session files stored as markdown in
|
||||
* ~/.claude/session-data/ with legacy read compatibility for ~/.claude/sessions/.
|
||||
*/
|
||||
|
||||
/** Parsed metadata from a session filename */
|
||||
export interface SessionFilenameMeta {
|
||||
/** Original filename */
|
||||
filename: string;
|
||||
/** Short ID extracted from filename, or "no-id" for old format */
|
||||
shortId: string;
|
||||
/** Date string in YYYY-MM-DD format */
|
||||
date: string;
|
||||
/** Parsed Date object from the date string */
|
||||
datetime: Date;
|
||||
}
|
||||
|
||||
/** Metadata parsed from session markdown content */
|
||||
export interface SessionMetadata {
|
||||
title: string | null;
|
||||
date: string | null;
|
||||
started: string | null;
|
||||
lastUpdated: string | null;
|
||||
completed: string[];
|
||||
inProgress: string[];
|
||||
notes: string;
|
||||
context: string;
|
||||
}
|
||||
|
||||
/** Statistics computed from session content */
|
||||
export interface SessionStats {
|
||||
totalItems: number;
|
||||
completedItems: number;
|
||||
inProgressItems: number;
|
||||
lineCount: number;
|
||||
hasNotes: boolean;
|
||||
hasContext: boolean;
|
||||
}
|
||||
|
||||
/** A session object returned by getAllSessions and getSessionById */
|
||||
export interface Session extends SessionFilenameMeta {
|
||||
/** Full filesystem path to the session file */
|
||||
sessionPath: string;
|
||||
/** Whether the file has any content */
|
||||
hasContent?: boolean;
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
/** Last modification time */
|
||||
modifiedTime: Date;
|
||||
/** File creation time (falls back to ctime on Linux) */
|
||||
createdTime: Date;
|
||||
/** Session markdown content (only when includeContent=true) */
|
||||
content?: string | null;
|
||||
/** Parsed metadata (only when includeContent=true) */
|
||||
metadata?: SessionMetadata;
|
||||
/** Session statistics (only when includeContent=true) */
|
||||
stats?: SessionStats;
|
||||
}
|
||||
|
||||
/** Pagination result from getAllSessions */
|
||||
export interface SessionListResult {
|
||||
sessions: Session[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface GetAllSessionsOptions {
|
||||
/** Maximum number of sessions to return (default: 50) */
|
||||
limit?: number;
|
||||
/** Number of sessions to skip (default: 0) */
|
||||
offset?: number;
|
||||
/** Filter by date in YYYY-MM-DD format */
|
||||
date?: string | null;
|
||||
/** Search in short ID */
|
||||
search?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a session filename to extract date and short ID.
|
||||
* @returns Parsed metadata, or null if the filename doesn't match the expected pattern
|
||||
*/
|
||||
export function parseSessionFilename(filename: string): SessionFilenameMeta | null;
|
||||
|
||||
/** Get the full filesystem path for a session filename */
|
||||
export function getSessionPath(filename: string): string;
|
||||
|
||||
/**
|
||||
* Read session markdown content from disk.
|
||||
* @returns Content string, or null if the file doesn't exist
|
||||
*/
|
||||
export function getSessionContent(sessionPath: string): string | null;
|
||||
|
||||
/** Parse session metadata from markdown content */
|
||||
export function parseSessionMetadata(content: string | null): SessionMetadata;
|
||||
|
||||
/**
|
||||
* Calculate statistics for a session.
|
||||
* Accepts either a file path (absolute, ending in .tmp) or pre-read content string.
|
||||
* Supports both Unix (/path/to/session.tmp) and Windows (C:\path\to\session.tmp) paths.
|
||||
*/
|
||||
export function getSessionStats(sessionPathOrContent: string): SessionStats;
|
||||
|
||||
/** Get the title from a session file, or "Untitled Session" if none */
|
||||
export function getSessionTitle(sessionPath: string): string;
|
||||
|
||||
/** Get human-readable file size (e.g., "1.2 KB") */
|
||||
export function getSessionSize(sessionPath: string): string;
|
||||
|
||||
/** Get all sessions with optional filtering and pagination */
|
||||
export function getAllSessions(options?: GetAllSessionsOptions): SessionListResult;
|
||||
|
||||
/**
|
||||
* Find a session by short ID or filename.
|
||||
* @param sessionId - Short ID prefix, full filename, or filename without .tmp
|
||||
* @param includeContent - Whether to read and parse the session content
|
||||
*/
|
||||
export function getSessionById(sessionId: string, includeContent?: boolean): Session | null;
|
||||
|
||||
/** Write markdown content to a session file */
|
||||
export function writeSessionContent(sessionPath: string, content: string): boolean;
|
||||
|
||||
/** Append content to an existing session file */
|
||||
export function appendSessionContent(sessionPath: string, content: string): boolean;
|
||||
|
||||
/** Delete a session file */
|
||||
export function deleteSession(sessionPath: string): boolean;
|
||||
|
||||
/** Check if a session file exists and is a regular file */
|
||||
export function sessionExists(sessionPath: string): boolean;
|
||||
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* Session Manager Library for Claude Code
|
||||
* Provides core session CRUD operations for listing, loading, and managing sessions
|
||||
*
|
||||
* Sessions are stored as markdown files in ~/.claude/session-data/ with
|
||||
* legacy read compatibility for ~/.claude/sessions/:
|
||||
* - YYYY-MM-DD-session.tmp (old format)
|
||||
* - YYYY-MM-DD-<short-id>-session.tmp (new format)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
getSessionsDir,
|
||||
getSessionSearchDirs,
|
||||
readFile,
|
||||
log
|
||||
} = require('./utils');
|
||||
|
||||
// Session filename pattern: YYYY-MM-DD-[session-id]-session.tmp
|
||||
// The session-id is optional (old format) and can include letters, digits,
|
||||
// underscores, and hyphens, but must not start with a hyphen.
|
||||
// Matches: "2026-02-01-session.tmp", "2026-02-01-a1b2c3d4-session.tmp",
|
||||
// "2026-02-01-frontend-worktree-1-session.tmp", and
|
||||
// "2026-02-01-ChezMoi_2-session.tmp"
|
||||
const SESSION_FILENAME_REGEX = /^(\d{4}-\d{2}-\d{2})(?:-([a-zA-Z0-9_][a-zA-Z0-9_-]*))?-session\.tmp$/;
|
||||
|
||||
/**
|
||||
* Resolve a file's creation time, preferring birthtime but falling back to
|
||||
* ctime when birthtime is unavailable. Some filesystems (e.g. overlayfs in
|
||||
* containers) report birthtime as epoch 0; a Date object is always truthy, so
|
||||
* `birthtime || ctime` would never fall back. Compare on milliseconds instead.
|
||||
* @param {import('fs').Stats} stats
|
||||
* @returns {Date}
|
||||
*/
|
||||
function resolveCreatedTime(stats) {
|
||||
return stats.birthtimeMs > 0 ? stats.birthtime : stats.ctime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse session filename to extract metadata
|
||||
* @param {string} filename - Session filename (e.g., "2026-01-17-abc123-session.tmp" or "2026-01-17-session.tmp")
|
||||
* @returns {object|null} Parsed metadata or null if invalid
|
||||
*/
|
||||
function parseSessionFilename(filename) {
|
||||
if (!filename || typeof filename !== 'string') return null;
|
||||
const match = filename.match(SESSION_FILENAME_REGEX);
|
||||
if (!match) return null;
|
||||
|
||||
const dateStr = match[1];
|
||||
|
||||
// Validate date components are calendar-accurate (not just format)
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
|
||||
// Reject impossible dates like Feb 31, Apr 31 — Date constructor rolls
|
||||
// over invalid days (e.g., Feb 31 → Mar 3), so check month roundtrips
|
||||
const d = new Date(year, month - 1, day);
|
||||
if (d.getMonth() !== month - 1 || d.getDate() !== day) return null;
|
||||
|
||||
// match[2] is undefined for old format (no ID)
|
||||
const shortId = match[2] || 'no-id';
|
||||
|
||||
return {
|
||||
filename,
|
||||
shortId,
|
||||
date: dateStr,
|
||||
// Use local-time constructor (consistent with validation on line 40)
|
||||
// new Date(dateStr) interprets YYYY-MM-DD as UTC midnight which shows
|
||||
// as the previous day in negative UTC offset timezones
|
||||
datetime: new Date(year, month - 1, day)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full path to a session file
|
||||
* @param {string} filename - Session filename
|
||||
* @returns {string} Full path to session file
|
||||
*/
|
||||
function getSessionPath(filename) {
|
||||
return path.join(getSessionsDir(), filename);
|
||||
}
|
||||
|
||||
function getSessionCandidates(options = {}) {
|
||||
const {
|
||||
date = null,
|
||||
search = null
|
||||
} = options;
|
||||
|
||||
const candidates = [];
|
||||
|
||||
for (const sessionsDir of getSessionSearchDirs()) {
|
||||
if (!fs.existsSync(sessionsDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
log(`[SessionManager] Error reading sessions directory ${sessionsDir}: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue;
|
||||
|
||||
const filename = entry.name;
|
||||
const metadata = parseSessionFilename(filename);
|
||||
|
||||
if (!metadata) continue;
|
||||
if (date && metadata.date !== date) continue;
|
||||
if (search && !metadata.shortId.includes(search)) continue;
|
||||
|
||||
const sessionPath = path.join(sessionsDir, filename);
|
||||
|
||||
let stats;
|
||||
try {
|
||||
stats = fs.statSync(sessionPath);
|
||||
} catch (error) {
|
||||
log(`[SessionManager] Error stating session ${sessionPath}: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
...metadata,
|
||||
sessionPath,
|
||||
hasContent: stats.size > 0,
|
||||
size: stats.size,
|
||||
modifiedTime: stats.mtime,
|
||||
createdTime: resolveCreatedTime(stats)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const deduped = [];
|
||||
const seenFilenames = new Set();
|
||||
|
||||
for (const session of candidates) {
|
||||
if (seenFilenames.has(session.filename)) {
|
||||
continue;
|
||||
}
|
||||
seenFilenames.add(session.filename);
|
||||
deduped.push(session);
|
||||
}
|
||||
|
||||
deduped.sort((a, b) => b.modifiedTime - a.modifiedTime);
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function buildSessionRecord(sessionPath, metadata) {
|
||||
let stats;
|
||||
try {
|
||||
stats = fs.statSync(sessionPath);
|
||||
} catch (error) {
|
||||
log(`[SessionManager] Error stating session ${sessionPath}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
sessionPath,
|
||||
hasContent: stats.size > 0,
|
||||
size: stats.size,
|
||||
modifiedTime: stats.mtime,
|
||||
createdTime: resolveCreatedTime(stats)
|
||||
};
|
||||
}
|
||||
|
||||
function sessionMatchesId(metadata, normalizedSessionId) {
|
||||
const filename = metadata.filename;
|
||||
const shortIdMatch = metadata.shortId !== 'no-id' && metadata.shortId.startsWith(normalizedSessionId);
|
||||
const filenameMatch = filename === normalizedSessionId || filename === `${normalizedSessionId}.tmp`;
|
||||
const noIdMatch = metadata.shortId === 'no-id' && filename === `${normalizedSessionId}-session.tmp`;
|
||||
|
||||
return shortIdMatch || filenameMatch || noIdMatch;
|
||||
}
|
||||
|
||||
function getMatchingSessionCandidates(normalizedSessionId) {
|
||||
const matches = [];
|
||||
const seenFilenames = new Set();
|
||||
|
||||
for (const sessionsDir of getSessionSearchDirs()) {
|
||||
if (!fs.existsSync(sessionsDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
log(`[SessionManager] Error reading sessions directory ${sessionsDir}: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue;
|
||||
|
||||
const metadata = parseSessionFilename(entry.name);
|
||||
if (!metadata || !sessionMatchesId(metadata, normalizedSessionId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seenFilenames.has(metadata.filename)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionPath = path.join(sessionsDir, metadata.filename);
|
||||
const sessionRecord = buildSessionRecord(sessionPath, metadata);
|
||||
if (!sessionRecord) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenFilenames.add(metadata.filename);
|
||||
matches.push(sessionRecord);
|
||||
}
|
||||
}
|
||||
|
||||
matches.sort((a, b) => b.modifiedTime - a.modifiedTime);
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse session markdown content
|
||||
* @param {string} sessionPath - Full path to session file
|
||||
* @returns {string|null} Session content or null if not found
|
||||
*/
|
||||
function getSessionContent(sessionPath) {
|
||||
return readFile(sessionPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse session metadata from markdown content
|
||||
* @param {string} content - Session markdown content
|
||||
* @returns {object} Parsed metadata
|
||||
*/
|
||||
function parseSessionMetadata(content) {
|
||||
const metadata = {
|
||||
title: null,
|
||||
date: null,
|
||||
started: null,
|
||||
lastUpdated: null,
|
||||
project: null,
|
||||
branch: null,
|
||||
worktree: null,
|
||||
completed: [],
|
||||
inProgress: [],
|
||||
notes: '',
|
||||
context: ''
|
||||
};
|
||||
|
||||
if (!content) return metadata;
|
||||
|
||||
// Extract title from first heading
|
||||
const titleMatch = content.match(/^#\s+(.+)$/m);
|
||||
if (titleMatch) {
|
||||
metadata.title = titleMatch[1].trim();
|
||||
}
|
||||
|
||||
// Extract date
|
||||
const dateMatch = content.match(/\*\*Date:\*\*\s*(\d{4}-\d{2}-\d{2})/);
|
||||
if (dateMatch) {
|
||||
metadata.date = dateMatch[1];
|
||||
}
|
||||
|
||||
// Extract started time
|
||||
const startedMatch = content.match(/\*\*Started:\*\*\s*([\d:]+)/);
|
||||
if (startedMatch) {
|
||||
metadata.started = startedMatch[1];
|
||||
}
|
||||
|
||||
// Extract last updated
|
||||
const updatedMatch = content.match(/\*\*Last Updated:\*\*\s*([\d:]+)/);
|
||||
if (updatedMatch) {
|
||||
metadata.lastUpdated = updatedMatch[1];
|
||||
}
|
||||
|
||||
// Extract control-plane metadata
|
||||
const projectMatch = content.match(/\*\*Project:\*\*\s*(.+)$/m);
|
||||
if (projectMatch) {
|
||||
metadata.project = projectMatch[1].trim();
|
||||
}
|
||||
|
||||
const branchMatch = content.match(/\*\*Branch:\*\*\s*(.+)$/m);
|
||||
if (branchMatch) {
|
||||
metadata.branch = branchMatch[1].trim();
|
||||
}
|
||||
|
||||
const worktreeMatch = content.match(/\*\*Worktree:\*\*\s*(.+)$/m);
|
||||
if (worktreeMatch) {
|
||||
metadata.worktree = worktreeMatch[1].trim();
|
||||
}
|
||||
|
||||
// Extract completed items
|
||||
const completedSection = content.match(/### Completed\s*\n([\s\S]*?)(?=###|\n\n|$)/);
|
||||
if (completedSection) {
|
||||
const items = completedSection[1].match(/- \[x\]\s*(.+)/g);
|
||||
if (items) {
|
||||
metadata.completed = items.map(item => item.replace(/- \[x\]\s*/, '').trim());
|
||||
}
|
||||
}
|
||||
|
||||
// Extract in-progress items
|
||||
const progressSection = content.match(/### In Progress\s*\n([\s\S]*?)(?=###|\n\n|$)/);
|
||||
if (progressSection) {
|
||||
const items = progressSection[1].match(/- \[ \]\s*(.+)/g);
|
||||
if (items) {
|
||||
metadata.inProgress = items.map(item => item.replace(/- \[ \]\s*/, '').trim());
|
||||
}
|
||||
}
|
||||
|
||||
// Extract notes
|
||||
const notesSection = content.match(/### Notes for Next Session\s*\n([\s\S]*?)(?=###|\n\n|$)/);
|
||||
if (notesSection) {
|
||||
metadata.notes = notesSection[1].trim();
|
||||
}
|
||||
|
||||
// Extract context to load
|
||||
const contextSection = content.match(/### Context to Load\s*\n```\n([\s\S]*?)```/);
|
||||
if (contextSection) {
|
||||
metadata.context = contextSection[1].trim();
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate statistics for a session
|
||||
* @param {string} sessionPathOrContent - Full path to session file, OR
|
||||
* the pre-read content string (to avoid redundant disk reads when
|
||||
* the caller already has the content loaded).
|
||||
* @returns {object} Statistics object
|
||||
*/
|
||||
function getSessionStats(sessionPathOrContent) {
|
||||
// Accept pre-read content string to avoid redundant file reads.
|
||||
// If the argument looks like a file path (no newlines, ends with .tmp,
|
||||
// starts with / on Unix or drive letter on Windows), read from disk.
|
||||
// Otherwise treat it as content.
|
||||
const looksLikePath = typeof sessionPathOrContent === 'string' &&
|
||||
!sessionPathOrContent.includes('\n') &&
|
||||
sessionPathOrContent.endsWith('.tmp') &&
|
||||
(sessionPathOrContent.startsWith('/') || /^[A-Za-z]:[/\\]/.test(sessionPathOrContent));
|
||||
const content = looksLikePath
|
||||
? getSessionContent(sessionPathOrContent)
|
||||
: sessionPathOrContent;
|
||||
|
||||
const metadata = parseSessionMetadata(content);
|
||||
|
||||
return {
|
||||
totalItems: metadata.completed.length + metadata.inProgress.length,
|
||||
completedItems: metadata.completed.length,
|
||||
inProgressItems: metadata.inProgress.length,
|
||||
lineCount: content ? content.split('\n').length : 0,
|
||||
hasNotes: !!metadata.notes,
|
||||
hasContext: !!metadata.context
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sessions with optional filtering and pagination
|
||||
* @param {object} options - Options object
|
||||
* @param {number} options.limit - Maximum number of sessions to return
|
||||
* @param {number} options.offset - Number of sessions to skip
|
||||
* @param {string} options.date - Filter by date (YYYY-MM-DD format)
|
||||
* @param {string} options.search - Search in short ID
|
||||
* @returns {object} Object with sessions array and pagination info
|
||||
*/
|
||||
function getAllSessions(options = {}) {
|
||||
const {
|
||||
limit: rawLimit = 50,
|
||||
offset: rawOffset = 0,
|
||||
date = null,
|
||||
search = null
|
||||
} = options;
|
||||
|
||||
// Clamp offset and limit to safe non-negative integers.
|
||||
// Without this, negative offset causes slice() to count from the end,
|
||||
// and NaN values cause slice() to return empty or unexpected results.
|
||||
// Note: cannot use `|| default` because 0 is falsy — use isNaN instead.
|
||||
const offsetNum = Number(rawOffset);
|
||||
const offset = Number.isNaN(offsetNum) ? 0 : Math.max(0, Math.floor(offsetNum));
|
||||
const limitNum = Number(rawLimit);
|
||||
const limit = Number.isNaN(limitNum) ? 50 : Math.max(1, Math.floor(limitNum));
|
||||
|
||||
const sessions = getSessionCandidates({ date, search });
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return { sessions: [], total: 0, offset, limit, hasMore: false };
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
const paginatedSessions = sessions.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
sessions: paginatedSessions,
|
||||
total: sessions.length,
|
||||
offset,
|
||||
limit,
|
||||
hasMore: offset + limit < sessions.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single session by ID (short ID or full path)
|
||||
* @param {string} sessionId - Short ID or session filename
|
||||
* @param {boolean} includeContent - Include session content
|
||||
* @returns {object|null} Session object or null if not found
|
||||
*/
|
||||
function getSessionById(sessionId, includeContent = false) {
|
||||
if (typeof sessionId !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
if (!normalizedSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessions = getMatchingSessionCandidates(normalizedSessionId);
|
||||
|
||||
for (const session of sessions) {
|
||||
const sessionRecord = { ...session };
|
||||
|
||||
if (includeContent) {
|
||||
sessionRecord.content = getSessionContent(sessionRecord.sessionPath);
|
||||
sessionRecord.metadata = parseSessionMetadata(sessionRecord.content);
|
||||
// Pass pre-read content to avoid a redundant disk read
|
||||
sessionRecord.stats = getSessionStats(sessionRecord.content || '');
|
||||
}
|
||||
|
||||
return sessionRecord;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session title from content
|
||||
* @param {string} sessionPath - Full path to session file
|
||||
* @returns {string} Title or default text
|
||||
*/
|
||||
function getSessionTitle(sessionPath) {
|
||||
const content = getSessionContent(sessionPath);
|
||||
const metadata = parseSessionMetadata(content);
|
||||
|
||||
return metadata.title || 'Untitled Session';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format session size in human-readable format
|
||||
* @param {string} sessionPath - Full path to session file
|
||||
* @returns {string} Formatted size (e.g., "1.2 KB")
|
||||
*/
|
||||
function getSessionSize(sessionPath) {
|
||||
let stats;
|
||||
try {
|
||||
stats = fs.statSync(sessionPath);
|
||||
} catch {
|
||||
return '0 B';
|
||||
}
|
||||
const size = stats.size;
|
||||
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write session content to file
|
||||
* @param {string} sessionPath - Full path to session file
|
||||
* @param {string} content - Markdown content to write
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
function writeSessionContent(sessionPath, content) {
|
||||
try {
|
||||
fs.writeFileSync(sessionPath, content, 'utf8');
|
||||
return true;
|
||||
} catch (err) {
|
||||
log(`[SessionManager] Error writing session: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append content to a session
|
||||
* @param {string} sessionPath - Full path to session file
|
||||
* @param {string} content - Content to append
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
function appendSessionContent(sessionPath, content) {
|
||||
try {
|
||||
fs.appendFileSync(sessionPath, content, 'utf8');
|
||||
return true;
|
||||
} catch (err) {
|
||||
log(`[SessionManager] Error appending to session: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session file
|
||||
* @param {string} sessionPath - Full path to session file
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
function deleteSession(sessionPath) {
|
||||
try {
|
||||
if (fs.existsSync(sessionPath)) {
|
||||
fs.unlinkSync(sessionPath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
log(`[SessionManager] Error deleting session: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session exists
|
||||
* @param {string} sessionPath - Full path to session file
|
||||
* @returns {boolean} True if session exists
|
||||
*/
|
||||
function sessionExists(sessionPath) {
|
||||
try {
|
||||
return fs.statSync(sessionPath).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseSessionFilename,
|
||||
getSessionPath,
|
||||
getSessionContent,
|
||||
parseSessionMetadata,
|
||||
getSessionStats,
|
||||
getSessionTitle,
|
||||
getSessionSize,
|
||||
getAllSessions,
|
||||
getSessionById,
|
||||
writeSessionContent,
|
||||
appendSessionContent,
|
||||
deleteSession,
|
||||
sessionExists
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Split a shell command into segments by operators (&&, ||, ;, &)
|
||||
* while respecting quoting (single/double) and escaped characters.
|
||||
* Redirection operators (&>, >&, 2>&1) are NOT treated as separators.
|
||||
*/
|
||||
function splitShellSegments(command) {
|
||||
const segments = [];
|
||||
let current = '';
|
||||
let quote = null;
|
||||
|
||||
for (let i = 0; i < command.length; i++) {
|
||||
const ch = command[i];
|
||||
|
||||
// Inside quotes: handle escapes and closing quote
|
||||
if (quote) {
|
||||
if (ch === '\\' && i + 1 < command.length) {
|
||||
current += ch + command[i + 1];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) quote = null;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Backslash escape outside quotes
|
||||
if (ch === '\\' && i + 1 < command.length) {
|
||||
current += ch + command[i + 1];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Opening quote
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = command[i + 1] || '';
|
||||
const prev = i > 0 ? command[i - 1] : '';
|
||||
|
||||
// && operator
|
||||
if (ch === '&' && next === '&') {
|
||||
if (current.trim()) segments.push(current.trim());
|
||||
current = '';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// || operator
|
||||
if (ch === '|' && next === '|') {
|
||||
if (current.trim()) segments.push(current.trim());
|
||||
current = '';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ; separator
|
||||
if (ch === ';') {
|
||||
if (current.trim()) segments.push(current.trim());
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single & — but skip redirection patterns (&>, >&, digit>&)
|
||||
if (ch === '&' && next !== '&') {
|
||||
if (next === '>' || prev === '>') {
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (current.trim()) segments.push(current.trim());
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
current += ch;
|
||||
}
|
||||
|
||||
if (current.trim()) segments.push(current.trim());
|
||||
return segments;
|
||||
}
|
||||
|
||||
module.exports = { splitShellSegments };
|
||||
@@ -0,0 +1,494 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Extract executable command-substitution bodies from a shell line.
|
||||
*
|
||||
* Single quotes are literal, so substitutions inside them are ignored;
|
||||
* double quotes still permit substitutions, so those bodies are scanned
|
||||
* before quoted text is stripped. Returns each substitution body plus
|
||||
* any nested substitutions discovered recursively.
|
||||
*
|
||||
* Originally introduced in scripts/hooks/gateguard-fact-force.js
|
||||
* (PR #1853 round 2). Extracted to a shared lib so other PreToolUse
|
||||
* hooks that need the same "scan inside `$(...)` and backticks"
|
||||
* behavior can reuse it without duplicating the parser.
|
||||
*
|
||||
* @param {string} input
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function extractCommandSubstitutions(input) {
|
||||
const source = String(input || '');
|
||||
const substitutions = [];
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
const prev = source[i - 1];
|
||||
|
||||
if (ch === '\\' && !inSingle) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" && !inDouble && prev !== '\\') {
|
||||
inSingle = !inSingle;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' && !inSingle && prev !== '\\') {
|
||||
inDouble = !inDouble;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inSingle) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '`') {
|
||||
let body = '';
|
||||
i += 1;
|
||||
while (i < source.length) {
|
||||
const inner = source[i];
|
||||
if (inner === '\\') {
|
||||
body += inner;
|
||||
if (i + 1 < source.length) {
|
||||
body += source[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (inner === '`') {
|
||||
break;
|
||||
}
|
||||
body += inner;
|
||||
i += 1;
|
||||
}
|
||||
if (body.trim()) {
|
||||
substitutions.push(body);
|
||||
substitutions.push(...extractCommandSubstitutions(body));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '$' && source[i + 1] === '(') {
|
||||
let depth = 1;
|
||||
let body = '';
|
||||
let bodyInSingle = false;
|
||||
let bodyInDouble = false;
|
||||
i += 2;
|
||||
while (i < source.length && depth > 0) {
|
||||
const inner = source[i];
|
||||
const innerPrev = source[i - 1];
|
||||
if (inner === '\\' && !bodyInSingle) {
|
||||
body += inner;
|
||||
if (i + 1 < source.length) {
|
||||
body += source[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (inner === "'" && !bodyInDouble && innerPrev !== '\\') {
|
||||
bodyInSingle = !bodyInSingle;
|
||||
} else if (inner === '"' && !bodyInSingle && innerPrev !== '\\') {
|
||||
bodyInDouble = !bodyInDouble;
|
||||
} else if (!bodyInSingle && !bodyInDouble) {
|
||||
if (inner === '(') {
|
||||
depth += 1;
|
||||
} else if (inner === ')') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
body += inner;
|
||||
i += 1;
|
||||
}
|
||||
if (body.trim()) {
|
||||
substitutions.push(body);
|
||||
substitutions.push(...extractCommandSubstitutions(body));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return substitutions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract bodies of plain `(...)` subshell groups.
|
||||
*
|
||||
* Bash treats `(npm run dev)` as a subshell that executes its contents, but
|
||||
* the regex-light segment splitters used by our PreToolUse hooks don't peer
|
||||
* inside those parens. This helper finds top-level `(...)` groups (skipping
|
||||
* `$(...)` command substitutions and backticks, which `extractCommandSubstitutions`
|
||||
* already covers) and returns each body, recursing for nested groups.
|
||||
*
|
||||
* Quote semantics:
|
||||
* - Single quotes are literal: `'( ... )'` is a string, not a subshell.
|
||||
* - Double quotes are literal *for parens*: `"( ... )"` is a string too —
|
||||
* bash only honors `$( )` inside double quotes, not bare `( )`.
|
||||
*
|
||||
* @param {string} input
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function extractSubshellGroups(input) {
|
||||
const source = String(input || '');
|
||||
const groups = [];
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
const prev = source[i - 1];
|
||||
|
||||
if (ch === '\\' && !inSingle) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" && !inDouble && prev !== '\\') {
|
||||
inSingle = !inSingle;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' && !inSingle && prev !== '\\') {
|
||||
inDouble = !inDouble;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inSingle || inDouble) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '$' && source[i + 1] === '(') {
|
||||
let depth = 1;
|
||||
let skipInSingle = false;
|
||||
let skipInDouble = false;
|
||||
i += 2;
|
||||
while (i < source.length && depth > 0) {
|
||||
const inner = source[i];
|
||||
const innerPrev = source[i - 1];
|
||||
if (inner === '\\' && !skipInSingle) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (inner === "'" && !skipInDouble && innerPrev !== '\\') {
|
||||
skipInSingle = !skipInSingle;
|
||||
} else if (inner === '"' && !skipInSingle && innerPrev !== '\\') {
|
||||
skipInDouble = !skipInDouble;
|
||||
} else if (!skipInSingle && !skipInDouble) {
|
||||
if (inner === '(') depth += 1;
|
||||
else if (inner === ')') depth -= 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
i -= 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '`') {
|
||||
i += 1;
|
||||
while (i < source.length && source[i] !== '`') {
|
||||
if (source[i] === '\\' && i + 1 < source.length) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '(') {
|
||||
let depth = 1;
|
||||
let body = '';
|
||||
let bodyInSingle = false;
|
||||
let bodyInDouble = false;
|
||||
i += 1;
|
||||
while (i < source.length && depth > 0) {
|
||||
const inner = source[i];
|
||||
const innerPrev = source[i - 1];
|
||||
if (inner === '\\' && !bodyInSingle) {
|
||||
body += inner;
|
||||
if (i + 1 < source.length) {
|
||||
body += source[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (inner === "'" && !bodyInDouble && innerPrev !== '\\') {
|
||||
bodyInSingle = !bodyInSingle;
|
||||
} else if (inner === '"' && !bodyInSingle && innerPrev !== '\\') {
|
||||
bodyInDouble = !bodyInDouble;
|
||||
} else if (!bodyInSingle && !bodyInDouble) {
|
||||
if (inner === '(') {
|
||||
depth += 1;
|
||||
} else if (inner === ')') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
body += inner;
|
||||
i += 1;
|
||||
}
|
||||
if (body.trim()) {
|
||||
groups.push(body);
|
||||
groups.push(...extractSubshellGroups(body));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract bodies of `{ ...; }` brace groups.
|
||||
*
|
||||
* Bash brace groups run their body in the *current* shell (unlike `(...)`,
|
||||
* which forks a subshell). Both forms group multiple commands, so for the
|
||||
* purposes of destructive-bash and dev-server detection they are equivalent:
|
||||
* a `rm -rf` or `npm run dev` inside `{ ...; }` still executes.
|
||||
*
|
||||
* Recognition rules match bash's own reserved-word semantics:
|
||||
* - `{` is a reserved word only when followed by whitespace and preceded by
|
||||
* the line start, whitespace, or a shell operator (`;`, `|`, `&`, `(`).
|
||||
* So `{npm run dev}` is NOT a brace group (single token starting with `{`).
|
||||
* - `}` closes the group only when preceded by `;` or whitespace.
|
||||
* So `foo}` inside the body is not a closing brace.
|
||||
* - Single quotes are literal; double quotes are also literal for `{`/`}`.
|
||||
* - `$(...)`, backticks, and plain `(...)` spans are skipped so we don't
|
||||
* double-extract bodies the sibling extractors already cover.
|
||||
*
|
||||
* @param {string} input
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function extractBraceGroups(input) {
|
||||
const source = String(input || '');
|
||||
const groups = [];
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
const prev = source[i - 1];
|
||||
|
||||
if (ch === '\\' && !inSingle) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" && !inDouble && prev !== '\\') {
|
||||
inSingle = !inSingle;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' && !inSingle && prev !== '\\') {
|
||||
inDouble = !inDouble;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inSingle || inDouble) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '$' && source[i + 1] === '(') {
|
||||
let depth = 1;
|
||||
let skipInSingle = false;
|
||||
let skipInDouble = false;
|
||||
i += 2;
|
||||
while (i < source.length && depth > 0) {
|
||||
const inner = source[i];
|
||||
const innerPrev = source[i - 1];
|
||||
if (inner === '\\' && !skipInSingle) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (inner === "'" && !skipInDouble && innerPrev !== '\\') {
|
||||
skipInSingle = !skipInSingle;
|
||||
} else if (inner === '"' && !skipInSingle && innerPrev !== '\\') {
|
||||
skipInDouble = !skipInDouble;
|
||||
} else if (!skipInSingle && !skipInDouble) {
|
||||
if (inner === '(') depth += 1;
|
||||
else if (inner === ')') depth -= 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
i -= 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '`') {
|
||||
i += 1;
|
||||
while (i < source.length && source[i] !== '`') {
|
||||
if (source[i] === '\\' && i + 1 < source.length) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '(') {
|
||||
let depth = 1;
|
||||
let skipInSingle = false;
|
||||
let skipInDouble = false;
|
||||
i += 1;
|
||||
while (i < source.length && depth > 0) {
|
||||
const inner = source[i];
|
||||
const innerPrev = source[i - 1];
|
||||
if (inner === '\\' && !skipInSingle) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (inner === "'" && !skipInDouble && innerPrev !== '\\') {
|
||||
skipInSingle = !skipInSingle;
|
||||
} else if (inner === '"' && !skipInSingle && innerPrev !== '\\') {
|
||||
skipInDouble = !skipInDouble;
|
||||
} else if (!skipInSingle && !skipInDouble) {
|
||||
if (inner === '(') depth += 1;
|
||||
else if (inner === ')') depth -= 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
i -= 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '{' && /\s/.test(source[i + 1] || '')) {
|
||||
const prevIsBoundary = i === 0 || /[\s;|&(]/.test(prev);
|
||||
if (!prevIsBoundary) continue;
|
||||
|
||||
let depth = 1;
|
||||
let body = '';
|
||||
let bodyInSingle = false;
|
||||
let bodyInDouble = false;
|
||||
i += 1;
|
||||
while (i < source.length && depth > 0) {
|
||||
const inner = source[i];
|
||||
const innerPrev = source[i - 1];
|
||||
if (inner === '\\' && !bodyInSingle) {
|
||||
body += inner;
|
||||
if (i + 1 < source.length) {
|
||||
body += source[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (inner === "'" && !bodyInDouble && innerPrev !== '\\') {
|
||||
bodyInSingle = !bodyInSingle;
|
||||
body += inner;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (inner === '"' && !bodyInSingle && innerPrev !== '\\') {
|
||||
bodyInDouble = !bodyInDouble;
|
||||
body += inner;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (bodyInSingle || bodyInDouble) {
|
||||
body += inner;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Skip $(...) spans — a quoted `}` or `}`-as-text inside a
|
||||
// substitution body must not close the enclosing brace group.
|
||||
if (inner === '$' && source[i + 1] === '(') {
|
||||
body += inner + source[i + 1];
|
||||
let subDepth = 1;
|
||||
let subInSingle = false;
|
||||
let subInDouble = false;
|
||||
i += 2;
|
||||
while (i < source.length && subDepth > 0) {
|
||||
const c = source[i];
|
||||
const p = source[i - 1];
|
||||
body += c;
|
||||
if (c === '\\' && !subInSingle && i + 1 < source.length) {
|
||||
body += source[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === "'" && !subInDouble && p !== '\\') subInSingle = !subInSingle;
|
||||
else if (c === '"' && !subInSingle && p !== '\\') subInDouble = !subInDouble;
|
||||
else if (!subInSingle && !subInDouble) {
|
||||
if (c === '(') subDepth += 1;
|
||||
else if (c === ')') subDepth -= 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Skip backtick spans for the same reason.
|
||||
if (inner === '`') {
|
||||
body += inner;
|
||||
i += 1;
|
||||
while (i < source.length && source[i] !== '`') {
|
||||
if (source[i] === '\\' && i + 1 < source.length) {
|
||||
body += source[i] + source[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
body += source[i];
|
||||
i += 1;
|
||||
}
|
||||
if (i < source.length) {
|
||||
body += source[i];
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Skip plain (...) subshell spans for the same reason.
|
||||
if (inner === '(') {
|
||||
body += inner;
|
||||
let subDepth = 1;
|
||||
let subInSingle = false;
|
||||
let subInDouble = false;
|
||||
i += 1;
|
||||
while (i < source.length && subDepth > 0) {
|
||||
const c = source[i];
|
||||
const p = source[i - 1];
|
||||
body += c;
|
||||
if (c === '\\' && !subInSingle && i + 1 < source.length) {
|
||||
body += source[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === "'" && !subInDouble && p !== '\\') subInSingle = !subInSingle;
|
||||
else if (c === '"' && !subInSingle && p !== '\\') subInDouble = !subInDouble;
|
||||
else if (!subInSingle && !subInDouble) {
|
||||
if (c === '(') subDepth += 1;
|
||||
else if (c === ')') subDepth -= 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inner === '{' && /\s/.test(source[i + 1] || '')) {
|
||||
// Match the outer-scan boundary rule for nested `{` so
|
||||
// tokens like `foo{` (no boundary, but followed by space
|
||||
// via `foo{ bar`) cannot bump nested depth.
|
||||
const nestedPrevIsBoundary = /[\s;|&(]/.test(innerPrev);
|
||||
if (nestedPrevIsBoundary) depth += 1;
|
||||
} else if (inner === '}' && (innerPrev === ';' || /\s/.test(innerPrev))) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
body += inner;
|
||||
i += 1;
|
||||
}
|
||||
if (body.trim()) {
|
||||
groups.push(body);
|
||||
groups.push(...extractBraceGroups(body));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
module.exports = { extractCommandSubstitutions, extractSubshellGroups, extractBraceGroups };
|
||||
@@ -0,0 +1,401 @@
|
||||
'use strict';
|
||||
|
||||
const health = require('./health');
|
||||
const tracker = require('./tracker');
|
||||
const versioning = require('./versioning');
|
||||
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||
const SPARKLINE_CHARS = '\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588';
|
||||
const EMPTY_BLOCK = '\u2591';
|
||||
const FILL_BLOCK = '\u2588';
|
||||
const DEFAULT_PANEL_WIDTH = 64;
|
||||
const VALID_PANELS = new Set(['success-rate', 'failures', 'amendments', 'versions']);
|
||||
|
||||
function sparkline(values) {
|
||||
if (!Array.isArray(values) || values.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return values.map(value => {
|
||||
if (value === null || value === undefined) {
|
||||
return EMPTY_BLOCK;
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(1, value));
|
||||
const index = Math.min(Math.round(clamped * (SPARKLINE_CHARS.length - 1)), SPARKLINE_CHARS.length - 1);
|
||||
return SPARKLINE_CHARS[index];
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function horizontalBar(value, max, width) {
|
||||
if (max <= 0 || width <= 0) {
|
||||
return EMPTY_BLOCK.repeat(width || 0);
|
||||
}
|
||||
|
||||
const filled = Math.round((Math.min(value, max) / max) * width);
|
||||
const empty = width - filled;
|
||||
return FILL_BLOCK.repeat(filled) + EMPTY_BLOCK.repeat(empty);
|
||||
}
|
||||
|
||||
function panelBox(title, lines, width) {
|
||||
const innerWidth = width || DEFAULT_PANEL_WIDTH;
|
||||
const output = [];
|
||||
output.push('\u250C\u2500 ' + title + ' ' + '\u2500'.repeat(Math.max(0, innerWidth - title.length - 4)) + '\u2510');
|
||||
|
||||
for (const line of lines) {
|
||||
const truncated = line.length > innerWidth - 2
|
||||
? line.slice(0, innerWidth - 2)
|
||||
: line;
|
||||
output.push('\u2502 ' + truncated.padEnd(innerWidth - 2) + '\u2502');
|
||||
}
|
||||
|
||||
output.push('\u2514' + '\u2500'.repeat(innerWidth - 1) + '\u2518');
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
function bucketByDay(records, nowMs, days) {
|
||||
const buckets = [];
|
||||
for (let i = days - 1; i >= 0; i -= 1) {
|
||||
const dayEnd = nowMs - (i * DAY_IN_MS);
|
||||
const dayStart = dayEnd - DAY_IN_MS;
|
||||
const dateStr = new Date(dayEnd).toISOString().slice(0, 10);
|
||||
buckets.push({ date: dateStr, start: dayStart, end: dayEnd, records: [] });
|
||||
}
|
||||
|
||||
for (const record of records) {
|
||||
const recordMs = Date.parse(record.recorded_at);
|
||||
if (Number.isNaN(recordMs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const bucket of buckets) {
|
||||
if (recordMs > bucket.start && recordMs <= bucket.end) {
|
||||
bucket.records.push(record);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buckets.map(bucket => ({
|
||||
date: bucket.date,
|
||||
rate: bucket.records.length > 0
|
||||
? health.calculateSuccessRate(bucket.records)
|
||||
: null,
|
||||
runs: bucket.records.length,
|
||||
}));
|
||||
}
|
||||
|
||||
function getTrendArrow(successRate7d, successRate30d) {
|
||||
if (successRate7d === null || successRate30d === null) {
|
||||
return '\u2192';
|
||||
}
|
||||
|
||||
const delta = successRate7d - successRate30d;
|
||||
if (delta >= 0.1) {
|
||||
return '\u2197';
|
||||
}
|
||||
|
||||
if (delta <= -0.1) {
|
||||
return '\u2198';
|
||||
}
|
||||
|
||||
return '\u2192';
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
if (value === null) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function groupRecordsBySkill(records) {
|
||||
return records.reduce((grouped, record) => {
|
||||
const skillId = record.skill_id;
|
||||
if (!grouped.has(skillId)) {
|
||||
grouped.set(skillId, []);
|
||||
}
|
||||
|
||||
grouped.get(skillId).push(record);
|
||||
return grouped;
|
||||
}, new Map());
|
||||
}
|
||||
|
||||
function renderSuccessRatePanel(records, skills, options = {}) {
|
||||
const nowMs = Date.parse(options.now || new Date().toISOString());
|
||||
const days = options.days || 30;
|
||||
const width = options.width || DEFAULT_PANEL_WIDTH;
|
||||
const recordsBySkill = groupRecordsBySkill(records);
|
||||
|
||||
const skillData = [];
|
||||
const skillIds = Array.from(new Set([
|
||||
...Array.from(recordsBySkill.keys()),
|
||||
...skills.map(s => s.skill_id),
|
||||
])).sort();
|
||||
|
||||
for (const skillId of skillIds) {
|
||||
const skillRecords = recordsBySkill.get(skillId) || [];
|
||||
const dailyRates = bucketByDay(skillRecords, nowMs, days);
|
||||
const rateValues = dailyRates.map(b => b.rate);
|
||||
const records7d = health.filterRecordsWithinDays(skillRecords, nowMs, 7);
|
||||
const records30d = health.filterRecordsWithinDays(skillRecords, nowMs, 30);
|
||||
const current7d = health.calculateSuccessRate(records7d);
|
||||
const current30d = health.calculateSuccessRate(records30d);
|
||||
const trend = getTrendArrow(current7d, current30d);
|
||||
|
||||
skillData.push({
|
||||
skill_id: skillId,
|
||||
daily_rates: dailyRates,
|
||||
sparkline: sparkline(rateValues),
|
||||
current_7d: current7d,
|
||||
trend,
|
||||
});
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
if (skillData.length === 0) {
|
||||
lines.push('No skill execution data available.');
|
||||
} else {
|
||||
for (const skill of skillData) {
|
||||
const nameCol = skill.skill_id.slice(0, 14).padEnd(14);
|
||||
const sparkCol = skill.sparkline.slice(0, 30);
|
||||
const rateCol = formatPercent(skill.current_7d).padStart(5);
|
||||
lines.push(`${nameCol} ${sparkCol} ${rateCol} ${skill.trend}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: panelBox('Success Rate (30d)', lines, width),
|
||||
data: { skills: skillData },
|
||||
};
|
||||
}
|
||||
|
||||
function renderFailureClusterPanel(records, options = {}) {
|
||||
const width = options.width || DEFAULT_PANEL_WIDTH;
|
||||
const failures = records.filter(r => r.outcome === 'failure');
|
||||
|
||||
const clusterMap = new Map();
|
||||
for (const record of failures) {
|
||||
const reason = (record.failure_reason || 'unknown').toLowerCase().trim();
|
||||
if (!clusterMap.has(reason)) {
|
||||
clusterMap.set(reason, { count: 0, skill_ids: new Set() });
|
||||
}
|
||||
|
||||
const cluster = clusterMap.get(reason);
|
||||
cluster.count += 1;
|
||||
cluster.skill_ids.add(record.skill_id);
|
||||
}
|
||||
|
||||
const clusters = Array.from(clusterMap.entries())
|
||||
.map(([pattern, data]) => ({
|
||||
pattern,
|
||||
count: data.count,
|
||||
skill_ids: Array.from(data.skill_ids).sort(),
|
||||
percentage: failures.length > 0
|
||||
? Math.round((data.count / failures.length) * 100)
|
||||
: 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count || a.pattern.localeCompare(b.pattern));
|
||||
|
||||
const maxCount = clusters.length > 0 ? clusters[0].count : 0;
|
||||
const lines = [];
|
||||
|
||||
if (clusters.length === 0) {
|
||||
lines.push('No failure patterns detected.');
|
||||
} else {
|
||||
for (const cluster of clusters) {
|
||||
const label = cluster.pattern.slice(0, 20).padEnd(20);
|
||||
const bar = horizontalBar(cluster.count, maxCount, 16);
|
||||
const skillCount = cluster.skill_ids.length;
|
||||
const suffix = skillCount === 1 ? 'skill' : 'skills';
|
||||
lines.push(`${label} ${bar} ${String(cluster.count).padStart(3)} (${skillCount} ${suffix})`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: panelBox('Failure Patterns', lines, width),
|
||||
data: { clusters, total_failures: failures.length },
|
||||
};
|
||||
}
|
||||
|
||||
function renderAmendmentPanel(skillsById, options = {}) {
|
||||
const width = options.width || DEFAULT_PANEL_WIDTH;
|
||||
const amendments = [];
|
||||
|
||||
for (const [skillId, skill] of skillsById) {
|
||||
if (!skill.skill_dir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const log = versioning.getEvolutionLog(skill.skill_dir, 'amendments');
|
||||
for (const entry of log) {
|
||||
const status = typeof entry.status === 'string' ? entry.status : null;
|
||||
const isPending = status
|
||||
? health.PENDING_AMENDMENT_STATUSES.has(status)
|
||||
: entry.event === 'proposal';
|
||||
|
||||
if (isPending) {
|
||||
amendments.push({
|
||||
skill_id: skillId,
|
||||
event: entry.event || 'proposal',
|
||||
status: status || 'pending',
|
||||
created_at: entry.created_at || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
amendments.sort((a, b) => {
|
||||
const timeA = a.created_at ? Date.parse(a.created_at) : 0;
|
||||
const timeB = b.created_at ? Date.parse(b.created_at) : 0;
|
||||
return timeB - timeA;
|
||||
});
|
||||
|
||||
const lines = [];
|
||||
if (amendments.length === 0) {
|
||||
lines.push('No pending amendments.');
|
||||
} else {
|
||||
for (const amendment of amendments) {
|
||||
const name = amendment.skill_id.slice(0, 14).padEnd(14);
|
||||
const event = amendment.event.padEnd(10);
|
||||
const status = amendment.status.padEnd(10);
|
||||
const time = amendment.created_at ? amendment.created_at.slice(0, 19) : '-';
|
||||
lines.push(`${name} ${event} ${status} ${time}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(`${amendments.length} amendment${amendments.length === 1 ? '' : 's'} pending review`);
|
||||
}
|
||||
|
||||
return {
|
||||
text: panelBox('Pending Amendments', lines, width),
|
||||
data: { amendments, total: amendments.length },
|
||||
};
|
||||
}
|
||||
|
||||
function renderVersionTimelinePanel(skillsById, options = {}) {
|
||||
const width = options.width || DEFAULT_PANEL_WIDTH;
|
||||
const skillVersions = [];
|
||||
|
||||
for (const [skillId, skill] of skillsById) {
|
||||
if (!skill.skill_dir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const versions = versioning.listVersions(skill.skill_dir);
|
||||
if (versions.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const amendmentLog = versioning.getEvolutionLog(skill.skill_dir, 'amendments');
|
||||
const reasonByVersion = new Map();
|
||||
for (const entry of amendmentLog) {
|
||||
if (entry.version && entry.reason) {
|
||||
reasonByVersion.set(entry.version, entry.reason);
|
||||
}
|
||||
}
|
||||
|
||||
skillVersions.push({
|
||||
skill_id: skillId,
|
||||
versions: versions.map(v => ({
|
||||
version: v.version,
|
||||
created_at: v.created_at,
|
||||
reason: reasonByVersion.get(v.version) || null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
skillVersions.sort((a, b) => a.skill_id.localeCompare(b.skill_id));
|
||||
|
||||
const lines = [];
|
||||
if (skillVersions.length === 0) {
|
||||
lines.push('No version history available.');
|
||||
} else {
|
||||
for (const skill of skillVersions) {
|
||||
lines.push(skill.skill_id);
|
||||
for (const version of skill.versions) {
|
||||
const date = version.created_at ? version.created_at.slice(0, 10) : '-';
|
||||
const reason = version.reason || '-';
|
||||
lines.push(` v${version.version} \u2500\u2500 ${date} \u2500\u2500 ${reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: panelBox('Version History', lines, width),
|
||||
data: { skills: skillVersions },
|
||||
};
|
||||
}
|
||||
|
||||
function renderDashboard(options = {}) {
|
||||
const now = options.now || new Date().toISOString();
|
||||
const nowMs = Date.parse(now);
|
||||
if (Number.isNaN(nowMs)) {
|
||||
throw new Error(`Invalid now timestamp: ${now}`);
|
||||
}
|
||||
|
||||
const dashboardOptions = { ...options, now };
|
||||
const records = tracker.readSkillExecutionRecords(dashboardOptions);
|
||||
const skillsById = health.discoverSkills(dashboardOptions);
|
||||
const report = health.collectSkillHealth(dashboardOptions);
|
||||
const summary = health.summarizeHealthReport(report);
|
||||
|
||||
const panelRenderers = {
|
||||
'success-rate': () => renderSuccessRatePanel(records, report.skills, dashboardOptions),
|
||||
'failures': () => renderFailureClusterPanel(records, dashboardOptions),
|
||||
'amendments': () => renderAmendmentPanel(skillsById, dashboardOptions),
|
||||
'versions': () => renderVersionTimelinePanel(skillsById, dashboardOptions),
|
||||
};
|
||||
|
||||
const selectedPanel = options.panel || null;
|
||||
if (selectedPanel && !VALID_PANELS.has(selectedPanel)) {
|
||||
throw new Error(`Unknown panel: ${selectedPanel}. Valid panels: ${Array.from(VALID_PANELS).join(', ')}`);
|
||||
}
|
||||
|
||||
const panels = {};
|
||||
const textParts = [];
|
||||
|
||||
const header = [
|
||||
'ECC Skill Health Dashboard',
|
||||
`Generated: ${now}`,
|
||||
`Skills: ${summary.total_skills} total, ${summary.healthy_skills} healthy, ${summary.declining_skills} declining`,
|
||||
'',
|
||||
];
|
||||
|
||||
textParts.push(header.join('\n'));
|
||||
|
||||
if (selectedPanel) {
|
||||
const result = panelRenderers[selectedPanel]();
|
||||
panels[selectedPanel] = result.data;
|
||||
textParts.push(result.text);
|
||||
} else {
|
||||
for (const [panelName, renderer] of Object.entries(panelRenderers)) {
|
||||
const result = renderer();
|
||||
panels[panelName] = result.data;
|
||||
textParts.push(result.text);
|
||||
}
|
||||
}
|
||||
|
||||
const text = textParts.join('\n\n') + '\n';
|
||||
const data = {
|
||||
generated_at: now,
|
||||
summary,
|
||||
panels,
|
||||
};
|
||||
|
||||
return { text, data };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
VALID_PANELS,
|
||||
bucketByDay,
|
||||
horizontalBar,
|
||||
panelBox,
|
||||
renderAmendmentPanel,
|
||||
renderDashboard,
|
||||
renderFailureClusterPanel,
|
||||
renderSuccessRatePanel,
|
||||
renderVersionTimelinePanel,
|
||||
sparkline,
|
||||
};
|
||||
@@ -0,0 +1,263 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const provenance = require('./provenance');
|
||||
const tracker = require('./tracker');
|
||||
const versioning = require('./versioning');
|
||||
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||
const PENDING_AMENDMENT_STATUSES = Object.freeze(new Set(['pending', 'proposed', 'queued', 'open']));
|
||||
|
||||
function roundRate(value) {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.round(value * 10000) / 10000;
|
||||
}
|
||||
|
||||
function formatRate(value) {
|
||||
if (value === null) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function summarizeHealthReport(report) {
|
||||
const totalSkills = report.skills.length;
|
||||
const decliningSkills = report.skills.filter(skill => skill.declining).length;
|
||||
const healthySkills = totalSkills - decliningSkills;
|
||||
|
||||
return {
|
||||
total_skills: totalSkills,
|
||||
healthy_skills: healthySkills,
|
||||
declining_skills: decliningSkills,
|
||||
};
|
||||
}
|
||||
|
||||
function listSkillsInRoot(rootPath) {
|
||||
if (!rootPath || !fs.existsSync(rootPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(rootPath, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => ({
|
||||
skill_id: entry.name,
|
||||
skill_dir: path.join(rootPath, entry.name),
|
||||
}))
|
||||
.filter(entry => fs.existsSync(path.join(entry.skill_dir, 'SKILL.md')));
|
||||
}
|
||||
|
||||
function discoverSkills(options = {}) {
|
||||
const roots = provenance.getSkillRoots(options);
|
||||
const discoveredSkills = [
|
||||
...listSkillsInRoot(options.skillsRoot || roots.curated).map(skill => ({
|
||||
...skill,
|
||||
skill_type: provenance.SKILL_TYPES.CURATED,
|
||||
})),
|
||||
...listSkillsInRoot(options.learnedRoot || roots.learned).map(skill => ({
|
||||
...skill,
|
||||
skill_type: provenance.SKILL_TYPES.LEARNED,
|
||||
})),
|
||||
...listSkillsInRoot(options.importedRoot || roots.imported).map(skill => ({
|
||||
...skill,
|
||||
skill_type: provenance.SKILL_TYPES.IMPORTED,
|
||||
})),
|
||||
];
|
||||
|
||||
return discoveredSkills.reduce((skillsById, skill) => {
|
||||
if (!skillsById.has(skill.skill_id)) {
|
||||
skillsById.set(skill.skill_id, skill);
|
||||
}
|
||||
return skillsById;
|
||||
}, new Map());
|
||||
}
|
||||
|
||||
function calculateSuccessRate(records) {
|
||||
if (records.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const successfulRecords = records.filter(record => record.outcome === 'success').length;
|
||||
return roundRate(successfulRecords / records.length);
|
||||
}
|
||||
|
||||
function filterRecordsWithinDays(records, nowMs, days) {
|
||||
const cutoff = nowMs - (days * DAY_IN_MS);
|
||||
return records.filter(record => {
|
||||
const recordedAtMs = Date.parse(record.recorded_at);
|
||||
return !Number.isNaN(recordedAtMs) && recordedAtMs >= cutoff && recordedAtMs <= nowMs;
|
||||
});
|
||||
}
|
||||
|
||||
function getFailureTrend(successRate7d, successRate30d, warnThreshold) {
|
||||
if (successRate7d === null || successRate30d === null) {
|
||||
return 'stable';
|
||||
}
|
||||
|
||||
const delta = roundRate(successRate7d - successRate30d);
|
||||
if (delta <= (-1 * warnThreshold)) {
|
||||
return 'worsening';
|
||||
}
|
||||
|
||||
if (delta >= warnThreshold) {
|
||||
return 'improving';
|
||||
}
|
||||
|
||||
return 'stable';
|
||||
}
|
||||
|
||||
function countPendingAmendments(skillDir) {
|
||||
if (!skillDir) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return versioning.getEvolutionLog(skillDir, 'amendments')
|
||||
.filter(entry => {
|
||||
if (typeof entry.status === 'string') {
|
||||
return PENDING_AMENDMENT_STATUSES.has(entry.status);
|
||||
}
|
||||
|
||||
return entry.event === 'proposal';
|
||||
})
|
||||
.length;
|
||||
}
|
||||
|
||||
function getLastRun(records) {
|
||||
if (records.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return records
|
||||
.map(record => ({
|
||||
timestamp: record.recorded_at,
|
||||
timeMs: Date.parse(record.recorded_at),
|
||||
}))
|
||||
.filter(entry => !Number.isNaN(entry.timeMs))
|
||||
.sort((left, right) => left.timeMs - right.timeMs)
|
||||
.at(-1)?.timestamp || null;
|
||||
}
|
||||
|
||||
function collectSkillHealth(options = {}) {
|
||||
const now = options.now || new Date().toISOString();
|
||||
const nowMs = Date.parse(now);
|
||||
if (Number.isNaN(nowMs)) {
|
||||
throw new Error(`Invalid now timestamp: ${now}`);
|
||||
}
|
||||
|
||||
const warnThreshold = typeof options.warnThreshold === 'number'
|
||||
? options.warnThreshold
|
||||
: Number(options.warnThreshold || 0.1);
|
||||
if (!Number.isFinite(warnThreshold) || warnThreshold < 0) {
|
||||
throw new Error(`Invalid warn threshold: ${options.warnThreshold}`);
|
||||
}
|
||||
|
||||
const records = tracker.readSkillExecutionRecords(options);
|
||||
const skillsById = discoverSkills(options);
|
||||
const recordsBySkill = records.reduce((groupedRecords, record) => {
|
||||
if (!groupedRecords.has(record.skill_id)) {
|
||||
groupedRecords.set(record.skill_id, []);
|
||||
}
|
||||
|
||||
groupedRecords.get(record.skill_id).push(record);
|
||||
return groupedRecords;
|
||||
}, new Map());
|
||||
|
||||
for (const skillId of recordsBySkill.keys()) {
|
||||
if (!skillsById.has(skillId)) {
|
||||
skillsById.set(skillId, {
|
||||
skill_id: skillId,
|
||||
skill_dir: null,
|
||||
skill_type: provenance.SKILL_TYPES.UNKNOWN,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const skills = Array.from(skillsById.values())
|
||||
.sort((left, right) => left.skill_id.localeCompare(right.skill_id))
|
||||
.map(skill => {
|
||||
const skillRecords = recordsBySkill.get(skill.skill_id) || [];
|
||||
const records7d = filterRecordsWithinDays(skillRecords, nowMs, 7);
|
||||
const records30d = filterRecordsWithinDays(skillRecords, nowMs, 30);
|
||||
const successRate7d = calculateSuccessRate(records7d);
|
||||
const successRate30d = calculateSuccessRate(records30d);
|
||||
const currentVersionNumber = skill.skill_dir ? versioning.getCurrentVersion(skill.skill_dir) : 0;
|
||||
const failureTrend = getFailureTrend(successRate7d, successRate30d, warnThreshold);
|
||||
|
||||
return {
|
||||
skill_id: skill.skill_id,
|
||||
skill_type: skill.skill_type,
|
||||
current_version: currentVersionNumber > 0 ? `v${currentVersionNumber}` : null,
|
||||
pending_amendments: countPendingAmendments(skill.skill_dir),
|
||||
success_rate_7d: successRate7d,
|
||||
success_rate_30d: successRate30d,
|
||||
failure_trend: failureTrend,
|
||||
declining: failureTrend === 'worsening',
|
||||
last_run: getLastRun(skillRecords),
|
||||
run_count_7d: records7d.length,
|
||||
run_count_30d: records30d.length,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
generated_at: now,
|
||||
warn_threshold: warnThreshold,
|
||||
skills,
|
||||
};
|
||||
}
|
||||
|
||||
function formatHealthReport(report, options = {}) {
|
||||
if (options.json) {
|
||||
return `${JSON.stringify(report, null, 2)}\n`;
|
||||
}
|
||||
|
||||
const summary = summarizeHealthReport(report);
|
||||
|
||||
if (!report.skills.length) {
|
||||
return [
|
||||
'ECC skill health',
|
||||
`Generated: ${report.generated_at}`,
|
||||
'',
|
||||
'No skill execution records found.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const lines = [
|
||||
'ECC skill health',
|
||||
`Generated: ${report.generated_at}`,
|
||||
`Skills: ${summary.total_skills} total, ${summary.healthy_skills} healthy, ${summary.declining_skills} declining`,
|
||||
'',
|
||||
'skill version 7d 30d trend pending last run',
|
||||
'--------------------------------------------------------------------------',
|
||||
];
|
||||
|
||||
for (const skill of report.skills) {
|
||||
const statusLabel = skill.declining ? '!' : ' ';
|
||||
lines.push([
|
||||
`${statusLabel}${skill.skill_id}`.padEnd(16),
|
||||
String(skill.current_version || '-').padEnd(9),
|
||||
formatRate(skill.success_rate_7d).padEnd(6),
|
||||
formatRate(skill.success_rate_30d).padEnd(6),
|
||||
skill.failure_trend.padEnd(11),
|
||||
String(skill.pending_amendments).padEnd(9),
|
||||
skill.last_run || '-',
|
||||
].join(' '));
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PENDING_AMENDMENT_STATUSES,
|
||||
calculateSuccessRate,
|
||||
collectSkillHealth,
|
||||
discoverSkills,
|
||||
filterRecordsWithinDays,
|
||||
formatHealthReport,
|
||||
summarizeHealthReport,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
|
||||
const provenance = require('./provenance');
|
||||
const versioning = require('./versioning');
|
||||
const tracker = require('./tracker');
|
||||
const health = require('./health');
|
||||
const dashboard = require('./dashboard');
|
||||
|
||||
module.exports = {
|
||||
...provenance,
|
||||
...versioning,
|
||||
...tracker,
|
||||
...health,
|
||||
...dashboard,
|
||||
provenance,
|
||||
versioning,
|
||||
tracker,
|
||||
health,
|
||||
dashboard,
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { ensureDir } = require('../utils');
|
||||
|
||||
const PROVENANCE_FILE_NAME = '.provenance.json';
|
||||
const SKILL_TYPES = Object.freeze({
|
||||
CURATED: 'curated',
|
||||
LEARNED: 'learned',
|
||||
IMPORTED: 'imported',
|
||||
UNKNOWN: 'unknown',
|
||||
});
|
||||
|
||||
function resolveRepoRoot(repoRoot) {
|
||||
if (repoRoot) {
|
||||
return path.resolve(repoRoot);
|
||||
}
|
||||
|
||||
return path.resolve(__dirname, '..', '..', '..');
|
||||
}
|
||||
|
||||
function resolveHomeDir(homeDir) {
|
||||
return homeDir ? path.resolve(homeDir) : os.homedir();
|
||||
}
|
||||
|
||||
function normalizeSkillDir(skillPath) {
|
||||
if (!skillPath || typeof skillPath !== 'string') {
|
||||
throw new Error('skillPath is required');
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(skillPath);
|
||||
if (path.basename(resolvedPath) === 'SKILL.md') {
|
||||
return path.dirname(resolvedPath);
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
function isWithinRoot(targetPath, rootPath) {
|
||||
const relativePath = path.relative(rootPath, targetPath);
|
||||
return relativePath === '' || (
|
||||
!relativePath.startsWith('..')
|
||||
&& !path.isAbsolute(relativePath)
|
||||
);
|
||||
}
|
||||
|
||||
function getSkillRoots(options = {}) {
|
||||
const repoRoot = resolveRepoRoot(options.repoRoot);
|
||||
const homeDir = resolveHomeDir(options.homeDir);
|
||||
|
||||
return {
|
||||
curated: path.join(repoRoot, 'skills'),
|
||||
learned: path.join(homeDir, '.claude', 'skills', 'learned'),
|
||||
imported: path.join(homeDir, '.claude', 'skills', 'imported'),
|
||||
};
|
||||
}
|
||||
|
||||
function classifySkillPath(skillPath, options = {}) {
|
||||
const skillDir = normalizeSkillDir(skillPath);
|
||||
const roots = getSkillRoots(options);
|
||||
|
||||
if (isWithinRoot(skillDir, roots.curated)) {
|
||||
return SKILL_TYPES.CURATED;
|
||||
}
|
||||
|
||||
if (isWithinRoot(skillDir, roots.learned)) {
|
||||
return SKILL_TYPES.LEARNED;
|
||||
}
|
||||
|
||||
if (isWithinRoot(skillDir, roots.imported)) {
|
||||
return SKILL_TYPES.IMPORTED;
|
||||
}
|
||||
|
||||
return SKILL_TYPES.UNKNOWN;
|
||||
}
|
||||
|
||||
function requiresProvenance(skillPath, options = {}) {
|
||||
const skillType = classifySkillPath(skillPath, options);
|
||||
return skillType === SKILL_TYPES.LEARNED || skillType === SKILL_TYPES.IMPORTED;
|
||||
}
|
||||
|
||||
function getProvenancePath(skillPath) {
|
||||
return path.join(normalizeSkillDir(skillPath), PROVENANCE_FILE_NAME);
|
||||
}
|
||||
|
||||
function isIsoTimestamp(value) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timestamp = Date.parse(value);
|
||||
return !Number.isNaN(timestamp);
|
||||
}
|
||||
|
||||
function validateProvenance(record) {
|
||||
const errors = [];
|
||||
|
||||
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
||||
errors.push('provenance record must be an object');
|
||||
return {
|
||||
valid: false,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof record.source !== 'string' || record.source.trim().length === 0) {
|
||||
errors.push('source is required');
|
||||
}
|
||||
|
||||
if (!isIsoTimestamp(record.created_at)) {
|
||||
errors.push('created_at must be an ISO timestamp');
|
||||
}
|
||||
|
||||
if (typeof record.confidence !== 'number' || Number.isNaN(record.confidence)) {
|
||||
errors.push('confidence must be a number');
|
||||
} else if (record.confidence < 0 || record.confidence > 1) {
|
||||
errors.push('confidence must be between 0 and 1');
|
||||
}
|
||||
|
||||
if (typeof record.author !== 'string' || record.author.trim().length === 0) {
|
||||
errors.push('author is required');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
function assertValidProvenance(record) {
|
||||
const validation = validateProvenance(record);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid provenance metadata: ${validation.errors.join('; ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function readProvenance(skillPath, options = {}) {
|
||||
const skillDir = normalizeSkillDir(skillPath);
|
||||
const provenancePath = getProvenancePath(skillDir);
|
||||
const provenanceRequired = options.required === true || requiresProvenance(skillDir, options);
|
||||
|
||||
if (!fs.existsSync(provenancePath)) {
|
||||
if (provenanceRequired) {
|
||||
throw new Error(`Missing provenance metadata for ${skillDir}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = JSON.parse(fs.readFileSync(provenancePath, 'utf8'));
|
||||
assertValidProvenance(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
function writeProvenance(skillPath, record, options = {}) {
|
||||
const skillDir = normalizeSkillDir(skillPath);
|
||||
|
||||
if (!requiresProvenance(skillDir, options)) {
|
||||
throw new Error(`Provenance metadata is only required for learned or imported skills: ${skillDir}`);
|
||||
}
|
||||
|
||||
assertValidProvenance(record);
|
||||
|
||||
const provenancePath = getProvenancePath(skillDir);
|
||||
ensureDir(skillDir);
|
||||
fs.writeFileSync(provenancePath, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
||||
|
||||
return {
|
||||
path: provenancePath,
|
||||
record: { ...record },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROVENANCE_FILE_NAME,
|
||||
SKILL_TYPES,
|
||||
classifySkillPath,
|
||||
getProvenancePath,
|
||||
getSkillRoots,
|
||||
readProvenance,
|
||||
requiresProvenance,
|
||||
validateProvenance,
|
||||
writeProvenance,
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { appendFile } = require('../utils');
|
||||
|
||||
const VALID_OUTCOMES = new Set(['success', 'failure', 'partial']);
|
||||
const VALID_FEEDBACK = new Set(['accepted', 'corrected', 'rejected']);
|
||||
|
||||
function resolveHomeDir(homeDir) {
|
||||
return homeDir ? path.resolve(homeDir) : os.homedir();
|
||||
}
|
||||
|
||||
function getRunsFilePath(options = {}) {
|
||||
if (options.runsFilePath) {
|
||||
return path.resolve(options.runsFilePath);
|
||||
}
|
||||
|
||||
return path.join(resolveHomeDir(options.homeDir), '.claude', 'state', 'skill-runs.jsonl');
|
||||
}
|
||||
|
||||
function toNullableNumber(value, fieldName) {
|
||||
if (value === null || typeof value === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numericValue = Number(value);
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
throw new Error(`${fieldName} must be a number`);
|
||||
}
|
||||
|
||||
return numericValue;
|
||||
}
|
||||
|
||||
function normalizeExecutionRecord(input, options = {}) {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
throw new Error('skill execution payload must be an object');
|
||||
}
|
||||
|
||||
const skillId = input.skill_id || input.skillId;
|
||||
const skillVersion = input.skill_version || input.skillVersion;
|
||||
const taskDescription = input.task_description || input.task_attempted || input.taskAttempted;
|
||||
const outcome = input.outcome;
|
||||
const recordedAt = input.recorded_at || options.now || new Date().toISOString();
|
||||
const userFeedback = input.user_feedback || input.userFeedback || null;
|
||||
|
||||
if (typeof skillId !== 'string' || skillId.trim().length === 0) {
|
||||
throw new Error('skill_id is required');
|
||||
}
|
||||
|
||||
if (typeof skillVersion !== 'string' || skillVersion.trim().length === 0) {
|
||||
throw new Error('skill_version is required');
|
||||
}
|
||||
|
||||
if (typeof taskDescription !== 'string' || taskDescription.trim().length === 0) {
|
||||
throw new Error('task_description is required');
|
||||
}
|
||||
|
||||
if (!VALID_OUTCOMES.has(outcome)) {
|
||||
throw new Error('outcome must be one of success, failure, or partial');
|
||||
}
|
||||
|
||||
if (userFeedback !== null && !VALID_FEEDBACK.has(userFeedback)) {
|
||||
throw new Error('user_feedback must be accepted, corrected, rejected, or null');
|
||||
}
|
||||
|
||||
if (Number.isNaN(Date.parse(recordedAt))) {
|
||||
throw new Error('recorded_at must be an ISO timestamp');
|
||||
}
|
||||
|
||||
return {
|
||||
skill_id: skillId,
|
||||
skill_version: skillVersion,
|
||||
task_description: taskDescription,
|
||||
outcome,
|
||||
failure_reason: input.failure_reason || input.failureReason || null,
|
||||
tokens_used: toNullableNumber(input.tokens_used ?? input.tokensUsed, 'tokens_used'),
|
||||
duration_ms: toNullableNumber(input.duration_ms ?? input.durationMs, 'duration_ms'),
|
||||
user_feedback: userFeedback,
|
||||
recorded_at: recordedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function readJsonl(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readFileSync(filePath, 'utf8')
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.reduce((rows, line) => {
|
||||
try {
|
||||
rows.push(JSON.parse(line));
|
||||
} catch {
|
||||
// Ignore malformed rows so analytics remain best-effort.
|
||||
}
|
||||
return rows;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function recordSkillExecution(input, options = {}) {
|
||||
const record = normalizeExecutionRecord(input, options);
|
||||
|
||||
if (options.stateStore && typeof options.stateStore.recordSkillExecution === 'function') {
|
||||
try {
|
||||
const result = options.stateStore.recordSkillExecution(record);
|
||||
return {
|
||||
storage: 'state-store',
|
||||
record,
|
||||
result,
|
||||
};
|
||||
} catch {
|
||||
// Fall back to JSONL until the formal state-store exists on this branch.
|
||||
}
|
||||
}
|
||||
|
||||
const runsFilePath = getRunsFilePath(options);
|
||||
appendFile(runsFilePath, `${JSON.stringify(record)}\n`);
|
||||
|
||||
return {
|
||||
storage: 'jsonl',
|
||||
path: runsFilePath,
|
||||
record,
|
||||
};
|
||||
}
|
||||
|
||||
function readSkillExecutionRecords(options = {}) {
|
||||
if (options.stateStore && typeof options.stateStore.listSkillExecutionRecords === 'function') {
|
||||
return options.stateStore.listSkillExecutionRecords();
|
||||
}
|
||||
|
||||
return readJsonl(getRunsFilePath(options));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
VALID_FEEDBACK,
|
||||
VALID_OUTCOMES,
|
||||
getRunsFilePath,
|
||||
normalizeExecutionRecord,
|
||||
readSkillExecutionRecords,
|
||||
recordSkillExecution,
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { appendFile, ensureDir } = require('../utils');
|
||||
|
||||
const VERSION_DIRECTORY_NAME = '.versions';
|
||||
const EVOLUTION_DIRECTORY_NAME = '.evolution';
|
||||
const EVOLUTION_LOG_TYPES = Object.freeze([
|
||||
'observations',
|
||||
'inspections',
|
||||
'amendments',
|
||||
]);
|
||||
|
||||
function normalizeSkillDir(skillPath) {
|
||||
if (!skillPath || typeof skillPath !== 'string') {
|
||||
throw new Error('skillPath is required');
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(skillPath);
|
||||
if (path.basename(resolvedPath) === 'SKILL.md') {
|
||||
return path.dirname(resolvedPath);
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
function getSkillFilePath(skillPath) {
|
||||
return path.join(normalizeSkillDir(skillPath), 'SKILL.md');
|
||||
}
|
||||
|
||||
function ensureSkillExists(skillPath) {
|
||||
const skillFilePath = getSkillFilePath(skillPath);
|
||||
if (!fs.existsSync(skillFilePath)) {
|
||||
throw new Error(`Skill file not found: ${skillFilePath}`);
|
||||
}
|
||||
|
||||
return skillFilePath;
|
||||
}
|
||||
|
||||
function getVersionsDir(skillPath) {
|
||||
return path.join(normalizeSkillDir(skillPath), VERSION_DIRECTORY_NAME);
|
||||
}
|
||||
|
||||
function getEvolutionDir(skillPath) {
|
||||
return path.join(normalizeSkillDir(skillPath), EVOLUTION_DIRECTORY_NAME);
|
||||
}
|
||||
|
||||
function getEvolutionLogPath(skillPath, logType) {
|
||||
if (!EVOLUTION_LOG_TYPES.includes(logType)) {
|
||||
throw new Error(`Unknown evolution log type: ${logType}`);
|
||||
}
|
||||
|
||||
return path.join(getEvolutionDir(skillPath), `${logType}.jsonl`);
|
||||
}
|
||||
|
||||
function ensureSkillVersioning(skillPath) {
|
||||
ensureSkillExists(skillPath);
|
||||
|
||||
const versionsDir = getVersionsDir(skillPath);
|
||||
const evolutionDir = getEvolutionDir(skillPath);
|
||||
|
||||
ensureDir(versionsDir);
|
||||
ensureDir(evolutionDir);
|
||||
|
||||
for (const logType of EVOLUTION_LOG_TYPES) {
|
||||
const logPath = getEvolutionLogPath(skillPath, logType);
|
||||
if (!fs.existsSync(logPath)) {
|
||||
fs.writeFileSync(logPath, '', 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
versionsDir,
|
||||
evolutionDir,
|
||||
};
|
||||
}
|
||||
|
||||
function parseVersionNumber(fileName) {
|
||||
const match = /^v(\d+)\.md$/.exec(fileName);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
function listVersions(skillPath) {
|
||||
const versionsDir = getVersionsDir(skillPath);
|
||||
if (!fs.existsSync(versionsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(versionsDir)
|
||||
.map(fileName => {
|
||||
const version = parseVersionNumber(fileName);
|
||||
if (version === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePath = path.join(versionsDir, fileName);
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
return {
|
||||
version,
|
||||
path: filePath,
|
||||
created_at: stats.mtime.toISOString(),
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.version - right.version);
|
||||
}
|
||||
|
||||
function getCurrentVersion(skillPath) {
|
||||
const skillFilePath = getSkillFilePath(skillPath);
|
||||
if (!fs.existsSync(skillFilePath)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const versions = listVersions(skillPath);
|
||||
if (versions.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return versions[versions.length - 1].version;
|
||||
}
|
||||
|
||||
function appendEvolutionRecord(skillPath, logType, record) {
|
||||
ensureSkillVersioning(skillPath);
|
||||
appendFile(getEvolutionLogPath(skillPath, logType), `${JSON.stringify(record)}\n`);
|
||||
return { ...record };
|
||||
}
|
||||
|
||||
function readJsonl(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readFileSync(filePath, 'utf8')
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.reduce((rows, line) => {
|
||||
try {
|
||||
rows.push(JSON.parse(line));
|
||||
} catch {
|
||||
// Ignore malformed rows so the log remains append-only and resilient.
|
||||
}
|
||||
return rows;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function getEvolutionLog(skillPath, logType) {
|
||||
return readJsonl(getEvolutionLogPath(skillPath, logType));
|
||||
}
|
||||
|
||||
function createVersion(skillPath, options = {}) {
|
||||
const skillFilePath = ensureSkillExists(skillPath);
|
||||
ensureSkillVersioning(skillPath);
|
||||
|
||||
const versions = listVersions(skillPath);
|
||||
const nextVersion = versions.length === 0 ? 1 : versions[versions.length - 1].version + 1;
|
||||
const snapshotPath = path.join(getVersionsDir(skillPath), `v${nextVersion}.md`);
|
||||
const skillContent = fs.readFileSync(skillFilePath, 'utf8');
|
||||
const createdAt = options.timestamp || new Date().toISOString();
|
||||
|
||||
fs.writeFileSync(snapshotPath, skillContent, 'utf8');
|
||||
appendEvolutionRecord(skillPath, 'amendments', {
|
||||
event: 'snapshot',
|
||||
version: nextVersion,
|
||||
reason: options.reason || null,
|
||||
author: options.author || null,
|
||||
status: 'applied',
|
||||
created_at: createdAt,
|
||||
});
|
||||
|
||||
return {
|
||||
version: nextVersion,
|
||||
path: snapshotPath,
|
||||
created_at: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackTo(skillPath, targetVersion, options = {}) {
|
||||
const normalizedTargetVersion = Number(targetVersion);
|
||||
if (!Number.isInteger(normalizedTargetVersion) || normalizedTargetVersion <= 0) {
|
||||
throw new Error(`Invalid target version: ${targetVersion}`);
|
||||
}
|
||||
|
||||
ensureSkillExists(skillPath);
|
||||
ensureSkillVersioning(skillPath);
|
||||
|
||||
const targetPath = path.join(getVersionsDir(skillPath), `v${normalizedTargetVersion}.md`);
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
throw new Error(`Version not found: v${normalizedTargetVersion}`);
|
||||
}
|
||||
|
||||
const currentVersion = getCurrentVersion(skillPath);
|
||||
const targetContent = fs.readFileSync(targetPath, 'utf8');
|
||||
fs.writeFileSync(getSkillFilePath(skillPath), targetContent, 'utf8');
|
||||
|
||||
const createdVersion = createVersion(skillPath, {
|
||||
timestamp: options.timestamp,
|
||||
reason: options.reason || `rollback to v${normalizedTargetVersion}`,
|
||||
author: options.author || null,
|
||||
});
|
||||
|
||||
appendEvolutionRecord(skillPath, 'amendments', {
|
||||
event: 'rollback',
|
||||
version: createdVersion.version,
|
||||
source_version: currentVersion,
|
||||
target_version: normalizedTargetVersion,
|
||||
reason: options.reason || null,
|
||||
author: options.author || null,
|
||||
status: 'applied',
|
||||
created_at: options.timestamp || new Date().toISOString(),
|
||||
});
|
||||
|
||||
return createdVersion;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EVOLUTION_DIRECTORY_NAME,
|
||||
EVOLUTION_LOG_TYPES,
|
||||
VERSION_DIRECTORY_NAME,
|
||||
appendEvolutionRecord,
|
||||
createVersion,
|
||||
ensureSkillVersioning,
|
||||
getCurrentVersion,
|
||||
getEvolutionDir,
|
||||
getEvolutionLog,
|
||||
getEvolutionLogPath,
|
||||
getVersionsDir,
|
||||
listVersions,
|
||||
rollbackTo,
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
'use strict';
|
||||
|
||||
const { buildSkillHealthReport } = require('./health');
|
||||
|
||||
const AMENDMENT_SCHEMA_VERSION = 'ecc.skill-amendment-proposal.v1';
|
||||
|
||||
function createProposalId(skillId) {
|
||||
return `amend-${skillId}-${Date.now()}`;
|
||||
}
|
||||
|
||||
function summarizePatchPreview(skillId, health) {
|
||||
const lines = [
|
||||
'## Failure-Driven Amendments',
|
||||
'',
|
||||
`- Focus skill routing for \`${skillId}\` when tasks match the proven success cases.`,
|
||||
];
|
||||
|
||||
if (health.recurringErrors[0]) {
|
||||
lines.push(`- Add explicit guardrails for recurring failure: ${health.recurringErrors[0].error}.`);
|
||||
}
|
||||
|
||||
if (health.recurringTasks[0]) {
|
||||
lines.push(`- Add an example workflow for task pattern: ${health.recurringTasks[0].task}.`);
|
||||
}
|
||||
|
||||
if (health.recurringFeedback[0]) {
|
||||
lines.push(`- Address repeated user feedback: ${health.recurringFeedback[0].feedback}.`);
|
||||
}
|
||||
|
||||
lines.push('- Add a verification checklist before declaring the skill output complete.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function proposeSkillAmendment(skillId, records, options = {}) {
|
||||
const report = buildSkillHealthReport(records, {
|
||||
...options,
|
||||
skillId,
|
||||
minFailureCount: options.minFailureCount || 1
|
||||
});
|
||||
const [health] = report.skills;
|
||||
|
||||
if (!health || health.failures === 0) {
|
||||
return {
|
||||
schemaVersion: AMENDMENT_SCHEMA_VERSION,
|
||||
skill: {
|
||||
id: skillId,
|
||||
path: null
|
||||
},
|
||||
status: 'insufficient-evidence',
|
||||
rationale: ['No failed observations were available for this skill.'],
|
||||
patch: null
|
||||
};
|
||||
}
|
||||
|
||||
const preview = summarizePatchPreview(skillId, health);
|
||||
|
||||
return {
|
||||
schemaVersion: AMENDMENT_SCHEMA_VERSION,
|
||||
proposalId: createProposalId(skillId),
|
||||
generatedAt: new Date().toISOString(),
|
||||
status: 'proposed',
|
||||
skill: {
|
||||
id: skillId,
|
||||
path: health.skill.path || null
|
||||
},
|
||||
evidence: {
|
||||
totalRuns: health.totalRuns,
|
||||
failures: health.failures,
|
||||
successRate: health.successRate,
|
||||
recurringErrors: health.recurringErrors,
|
||||
recurringTasks: health.recurringTasks,
|
||||
recurringFeedback: health.recurringFeedback
|
||||
},
|
||||
rationale: [
|
||||
'Proposals are generated from repeated failed runs rather than a single anecdotal error.',
|
||||
'The suggested patch is additive so the original SKILL.md intent remains auditable.'
|
||||
],
|
||||
patch: {
|
||||
format: 'markdown-fragment',
|
||||
targetPath: health.skill.path || `skills/${skillId}/SKILL.md`,
|
||||
preview
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AMENDMENT_SCHEMA_VERSION,
|
||||
proposeSkillAmendment
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
'use strict';
|
||||
|
||||
const EVALUATION_SCHEMA_VERSION = 'ecc.skill-evaluation.v1';
|
||||
|
||||
function roundRate(value) {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
function summarize(records) {
|
||||
const runs = records.length;
|
||||
const successes = records.filter(record => record.outcome && record.outcome.success).length;
|
||||
const failures = runs - successes;
|
||||
return {
|
||||
runs,
|
||||
successes,
|
||||
failures,
|
||||
successRate: runs > 0 ? roundRate(successes / runs) : 0
|
||||
};
|
||||
}
|
||||
|
||||
function buildSkillEvaluationScaffold(skillId, records, options = {}) {
|
||||
const minimumRunsPerVariant = options.minimumRunsPerVariant || 2;
|
||||
const amendmentId = options.amendmentId || null;
|
||||
const filtered = records.filter(record => record.skill && record.skill.id === skillId);
|
||||
const baseline = filtered.filter(record => !record.run || record.run.variant !== 'amended');
|
||||
const amended = filtered.filter(record => record.run && record.run.variant === 'amended')
|
||||
.filter(record => !amendmentId || record.run.amendmentId === amendmentId);
|
||||
|
||||
const baselineSummary = summarize(baseline);
|
||||
const amendedSummary = summarize(amended);
|
||||
const delta = {
|
||||
successRate: roundRate(amendedSummary.successRate - baselineSummary.successRate),
|
||||
failures: amendedSummary.failures - baselineSummary.failures
|
||||
};
|
||||
|
||||
let recommendation = 'insufficient-data';
|
||||
if (baselineSummary.runs >= minimumRunsPerVariant && amendedSummary.runs >= minimumRunsPerVariant) {
|
||||
recommendation = delta.successRate > 0 ? 'promote-amendment' : 'keep-baseline';
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: EVALUATION_SCHEMA_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
skillId,
|
||||
amendmentId,
|
||||
gate: {
|
||||
minimumRunsPerVariant
|
||||
},
|
||||
baseline: baselineSummary,
|
||||
amended: amendedSummary,
|
||||
delta,
|
||||
recommendation
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EVALUATION_SCHEMA_VERSION,
|
||||
buildSkillEvaluationScaffold
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
'use strict';
|
||||
|
||||
const HEALTH_SCHEMA_VERSION = 'ecc.skill-health.v1';
|
||||
|
||||
function roundRate(value) {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
function rankCounts(values) {
|
||||
return Array.from(values.entries())
|
||||
.map(([value, count]) => ({ value, count }))
|
||||
.sort((left, right) => right.count - left.count || left.value.localeCompare(right.value));
|
||||
}
|
||||
|
||||
function summarizeVariantRuns(records) {
|
||||
return records.reduce((accumulator, record) => {
|
||||
const key = record.run && record.run.variant ? record.run.variant : 'baseline';
|
||||
if (!accumulator[key]) {
|
||||
accumulator[key] = { runs: 0, successes: 0, failures: 0 };
|
||||
}
|
||||
|
||||
accumulator[key].runs += 1;
|
||||
if (record.outcome && record.outcome.success) {
|
||||
accumulator[key].successes += 1;
|
||||
} else {
|
||||
accumulator[key].failures += 1;
|
||||
}
|
||||
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function deriveSkillStatus(skillSummary, options = {}) {
|
||||
const minFailureCount = options.minFailureCount || 2;
|
||||
if (skillSummary.failures >= minFailureCount) {
|
||||
return 'failing';
|
||||
}
|
||||
|
||||
if (skillSummary.failures > 0) {
|
||||
return 'watch';
|
||||
}
|
||||
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
function buildSkillHealthReport(records, options = {}) {
|
||||
const filterSkillId = options.skillId || null;
|
||||
const filtered = filterSkillId
|
||||
? records.filter(record => record.skill && record.skill.id === filterSkillId)
|
||||
: records.slice();
|
||||
|
||||
const grouped = filtered.reduce((accumulator, record) => {
|
||||
const skillId = record.skill.id;
|
||||
if (!accumulator.has(skillId)) {
|
||||
accumulator.set(skillId, []);
|
||||
}
|
||||
accumulator.get(skillId).push(record);
|
||||
return accumulator;
|
||||
}, new Map());
|
||||
|
||||
const skills = Array.from(grouped.entries())
|
||||
.map(([skillId, skillRecords]) => {
|
||||
const successes = skillRecords.filter(record => record.outcome && record.outcome.success).length;
|
||||
const failures = skillRecords.length - successes;
|
||||
const recurringErrors = new Map();
|
||||
const recurringTasks = new Map();
|
||||
const recurringFeedback = new Map();
|
||||
|
||||
skillRecords.forEach(record => {
|
||||
if (!record.outcome || record.outcome.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (record.outcome.error) {
|
||||
recurringErrors.set(record.outcome.error, (recurringErrors.get(record.outcome.error) || 0) + 1);
|
||||
}
|
||||
if (record.task) {
|
||||
recurringTasks.set(record.task, (recurringTasks.get(record.task) || 0) + 1);
|
||||
}
|
||||
if (record.outcome.feedback) {
|
||||
recurringFeedback.set(record.outcome.feedback, (recurringFeedback.get(record.outcome.feedback) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
const summary = {
|
||||
skill: {
|
||||
id: skillId,
|
||||
path: skillRecords[0].skill.path || null
|
||||
},
|
||||
totalRuns: skillRecords.length,
|
||||
successes,
|
||||
failures,
|
||||
successRate: skillRecords.length > 0 ? roundRate(successes / skillRecords.length) : 0,
|
||||
status: 'healthy',
|
||||
recurringErrors: rankCounts(recurringErrors).map(entry => ({ error: entry.value, count: entry.count })),
|
||||
recurringTasks: rankCounts(recurringTasks).map(entry => ({ task: entry.value, count: entry.count })),
|
||||
recurringFeedback: rankCounts(recurringFeedback).map(entry => ({ feedback: entry.value, count: entry.count })),
|
||||
variants: summarizeVariantRuns(skillRecords)
|
||||
};
|
||||
|
||||
summary.status = deriveSkillStatus(summary, options);
|
||||
return summary;
|
||||
})
|
||||
.sort((left, right) => right.failures - left.failures || left.skill.id.localeCompare(right.skill.id));
|
||||
|
||||
return {
|
||||
schemaVersion: HEALTH_SCHEMA_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
totalObservations: filtered.length,
|
||||
skillCount: skills.length,
|
||||
skills
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HEALTH_SCHEMA_VERSION,
|
||||
buildSkillHealthReport
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const OBSERVATION_SCHEMA_VERSION = 'ecc.skill-observation.v1';
|
||||
|
||||
function resolveProjectRoot(options = {}) {
|
||||
return path.resolve(options.projectRoot || options.cwd || process.cwd());
|
||||
}
|
||||
|
||||
function getSkillTelemetryRoot(options = {}) {
|
||||
return path.join(resolveProjectRoot(options), '.claude', 'ecc', 'skills');
|
||||
}
|
||||
|
||||
function getSkillObservationsPath(options = {}) {
|
||||
return path.join(getSkillTelemetryRoot(options), 'observations.jsonl');
|
||||
}
|
||||
|
||||
function ensureString(value, label) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`${label} must be a non-empty string`);
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function createObservationId() {
|
||||
return `obs-${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function createSkillObservation(input) {
|
||||
const task = ensureString(input.task, 'task');
|
||||
const skillId = ensureString(input.skill && input.skill.id, 'skill.id');
|
||||
const skillPath = typeof input.skill.path === 'string' && input.skill.path.trim().length > 0
|
||||
? input.skill.path.trim()
|
||||
: null;
|
||||
const success = Boolean(input.success);
|
||||
const error = input.error === null || input.error === undefined ? null : String(input.error);
|
||||
const feedback = input.feedback === null || input.feedback === undefined ? null : String(input.feedback);
|
||||
const variant = typeof input.variant === 'string' && input.variant.trim().length > 0
|
||||
? input.variant.trim()
|
||||
: 'baseline';
|
||||
|
||||
return {
|
||||
schemaVersion: OBSERVATION_SCHEMA_VERSION,
|
||||
observationId: typeof input.observationId === 'string' && input.observationId.length > 0
|
||||
? input.observationId
|
||||
: createObservationId(),
|
||||
timestamp: typeof input.timestamp === 'string' && input.timestamp.length > 0
|
||||
? input.timestamp
|
||||
: new Date().toISOString(),
|
||||
task,
|
||||
skill: {
|
||||
id: skillId,
|
||||
path: skillPath
|
||||
},
|
||||
outcome: {
|
||||
success,
|
||||
status: success ? 'success' : 'failure',
|
||||
error,
|
||||
feedback
|
||||
},
|
||||
run: {
|
||||
variant,
|
||||
amendmentId: input.amendmentId || null,
|
||||
sessionId: input.sessionId || null,
|
||||
source: input.source || 'manual'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function appendSkillObservation(observation, options = {}) {
|
||||
const outputPath = getSkillObservationsPath(options);
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.appendFileSync(outputPath, `${JSON.stringify(observation)}${os.EOL}`, 'utf8');
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
function readSkillObservations(options = {}) {
|
||||
const observationPath = path.resolve(options.observationsPath || getSkillObservationsPath(options));
|
||||
if (!fs.existsSync(observationPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readFileSync(observationPath, 'utf8')
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map(line => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(record => record && record.schemaVersion === OBSERVATION_SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
OBSERVATION_SCHEMA_VERSION,
|
||||
appendSkillObservation,
|
||||
createSkillObservation,
|
||||
getSkillObservationsPath,
|
||||
getSkillTelemetryRoot,
|
||||
readSkillObservations,
|
||||
resolveProjectRoot
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const initSqlJs = require('sql.js');
|
||||
|
||||
const { applyMigrations, getAppliedMigrations } = require('./migrations');
|
||||
const { createQueryApi } = require('./queries');
|
||||
const { assertValidEntity, validateEntity } = require('./schema');
|
||||
|
||||
const DEFAULT_STATE_STORE_RELATIVE_PATH = path.join('.claude', 'ecc', 'state.db');
|
||||
|
||||
function resolveStateStorePath(options = {}) {
|
||||
if (options.dbPath) {
|
||||
if (options.dbPath === ':memory:') {
|
||||
return options.dbPath;
|
||||
}
|
||||
return path.resolve(options.dbPath);
|
||||
}
|
||||
|
||||
const homeDir = options.homeDir || process.env.HOME || os.homedir();
|
||||
return path.join(homeDir, DEFAULT_STATE_STORE_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a sql.js Database with a better-sqlite3-compatible API surface so
|
||||
* that the rest of the state-store code (migrations.js, queries.js) can
|
||||
* operate without knowing which driver is in use.
|
||||
*
|
||||
* IMPORTANT: sql.js db.export() implicitly ends any active transaction, so
|
||||
* we must defer all disk writes until after the transaction commits.
|
||||
*/
|
||||
function wrapSqlJsDatabase(rawDb, dbPath) {
|
||||
let inTransaction = false;
|
||||
|
||||
function saveToDisk() {
|
||||
if (dbPath === ':memory:' || inTransaction) {
|
||||
return;
|
||||
}
|
||||
const data = rawDb.export();
|
||||
const buffer = Buffer.from(data);
|
||||
fs.writeFileSync(dbPath, buffer);
|
||||
}
|
||||
|
||||
const db = {
|
||||
exec(sql) {
|
||||
rawDb.run(sql);
|
||||
saveToDisk();
|
||||
},
|
||||
|
||||
pragma(pragmaStr) {
|
||||
try {
|
||||
rawDb.run(`PRAGMA ${pragmaStr}`);
|
||||
} catch (_error) {
|
||||
// Ignore unsupported pragmas (e.g. WAL for in-memory databases).
|
||||
}
|
||||
},
|
||||
|
||||
prepare(sql) {
|
||||
return {
|
||||
all(...positionalArgs) {
|
||||
const stmt = rawDb.prepare(sql);
|
||||
if (positionalArgs.length === 1 && typeof positionalArgs[0] !== 'object') {
|
||||
stmt.bind([positionalArgs[0]]);
|
||||
} else if (positionalArgs.length > 1) {
|
||||
stmt.bind(positionalArgs);
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
while (stmt.step()) {
|
||||
rows.push(stmt.getAsObject());
|
||||
}
|
||||
stmt.free();
|
||||
return rows;
|
||||
},
|
||||
|
||||
get(...positionalArgs) {
|
||||
const stmt = rawDb.prepare(sql);
|
||||
if (positionalArgs.length === 1 && typeof positionalArgs[0] !== 'object') {
|
||||
stmt.bind([positionalArgs[0]]);
|
||||
} else if (positionalArgs.length > 1) {
|
||||
stmt.bind(positionalArgs);
|
||||
}
|
||||
|
||||
let row = null;
|
||||
if (stmt.step()) {
|
||||
row = stmt.getAsObject();
|
||||
}
|
||||
stmt.free();
|
||||
return row;
|
||||
},
|
||||
|
||||
run(namedParams) {
|
||||
const stmt = rawDb.prepare(sql);
|
||||
if (namedParams && typeof namedParams === 'object' && !Array.isArray(namedParams)) {
|
||||
const sqlJsParams = {};
|
||||
for (const [key, value] of Object.entries(namedParams)) {
|
||||
sqlJsParams[`@${key}`] = value === undefined ? null : value;
|
||||
}
|
||||
stmt.bind(sqlJsParams);
|
||||
}
|
||||
stmt.step();
|
||||
stmt.free();
|
||||
saveToDisk();
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
transaction(fn) {
|
||||
return (...args) => {
|
||||
rawDb.run('BEGIN');
|
||||
inTransaction = true;
|
||||
try {
|
||||
const result = fn(...args);
|
||||
rawDb.run('COMMIT');
|
||||
inTransaction = false;
|
||||
saveToDisk();
|
||||
return result;
|
||||
} catch (error) {
|
||||
try {
|
||||
rawDb.run('ROLLBACK');
|
||||
} catch (_rollbackError) {
|
||||
// Transaction may already be rolled back.
|
||||
}
|
||||
inTransaction = false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
close() {
|
||||
saveToDisk();
|
||||
rawDb.close();
|
||||
},
|
||||
};
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
async function openDatabase(SQL, dbPath) {
|
||||
if (dbPath !== ':memory:') {
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
}
|
||||
|
||||
let rawDb;
|
||||
if (dbPath !== ':memory:' && fs.existsSync(dbPath)) {
|
||||
const fileBuffer = fs.readFileSync(dbPath);
|
||||
rawDb = new SQL.Database(fileBuffer);
|
||||
} else {
|
||||
rawDb = new SQL.Database();
|
||||
}
|
||||
|
||||
const db = wrapSqlJsDatabase(rawDb, dbPath);
|
||||
db.pragma('foreign_keys = ON');
|
||||
try {
|
||||
db.pragma('journal_mode = WAL');
|
||||
} catch (_error) {
|
||||
// Some SQLite environments reject WAL for in-memory or readonly contexts.
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
async function createStateStore(options = {}) {
|
||||
const dbPath = resolveStateStorePath(options);
|
||||
const SQL = await initSqlJs();
|
||||
const db = await openDatabase(SQL, dbPath);
|
||||
const appliedMigrations = applyMigrations(db);
|
||||
const queryApi = createQueryApi(db);
|
||||
|
||||
return {
|
||||
dbPath,
|
||||
close() {
|
||||
db.close();
|
||||
},
|
||||
getAppliedMigrations() {
|
||||
return getAppliedMigrations(db);
|
||||
},
|
||||
validateEntity,
|
||||
assertValidEntity,
|
||||
...queryApi,
|
||||
_database: db,
|
||||
_migrations: appliedMigrations,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_STATE_STORE_RELATIVE_PATH,
|
||||
createStateStore,
|
||||
resolveStateStorePath,
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
'use strict';
|
||||
|
||||
const INITIAL_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
adapter_id TEXT NOT NULL,
|
||||
harness TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
repo_root TEXT,
|
||||
started_at TEXT,
|
||||
ended_at TEXT,
|
||||
snapshot TEXT NOT NULL CHECK (json_valid(snapshot))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_state_started_at
|
||||
ON sessions (state, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_started_at
|
||||
ON sessions (started_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS skill_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
skill_id TEXT NOT NULL,
|
||||
skill_version TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
task_description TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
failure_reason TEXT,
|
||||
tokens_used INTEGER,
|
||||
duration_ms INTEGER,
|
||||
user_feedback TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_runs_session_id_created_at
|
||||
ON skill_runs (session_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_runs_created_at
|
||||
ON skill_runs (created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_runs_outcome_created_at
|
||||
ON skill_runs (outcome, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS skill_versions (
|
||||
skill_id TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
amendment_reason TEXT,
|
||||
promoted_at TEXT,
|
||||
rolled_back_at TEXT,
|
||||
PRIMARY KEY (skill_id, version)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_versions_promoted_at
|
||||
ON skill_versions (promoted_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
rationale TEXT NOT NULL,
|
||||
alternatives TEXT NOT NULL CHECK (json_valid(alternatives)),
|
||||
supersedes TEXT,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (supersedes) REFERENCES decisions (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_session_id_created_at
|
||||
ON decisions (session_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_status_created_at
|
||||
ON decisions (status, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS install_state (
|
||||
target_id TEXT NOT NULL,
|
||||
target_root TEXT NOT NULL,
|
||||
profile TEXT,
|
||||
modules TEXT NOT NULL CHECK (json_valid(modules)),
|
||||
operations TEXT NOT NULL CHECK (json_valid(operations)),
|
||||
installed_at TEXT NOT NULL,
|
||||
source_version TEXT,
|
||||
PRIMARY KEY (target_id, target_root)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_install_state_installed_at
|
||||
ON install_state (installed_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS governance_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL CHECK (json_valid(payload)),
|
||||
resolved_at TEXT,
|
||||
resolution TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_governance_events_resolved_at_created_at
|
||||
ON governance_events (resolved_at, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_governance_events_session_id_created_at
|
||||
ON governance_events (session_id, created_at DESC);
|
||||
`;
|
||||
|
||||
const WORK_ITEMS_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS 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,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_work_items_status_updated_at
|
||||
ON work_items (status, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_work_items_source_source_id
|
||||
ON work_items (source, source_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_work_items_session_id_updated_at
|
||||
ON work_items (session_id, updated_at DESC);
|
||||
`;
|
||||
|
||||
const MIGRATIONS = [
|
||||
{
|
||||
version: 1,
|
||||
name: '001_initial_state_store',
|
||||
sql: INITIAL_SCHEMA_SQL,
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
name: '002_work_items',
|
||||
sql: WORK_ITEMS_SQL,
|
||||
},
|
||||
];
|
||||
|
||||
function ensureMigrationTable(db) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
function getAppliedMigrations(db) {
|
||||
ensureMigrationTable(db);
|
||||
return db
|
||||
.prepare(`
|
||||
SELECT version, name, applied_at
|
||||
FROM schema_migrations
|
||||
ORDER BY version ASC
|
||||
`)
|
||||
.all()
|
||||
.map(row => ({
|
||||
version: row.version,
|
||||
name: row.name,
|
||||
appliedAt: row.applied_at,
|
||||
}));
|
||||
}
|
||||
|
||||
function applyMigrations(db) {
|
||||
ensureMigrationTable(db);
|
||||
|
||||
const appliedVersions = new Set(
|
||||
db.prepare('SELECT version FROM schema_migrations').all().map(row => row.version)
|
||||
);
|
||||
const insertMigration = db.prepare(`
|
||||
INSERT INTO schema_migrations (version, name, applied_at)
|
||||
VALUES (@version, @name, @applied_at)
|
||||
`);
|
||||
|
||||
const applyPending = db.transaction(() => {
|
||||
for (const migration of MIGRATIONS) {
|
||||
if (appliedVersions.has(migration.version)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
db.exec(migration.sql);
|
||||
insertMigration.run({
|
||||
version: migration.version,
|
||||
name: migration.name,
|
||||
applied_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
applyPending();
|
||||
return getAppliedMigrations(db);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MIGRATIONS,
|
||||
applyMigrations,
|
||||
getAppliedMigrations,
|
||||
};
|
||||
@@ -0,0 +1,906 @@
|
||||
'use strict';
|
||||
|
||||
const { assertValidEntity } = require('./schema');
|
||||
|
||||
const ACTIVE_SESSION_STATES = ['active', 'running', 'idle'];
|
||||
const SUCCESS_OUTCOMES = new Set(['success', 'succeeded', 'passed']);
|
||||
const FAILURE_OUTCOMES = new Set(['failure', 'failed', 'error']);
|
||||
const CLOSED_WORK_ITEM_STATUSES = new Set(['done', 'closed', 'resolved', 'merged', 'cancelled']);
|
||||
const ATTENTION_WORK_ITEM_STATUSES = new Set(['blocked', 'needs-review', 'failed', 'stalled']);
|
||||
|
||||
function normalizeLimit(value, fallback) {
|
||||
if (value === undefined || value === null) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`Invalid limit: ${value}`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseJsonColumn(value, fallback) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return JSON.parse(value);
|
||||
}
|
||||
|
||||
function stringifyJson(value, label) {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to serialize ${label}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function mapSessionRow(row) {
|
||||
const snapshot = parseJsonColumn(row.snapshot, {});
|
||||
return {
|
||||
id: row.id,
|
||||
adapterId: row.adapter_id,
|
||||
harness: row.harness,
|
||||
state: row.state,
|
||||
repoRoot: row.repo_root,
|
||||
startedAt: row.started_at,
|
||||
endedAt: row.ended_at,
|
||||
snapshot,
|
||||
workerCount: Array.isArray(snapshot && snapshot.workers) ? snapshot.workers.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function mapSkillRunRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
skillId: row.skill_id,
|
||||
skillVersion: row.skill_version,
|
||||
sessionId: row.session_id,
|
||||
taskDescription: row.task_description,
|
||||
outcome: row.outcome,
|
||||
failureReason: row.failure_reason,
|
||||
tokensUsed: row.tokens_used,
|
||||
durationMs: row.duration_ms,
|
||||
userFeedback: row.user_feedback,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapSkillVersionRow(row) {
|
||||
return {
|
||||
skillId: row.skill_id,
|
||||
version: row.version,
|
||||
contentHash: row.content_hash,
|
||||
amendmentReason: row.amendment_reason,
|
||||
promotedAt: row.promoted_at,
|
||||
rolledBackAt: row.rolled_back_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDecisionRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
title: row.title,
|
||||
rationale: row.rationale,
|
||||
alternatives: parseJsonColumn(row.alternatives, []),
|
||||
supersedes: row.supersedes,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapInstallStateRow(row) {
|
||||
const modules = parseJsonColumn(row.modules, []);
|
||||
const operations = parseJsonColumn(row.operations, []);
|
||||
const status = row.source_version && row.installed_at ? 'healthy' : 'warning';
|
||||
|
||||
return {
|
||||
targetId: row.target_id,
|
||||
targetRoot: row.target_root,
|
||||
profile: row.profile,
|
||||
modules,
|
||||
operations,
|
||||
installedAt: row.installed_at,
|
||||
sourceVersion: row.source_version,
|
||||
moduleCount: Array.isArray(modules) ? modules.length : 0,
|
||||
operationCount: Array.isArray(operations) ? operations.length : 0,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
function mapGovernanceEventRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
eventType: row.event_type,
|
||||
payload: parseJsonColumn(row.payload, null),
|
||||
resolvedAt: row.resolved_at,
|
||||
resolution: row.resolution,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapWorkItemRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
source: row.source,
|
||||
sourceId: row.source_id,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
url: row.url,
|
||||
owner: row.owner,
|
||||
repoRoot: row.repo_root,
|
||||
sessionId: row.session_id,
|
||||
metadata: parseJsonColumn(row.metadata, null),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function classifyOutcome(outcome) {
|
||||
const normalized = String(outcome || '').toLowerCase();
|
||||
if (SUCCESS_OUTCOMES.has(normalized)) {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (FAILURE_OUTCOMES.has(normalized)) {
|
||||
return 'failure';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function classifyWorkItemStatus(status) {
|
||||
const normalized = String(status || '').toLowerCase();
|
||||
if (CLOSED_WORK_ITEM_STATUSES.has(normalized)) {
|
||||
return 'closed';
|
||||
}
|
||||
|
||||
if (ATTENTION_WORK_ITEM_STATUSES.has(normalized)) {
|
||||
return 'attention';
|
||||
}
|
||||
|
||||
return 'open';
|
||||
}
|
||||
|
||||
function toPercent(numerator, denominator) {
|
||||
if (denominator === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Number(((numerator / denominator) * 100).toFixed(1));
|
||||
}
|
||||
|
||||
function summarizeSkillRuns(skillRuns) {
|
||||
const summary = {
|
||||
totalCount: skillRuns.length,
|
||||
knownCount: 0,
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
unknownCount: 0,
|
||||
successRate: null,
|
||||
failureRate: null,
|
||||
};
|
||||
|
||||
for (const skillRun of skillRuns) {
|
||||
const classification = classifyOutcome(skillRun.outcome);
|
||||
if (classification === 'success') {
|
||||
summary.successCount += 1;
|
||||
summary.knownCount += 1;
|
||||
} else if (classification === 'failure') {
|
||||
summary.failureCount += 1;
|
||||
summary.knownCount += 1;
|
||||
} else {
|
||||
summary.unknownCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
summary.successRate = toPercent(summary.successCount, summary.knownCount);
|
||||
summary.failureRate = toPercent(summary.failureCount, summary.knownCount);
|
||||
return summary;
|
||||
}
|
||||
|
||||
function summarizeInstallHealth(installations) {
|
||||
if (installations.length === 0) {
|
||||
return {
|
||||
status: 'missing',
|
||||
totalCount: 0,
|
||||
healthyCount: 0,
|
||||
warningCount: 0,
|
||||
installations: [],
|
||||
};
|
||||
}
|
||||
|
||||
const summary = installations.reduce((result, installation) => {
|
||||
if (installation.status === 'healthy') {
|
||||
result.healthyCount += 1;
|
||||
} else {
|
||||
result.warningCount += 1;
|
||||
}
|
||||
return result;
|
||||
}, {
|
||||
totalCount: installations.length,
|
||||
healthyCount: 0,
|
||||
warningCount: 0,
|
||||
});
|
||||
|
||||
return {
|
||||
status: summary.warningCount > 0 ? 'warning' : 'healthy',
|
||||
...summary,
|
||||
installations,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeWorkItems(workItems) {
|
||||
const summary = {
|
||||
totalCount: workItems.length,
|
||||
openCount: 0,
|
||||
blockedCount: 0,
|
||||
closedCount: 0,
|
||||
items: workItems,
|
||||
};
|
||||
|
||||
for (const workItem of workItems) {
|
||||
const classification = classifyWorkItemStatus(workItem.status);
|
||||
if (classification === 'closed') {
|
||||
summary.closedCount += 1;
|
||||
} else if (classification === 'attention') {
|
||||
summary.openCount += 1;
|
||||
summary.blockedCount += 1;
|
||||
} else {
|
||||
summary.openCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
function summarizeReadiness({ activeSessionCount, skillRuns, installHealth, pendingGovernanceCount, workItems }) {
|
||||
const failedSkillRuns = skillRuns.summary.failureCount;
|
||||
const warningInstallations = installHealth.warningCount;
|
||||
const pendingGovernanceEvents = pendingGovernanceCount;
|
||||
const blockedWorkItems = workItems.blockedCount;
|
||||
const attentionCount = failedSkillRuns + warningInstallations + pendingGovernanceEvents + blockedWorkItems;
|
||||
|
||||
return {
|
||||
status: attentionCount > 0 ? 'attention' : 'ok',
|
||||
attentionCount,
|
||||
activeSessions: activeSessionCount,
|
||||
failedSkillRuns,
|
||||
warningInstallations,
|
||||
pendingGovernanceEvents,
|
||||
blockedWorkItems,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSessionInput(session) {
|
||||
return {
|
||||
id: session.id,
|
||||
adapterId: session.adapterId,
|
||||
harness: session.harness,
|
||||
state: session.state,
|
||||
repoRoot: session.repoRoot ?? null,
|
||||
startedAt: session.startedAt ?? null,
|
||||
endedAt: session.endedAt ?? null,
|
||||
snapshot: session.snapshot ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSkillRunInput(skillRun) {
|
||||
return {
|
||||
id: skillRun.id,
|
||||
skillId: skillRun.skillId,
|
||||
skillVersion: skillRun.skillVersion,
|
||||
sessionId: skillRun.sessionId,
|
||||
taskDescription: skillRun.taskDescription,
|
||||
outcome: skillRun.outcome,
|
||||
failureReason: skillRun.failureReason ?? null,
|
||||
tokensUsed: skillRun.tokensUsed ?? null,
|
||||
durationMs: skillRun.durationMs ?? null,
|
||||
userFeedback: skillRun.userFeedback ?? null,
|
||||
createdAt: skillRun.createdAt || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSkillVersionInput(skillVersion) {
|
||||
return {
|
||||
skillId: skillVersion.skillId,
|
||||
version: skillVersion.version,
|
||||
contentHash: skillVersion.contentHash,
|
||||
amendmentReason: skillVersion.amendmentReason ?? null,
|
||||
promotedAt: skillVersion.promotedAt ?? null,
|
||||
rolledBackAt: skillVersion.rolledBackAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDecisionInput(decision) {
|
||||
return {
|
||||
id: decision.id,
|
||||
sessionId: decision.sessionId,
|
||||
title: decision.title,
|
||||
rationale: decision.rationale,
|
||||
alternatives: decision.alternatives === undefined || decision.alternatives === null
|
||||
? []
|
||||
: decision.alternatives,
|
||||
supersedes: decision.supersedes ?? null,
|
||||
status: decision.status,
|
||||
createdAt: decision.createdAt || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeInstallStateInput(installState) {
|
||||
return {
|
||||
targetId: installState.targetId,
|
||||
targetRoot: installState.targetRoot,
|
||||
profile: installState.profile ?? null,
|
||||
modules: installState.modules === undefined || installState.modules === null
|
||||
? []
|
||||
: installState.modules,
|
||||
operations: installState.operations === undefined || installState.operations === null
|
||||
? []
|
||||
: installState.operations,
|
||||
installedAt: installState.installedAt || new Date().toISOString(),
|
||||
sourceVersion: installState.sourceVersion ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGovernanceEventInput(governanceEvent) {
|
||||
return {
|
||||
id: governanceEvent.id,
|
||||
sessionId: governanceEvent.sessionId ?? null,
|
||||
eventType: governanceEvent.eventType,
|
||||
payload: governanceEvent.payload ?? null,
|
||||
resolvedAt: governanceEvent.resolvedAt ?? null,
|
||||
resolution: governanceEvent.resolution ?? null,
|
||||
createdAt: governanceEvent.createdAt || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWorkItemInput(workItem) {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: workItem.id,
|
||||
source: workItem.source,
|
||||
sourceId: workItem.sourceId ?? null,
|
||||
title: workItem.title,
|
||||
status: workItem.status,
|
||||
priority: workItem.priority ?? null,
|
||||
url: workItem.url ?? null,
|
||||
owner: workItem.owner ?? null,
|
||||
repoRoot: workItem.repoRoot ?? null,
|
||||
sessionId: workItem.sessionId ?? null,
|
||||
metadata: workItem.metadata ?? null,
|
||||
createdAt: workItem.createdAt || now,
|
||||
updatedAt: workItem.updatedAt || now,
|
||||
};
|
||||
}
|
||||
|
||||
function createQueryApi(db) {
|
||||
const listRecentSessionsStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM sessions
|
||||
ORDER BY COALESCE(started_at, ended_at, '') DESC, id DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
const countSessionsStatement = db.prepare(`
|
||||
SELECT COUNT(*) AS total_count
|
||||
FROM sessions
|
||||
`);
|
||||
const getSessionStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM sessions
|
||||
WHERE id = ?
|
||||
`);
|
||||
const getSessionSkillRunsStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM skill_runs
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at DESC, id DESC
|
||||
`);
|
||||
const getSessionDecisionsStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM decisions
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at DESC, id DESC
|
||||
`);
|
||||
const listActiveSessionsStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM sessions
|
||||
WHERE ended_at IS NULL
|
||||
AND state IN ('active', 'running', 'idle')
|
||||
ORDER BY COALESCE(started_at, ended_at, '') DESC, id DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
const countActiveSessionsStatement = db.prepare(`
|
||||
SELECT COUNT(*) AS total_count
|
||||
FROM sessions
|
||||
WHERE ended_at IS NULL
|
||||
AND state IN ('active', 'running', 'idle')
|
||||
`);
|
||||
const listRecentSkillRunsStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM skill_runs
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
const listInstallStateStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM install_state
|
||||
ORDER BY installed_at DESC, target_id ASC
|
||||
`);
|
||||
const countPendingGovernanceStatement = db.prepare(`
|
||||
SELECT COUNT(*) AS total_count
|
||||
FROM governance_events
|
||||
WHERE resolved_at IS NULL
|
||||
`);
|
||||
const listPendingGovernanceStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM governance_events
|
||||
WHERE resolved_at IS NULL
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
const listWorkItemsStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM work_items
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
const countWorkItemsStatement = db.prepare(`
|
||||
SELECT COUNT(*) AS total_count
|
||||
FROM work_items
|
||||
`);
|
||||
const listAllWorkItemsStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM work_items
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
`);
|
||||
const getWorkItemStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM work_items
|
||||
WHERE id = ?
|
||||
`);
|
||||
const getSkillVersionStatement = db.prepare(`
|
||||
SELECT *
|
||||
FROM skill_versions
|
||||
WHERE skill_id = ? AND version = ?
|
||||
`);
|
||||
|
||||
const upsertSessionStatement = db.prepare(`
|
||||
INSERT INTO sessions (
|
||||
id,
|
||||
adapter_id,
|
||||
harness,
|
||||
state,
|
||||
repo_root,
|
||||
started_at,
|
||||
ended_at,
|
||||
snapshot
|
||||
) VALUES (
|
||||
@id,
|
||||
@adapter_id,
|
||||
@harness,
|
||||
@state,
|
||||
@repo_root,
|
||||
@started_at,
|
||||
@ended_at,
|
||||
@snapshot
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
adapter_id = excluded.adapter_id,
|
||||
harness = excluded.harness,
|
||||
state = excluded.state,
|
||||
repo_root = excluded.repo_root,
|
||||
started_at = excluded.started_at,
|
||||
ended_at = excluded.ended_at,
|
||||
snapshot = excluded.snapshot
|
||||
`);
|
||||
|
||||
const insertSkillRunStatement = db.prepare(`
|
||||
INSERT INTO skill_runs (
|
||||
id,
|
||||
skill_id,
|
||||
skill_version,
|
||||
session_id,
|
||||
task_description,
|
||||
outcome,
|
||||
failure_reason,
|
||||
tokens_used,
|
||||
duration_ms,
|
||||
user_feedback,
|
||||
created_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@skill_id,
|
||||
@skill_version,
|
||||
@session_id,
|
||||
@task_description,
|
||||
@outcome,
|
||||
@failure_reason,
|
||||
@tokens_used,
|
||||
@duration_ms,
|
||||
@user_feedback,
|
||||
@created_at
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
skill_id = excluded.skill_id,
|
||||
skill_version = excluded.skill_version,
|
||||
session_id = excluded.session_id,
|
||||
task_description = excluded.task_description,
|
||||
outcome = excluded.outcome,
|
||||
failure_reason = excluded.failure_reason,
|
||||
tokens_used = excluded.tokens_used,
|
||||
duration_ms = excluded.duration_ms,
|
||||
user_feedback = excluded.user_feedback,
|
||||
created_at = excluded.created_at
|
||||
`);
|
||||
|
||||
const upsertSkillVersionStatement = db.prepare(`
|
||||
INSERT INTO skill_versions (
|
||||
skill_id,
|
||||
version,
|
||||
content_hash,
|
||||
amendment_reason,
|
||||
promoted_at,
|
||||
rolled_back_at
|
||||
) VALUES (
|
||||
@skill_id,
|
||||
@version,
|
||||
@content_hash,
|
||||
@amendment_reason,
|
||||
@promoted_at,
|
||||
@rolled_back_at
|
||||
)
|
||||
ON CONFLICT(skill_id, version) DO UPDATE SET
|
||||
content_hash = excluded.content_hash,
|
||||
amendment_reason = excluded.amendment_reason,
|
||||
promoted_at = excluded.promoted_at,
|
||||
rolled_back_at = excluded.rolled_back_at
|
||||
`);
|
||||
|
||||
const insertDecisionStatement = db.prepare(`
|
||||
INSERT INTO decisions (
|
||||
id,
|
||||
session_id,
|
||||
title,
|
||||
rationale,
|
||||
alternatives,
|
||||
supersedes,
|
||||
status,
|
||||
created_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@session_id,
|
||||
@title,
|
||||
@rationale,
|
||||
@alternatives,
|
||||
@supersedes,
|
||||
@status,
|
||||
@created_at
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
session_id = excluded.session_id,
|
||||
title = excluded.title,
|
||||
rationale = excluded.rationale,
|
||||
alternatives = excluded.alternatives,
|
||||
supersedes = excluded.supersedes,
|
||||
status = excluded.status,
|
||||
created_at = excluded.created_at
|
||||
`);
|
||||
|
||||
const upsertInstallStateStatement = db.prepare(`
|
||||
INSERT INTO install_state (
|
||||
target_id,
|
||||
target_root,
|
||||
profile,
|
||||
modules,
|
||||
operations,
|
||||
installed_at,
|
||||
source_version
|
||||
) VALUES (
|
||||
@target_id,
|
||||
@target_root,
|
||||
@profile,
|
||||
@modules,
|
||||
@operations,
|
||||
@installed_at,
|
||||
@source_version
|
||||
)
|
||||
ON CONFLICT(target_id, target_root) DO UPDATE SET
|
||||
profile = excluded.profile,
|
||||
modules = excluded.modules,
|
||||
operations = excluded.operations,
|
||||
installed_at = excluded.installed_at,
|
||||
source_version = excluded.source_version
|
||||
`);
|
||||
|
||||
const insertGovernanceEventStatement = db.prepare(`
|
||||
INSERT INTO governance_events (
|
||||
id,
|
||||
session_id,
|
||||
event_type,
|
||||
payload,
|
||||
resolved_at,
|
||||
resolution,
|
||||
created_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@session_id,
|
||||
@event_type,
|
||||
@payload,
|
||||
@resolved_at,
|
||||
@resolution,
|
||||
@created_at
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
session_id = excluded.session_id,
|
||||
event_type = excluded.event_type,
|
||||
payload = excluded.payload,
|
||||
resolved_at = excluded.resolved_at,
|
||||
resolution = excluded.resolution,
|
||||
created_at = excluded.created_at
|
||||
`);
|
||||
|
||||
const upsertWorkItemStatement = 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 (
|
||||
@id,
|
||||
@source,
|
||||
@source_id,
|
||||
@title,
|
||||
@status,
|
||||
@priority,
|
||||
@url,
|
||||
@owner,
|
||||
@repo_root,
|
||||
@session_id,
|
||||
@metadata,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
source = excluded.source,
|
||||
source_id = excluded.source_id,
|
||||
title = excluded.title,
|
||||
status = excluded.status,
|
||||
priority = excluded.priority,
|
||||
url = excluded.url,
|
||||
owner = excluded.owner,
|
||||
repo_root = excluded.repo_root,
|
||||
session_id = excluded.session_id,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = excluded.updated_at
|
||||
`);
|
||||
|
||||
function getSessionById(id) {
|
||||
const row = getSessionStatement.get(id);
|
||||
return row ? mapSessionRow(row) : null;
|
||||
}
|
||||
|
||||
function getWorkItemById(id) {
|
||||
const row = getWorkItemStatement.get(id);
|
||||
return row ? mapWorkItemRow(row) : null;
|
||||
}
|
||||
|
||||
function listRecentSessions(options = {}) {
|
||||
const limit = normalizeLimit(options.limit, 10);
|
||||
return {
|
||||
totalCount: countSessionsStatement.get().total_count,
|
||||
sessions: listRecentSessionsStatement.all(limit).map(mapSessionRow),
|
||||
};
|
||||
}
|
||||
|
||||
function getSessionDetail(id) {
|
||||
const session = getSessionById(id);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workers = Array.isArray(session.snapshot && session.snapshot.workers)
|
||||
? session.snapshot.workers.map(worker => ({ ...worker }))
|
||||
: [];
|
||||
|
||||
return {
|
||||
session,
|
||||
workers,
|
||||
skillRuns: getSessionSkillRunsStatement.all(id).map(mapSkillRunRow),
|
||||
decisions: getSessionDecisionsStatement.all(id).map(mapDecisionRow),
|
||||
};
|
||||
}
|
||||
|
||||
function listWorkItems(options = {}) {
|
||||
const limit = normalizeLimit(options.limit, 20);
|
||||
return {
|
||||
totalCount: countWorkItemsStatement.get().total_count,
|
||||
items: listWorkItemsStatement.all(limit).map(mapWorkItemRow),
|
||||
};
|
||||
}
|
||||
|
||||
function getStatus(options = {}) {
|
||||
const activeLimit = normalizeLimit(options.activeLimit, 5);
|
||||
const recentSkillRunLimit = normalizeLimit(options.recentSkillRunLimit, 20);
|
||||
const pendingLimit = normalizeLimit(options.pendingLimit, 5);
|
||||
const workItemLimit = normalizeLimit(options.workItemLimit, 10);
|
||||
|
||||
const activeSessions = listActiveSessionsStatement.all(activeLimit).map(mapSessionRow);
|
||||
const activeSessionCount = countActiveSessionsStatement.get().total_count;
|
||||
const recentSkillRuns = listRecentSkillRunsStatement.all(recentSkillRunLimit).map(mapSkillRunRow);
|
||||
const installations = listInstallStateStatement.all().map(mapInstallStateRow);
|
||||
const pendingGovernanceEvents = listPendingGovernanceStatement.all(pendingLimit).map(mapGovernanceEventRow);
|
||||
const workItems = summarizeWorkItems(listAllWorkItemsStatement.all().map(mapWorkItemRow));
|
||||
workItems.items = listWorkItemsStatement.all(workItemLimit).map(mapWorkItemRow);
|
||||
const skillRuns = {
|
||||
windowSize: recentSkillRunLimit,
|
||||
summary: summarizeSkillRuns(recentSkillRuns),
|
||||
recent: recentSkillRuns,
|
||||
};
|
||||
const installHealth = summarizeInstallHealth(installations);
|
||||
const pendingGovernanceCount = countPendingGovernanceStatement.get().total_count;
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
readiness: summarizeReadiness({
|
||||
activeSessionCount,
|
||||
skillRuns,
|
||||
installHealth,
|
||||
pendingGovernanceCount,
|
||||
workItems,
|
||||
}),
|
||||
activeSessions: {
|
||||
activeCount: activeSessionCount,
|
||||
sessions: activeSessions,
|
||||
},
|
||||
skillRuns,
|
||||
installHealth,
|
||||
governance: {
|
||||
pendingCount: pendingGovernanceCount,
|
||||
events: pendingGovernanceEvents,
|
||||
},
|
||||
workItems,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
getSessionById,
|
||||
getSessionDetail,
|
||||
getWorkItemById,
|
||||
getStatus,
|
||||
insertDecision(decision) {
|
||||
const normalized = normalizeDecisionInput(decision);
|
||||
assertValidEntity('decision', normalized);
|
||||
insertDecisionStatement.run({
|
||||
id: normalized.id,
|
||||
session_id: normalized.sessionId,
|
||||
title: normalized.title,
|
||||
rationale: normalized.rationale,
|
||||
alternatives: stringifyJson(normalized.alternatives, 'decision.alternatives'),
|
||||
supersedes: normalized.supersedes,
|
||||
status: normalized.status,
|
||||
created_at: normalized.createdAt,
|
||||
});
|
||||
return normalized;
|
||||
},
|
||||
insertGovernanceEvent(governanceEvent) {
|
||||
const normalized = normalizeGovernanceEventInput(governanceEvent);
|
||||
assertValidEntity('governanceEvent', normalized);
|
||||
insertGovernanceEventStatement.run({
|
||||
id: normalized.id,
|
||||
session_id: normalized.sessionId,
|
||||
event_type: normalized.eventType,
|
||||
payload: stringifyJson(normalized.payload, 'governanceEvent.payload'),
|
||||
resolved_at: normalized.resolvedAt,
|
||||
resolution: normalized.resolution,
|
||||
created_at: normalized.createdAt,
|
||||
});
|
||||
return normalized;
|
||||
},
|
||||
insertSkillRun(skillRun) {
|
||||
const normalized = normalizeSkillRunInput(skillRun);
|
||||
assertValidEntity('skillRun', normalized);
|
||||
insertSkillRunStatement.run({
|
||||
id: normalized.id,
|
||||
skill_id: normalized.skillId,
|
||||
skill_version: normalized.skillVersion,
|
||||
session_id: normalized.sessionId,
|
||||
task_description: normalized.taskDescription,
|
||||
outcome: normalized.outcome,
|
||||
failure_reason: normalized.failureReason,
|
||||
tokens_used: normalized.tokensUsed,
|
||||
duration_ms: normalized.durationMs,
|
||||
user_feedback: normalized.userFeedback,
|
||||
created_at: normalized.createdAt,
|
||||
});
|
||||
return normalized;
|
||||
},
|
||||
listRecentSessions,
|
||||
listWorkItems,
|
||||
upsertInstallState(installState) {
|
||||
const normalized = normalizeInstallStateInput(installState);
|
||||
assertValidEntity('installState', normalized);
|
||||
upsertInstallStateStatement.run({
|
||||
target_id: normalized.targetId,
|
||||
target_root: normalized.targetRoot,
|
||||
profile: normalized.profile,
|
||||
modules: stringifyJson(normalized.modules, 'installState.modules'),
|
||||
operations: stringifyJson(normalized.operations, 'installState.operations'),
|
||||
installed_at: normalized.installedAt,
|
||||
source_version: normalized.sourceVersion,
|
||||
});
|
||||
return normalized;
|
||||
},
|
||||
upsertWorkItem(workItem) {
|
||||
const normalized = normalizeWorkItemInput(workItem);
|
||||
assertValidEntity('workItem', normalized);
|
||||
upsertWorkItemStatement.run({
|
||||
id: normalized.id,
|
||||
source: normalized.source,
|
||||
source_id: normalized.sourceId,
|
||||
title: normalized.title,
|
||||
status: normalized.status,
|
||||
priority: normalized.priority,
|
||||
url: normalized.url,
|
||||
owner: normalized.owner,
|
||||
repo_root: normalized.repoRoot,
|
||||
session_id: normalized.sessionId,
|
||||
metadata: stringifyJson(normalized.metadata, 'workItem.metadata'),
|
||||
created_at: normalized.createdAt,
|
||||
updated_at: normalized.updatedAt,
|
||||
});
|
||||
const row = getWorkItemStatement.get(normalized.id);
|
||||
return row ? mapWorkItemRow(row) : null;
|
||||
},
|
||||
upsertSession(session) {
|
||||
const normalized = normalizeSessionInput(session);
|
||||
assertValidEntity('session', normalized);
|
||||
upsertSessionStatement.run({
|
||||
id: normalized.id,
|
||||
adapter_id: normalized.adapterId,
|
||||
harness: normalized.harness,
|
||||
state: normalized.state,
|
||||
repo_root: normalized.repoRoot,
|
||||
started_at: normalized.startedAt,
|
||||
ended_at: normalized.endedAt,
|
||||
snapshot: stringifyJson(normalized.snapshot, 'session.snapshot'),
|
||||
});
|
||||
return getSessionById(normalized.id);
|
||||
},
|
||||
upsertSkillVersion(skillVersion) {
|
||||
const normalized = normalizeSkillVersionInput(skillVersion);
|
||||
assertValidEntity('skillVersion', normalized);
|
||||
upsertSkillVersionStatement.run({
|
||||
skill_id: normalized.skillId,
|
||||
version: normalized.version,
|
||||
content_hash: normalized.contentHash,
|
||||
amendment_reason: normalized.amendmentReason,
|
||||
promoted_at: normalized.promotedAt,
|
||||
rolled_back_at: normalized.rolledBackAt,
|
||||
});
|
||||
const row = getSkillVersionStatement.get(normalized.skillId, normalized.version);
|
||||
return row ? mapSkillVersionRow(row) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ACTIVE_SESSION_STATES,
|
||||
FAILURE_OUTCOMES,
|
||||
SUCCESS_OUTCOMES,
|
||||
createQueryApi,
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Ajv = require('ajv');
|
||||
|
||||
const SCHEMA_PATH = path.join(__dirname, '..', '..', '..', 'schemas', 'state-store.schema.json');
|
||||
|
||||
const ENTITY_DEFINITIONS = {
|
||||
session: 'session',
|
||||
skillRun: 'skillRun',
|
||||
skillVersion: 'skillVersion',
|
||||
decision: 'decision',
|
||||
installState: 'installState',
|
||||
governanceEvent: 'governanceEvent',
|
||||
workItem: 'workItem',
|
||||
};
|
||||
|
||||
let cachedSchema = null;
|
||||
let cachedAjv = null;
|
||||
const cachedValidators = new Map();
|
||||
|
||||
function readSchema() {
|
||||
if (cachedSchema) {
|
||||
return cachedSchema;
|
||||
}
|
||||
|
||||
cachedSchema = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8'));
|
||||
return cachedSchema;
|
||||
}
|
||||
|
||||
function getAjv() {
|
||||
if (cachedAjv) {
|
||||
return cachedAjv;
|
||||
}
|
||||
|
||||
cachedAjv = new Ajv({
|
||||
allErrors: true,
|
||||
strict: false,
|
||||
});
|
||||
return cachedAjv;
|
||||
}
|
||||
|
||||
function getEntityValidator(entityName) {
|
||||
if (cachedValidators.has(entityName)) {
|
||||
return cachedValidators.get(entityName);
|
||||
}
|
||||
|
||||
const schema = readSchema();
|
||||
const definitionName = ENTITY_DEFINITIONS[entityName];
|
||||
|
||||
if (!definitionName || !schema.$defs || !schema.$defs[definitionName]) {
|
||||
throw new Error(`Unknown state-store schema entity: ${entityName}`);
|
||||
}
|
||||
|
||||
const validatorSchema = {
|
||||
$schema: schema.$schema,
|
||||
...schema.$defs[definitionName],
|
||||
$defs: schema.$defs,
|
||||
};
|
||||
const validator = getAjv().compile(validatorSchema);
|
||||
cachedValidators.set(entityName, validator);
|
||||
return validator;
|
||||
}
|
||||
|
||||
function formatValidationErrors(errors = []) {
|
||||
return errors
|
||||
.map(error => `${error.instancePath || '/'} ${error.message}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function validateEntity(entityName, payload) {
|
||||
const validator = getEntityValidator(entityName);
|
||||
const valid = validator(payload);
|
||||
return {
|
||||
valid,
|
||||
errors: validator.errors || [],
|
||||
};
|
||||
}
|
||||
|
||||
function assertValidEntity(entityName, payload, label) {
|
||||
const result = validateEntity(entityName, payload);
|
||||
if (!result.valid) {
|
||||
throw new Error(`Invalid ${entityName}${label ? ` (${label})` : ''}: ${formatValidationErrors(result.errors)}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertValidEntity,
|
||||
formatValidationErrors,
|
||||
readSchema,
|
||||
validateEntity,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user