Zion Boggan zionboggan.com ↗

Detect destructive-then-recovery bad paths and pick a real next-work direction

Add badPathEpisode detection so Known bad paths records destructive then
recovered episodes (deleted, lost, broken files that had to be restored)
instead of always reporting no abandoned paths. Rework Preferred next work
to surface the most recent accepted strategic direction and skip resolved
remediation and apology turns, with a project-goal fallback.
a96c1d8   Zion Boggan committed on Jun 13, 2026 (1 week ago)
src/analyze.js +76 -7
@@ -70,6 +70,13 @@ const CONSTRAINT_NAMED = [
{ re: /\b(?:no\s+ai|ai[\s-]?(?:generated|authored|written|tell))\b/i, label: 'No AI-authorship tells' },
];
+const DESTRUCTIVE_RE =
+ /\b(?:messed up|screwed up|broke|broken|deleted|wiped|nuked|lost|gone|overwrote|overwritten|corrupted|trashed|removed by accident|accidentally (?:deleted|removed|overwrote|ran))\b/i;
+const RECOVERY_RE =
+ /\b(?:bring it back|bring them back|restore|recover|undo|revert|roll(?: |-)?back|get it back|put it back|can you (?:fix|recover|restore)|recreate)\b/i;
+const APOLOGY_RE = /\b(?:i'?m sorry|im sorry|sorry|my bad|my fault|oops|whoops)\b/i;
+const REMEDIATION_RE = new RegExp(`${DESTRUCTIVE_RE.source}|${RECOVERY_RE.source}`, 'i');
+
const SECURITY_FILE_RE = /(?:^|[\\/])(?:\.env[^\\/]*|[^\\/]*(?:auth|session|middleware|login|signin|signup|permission|rbac|access[-_]?control|secur|crypto|jwt|oauth|passwd|password|secret|credential|token)[^\\/]*)$/i;
const RISKY_CMD_RE = /(?:\brm\s+-rf\b|\bchmod\s+777\b|curl[^|]*\|\s*(?:sh|bash)|wget[^|]*\|\s*(?:sh|bash)|--no-verify\b|--force\b|\bDROP\s+TABLE\b|\bTRUNCATE\s+TABLE\b)/i;
const SECRET_CONTENT_RE = /(?:\bsource\s+[^\n]*\.env\b|(?:^|[;&|]|\s)\.\s+[^\n]*\.env\b|\.env\.(?:secrets|local|prod|production)\b|\bexport\s+[A-Z0-9_]*(?:_API_KEY|_TOKEN|_SECRET|_PASSWORD|API_KEY|SECRET_KEY|ACCESS_KEY|PRIVATE_KEY)\b|\b(?:wrangler|doppler|vault)\b|\bgh\s+auth\b|\baws\s+configure\b|\bgcloud\s+auth\b|\bkubectl\s+config\s+set-credentials\b)/i;
@@ -89,6 +96,34 @@ function securityActions(node) {
return out;
}
+function fileHint(node) {
+ for (const a of node.actions || []) {
+ if (a.file) return a.file;
+ }
+ const text = String(node.text || '');
+ const m = text.match(/\b([\w./\\-]+\.[a-z0-9]{1,5})\b/i) || text.match(/\b([A-Za-z]:[\\/][^\s"']+)/);
+ return m ? m[1] : null;
+}
+
+function badPathEpisode(node) {
+ const text = String(node.text || '');
+ if (text.length > WORDING_SCAN_MAX_CHARS) return null;
+ const destructive = DESTRUCTIVE_RE.test(text);
+ const recovery = RECOVERY_RE.test(text);
+ if (!destructive && !recovery) return null;
+ if (!destructive && recovery && !APOLOGY_RE.test(text)) return null;
+ const target = fileHint(node);
+ const where = target ? `\`${truncate(String(target), 70)}\`` : 'a file';
+ const tail = recovery
+ ? ' and had to be recovered; guard against destructive file operations.'
+ : ' was reported as lost or broken; guard against destructive file operations.';
+ return {
+ confidence: destructive && recovery ? 0.9 : 0.75,
+ tier: destructive && recovery ? 'verified' : 'high',
+ summary: `${where} was deleted or damaged${tail}`,
+ };
+}
+
export function analyzeTree(tree) {
if (tree.analysis) return tree.analysis;
@@ -260,6 +295,19 @@ export function analyzeTree(tree) {
return;
}
+ const destructive = badPathEpisode(node);
+ if (destructive) {
+ addFailure({
+ type: 'abandoned_path',
+ confidence: destructive.confidence,
+ tier: destructive.tier,
+ failureNode: node,
+ resolvedNode: nearestAcceptedAfter(tree.nodes, node, null),
+ evidence: `User reported a destructive event: "${quote(node.text)}"`,
+ summary: destructive.summary,
+ });
+ }
+
const shouldAnalyze =
node.kind === 'correction' ||
CORRECTION_HINT.test(node.text) ||
@@ -420,19 +468,40 @@ export function renderMemoryMarkdown(tree, opts = {}) {
lines.push('## Preferred next work');
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: ${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: ${escapeMd(truncate(n.title, 120))}`);
- if (!latest && !openCorrections.length) {
- lines.push('- Continue from the accepted decisions above and confirm scope with the user.');
+ const strategic = nodes.filter(
+ (n) =>
+ live(n) &&
+ (n.kind === 'root' || n.kind === 'direction' || n.kind === 'scope-change') &&
+ isStrategicDirection(n)
+ );
+ const latest = strategic[strategic.length - 1];
+ if (latest) {
+ lines.push(`- Continue the most recent accepted direction: ${escapeMd(truncate(latest.title, 140))}`);
+ } else {
+ lines.push(`- No open forward direction was stated; resume the goal of ${escapeMd(projectName)} and confirm scope with the user.`);
}
+ const openCorrections = nodes
+ .filter((n) => live(n) && n.kind === 'correction' && isStrategicDirection(n))
+ .slice(-3);
+ for (const n of openCorrections) lines.push(`- Keep this correction satisfied: ${escapeMd(truncate(n.title, 120))}`);
lines.push('');
return lines.join('\n');
}
+function isStrategicDirection(node) {
+ const text = String(node.text || '').trim();
+ if (!text) return false;
+ if (REMEDIATION_RE.test(text) || APOLOGY_RE.test(text)) return false;
+ const stripped = text.replace(/[\s.!?]+$/g, '');
+ if (stripped.length < 12) return false;
+ if (/^(?:yes|yep|yeah|ok|okay|sure|nice|perfect|great|good|lgtm|thanks?|cool|agreed?)\b/i.test(stripped)) {
+ if (stripped.length < 40) return false;
+ }
+ if (/\?\s*$/.test(text) && text.length < 80) return false;
+ return true;
+}
+
function constraintClauses(text) {
return String(text || '')
.split(/(?:[.!?\n]+|\s*;\s*|\s+-\s+|,\s+(?=(?:no|don'?t|do not|never|must|always|only|keep|ensure|make sure|avoid|stay)\b))/i)
test/treetrace.test.js +25 -0
@@ -325,6 +325,31 @@ test('analysis: a benign descriptive prompt with no directive yields no false co
assert.ok(/No explicit constraints were flagged/.test(block), 'benign descriptive text should not mint constraints');
});
+test('analysis: a destructive-then-recovery turn yields a known bad path and is not the preferred next work', () => {
+ const root = { id: 'node_001', text: 'build the marketing deck', title: 'build the marketing deck', kind: 'root', status: 'accepted', parent: null, actions: [] };
+ const direction = {
+ id: 'node_002', text: 'Also you can send an agent out to develop these sections',
+ title: 'send an agent out to develop these sections', kind: 'direction', status: 'accepted', parent: root, actions: [],
+ };
+ const mishap = {
+ id: 'node_003', text: 'Also messed up the deck file in the P:/ it is gone I am sorry can you bring it back',
+ title: 'Also messed up the deck file in the P:/ it is gone I am sorry can you bring it back',
+ kind: 'direction', status: 'accepted', parent: direction,
+ actions: [{ tool: 'Write', file: 'P:/deck/index.html' }],
+ };
+ const nodes = [root, direction, mishap];
+ const analysis = analyzeTree({ nodes });
+ const bad = analysis.failures.filter((f) => f.type === 'abandoned_path');
+ assert.ok(bad.length >= 1, 'destructive-then-recovery should produce a bad-path entry');
+ const memory = renderMemoryMarkdown({ nodes });
+ const badBlock = memory.slice(memory.indexOf('## Known bad paths'), memory.indexOf('## Security-sensitive'));
+ assert.ok(!/No abandoned paths were detected/.test(badBlock), 'must not claim no abandoned paths when a destructive event occurred');
+ assert.ok(/recover|destructive/i.test(badBlock), 'bad-path entry should warn about the destructive event');
+ const nextBlock = memory.slice(memory.indexOf('## Preferred next work'));
+ assert.ok(!/messed up the deck/i.test(nextBlock), 'preferred next work must not parrot the apology turn');
+ assert.ok(/develop these sections/i.test(nextBlock), 'preferred next work should point at the real forward direction');
+});
+
test('analysis: a keyword-only correction stays in the inferred or confirmed tier, not verified', () => {
const root = { id: 'node_001', text: 'build a dashboard', title: 'build a dashboard', kind: 'root', status: 'accepted', parent: null, actions: [] };
const corr = { id: 'node_002', text: 'no, that is overbuilt, keep it minimal', title: 'no, that is overbuilt', kind: 'correction', status: 'accepted', parent: root, actions: [] };