wogiflow 2.29.4 → 2.29.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wogiflow",
|
|
3
|
-
"version": "2.29.
|
|
3
|
+
"version": "2.29.5",
|
|
4
4
|
"description": "AI-powered development workflow management system with multi-model support",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"flow": "./scripts/flow",
|
|
13
|
-
"test": "NODE_ENV=test node --test tests/auto-compact-prompt.test.js tests/flow-paths.test.js tests/flow-io.test.js tests/flow-config-loader.test.js tests/flow-damage-control.test.js tests/flow-output.test.js tests/flow-constants.test.js tests/flow-session-state.test.js tests/flow-hooks-integration.test.js tests/flow-utils.test.js tests/flow-security.test.js tests/flow-memory-db.test.js tests/flow-durable-session.test.js tests/flow-skill-matcher.test.js tests/flow-bridge.test.js tests/flow-proactive-compact.test.js tests/flow-cascade-completion.test.js tests/flow-capture-gate.test.js tests/flow-correction-detector-hybrid.test.js tests/flow-promote.test.js tests/flow-archive-runs.test.js tests/flow-memory.test.js tests/flow-hooks-pre-tool-helpers.test.js tests/flow-hooks-bugfix-scope-gate.test.js tests/flow-hooks-routing-gate.test.js tests/flow-hooks-phase-read-gate.test.js tests/flow-hooks-commit-log-gate.test.js tests/flow-hooks-deploy-gate.test.js tests/flow-hooks-todowrite-gate.test.js tests/flow-hooks-git-safety-gate.test.js tests/flow-hooks-scope-mutation-gate.test.js tests/flow-hooks-strike-gate.test.js tests/flow-hooks-component-check.test.js tests/flow-hooks-scope-gate.test.js tests/flow-hooks-implementation-gate.test.js tests/flow-hooks-research-gate.test.js tests/flow-hooks-loop-check.test.js tests/flow-hooks-manager-boundary-gate.test.js tests/flow-hooks-phase-gate.test.js tests/flow-hooks-pre-tool-orchestrator.test.js tests/flow-hooks-observation-capture.test.js tests/flow-hooks-task-gate.test.js tests/flow-durable-session-suspension.test.js tests/flow-health-mcp-scopes.test.js tests/flow-lean-config.test.js tests/flow-workspace-autopickup.test.js tests/flow-worker-boundary-gate.test.js tests/flow-worker-question-classifier.test.js tests/flow-completion-truth-gate-contradictions.test.js tests/flow-structure-sensor.test.js tests/flow-workspace-dispatch-tracking.test.js tests/workspace-ipc-sqlite.test.js tests/workspace-ipc-multi-worker.test.js tests/flow-story-gates.test.js tests/flow-workspace-restart-handoff.test.js tests/flow-wogi-claude-wrapper.test.js tests/flow-wave1-integrations.test.js tests/flow-wave2-integrations.test.js tests/flow-wave3-integrations.test.js tests/flow-commit-claims-gate.test.js tests/auto-review.test.js tests/gate-telemetry-surface.test.js tests/agents-md-alias.test.js tests/flow-skill-manage.test.js tests/fuzzy-patch.test.js tests/mode-schema.test.js tests/flow-feature-dossier.test.js tests/flow-autonomous-mode.test.js tests/flow-epic-cascade.test.js tests/flow-workspace-summary.test.js tests/flow-hooks-research-evidence-gate.test.js tests/flow-worker-mcp-strip.test.js tests/flow-orchestrate-corrections.test.js tests/flow-source-fidelity.test.js tests/flow-hooks-long-input-enforcement.test.js && NODE_ENV=test node tests/run-quality-gates.test.js",
|
|
13
|
+
"test": "NODE_ENV=test node --test tests/auto-compact-prompt.test.js tests/flow-paths.test.js tests/flow-io.test.js tests/flow-config-loader.test.js tests/flow-damage-control.test.js tests/flow-output.test.js tests/flow-constants.test.js tests/flow-session-state.test.js tests/flow-hooks-integration.test.js tests/flow-utils.test.js tests/flow-security.test.js tests/flow-memory-db.test.js tests/flow-durable-session.test.js tests/flow-skill-matcher.test.js tests/flow-bridge.test.js tests/flow-proactive-compact.test.js tests/flow-cascade-completion.test.js tests/flow-capture-gate.test.js tests/flow-correction-detector-hybrid.test.js tests/flow-promote.test.js tests/flow-archive-runs.test.js tests/flow-memory.test.js tests/flow-hooks-pre-tool-helpers.test.js tests/flow-hooks-bugfix-scope-gate.test.js tests/flow-hooks-routing-gate.test.js tests/flow-hooks-phase-read-gate.test.js tests/flow-hooks-commit-log-gate.test.js tests/flow-hooks-deploy-gate.test.js tests/flow-hooks-todowrite-gate.test.js tests/flow-hooks-git-safety-gate.test.js tests/flow-hooks-scope-mutation-gate.test.js tests/flow-hooks-strike-gate.test.js tests/flow-hooks-component-check.test.js tests/flow-hooks-scope-gate.test.js tests/flow-hooks-implementation-gate.test.js tests/flow-hooks-research-gate.test.js tests/flow-hooks-loop-check.test.js tests/flow-hooks-manager-boundary-gate.test.js tests/flow-hooks-phase-gate.test.js tests/flow-hooks-pre-tool-orchestrator.test.js tests/flow-hooks-observation-capture.test.js tests/flow-hooks-task-gate.test.js tests/flow-durable-session-suspension.test.js tests/flow-health-mcp-scopes.test.js tests/flow-lean-config.test.js tests/flow-workspace-autopickup.test.js tests/flow-worker-boundary-gate.test.js tests/flow-worker-question-classifier.test.js tests/flow-completion-truth-gate-contradictions.test.js tests/flow-structure-sensor.test.js tests/flow-workspace-dispatch-tracking.test.js tests/workspace-ipc-sqlite.test.js tests/workspace-ipc-multi-worker.test.js tests/flow-story-gates.test.js tests/flow-workspace-restart-handoff.test.js tests/flow-wogi-claude-wrapper.test.js tests/flow-wave1-integrations.test.js tests/flow-wave2-integrations.test.js tests/flow-wave3-integrations.test.js tests/flow-commit-claims-gate.test.js tests/auto-review.test.js tests/gate-telemetry-surface.test.js tests/agents-md-alias.test.js tests/flow-skill-manage.test.js tests/fuzzy-patch.test.js tests/mode-schema.test.js tests/flow-feature-dossier.test.js tests/flow-autonomous-mode.test.js tests/flow-epic-cascade.test.js tests/flow-workspace-summary.test.js tests/flow-hooks-research-evidence-gate.test.js tests/flow-worker-mcp-strip.test.js tests/flow-orchestrate-corrections.test.js tests/flow-source-fidelity.test.js tests/flow-hooks-long-input-enforcement.test.js tests/workspace-channel-tracking.test.js tests/flow-hooks-deletion-log.test.js && NODE_ENV=test node tests/run-quality-gates.test.js",
|
|
14
14
|
"test:syntax": "find scripts/ lib/ -name '*.js' -not -path '*/node_modules/*' -exec node --check {} +",
|
|
15
15
|
"lint": "eslint scripts/ lib/ tests/",
|
|
16
16
|
"lint:ci": "eslint scripts/ lib/ tests/ --max-warnings 0",
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow — Deletion Log (Fork C, v2.29.5)
|
|
5
|
+
*
|
|
6
|
+
* Warn-only audit trail for AI deletions of user-facing UI files.
|
|
7
|
+
*
|
|
8
|
+
* Why this exists (wogi-hub 2026-04-27 incident, IntegrationConnectionSection.tsx):
|
|
9
|
+
* FE worker shipped commit 728faf2 "AC2 + dead-code cleanup" deleting an
|
|
10
|
+
* 896-LOC component justified by static-import-graph analysis. The file
|
|
11
|
+
* contained an active `CommunicationRule` component the owner used. The
|
|
12
|
+
* owner noticed only ~1 day later, while actively trying to use the
|
|
13
|
+
* feature. For features used monthly/quarterly, the noticing gap
|
|
14
|
+
* compounds to weeks/months — by which time the AI session has zero
|
|
15
|
+
* memory of the deletion, and recovery requires forensics.
|
|
16
|
+
*
|
|
17
|
+
* Rather than block deletions (which would have heavy false-positive cost
|
|
18
|
+
* on legitimate refactors), this module produces an append-only log at
|
|
19
|
+
* `.workflow/state/deletions-log.md` capturing for each deletion:
|
|
20
|
+
* - Detection shape (rm | git rm | edit-empty | write-empty)
|
|
21
|
+
* - Original-add commit (SHA, author, date, subject) when discoverable
|
|
22
|
+
* - File age in days
|
|
23
|
+
* - LOC of the deleted version
|
|
24
|
+
* - Top-N user-visible string excerpts (so the owner can grep "did we
|
|
25
|
+
* ever delete a feature called X?")
|
|
26
|
+
*
|
|
27
|
+
* The log is the artifact. The owner consults it when something feels
|
|
28
|
+
* missing. After 30 days of log accumulation, the actual deletion-rate
|
|
29
|
+
* and shape distribution will be visible — at which point Fork A or B
|
|
30
|
+
* (mechanical-enforcement variants) become design-from-data, not
|
|
31
|
+
* design-from-one-incident.
|
|
32
|
+
*
|
|
33
|
+
* All functions are pure / fail-open. The module never throws.
|
|
34
|
+
*
|
|
35
|
+
* Public surface:
|
|
36
|
+
* detectDeletionShape({toolName, toolInput, toolResponse}) → {deleted, files, shape} | null
|
|
37
|
+
* isUiSurfaceFile(filePath, uiGlobs) → boolean
|
|
38
|
+
* lookupOriginalAdd(filePath, opts) → {sha, date, author, subject, ageDays, originalLOC, userVisibleStrings} | null
|
|
39
|
+
* formatLogEntry({timestamp, filePath, shape, provenance, sessionId, taskId, currentCommitSubject}) → string
|
|
40
|
+
* appendLogEntry(workspaceRoot, entry) → boolean
|
|
41
|
+
* recordDeletion(input) → {logged, files, reason}
|
|
42
|
+
*
|
|
43
|
+
* The recordDeletion orchestrator is what PostToolUse calls. It composes
|
|
44
|
+
* the rest. Returns a structured result for tests; the entry-layer can
|
|
45
|
+
* ignore the result.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
const fs = require('node:fs');
|
|
49
|
+
const path = require('node:path');
|
|
50
|
+
const { execFileSync } = require('node:child_process');
|
|
51
|
+
|
|
52
|
+
const DEFAULT_UI_GLOBS = [
|
|
53
|
+
// Page / route / view files in monorepo packages and apps
|
|
54
|
+
/(?:^|\/)(packages|apps)\/[^/]+\/src\/(pages|routes|views|screens)\/.+\.(tsx|jsx|vue|svelte)$/i,
|
|
55
|
+
// Component files in monorepo packages and apps
|
|
56
|
+
/(?:^|\/)(packages|apps)\/[^/]+\/src\/components\/.+\.(tsx|jsx|vue|svelte)$/i,
|
|
57
|
+
// Single-package layout fallbacks
|
|
58
|
+
/(?:^|\/)src\/(pages|routes|views|screens|components)\/.+\.(tsx|jsx|vue|svelte)$/i,
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const DEFAULT_LOG_PATH = '.workflow/state/deletions-log.md';
|
|
62
|
+
const DEFAULT_MIN_LOC = 20; // skip tiny files; noise > signal
|
|
63
|
+
const DEFAULT_MAX_USER_VISIBLE_STRINGS = 5;
|
|
64
|
+
|
|
65
|
+
// JSX/HTML user-visible string heuristic: text nodes between tags, label/title/aria-label props.
|
|
66
|
+
// Lower bound at 5 chars to skip "<>", "OK", etc. Upper bound at 80 to skip code-like content.
|
|
67
|
+
const USER_VISIBLE_STRING_PATTERNS = [
|
|
68
|
+
/>([A-Z][^<>{}\n]{4,80})</g, // JSX text content starting with capital
|
|
69
|
+
/(?:label|title|placeholder|aria-label|alt)=["']([^"']{5,80})["']/gi,
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
// Detect Bash `rm` (with optional flags) extracting target paths.
|
|
73
|
+
// Conservative: only match when -r/-f/-rf appear or when no flags at all,
|
|
74
|
+
// so we don't get false positives on `rm-related` strings.
|
|
75
|
+
const RM_COMMAND_PATTERN = /^\s*rm\s+(?:-[rRfFv]+\s+)*([^\s|&;]+(?:\s+[^\s|&;]+)*)\s*$/;
|
|
76
|
+
const GIT_RM_PATTERN = /^\s*git\s+rm\s+(?:--cached\s+|-[rfq]+\s+)*([^\s|&;]+(?:\s+[^\s|&;]+)*)\s*$/;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Detect whether a tool invocation deleted one or more files, and what
|
|
80
|
+
* shape the deletion took. Returns null when no deletion occurred.
|
|
81
|
+
*
|
|
82
|
+
* Detection rules:
|
|
83
|
+
* - Bash `rm <files>` — straightforward; ignore flags, take rest as paths
|
|
84
|
+
* - Bash `git rm <files>` — same
|
|
85
|
+
* - Edit with new_string='' AND old_string covering full prior content —
|
|
86
|
+
* conservatively, treat any Edit with new_string='' as a deletion
|
|
87
|
+
* candidate (post-test confirms what's left)
|
|
88
|
+
* - Write with content='' — treated as deletion (the prior file's content
|
|
89
|
+
* is gone, even though the file still exists)
|
|
90
|
+
*
|
|
91
|
+
* Failure responses (non-deletion or ambiguous): returns null.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} ctx
|
|
94
|
+
* @returns {{deleted: true, shape: string, files: string[]} | null}
|
|
95
|
+
*/
|
|
96
|
+
function detectDeletionShape(ctx) {
|
|
97
|
+
if (!ctx || typeof ctx !== 'object') return null;
|
|
98
|
+
const { toolName, toolInput, toolResponse } = ctx;
|
|
99
|
+
if (!toolName || !toolInput) return null;
|
|
100
|
+
|
|
101
|
+
// Skip failed tool invocations — nothing was actually deleted.
|
|
102
|
+
if (toolResponse && (toolResponse.error || toolResponse.isError)) return null;
|
|
103
|
+
|
|
104
|
+
if (toolName === 'Bash') {
|
|
105
|
+
const cmd = toolInput.command;
|
|
106
|
+
if (typeof cmd !== 'string' || !cmd.trim()) return null;
|
|
107
|
+
|
|
108
|
+
// Reject compound commands (semicolons / pipes) — too many false-positive shapes.
|
|
109
|
+
// Future: parse with a real shell parser if needed.
|
|
110
|
+
if (/[;&|`$()]/.test(cmd)) return null;
|
|
111
|
+
|
|
112
|
+
let m = cmd.match(GIT_RM_PATTERN);
|
|
113
|
+
if (m) {
|
|
114
|
+
const files = m[1].split(/\s+/).filter(Boolean);
|
|
115
|
+
return { deleted: true, shape: 'git-rm', files };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
m = cmd.match(RM_COMMAND_PATTERN);
|
|
119
|
+
if (m) {
|
|
120
|
+
const files = m[1].split(/\s+/).filter(Boolean);
|
|
121
|
+
return { deleted: true, shape: 'rm', files };
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (toolName === 'Edit') {
|
|
127
|
+
const { new_string, file_path } = toolInput;
|
|
128
|
+
if (typeof file_path !== 'string') return null;
|
|
129
|
+
// Edit with empty new_string is a deletion candidate — but only counts
|
|
130
|
+
// when the old_string was substantial (else it's a small edit that
|
|
131
|
+
// happens to delete a snippet, not a file removal).
|
|
132
|
+
if (new_string !== '' && new_string !== undefined && new_string !== null) return null;
|
|
133
|
+
const oldStr = toolInput.old_string;
|
|
134
|
+
if (typeof oldStr !== 'string' || oldStr.length < 200) return null;
|
|
135
|
+
return { deleted: true, shape: 'edit-empty', files: [file_path] };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (toolName === 'Write') {
|
|
139
|
+
const { content, file_path } = toolInput;
|
|
140
|
+
if (typeof file_path !== 'string') return null;
|
|
141
|
+
if (content === '' || content === undefined || content === null) {
|
|
142
|
+
return { deleted: true, shape: 'write-empty', files: [file_path] };
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Test a file path against the configured UI-surface glob list.
|
|
152
|
+
* @param {string} filePath
|
|
153
|
+
* @param {Array<RegExp|string>} uiGlobs - default DEFAULT_UI_GLOBS
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
function isUiSurfaceFile(filePath, uiGlobs) {
|
|
157
|
+
if (typeof filePath !== 'string' || !filePath) return false;
|
|
158
|
+
const globs = Array.isArray(uiGlobs) && uiGlobs.length > 0 ? uiGlobs : DEFAULT_UI_GLOBS;
|
|
159
|
+
// Normalize to forward slashes for matching
|
|
160
|
+
const norm = filePath.replace(/\\/g, '/');
|
|
161
|
+
for (const g of globs) {
|
|
162
|
+
if (g instanceof RegExp) {
|
|
163
|
+
if (g.test(norm)) return true;
|
|
164
|
+
} else if (typeof g === 'string') {
|
|
165
|
+
// Convert simple glob string to RegExp: treat ** and * appropriately
|
|
166
|
+
const re = simpleGlobToRegex(g);
|
|
167
|
+
if (re.test(norm)) return true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function simpleGlobToRegex(glob) {
|
|
174
|
+
// ** = any path; * = any segment. Other regex chars escaped.
|
|
175
|
+
const escaped = glob
|
|
176
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
177
|
+
.replace(/\*\*/g, '__DBLSTAR__')
|
|
178
|
+
.replace(/\*/g, '[^/]*')
|
|
179
|
+
.replace(/__DBLSTAR__/g, '.*');
|
|
180
|
+
return new RegExp('^' + escaped + '$', 'i');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Extract user-visible string candidates from file content. Returns up to
|
|
185
|
+
* `max` unique strings (longest first), useful for owner-grep audit.
|
|
186
|
+
*/
|
|
187
|
+
function extractUserVisibleStrings(content, max) {
|
|
188
|
+
if (typeof content !== 'string') return [];
|
|
189
|
+
const limit = Number.isFinite(max) && max > 0 ? max : DEFAULT_MAX_USER_VISIBLE_STRINGS;
|
|
190
|
+
const found = new Set();
|
|
191
|
+
for (const re of USER_VISIBLE_STRING_PATTERNS) {
|
|
192
|
+
const flags = re.flags.includes('g') ? re.flags : re.flags + 'g';
|
|
193
|
+
const cloned = new RegExp(re.source, flags);
|
|
194
|
+
let match;
|
|
195
|
+
while ((match = cloned.exec(content)) !== null) {
|
|
196
|
+
const s = (match[1] || '').trim();
|
|
197
|
+
if (s.length >= 5 && s.length <= 80) found.add(s);
|
|
198
|
+
if (found.size > 50) break; // bound work
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Return longest-first up to limit
|
|
202
|
+
return Array.from(found).sort((a, b) => b.length - a.length).slice(0, limit);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Look up the commit that originally added a file via `git log --diff-filter=A --follow`.
|
|
207
|
+
* Returns null when git is unavailable, the file has no add commit (e.g., shallow
|
|
208
|
+
* clone or the file was never under git), or any subprocess error occurs.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} filePath - absolute or repo-relative path
|
|
211
|
+
* @param {object} [opts]
|
|
212
|
+
* @param {string} [opts.workspaceRoot] - cwd for git commands
|
|
213
|
+
* @param {Function} [opts.runGit] - injectable for tests; receives args array, returns string
|
|
214
|
+
* @returns {object|null}
|
|
215
|
+
*/
|
|
216
|
+
function lookupOriginalAdd(filePath, opts = {}) {
|
|
217
|
+
if (typeof filePath !== 'string' || !filePath) return null;
|
|
218
|
+
const cwd = opts.workspaceRoot || process.cwd();
|
|
219
|
+
const runGit = typeof opts.runGit === 'function'
|
|
220
|
+
? opts.runGit
|
|
221
|
+
: (args) => execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
222
|
+
|
|
223
|
+
let logOut;
|
|
224
|
+
try {
|
|
225
|
+
// --reverse so the oldest add commit comes first; we take the first line.
|
|
226
|
+
logOut = runGit([
|
|
227
|
+
'log', '--diff-filter=A', '--follow', '--reverse',
|
|
228
|
+
'--format=%H%x09%aI%x09%an%x09%s',
|
|
229
|
+
'--', filePath
|
|
230
|
+
]);
|
|
231
|
+
} catch (_err) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
if (!logOut || !logOut.trim()) return null;
|
|
235
|
+
const firstLine = logOut.split('\n').find(l => l.trim());
|
|
236
|
+
if (!firstLine) return null;
|
|
237
|
+
const parts = firstLine.split('\t');
|
|
238
|
+
if (parts.length < 4) return null;
|
|
239
|
+
const [sha, dateIso, author, subject] = parts;
|
|
240
|
+
if (!/^[0-9a-f]{7,40}$/i.test(sha)) return null;
|
|
241
|
+
|
|
242
|
+
let originalContent = '';
|
|
243
|
+
try {
|
|
244
|
+
originalContent = runGit(['show', `${sha}:${filePath}`]);
|
|
245
|
+
} catch (_err) { /* fail-open */ }
|
|
246
|
+
|
|
247
|
+
const originalLOC = originalContent ? originalContent.split('\n').length : null;
|
|
248
|
+
const userVisibleStrings = extractUserVisibleStrings(originalContent, DEFAULT_MAX_USER_VISIBLE_STRINGS);
|
|
249
|
+
|
|
250
|
+
let ageDays = null;
|
|
251
|
+
const ts = Date.parse(dateIso);
|
|
252
|
+
if (Number.isFinite(ts)) {
|
|
253
|
+
ageDays = Math.floor((Date.now() - ts) / (1000 * 60 * 60 * 24));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
sha: sha.slice(0, 12),
|
|
258
|
+
date: dateIso,
|
|
259
|
+
author,
|
|
260
|
+
subject,
|
|
261
|
+
ageDays,
|
|
262
|
+
originalLOC,
|
|
263
|
+
userVisibleStrings
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Format a single deletion log entry as markdown. Stable shape so the
|
|
269
|
+
* owner can grep / diff over time.
|
|
270
|
+
*
|
|
271
|
+
* @param {object} entry
|
|
272
|
+
* @returns {string}
|
|
273
|
+
*/
|
|
274
|
+
function formatLogEntry(entry) {
|
|
275
|
+
const ts = entry.timestamp || new Date().toISOString();
|
|
276
|
+
const lines = [];
|
|
277
|
+
lines.push(`## ${ts} — \`${entry.filePath}\``);
|
|
278
|
+
lines.push('');
|
|
279
|
+
lines.push(`- **Shape**: ${entry.shape}`);
|
|
280
|
+
if (entry.taskId) lines.push(`- **Task**: ${entry.taskId}`);
|
|
281
|
+
if (entry.sessionId) lines.push(`- **Session**: ${entry.sessionId.slice(0, 12)}`);
|
|
282
|
+
if (entry.currentCommitSubject) lines.push(`- **Deletion context**: ${entry.currentCommitSubject}`);
|
|
283
|
+
if (entry.provenance) {
|
|
284
|
+
const p = entry.provenance;
|
|
285
|
+
lines.push(`- **Original add**: \`${p.sha}\`${p.date ? ` (${p.date.slice(0, 10)})` : ''}${p.author ? ` by ${p.author}` : ''}`);
|
|
286
|
+
if (p.subject) lines.push(` - Subject: ${p.subject}`);
|
|
287
|
+
if (Number.isFinite(p.ageDays)) lines.push(` - Age: ${p.ageDays} days`);
|
|
288
|
+
if (Number.isFinite(p.originalLOC)) lines.push(` - Lines deleted: ${p.originalLOC}`);
|
|
289
|
+
if (Array.isArray(p.userVisibleStrings) && p.userVisibleStrings.length > 0) {
|
|
290
|
+
lines.push(` - User-visible strings (top ${p.userVisibleStrings.length}):`);
|
|
291
|
+
for (const s of p.userVisibleStrings) {
|
|
292
|
+
lines.push(` - "${s.replace(/"/g, '\\"')}"`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
lines.push('- **Original add**: not discoverable (shallow clone, file outside git, or no add commit)');
|
|
297
|
+
}
|
|
298
|
+
lines.push('');
|
|
299
|
+
return lines.join('\n');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Append an entry to the deletion log. Creates parent dirs as needed.
|
|
304
|
+
* Returns true on success, false on any I/O error (fail-open).
|
|
305
|
+
*/
|
|
306
|
+
function appendLogEntry(workspaceRoot, entryText, opts = {}) {
|
|
307
|
+
if (typeof entryText !== 'string' || !entryText) return false;
|
|
308
|
+
const root = workspaceRoot || process.cwd();
|
|
309
|
+
const logPath = path.join(root, opts.logPath || DEFAULT_LOG_PATH);
|
|
310
|
+
try {
|
|
311
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
312
|
+
if (!fs.existsSync(logPath)) {
|
|
313
|
+
const header = [
|
|
314
|
+
'# Deletions Log',
|
|
315
|
+
'',
|
|
316
|
+
'> Append-only audit trail for AI deletions of user-facing UI files.',
|
|
317
|
+
'> Emitted by `scripts/hooks/core/deletion-log.js` (PostToolUse hook).',
|
|
318
|
+
'> Owner-grep workflow: did we ever delete a feature called X?',
|
|
319
|
+
'',
|
|
320
|
+
'---',
|
|
321
|
+
''
|
|
322
|
+
].join('\n');
|
|
323
|
+
fs.appendFileSync(logPath, header);
|
|
324
|
+
}
|
|
325
|
+
fs.appendFileSync(logPath, entryText);
|
|
326
|
+
return true;
|
|
327
|
+
} catch (_err) {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Top-level orchestrator called from PostToolUse. Detects deletion,
|
|
334
|
+
* filters by glob, looks up provenance, and appends to log. Always
|
|
335
|
+
* returns a structured result; never throws.
|
|
336
|
+
*
|
|
337
|
+
* @param {object} ctx
|
|
338
|
+
* @param {string} ctx.toolName
|
|
339
|
+
* @param {object} ctx.toolInput
|
|
340
|
+
* @param {object} [ctx.toolResponse]
|
|
341
|
+
* @param {string} [ctx.workspaceRoot]
|
|
342
|
+
* @param {string} [ctx.sessionId]
|
|
343
|
+
* @param {string} [ctx.taskId]
|
|
344
|
+
* @param {object} [ctx.config] - hooks.rules.deletionLog block
|
|
345
|
+
* @param {Function} [ctx.runGit] - injectable for tests
|
|
346
|
+
* @returns {{logged: number, skipped: number, reasons: string[], entries: object[]}}
|
|
347
|
+
*/
|
|
348
|
+
function recordDeletion(ctx) {
|
|
349
|
+
const result = { logged: 0, skipped: 0, reasons: [], entries: [] };
|
|
350
|
+
if (!ctx || typeof ctx !== 'object') {
|
|
351
|
+
result.reasons.push('no-context');
|
|
352
|
+
return result;
|
|
353
|
+
}
|
|
354
|
+
const cfg = ctx.config || {};
|
|
355
|
+
if (cfg.enabled === false) {
|
|
356
|
+
result.reasons.push('disabled');
|
|
357
|
+
return result;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const detection = detectDeletionShape(ctx);
|
|
361
|
+
if (!detection) {
|
|
362
|
+
result.reasons.push('not-a-deletion');
|
|
363
|
+
return result;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const uiGlobs = Array.isArray(cfg.uiGlobs) ? cfg.uiGlobs : null;
|
|
367
|
+
const minLOC = Number.isFinite(cfg.minLOC) ? cfg.minLOC : DEFAULT_MIN_LOC;
|
|
368
|
+
const workspaceRoot = ctx.workspaceRoot || process.cwd();
|
|
369
|
+
|
|
370
|
+
for (const f of detection.files) {
|
|
371
|
+
if (!isUiSurfaceFile(f, uiGlobs)) {
|
|
372
|
+
result.skipped++;
|
|
373
|
+
result.reasons.push(`glob-miss:${f}`);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
let provenance = null;
|
|
377
|
+
try {
|
|
378
|
+
provenance = lookupOriginalAdd(f, { workspaceRoot, runGit: ctx.runGit });
|
|
379
|
+
} catch (_err) { /* fail-open */ }
|
|
380
|
+
|
|
381
|
+
if (provenance && Number.isFinite(provenance.originalLOC) && provenance.originalLOC < minLOC) {
|
|
382
|
+
result.skipped++;
|
|
383
|
+
result.reasons.push(`below-min-loc:${f}`);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const entry = {
|
|
388
|
+
timestamp: ctx.timestamp || new Date().toISOString(),
|
|
389
|
+
filePath: f,
|
|
390
|
+
shape: detection.shape,
|
|
391
|
+
provenance,
|
|
392
|
+
sessionId: ctx.sessionId || null,
|
|
393
|
+
taskId: ctx.taskId || null,
|
|
394
|
+
currentCommitSubject: ctx.currentCommitSubject || null
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const text = formatLogEntry(entry);
|
|
398
|
+
const ok = appendLogEntry(workspaceRoot, text, { logPath: cfg.logPath });
|
|
399
|
+
if (ok) {
|
|
400
|
+
result.logged++;
|
|
401
|
+
result.entries.push(entry);
|
|
402
|
+
} else {
|
|
403
|
+
result.skipped++;
|
|
404
|
+
result.reasons.push(`append-failed:${f}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return result;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
module.exports = {
|
|
411
|
+
// Constants (exposed for tests + integrators)
|
|
412
|
+
DEFAULT_UI_GLOBS,
|
|
413
|
+
DEFAULT_LOG_PATH,
|
|
414
|
+
DEFAULT_MIN_LOC,
|
|
415
|
+
DEFAULT_MAX_USER_VISIBLE_STRINGS,
|
|
416
|
+
USER_VISIBLE_STRING_PATTERNS,
|
|
417
|
+
// Functions
|
|
418
|
+
detectDeletionShape,
|
|
419
|
+
isUiSurfaceFile,
|
|
420
|
+
simpleGlobToRegex,
|
|
421
|
+
extractUserVisibleStrings,
|
|
422
|
+
lookupOriginalAdd,
|
|
423
|
+
formatLogEntry,
|
|
424
|
+
appendLogEntry,
|
|
425
|
+
recordDeletion
|
|
426
|
+
};
|
|
@@ -134,6 +134,40 @@ runHook('PostToolUse', async ({ parsedInput }) => {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// Deletion log (Fork C, v2.29.5): warn-only audit trail for AI
|
|
138
|
+
// deletions of user-facing UI files. Fire on Bash rm/git rm and
|
|
139
|
+
// Edit/Write that empty a UI-glob-matching path. Never blocks; just
|
|
140
|
+
// appends to .workflow/state/deletions-log.md.
|
|
141
|
+
if (!toolFailed) {
|
|
142
|
+
try {
|
|
143
|
+
const { recordDeletion } = require('../../core/deletion-log');
|
|
144
|
+
const { getConfig, PATHS } = require('../../../flow-utils');
|
|
145
|
+
const cfg = (() => {
|
|
146
|
+
try { return getConfig()?.hooks?.rules?.deletionLog || {}; }
|
|
147
|
+
catch (_err) { return {}; }
|
|
148
|
+
})();
|
|
149
|
+
// Read active task id (best-effort — not critical for the log)
|
|
150
|
+
let taskId = null;
|
|
151
|
+
try {
|
|
152
|
+
const { safeJsonParse: rj } = require('../../../flow-utils');
|
|
153
|
+
const ready = rj(require('node:path').join(PATHS.state, 'ready.json'), { inProgress: [] });
|
|
154
|
+
taskId = ready.inProgress?.[0]?.id || null;
|
|
155
|
+
} catch (_err) { /* fail-open */ }
|
|
156
|
+
recordDeletion({
|
|
157
|
+
toolName,
|
|
158
|
+
toolInput,
|
|
159
|
+
toolResponse,
|
|
160
|
+
sessionId: parsedInput.sessionId,
|
|
161
|
+
taskId,
|
|
162
|
+
config: cfg
|
|
163
|
+
});
|
|
164
|
+
} catch (err) {
|
|
165
|
+
if (process.env.DEBUG) {
|
|
166
|
+
console.error(`[post-tool-use] deletion-log: ${err.message}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
137
171
|
// Auto registry scan after successful git commit (fire-and-forget)
|
|
138
172
|
if (toolName === 'Bash' && toolInput.command && !toolFailed) {
|
|
139
173
|
const { isGitCommit } = require('../../core/commit-log-gate');
|