| @@ -1,6 +1,6 @@ | ||
| { | ||
| "name": "treetrace", | ||
| - | "version": "0.2.2", | |
| + | "version": "0.2.3", | |
| "description": "Turn AI coding sessions into regression-ready prompt lineage, failure analysis, eval cases, and handoff memory.", | ||
| "keywords": [ | ||
| "claude-code", |
| @@ -1,4 +1,4 @@ | ||
| - | import { truncate } from './util.js'; | |
| + | import { truncate, escapeMd } from './util.js'; | |
| const FAILURE_TYPES = new Set([ | ||
| 'ignored_constraint', | ||
| @@ -217,9 +217,9 @@ export function renderLessonsMarkdown(tree, opts = {}) { | ||
| return lines.join('\n'); | ||
| } | ||
| analysis.lessons.forEach((lesson, i) => { | ||
| - | lines.push(`## ${i + 1}. ${lesson.title}`); | |
| + | lines.push(`## ${i + 1}. ${escapeMd(lesson.title)}`); | |
| lines.push(''); | ||
| - | lines.push(lesson.text); | |
| + | lines.push(escapeMd(lesson.text)); | |
| lines.push(''); | ||
| lines.push(`Source nodes: ${lesson.nodeIds.join(', ')}`); | ||
| lines.push(''); | ||
| @@ -237,13 +237,13 @@ export function renderMemoryMarkdown(tree, opts = {}) { | ||
| const projectName = opts.projectName || 'this project'; | ||
| const nodes = tree.nodes || []; | ||
| const live = (n) => n.status !== 'abandoned'; | ||
| - | const lines = [`# TreeTrace Agent Memory`, '', `Project: ${projectName}`, '']; | |
| + | const lines = [`# TreeTrace Agent Memory`, '', `Project: ${escapeMd(projectName)}`, '']; | |
| lines.push('## Constraints the user enforced'); | ||
| lines.push(''); | ||
| const constraints = nodes.filter((n) => live(n) && (n.kind === 'correction' || n.kind === 'scope-change')); | ||
| if (constraints.length) { | ||
| - | for (const n of constraints.slice(0, 8)) lines.push(`- ${truncate(n.title, 140)}`); | |
| + | for (const n of constraints.slice(0, 8)) lines.push(`- ${escapeMd(truncate(n.title, 140))}`); | |
| } else { | ||
| lines.push('- No explicit constraints were flagged. Follow the accepted decisions in the handoff brief.'); | ||
| } | ||
| @@ -252,7 +252,7 @@ export function renderMemoryMarkdown(tree, opts = {}) { | ||
| lines.push('## Lessons from this lineage'); | ||
| lines.push(''); | ||
| if (analysis.lessons.length) { | ||
| - | for (const lesson of analysis.lessons.slice(0, 8)) lines.push(`- ${lesson.text}`); | |
| + | for (const lesson of analysis.lessons.slice(0, 8)) lines.push(`- ${escapeMd(lesson.text)}`); | |
| } else { | ||
| lines.push('- No high-confidence failure lessons were detected yet.'); | ||
| } | ||
| @@ -262,7 +262,7 @@ export function renderMemoryMarkdown(tree, opts = {}) { | ||
| lines.push(''); | ||
| const badPaths = analysis.failures.filter((f) => f.type === 'abandoned_path').slice(0, 6); | ||
| if (badPaths.length) { | ||
| - | for (const failure of badPaths) lines.push(`- ${failure.summary}`); | |
| + | for (const failure of badPaths) lines.push(`- ${escapeMd(failure.summary)}`); | |
| } else { | ||
| lines.push('- No abandoned paths were detected in this session.'); | ||
| } | ||
| @@ -272,9 +272,9 @@ export function renderMemoryMarkdown(tree, opts = {}) { | ||
| lines.push(''); | ||
| const accepted = nodes.filter((n) => live(n) && (n.kind === 'root' || n.kind === 'direction' || n.kind === 'scope-change')); | ||
| const latest = accepted[accepted.length - 1]; | ||
| - | if (latest) lines.push(`- Continue the most recent accepted direction: ${truncate(latest.title, 140)}`); | |
| + | if (latest) lines.push(`- Continue the most recent accepted direction: ${escapeMd(truncate(latest.title, 140))}`); | |
| const openCorrections = nodes.filter((n) => live(n) && n.kind === 'correction').slice(-3); | ||
| - | for (const n of openCorrections) lines.push(`- Keep this correction satisfied: ${truncate(n.title, 120)}`); | |
| + | for (const n of openCorrections) lines.push(`- Keep this correction satisfied: ${escapeMd(truncate(n.title, 120))}`); | |
| if (!latest && !openCorrections.length) { | ||
| lines.push('- Continue from the accepted decisions above and confirm scope with the user.'); | ||
| } |
| @@ -1,4 +1,4 @@ | ||
| - | import { truncate } from './util.js'; | |
| + | import { truncate, escapeMd } from './util.js'; | |
| import { analyzeTree } from './analyze.js'; | ||
| export function renderHandoff(tree, opts = {}) { | ||
| @@ -12,7 +12,7 @@ export function renderHandoff(tree, opts = {}) { | ||
| const lastCheckpoint = [...accepted].reverse().find((n) => n.kind === 'checkpoint'); | ||
| const lastAccepted = accepted.at(-1); | ||
| - | lines.push(`# Handoff brief: ${projectName}`); | |
| + | lines.push(`# Handoff brief: ${escapeMd(projectName)}`); | |
| lines.push(''); | ||
| lines.push( | ||
| `You are taking over an AI-assisted project. This brief was distilled from the real prompt lineage (${stats.promptCount} prompts, ${stats.sessionCount} sessions). Read it fully before acting.` | ||
| @@ -22,18 +22,18 @@ export function renderHandoff(tree, opts = {}) { | ||
| if (root) { | ||
| lines.push('## Original goal'); | ||
| lines.push(''); | ||
| - | lines.push(root.text.trim()); | |
| + | lines.push(escapeMd(root.text.trim())); | |
| lines.push(''); | ||
| } | ||
| lines.push('## Where things stand'); | ||
| lines.push(''); | ||
| if (lastCheckpoint) { | ||
| - | lines.push(`Last checkpoint: ${lastCheckpoint.text.trim()}`); | |
| + | lines.push(`Last checkpoint: ${escapeMd(lastCheckpoint.text.trim())}`); | |
| } | ||
| if (lastAccepted && lastAccepted !== lastCheckpoint) { | ||
| lines.push(''); | ||
| - | lines.push(`Most recent accepted direction: ${lastAccepted.text.trim()}`); | |
| + | lines.push(`Most recent accepted direction: ${escapeMd(lastAccepted.text.trim())}`); | |
| } | ||
| lines.push(''); | ||
| @@ -41,7 +41,7 @@ export function renderHandoff(tree, opts = {}) { | ||
| if (decisions.length) { | ||
| lines.push('## Accepted decisions (in order)'); | ||
| lines.push(''); | ||
| - | decisions.forEach((n, i) => lines.push(`${i + 1}. ${truncate(n.text.replace(/\s+/g, ' '), 360)}`)); | |
| + | decisions.forEach((n, i) => lines.push(`${i + 1}. ${escapeMd(truncate(n.text.replace(/\s+/g, ' '), 360))}`)); | |
| lines.push(''); | ||
| } | ||
| @@ -51,7 +51,7 @@ export function renderHandoff(tree, opts = {}) { | ||
| lines.push(''); | ||
| lines.push('These corrections were issued during the build. Do not repeat the mistakes they fixed:'); | ||
| lines.push(''); | ||
| - | corrections.forEach((n) => lines.push(`- ${truncate(n.text.replace(/\s+/g, ' '), 300)}`)); | |
| + | corrections.forEach((n) => lines.push(`- ${escapeMd(truncate(n.text.replace(/\s+/g, ' '), 300))}`)); | |
| lines.push(''); | ||
| } | ||
| @@ -63,7 +63,7 @@ export function renderHandoff(tree, opts = {}) { | ||
| lines.push(''); | ||
| lines.push('These approaches were tried and abandoned. Avoid unless told otherwise:'); | ||
| lines.push(''); | ||
| - | abandoned.forEach((n) => lines.push(`- ${truncate(n.text.replace(/\s+/g, ' '), 300)}`)); | |
| + | abandoned.forEach((n) => lines.push(`- ${escapeMd(truncate(n.text.replace(/\s+/g, ' '), 300))}`)); | |
| lines.push(''); | ||
| } | ||
| @@ -71,7 +71,7 @@ export function renderHandoff(tree, opts = {}) { | ||
| lines.push('## Agent memory lessons'); | ||
| lines.push(''); | ||
| analysis.lessons.slice(0, 6).forEach((lesson) => { | ||
| - | lines.push(`- ${truncate(lesson.text.replace(/\s+/g, ' '), 320)}`); | |
| + | lines.push(`- ${escapeMd(truncate(lesson.text.replace(/\s+/g, ' '), 320))}`); | |
| }); | ||
| lines.push(''); | ||
| } |
| @@ -1,4 +1,4 @@ | ||
| - | import { truncate, plural, formatDay, mdEscapePipe } from './util.js'; | |
| + | import { truncate, plural, formatDay, mdEscapePipe, escapeMd } from './util.js'; | |
| import { REPO_URL } from './config.js'; | ||
| const ICONS = { | ||
| @@ -17,7 +17,7 @@ export function renderMarkdown(tree, opts = {}) { | ||
| const { stats, roots, nodes, sessions } = tree; | ||
| const lines = []; | ||
| - | lines.push(`# ๐ณ Prompt Tree: ${projectName}`); | |
| + | lines.push(`# ๐ณ Prompt Tree: ${escapeMd(projectName)}`); | |
| lines.push(''); | ||
| lines.push(`> ${banner(stats)}`); | ||
| lines.push('>'); | ||
| @@ -52,7 +52,7 @@ export function renderMarkdown(tree, opts = {}) { | ||
| active.forEach((s, i) => { | ||
| lines.push( | ||
| `| ${i + 1} | ${formatDay(s.firstTs) || ''} | ${s.prompts.length} | ${mdEscapePipe( | ||
| - | s.title || s.sessionId || '' | |
| + | escapeMd(s.title || s.sessionId || '') | |
| )} |` | ||
| ); | ||
| }); | ||
| @@ -70,7 +70,7 @@ export function renderMarkdown(tree, opts = {}) { | ||
| lines.push(`**${plural(abandoned.length, 'abandoned branch', 'abandoned branches')}:**`); | ||
| lines.push(''); | ||
| for (const n of abandoned) { | ||
| - | lines.push(`- โ ${truncate(n.title, 110)}`); | |
| + | lines.push(`- โ ${escapeMd(truncate(n.title, 110))}`); | |
| } | ||
| lines.push(''); | ||
| } | ||
| @@ -78,7 +78,7 @@ export function renderMarkdown(tree, opts = {}) { | ||
| lines.push(`**${plural(corrections.length, 'correction')} along the way:**`); | ||
| lines.push(''); | ||
| for (const n of corrections) { | ||
| - | lines.push(`- โฉ ${truncate(n.title, 110)}`); | |
| + | lines.push(`- โฉ ${escapeMd(truncate(n.title, 110))}`); | |
| } | ||
| lines.push(''); | ||
| } | ||
| @@ -90,9 +90,11 @@ export function renderMarkdown(tree, opts = {}) { | ||
| 'A distilled, replayable version of the accepted path. Paste into a fresh agent to rebuild something like this:' | ||
| ); | ||
| lines.push(''); | ||
| - | lines.push('```text'); | |
| - | lines.push(promptPack(nodes)); | |
| - | lines.push('```'); | |
| + | const pack = promptPack(nodes); | |
| + | const fence = '`'.repeat(Math.max(3, longestRun(pack, '`') + 1)); | |
| + | lines.push(`${fence}text`); | |
| + | lines.push(pack); | |
| + | lines.push(fence); | |
| lines.push(''); | ||
| lines.push('---'); | ||
| @@ -140,7 +142,8 @@ function emitNode(node, depth, lines, { titlesOnly }) { | ||
| const indent = ' '.repeat(depth); | ||
| const icon = ICONS[node.kind] || 'โ'; | ||
| const dead = node.status === 'abandoned'; | ||
| - | const title = dead ? `~~${node.title}~~ โ` : node.kind === 'root' ? `**${node.title}**` : node.title; | |
| + | const safe = escapeMd(node.title); | |
| + | const title = dead ? `~~${safe}~~ โ` : node.kind === 'root' ? `**${safe}**` : safe; | |
| const session = node.sessionBoundary ? ` ${dim(`(new session${node.ts ? `, ${formatDay(node.ts)}` : ''})`)}` : ''; | ||
| const nudges = node.nudges > 1 ? ` ${dim(`(+${node.nudges} nudges)`)}` : ''; | ||
| const reruns = node.reruns ? ` ${dim(`(re-issued ร${node.reruns + 1})`)}` : ''; | ||
| @@ -167,8 +170,8 @@ function blockquote(text, indent = '') { | ||
| } | ||
| function clip(text, max) { | ||
| - | if (text.length <= max) return text; | |
| - | return `${text.slice(0, max).trimEnd()}\n\n*[...trimmed, ${text.length - max} more chars]*`; | |
| + | if (text.length <= max) return escapeMd(text); | |
| + | return `${escapeMd(text.slice(0, max).trimEnd())}\n\n*[...trimmed, ${text.length - max} more chars]*`; | |
| } | ||
| export function promptPack(nodes) { | ||
| @@ -192,3 +195,12 @@ export function promptPack(nodes) { | ||
| function condense(text, max = 420) { | ||
| return truncate(text.replace(/\s+/g, ' '), max); | ||
| } | ||
| + | ||
| + | function longestRun(text, ch) { | |
| + | let max = 0; | |
| + | let cur = 0; | |
| + | for (const c of text) { | |
| + | if (c === ch) { cur++; if (cur > max) max = cur; } else cur = 0; | |
| + | } | |
| + | return max; | |
| + | } |
| @@ -1,7 +1,7 @@ | ||
| import { analyzeTree, renderLessonsMarkdown, renderMemoryMarkdown } from './analyze.js'; | ||
| import { renderHandoff } from './handoff.js'; | ||
| import { renderMarkdown } from './render-md.js'; | ||
| - | import { plural, truncate } from './util.js'; | |
| + | import { plural, truncate, escapeMd } from './util.js'; | |
| import { REPO_URL } from './config.js'; | ||
| export function renderReportMarkdown(tree, opts = {}) { | ||
| @@ -10,7 +10,7 @@ export function renderReportMarkdown(tree, opts = {}) { | ||
| const analysis = analyzeTree(tree); | ||
| const lines = []; | ||
| - | lines.push(`# TreeTrace Report - ${projectName}`); | |
| + | lines.push(`# TreeTrace Report - ${escapeMd(projectName)}`); | |
| lines.push(''); | ||
| lines.push(`Generated: ${generatedAt}`); | ||
| lines.push(''); | ||
| @@ -63,7 +63,7 @@ export function renderReportMarkdown(tree, opts = {}) { | ||
| } | ||
| lines.push(''); | ||
| for (const failure of analysis.failures.slice(0, 8)) { | ||
| - | lines.push(`- ${failure.id} (${failure.type}, ${confidencePct(failure.confidence)}): ${failure.summary}`); | |
| + | lines.push(`- ${failure.id} (${failure.type}, ${confidencePct(failure.confidence)}): ${escapeMd(failure.summary)}`); | |
| } | ||
| if (analysis.failures.length > 8) { | ||
| lines.push(`- ... ${analysis.failures.length - 8} more in .treetrace/failures.json`); |
| @@ -72,3 +72,10 @@ export function shannonEntropy(s) { | ||
| export function mdEscapePipe(s) { | ||
| return String(s).replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); | ||
| } | ||
| + | ||
| + | export function escapeMd(text) { | |
| + | return String(text == null ? '' : text) | |
| + | .replace(/&/g, '&') | |
| + | .replace(/</g, '<') | |
| + | .replace(/>/g, '>'); | |
| + | } |
| @@ -22,7 +22,7 @@ import { | ||
| } from '../src/analyze.js'; | ||
| import { main } from '../src/cli.js'; | ||
| import { mungePath } from '../src/discover.js'; | ||
| - | import { sha256 } from '../src/util.js'; | |
| + | import { sha256, escapeMd } from '../src/util.js'; | |
| const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'synthetic-session.jsonl'); | ||
| @@ -158,6 +158,17 @@ test('redaction: benign text produces no high/medium findings', () => { | ||
| assert.deepEqual(hard, []); | ||
| }); | ||
| + | test('escapeMd neutralizes HTML-sensitive characters', () => { | |
| + | assert.equal(escapeMd('a<script>b</script>&c>'), 'a<script>b</script>&c>'); | |
| + | }); | |
| + | ||
| + | test('rendering escapes injection in project name and content', async () => { | |
| + | const { tree } = await fixtureTree(); | |
| + | const md = renderMarkdown(tree, { projectName: 'x</summary></details><script>alert(1)</script>' }); | |
| + | assert.ok(md.includes('# ๐ณ Prompt Tree: x</summary></details><script>'), 'project name not escaped'); | |
| + | assert.ok(!md.includes('Prompt Tree: x</summary>'), 'raw HTML in project name'); | |
| + | }); | |
| + | ||
| test('renderers: markdown, json, handoff are consistent and footer-credited', async () => { | ||
| const { tree } = await fixtureTree(); | ||
| analyzeTree(tree); |