Zion Boggan zionboggan.com ↗

Fix analysis heuristics and document Node requirement

- Dedupe lessons by failure type and eval candidates by eval type
- Drop self-referential correction chains (firstSeen never equals correctedBy)
- Ground eval inputs in the real correction text instead of boilerplate
- Standalone signals no longer fabricate a 'corrected by' reference
- Document Node 18+ requirement in the README
- Bump to 0.2.1
a655d37   Zion Boggan committed on Jun 12, 2026 (1 week ago)
README.md +4 -0
@@ -22,6 +22,10 @@ npx treetrace
No accounts. No uploads. No telemetry. Your transcripts never leave your machine.
+## Requirements
+
+Node.js 18 or newer. TreeTrace ships with no runtime dependencies, so `npx treetrace` needs nothing else installed.
+
## Why
Git history shows what changed. TreeTrace shows how the human had to steer the agent to get there.
package.json +1 -1
@@ -1,6 +1,6 @@
{
"name": "treetrace",
- "version": "0.2.0",
+ "version": "0.2.1",
"description": "Turn AI coding sessions into regression-ready prompt lineage, failure analysis, eval cases, and handoff memory.",
"keywords": [
"claude-code",
src/analyze.js +93 -46
@@ -42,26 +42,89 @@ export function analyzeTree(tree) {
const lessons = [];
const evalCandidates = [];
+ const pad = (n) => String(n).padStart(3, '0');
+ const uniq = (arr) => [...new Set(arr.filter(Boolean))];
+ const failureByKey = new Map();
+ const lessonByType = new Map();
+ const evalByType = new Map();
+
+ const linkChain = (type, confidence, failureNode, correctionNode, resolvedNode, summary) => {
+ if (!correctionNode || correctionNode.id === failureNode.id) return;
+ if (correctionChains.some((c) => c.failureNodeId === failureNode.id && c.correctionNodeId === correctionNode.id)) {
+ return;
+ }
+ correctionChains.push({
+ id: `chain_${pad(correctionChains.length + 1)}`,
+ failureNodeId: failureNode.id,
+ correctionNodeId: correctionNode.id,
+ resolvedNodeId: resolvedNode?.id || null,
+ failureType: type,
+ confidence: confidenceLabel(confidence),
+ summary,
+ });
+ };
+
const addFailure = ({ type, confidence, failureNode, correctionNode, resolvedNode, evidence, summary }) => {
if (!FAILURE_TYPES.has(type) || !failureNode) return null;
+ if (correctionNode && correctionNode.id === failureNode.id) correctionNode = null;
+
+ const ids = uniq([failureNode.id, correctionNode?.id, resolvedNode?.id]);
+ const key = `${type}:${failureNode.id}`;
+ const existing = failureByKey.get(key);
+ if (existing) {
+ if (confidence > existing.confidence) existing.confidence = confidence;
+ const lr = lessonByType.get(type);
+ if (lr) lr.nodeIds = uniq([...lr.nodeIds, ...ids]);
+ const er = evalByType.get(evalTypeFor(type));
+ if (er) er.sourceNodeIds = uniq([...er.sourceNodeIds, ...ids]);
+ if (correctionNode && !existing.correctedByNodeId) existing.correctedByNodeId = correctionNode.id;
+ linkChain(type, confidence, failureNode, correctionNode, resolvedNode, summary);
+ return existing;
+ }
- const failureId = `failure_${String(failures.length + 1).padStart(3, '0')}`;
- const lessonId = `lesson_${String(lessons.length + 1).padStart(3, '0')}`;
- const evalId = `eval_${String(evalCandidates.length + 1).padStart(3, '0')}`;
const lesson = lessonFor(type, correctionNode || failureNode);
+ let lessonRec = lessonByType.get(type);
+ if (!lessonRec) {
+ lessonRec = { id: `lesson_${pad(lessons.length + 1)}`, title: lesson.title, nodeIds: ids, text: lesson.text };
+ lessons.push(lessonRec);
+ lessonByType.set(type, lessonRec);
+ } else {
+ lessonRec.nodeIds = uniq([...lessonRec.nodeIds, ...ids]);
+ }
+
+ const evalType = evalTypeFor(type);
+ let evalRec = evalByType.get(evalType);
+ if (!evalRec) {
+ evalRec = {
+ id: `eval_${pad(evalCandidates.length + 1)}`,
+ source: 'treetrace',
+ type: evalType,
+ task: evalTaskFor(type),
+ context: summary,
+ input: correctionNode
+ ? `Honor this correction and keep building: "${quote(correctionNode.text)}"`
+ : `Honor this stated requirement and keep building: "${quote(failureNode.text)}"`,
+ expected_behavior: expectedBehaviorFor(type),
+ failure_mode: failureModeFor(type),
+ sourceNodeIds: ids,
+ };
+ evalCandidates.push(evalRec);
+ evalByType.set(evalType, evalRec);
+ } else {
+ evalRec.sourceNodeIds = uniq([...evalRec.sourceNodeIds, ...ids]);
+ }
- const signal = {
+ failureNode.failureSignals.push({
type,
confidence,
evidence,
resolvedBy: correctionNode?.id || resolvedNode?.id || null,
- };
- failureNode.failureSignals.push(signal);
+ });
failureNode.evalCandidate = true;
- failureNode.lessonIds.push(lessonId);
+ failureNode.lessonIds.push(lessonRec.id);
const failure = {
- id: failureId,
+ id: `failure_${pad(failures.length + 1)}`,
type,
confidence,
firstSeenNodeId: failureNode.id,
@@ -72,38 +135,8 @@ export function analyzeTree(tree) {
evalCandidate: true,
};
failures.push(failure);
-
- lessons.push({
- id: lessonId,
- title: lesson.title,
- nodeIds: [...new Set([failureNode.id, correctionNode?.id, resolvedNode?.id].filter(Boolean))],
- text: lesson.text,
- });
-
- evalCandidates.push({
- id: evalId,
- source: 'treetrace',
- type: evalTypeFor(type),
- task: evalTaskFor(type),
- context: summary,
- input: 'Continue development of the project while preserving the corrected direction and constraints.',
- expected_behavior: expectedBehaviorFor(type),
- failure_mode: failureModeFor(type),
- sourceNodeIds: [...new Set([failureNode.id, correctionNode?.id, resolvedNode?.id].filter(Boolean))],
- });
-
- if (correctionNode) {
- correctionChains.push({
- id: `chain_${String(correctionChains.length + 1).padStart(3, '0')}`,
- failureNodeId: failureNode.id,
- correctionNodeId: correctionNode.id,
- resolvedNodeId: resolvedNode?.id || null,
- failureType: type,
- confidence: confidenceLabel(confidence),
- summary,
- });
- }
-
+ failureByKey.set(key, failure);
+ linkChain(type, confidence, failureNode, correctionNode, resolvedNode, summary);
return failure;
};
@@ -127,7 +160,9 @@ export function analyzeTree(tree) {
PRIVACY_HINT.test(node.text);
if (!shouldAnalyze) return;
- const failureNode = nearestFailureTarget(node, tree.nodes, index);
+ const priorNode = nearestFailureTarget(node, tree.nodes, index);
+ const failureNode = priorNode || node;
+ const correctionNode = priorNode ? node : null;
const resolvedNode = nearestAcceptedAfter(tree.nodes, index);
const signals = inferSignals(node);
@@ -136,10 +171,10 @@ export function analyzeTree(tree) {
type: signal.type,
confidence: signal.confidence,
failureNode,
- correctionNode: node,
+ correctionNode,
resolvedNode,
evidence: `User said: "${quote(node.text)}"`,
- summary: summarizeFailure(signal.type, failureNode, node),
+ summary: summarizeFailure(signal.type, failureNode, correctionNode),
});
}
});
@@ -263,11 +298,11 @@ function inferSignals(node) {
}
function nearestFailureTarget(node, nodes, index) {
- if (node.parent && node.parent.status !== 'abandoned') return node.parent;
+ if (node.parent && node.parent.status !== 'abandoned' && node.parent.id !== node.id) return node.parent;
for (let i = index - 1; i >= 0; i--) {
- if (nodes[i].status !== 'abandoned') return nodes[i];
+ if (nodes[i].status !== 'abandoned' && nodes[i].id !== node.id) return nodes[i];
}
- return node;
+ return null;
}
function nearestAcceptedAfter(nodes, index) {
@@ -279,6 +314,18 @@ function nearestAcceptedAfter(nodes, index) {
function summarizeFailure(type, failureNode, correctionNode) {
const subject = truncate(failureNode?.title || 'a previous direction', 90);
+ if (!correctionNode) {
+ switch (type) {
+ case 'security_or_privacy_risk':
+ return `A privacy or security boundary was stated as a requirement at "${subject}".`;
+ case 'scope_drift':
+ return `A scope boundary was stated at "${subject}".`;
+ case 'format_violation':
+ return `A required output format was stated at "${subject}".`;
+ default:
+ return `A ${type.replace(/_/g, ' ')} concern was raised at "${subject}".`;
+ }
+ }
const correction = truncate(correctionNode?.title || 'a later correction', 90);
switch (type) {
case 'ignored_constraint':