Files
ECC/scripts/lib/plan-canvas/sessions.js
T
a511395613 feat: Plan Canvas, a browser review canvas for plans (#2467)
* feat: add Plan Canvas - browser annotate-and-approve review for plan artifacts

- scripts/plan-canvas.js CLI (open/await/end/stop/server; bin ecc-plan-canvas)
- loopback server + ECC-styled chrome + annotation SDK + zero-dep markdown renderer
- Approve/Request-changes verdicts wired to the /plan confirmation gate
- plan-canvas skill, /plan-canvas command, SessionStart hook surfacing open reviews
- shared scripts/lib/loopback-guard.js extracted from control-pane (API re-exported)
- 121 new tests incl. full-workflow E2E; registered in manifests, catalog, registry

Inspired by lavish-axi (https://github.com/kunchenguid/lavish-axi) by @kunchenguid;
original ECC-native implementation, not a port.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(plan-canvas): invoke via ecc-plan-canvas bin so the skill works from any project

Skill/command referenced a cwd-relative `node scripts/plan-canvas.js`, unusable
outside the ECC root. Switch to the ecc-plan-canvas bin (and $CLAUDE_PLUGIN_ROOT
fallback) and align CLI next_step hints so an agent can run it as a skill in any repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plan-canvas): render Mermaid diagrams + ship Codex cross-harness surface

- markdown renderer emits <pre class="mermaid"> for ```mermaid blocks (source
  entity-escaped so the browser decodes it for the renderer while blocking injection)
- artifact template loads a pinned Mermaid build only when a diagram is present,
  themed to ECC dark, securityLevel strict, graceful offline fallback to source
  (ECC_PLAN_CANVAS_MERMAID_URL overrides for a local mirror)
- skill teaches Mermaid-for-diagrams and states the CLI+JSON loop is harness-agnostic
- add .agents/skills/plan-canvas (Codex) with agents/openai.yaml interface manifest
- register in install-modules workflow-quality paths; docs updated

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(plan-canvas): add demo screenshot

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): sync yarn.lock with new bin; add contributor checklist

- yarn.lock records the ecc-plan-canvas bin so Yarn hardened-mode install no
  longer wants to modify the lockfile on public PRs
- PR template + CONTRIBUTING gain a pre-push checklist covering the lockfile
  trap and the full skill/command/CLI registration surfaces

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Haley Chen <2022hachen@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:12:48 -04:00

270 lines
8.0 KiB
JavaScript

'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
};