wogiflow 2.29.3 → 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.
|
@@ -450,6 +450,22 @@ function broadcastSSE(event) {
|
|
|
450
450
|
}
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
+
// ============================================================
|
|
454
|
+
// Dispatch tracking integration (silent-halt RCA fix, v2.29.4)
|
|
455
|
+
// ============================================================
|
|
456
|
+
// The channel server is the only place that sees EVERY inbound message,
|
|
457
|
+
// regardless of whether the manager dispatched via the programmatic
|
|
458
|
+
// `dispatchToChannel()` helper or a raw `curl POST`. Recording at this
|
|
459
|
+
// layer guarantees `dispatched-tasks.json` exists for every dispatch,
|
|
460
|
+
// closing the wogi-hub 2026-04-27 silent-halt failure shape.
|
|
461
|
+
//
|
|
462
|
+
// Helpers live in `workspace-channel-tracking.js` so they can be unit-
|
|
463
|
+
// tested without spawning the channel-server process. Both fail-open;
|
|
464
|
+
// idempotency lives at the call site (Fix A skips on existing record;
|
|
465
|
+
// Fix B delegates to reconcileDispatch which is idempotent).
|
|
466
|
+
|
|
467
|
+
const channelTracking = require('./workspace-channel-tracking');
|
|
468
|
+
|
|
453
469
|
// ============================================================
|
|
454
470
|
// HTTP Server
|
|
455
471
|
// ============================================================
|
|
@@ -506,6 +522,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
506
522
|
: cleanBody;
|
|
507
523
|
sendChannelNotification(notificationBody, meta);
|
|
508
524
|
|
|
525
|
+
// v2.29.4 silent-halt RCA fixes — both fail-open
|
|
526
|
+
const trackingCtx = { workspaceRoot: WORKSPACE_ROOT, repoName: REPO_NAME, from, body: cleanBody };
|
|
527
|
+
channelTracking.tryRecordInboundDispatch(trackingCtx);
|
|
528
|
+
channelTracking.tryReconcileInboundCompletion(trackingCtx);
|
|
529
|
+
|
|
509
530
|
// Also broadcast to SSE subscribers
|
|
510
531
|
if (sseClients.size > 0) {
|
|
511
532
|
const crypto = require('node:crypto');
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Workspace — Channel Server Dispatch Tracking Helpers (v2.29.4)
|
|
5
|
+
*
|
|
6
|
+
* Pure-function helpers that the channel server's HTTP POST handler calls
|
|
7
|
+
* to record inbound dispatches and reconcile inbound completions. Extracted
|
|
8
|
+
* from `workspace-channel-server.js` so they can be unit-tested without
|
|
9
|
+
* spawning the channel-server process.
|
|
10
|
+
*
|
|
11
|
+
* Why these exist (silent-halt RCA, 2026-04-27):
|
|
12
|
+
* `recordDispatch` was only called from the programmatic dispatch helper
|
|
13
|
+
* (`workspace-routing.js → dispatchToChannel`). Manager AI sessions that
|
|
14
|
+
* used raw `curl POST http://localhost:8801` bypassed it entirely. With
|
|
15
|
+
* no record, the overdue detector had nothing to detect — workers could
|
|
16
|
+
* die silently with zero manager-side signal.
|
|
17
|
+
*
|
|
18
|
+
* Both helpers are best-effort and fail-open. Idempotency lives at the
|
|
19
|
+
* call site (Fix A skips when a pending record already exists; Fix B
|
|
20
|
+
* delegates idempotency to `reconcileDispatch` which returns `null` when
|
|
21
|
+
* no pending record matches).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const TASK_ID_PATTERN = /\bwf-[0-9a-f]{8}\b/i;
|
|
25
|
+
const DISPATCH_BODY_PATTERN = /^\s*\/wogi-start\s+(wf-[0-9a-f]{8})\b/i;
|
|
26
|
+
const QUESTION_BODY_PATTERN = /^\s*##\s*QUESTION/im;
|
|
27
|
+
const COMPLETION_BODY_PATTERN = /##\s*Results\b|task-complete\b/i;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Record an inbound dispatch when the channel server (running in worker
|
|
31
|
+
* mode) receives a `/wogi-start <id>` POST from the manager.
|
|
32
|
+
*
|
|
33
|
+
* No-ops when:
|
|
34
|
+
* - workspaceRoot is missing
|
|
35
|
+
* - body is not a non-empty string
|
|
36
|
+
* - this server is in manager mode (REPO_NAME === 'manager')
|
|
37
|
+
* - the `from` header is not the manager
|
|
38
|
+
* - the body does not match the dispatch pattern
|
|
39
|
+
* - a pending record for (taskId, repoName) already exists (idempotency)
|
|
40
|
+
*
|
|
41
|
+
* @param {Object} ctx
|
|
42
|
+
* @param {string} ctx.workspaceRoot
|
|
43
|
+
* @param {string} ctx.repoName
|
|
44
|
+
* @param {string} ctx.from
|
|
45
|
+
* @param {string} ctx.body
|
|
46
|
+
* @param {Object} [tracking] — injectable for tests; defaults to the lib module
|
|
47
|
+
* @returns {{action: 'recorded'|'skip-existing'|'skip-not-worker'|'skip-bad-from'|'skip-no-match'|'skip-no-root'|'skip-empty-body'|'error', reason?: string, taskId?: string}}
|
|
48
|
+
*/
|
|
49
|
+
function tryRecordInboundDispatch(ctx, tracking) {
|
|
50
|
+
const { workspaceRoot, repoName, from, body } = ctx || {};
|
|
51
|
+
if (!workspaceRoot) return { action: 'skip-no-root' };
|
|
52
|
+
if (typeof body !== 'string' || !body) return { action: 'skip-empty-body' };
|
|
53
|
+
if (repoName === 'manager') return { action: 'skip-not-worker' };
|
|
54
|
+
if (from !== 'manager' && from !== 'workspace-manager') return { action: 'skip-bad-from' };
|
|
55
|
+
const m = body.match(DISPATCH_BODY_PATTERN);
|
|
56
|
+
if (!m) return { action: 'skip-no-match' };
|
|
57
|
+
const taskId = m[1].toLowerCase();
|
|
58
|
+
try {
|
|
59
|
+
const tr = tracking || require('./workspace-dispatch-tracking');
|
|
60
|
+
const existing = tr.readDispatches(workspaceRoot).find(r =>
|
|
61
|
+
r && r.taskId === taskId && r.repoName === repoName && r.status === 'pending'
|
|
62
|
+
);
|
|
63
|
+
if (existing) return { action: 'skip-existing', taskId };
|
|
64
|
+
tr.recordDispatch(workspaceRoot, {
|
|
65
|
+
taskId,
|
|
66
|
+
repoName,
|
|
67
|
+
dispatchedBy: from
|
|
68
|
+
});
|
|
69
|
+
return { action: 'recorded', taskId };
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return { action: 'error', reason: err.message, taskId };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reconcile an inbound completion when the channel server (running in
|
|
77
|
+
* manager mode) receives a worker-side POST that looks like a completion.
|
|
78
|
+
*
|
|
79
|
+
* No-ops when:
|
|
80
|
+
* - workspaceRoot is missing
|
|
81
|
+
* - body is not a non-empty string
|
|
82
|
+
* - this server is in worker mode (REPO_NAME !== 'manager')
|
|
83
|
+
* - the `from` header IS the manager (no self-completion)
|
|
84
|
+
* - the body looks like a `## QUESTION:` (escalation, not completion)
|
|
85
|
+
* - the body does not contain `## Results` or `task-complete`
|
|
86
|
+
* - the body does not contain a `wf-XXXXXXXX` reference
|
|
87
|
+
* - reconcileDispatch finds no pending record (idempotent)
|
|
88
|
+
*
|
|
89
|
+
* @param {Object} ctx
|
|
90
|
+
* @param {string} ctx.workspaceRoot
|
|
91
|
+
* @param {string} ctx.repoName
|
|
92
|
+
* @param {string} ctx.from
|
|
93
|
+
* @param {string} ctx.body
|
|
94
|
+
* @param {Object} [tracking] — injectable for tests; defaults to the lib module
|
|
95
|
+
* @returns {{action: 'reconciled'|'skip-not-manager'|'skip-self'|'skip-question'|'skip-not-completion'|'skip-no-id'|'skip-no-pending'|'skip-no-root'|'skip-empty-body'|'error', reason?: string, taskId?: string}}
|
|
96
|
+
*/
|
|
97
|
+
function tryReconcileInboundCompletion(ctx, tracking) {
|
|
98
|
+
const { workspaceRoot, repoName, from, body } = ctx || {};
|
|
99
|
+
if (!workspaceRoot) return { action: 'skip-no-root' };
|
|
100
|
+
if (typeof body !== 'string' || !body) return { action: 'skip-empty-body' };
|
|
101
|
+
if (repoName !== 'manager') return { action: 'skip-not-manager' };
|
|
102
|
+
if (from === 'manager' || from === 'workspace-manager') return { action: 'skip-self' };
|
|
103
|
+
if (QUESTION_BODY_PATTERN.test(body)) return { action: 'skip-question' };
|
|
104
|
+
if (!COMPLETION_BODY_PATTERN.test(body)) return { action: 'skip-not-completion' };
|
|
105
|
+
const m = body.match(TASK_ID_PATTERN);
|
|
106
|
+
if (!m) return { action: 'skip-no-id' };
|
|
107
|
+
const taskId = m[0].toLowerCase();
|
|
108
|
+
try {
|
|
109
|
+
const tr = tracking || require('./workspace-dispatch-tracking');
|
|
110
|
+
const result = tr.reconcileDispatch(workspaceRoot, taskId, 'completed', 'channel-server-completion');
|
|
111
|
+
if (!result) return { action: 'skip-no-pending', taskId };
|
|
112
|
+
return { action: 'reconciled', taskId };
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return { action: 'error', reason: err.message, taskId };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = {
|
|
119
|
+
TASK_ID_PATTERN,
|
|
120
|
+
DISPATCH_BODY_PATTERN,
|
|
121
|
+
QUESTION_BODY_PATTERN,
|
|
122
|
+
COMPLETION_BODY_PATTERN,
|
|
123
|
+
tryRecordInboundDispatch,
|
|
124
|
+
tryReconcileInboundCompletion
|
|
125
|
+
};
|
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');
|