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.4",
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');