wogiflow 2.17.0 → 2.18.0
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/.claude/commands/wogi-audit.md +212 -17
- package/.claude/commands/wogi-research.md +37 -0
- package/.claude/commands/wogi-review.md +200 -22
- package/.claude/commands/wogi-start.md +45 -0
- package/.claude/docs/claude-code-compatibility.md +46 -1
- package/.claude/docs/intent-grounded-review.md +209 -0
- package/.claude/settings.json +34 -1
- package/.workflow/agents/logic-adversary.md +8 -0
- package/.workflow/templates/claude-md.hbs +18 -0
- package/lib/installer.js +22 -0
- package/lib/utils.js +29 -3
- package/lib/workspace-changelog.js +2 -1
- package/lib/workspace-channel-server.js +4 -6
- package/lib/workspace-contracts.js +5 -4
- package/lib/workspace-events.js +8 -7
- package/lib/workspace-gates.js +4 -3
- package/lib/workspace-integration-tests.js +2 -1
- package/lib/workspace-intelligence.js +3 -2
- package/lib/workspace-locks.js +2 -1
- package/lib/workspace-messages.js +7 -6
- package/lib/workspace-routing.js +14 -26
- package/lib/workspace-session.js +7 -6
- package/lib/workspace-sync.js +9 -8
- package/package.json +4 -2
- package/scripts/base-workflow-step.js +1 -1
- package/scripts/flow +19 -0
- package/scripts/flow-adaptive-learning.js +1 -1
- package/scripts/flow-aggregate.js +2 -1
- package/scripts/flow-architect-pass.js +3 -3
- package/scripts/flow-archive-runs.js +372 -0
- package/scripts/flow-ask.js +1 -1
- package/scripts/flow-ast-grep.js +216 -0
- package/scripts/flow-audit-gates.js +1 -1
- package/scripts/flow-auto-learn.js +8 -11
- package/scripts/flow-bug.js +2 -2
- package/scripts/flow-capture-gate.js +644 -0
- package/scripts/flow-capture.js +4 -3
- package/scripts/flow-cli-flags.js +95 -0
- package/scripts/flow-community-sync.js +2 -1
- package/scripts/flow-community.js +6 -6
- package/scripts/flow-conclusion-classifier.js +310 -0
- package/scripts/flow-config-defaults.js +3 -3
- package/scripts/flow-constants.js +8 -11
- package/scripts/flow-context-scoring.js +1 -0
- package/scripts/flow-correction-detector.js +344 -3
- package/scripts/flow-damage-control.js +1 -1
- package/scripts/flow-decisions-merge.js +1 -0
- package/scripts/flow-done-gates.js +20 -0
- package/scripts/flow-done-report.js +2 -2
- package/scripts/flow-done.js +4 -4
- package/scripts/flow-epics.js +5 -11
- package/scripts/flow-health.js +145 -1
- package/scripts/flow-id.js +92 -0
- package/scripts/flow-io.js +15 -5
- package/scripts/flow-knowledge-router.js +2 -1
- package/scripts/flow-links.js +1 -1
- package/scripts/flow-log-manager.js +2 -1
- package/scripts/flow-logic-adversary.js +4 -4
- package/scripts/flow-long-input-cli.js +6 -0
- package/scripts/flow-long-input-stories.js +1 -1
- package/scripts/flow-loop-retry-learning.js +1 -1
- package/scripts/flow-mcp-capabilities.js +2 -3
- package/scripts/flow-mcp-docs.js +2 -1
- package/scripts/flow-memory-blocks.js +2 -1
- package/scripts/flow-memory-sync.js +1 -1
- package/scripts/flow-memory.js +767 -0
- package/scripts/flow-migrate-igr.js +1 -1
- package/scripts/flow-migrate.js +2 -1
- package/scripts/flow-model-adapter.js +1 -1
- package/scripts/flow-model-config.js +5 -1
- package/scripts/flow-model-profile.js +2 -1
- package/scripts/flow-orchestrate.js +3 -3
- package/scripts/flow-output.js +29 -0
- package/scripts/flow-parallel.js +10 -9
- package/scripts/flow-pattern-enforcer.js +2 -1
- package/scripts/flow-permissions-audit.js +124 -0
- package/scripts/flow-plugin-registry.js +2 -2
- package/scripts/flow-progress.js +5 -1
- package/scripts/flow-project-analyzer.js +1 -1
- package/scripts/flow-promote.js +510 -0
- package/scripts/flow-registries.js +86 -0
- package/scripts/flow-request-log.js +133 -0
- package/scripts/flow-research-protocol.js +0 -1
- package/scripts/flow-revision-tracker.js +2 -1
- package/scripts/flow-roadmap.js +2 -1
- package/scripts/flow-rules-sync.js +3 -7
- package/scripts/flow-session-end.js +3 -1
- package/scripts/flow-session-learning.js +6 -13
- package/scripts/flow-session-state.js +2 -2
- package/scripts/flow-setup-hooks.js +2 -1
- package/scripts/flow-skill-create.js +1 -1
- package/scripts/flow-skill-freshness.js +6 -7
- package/scripts/flow-skill-learn.js +1 -1
- package/scripts/flow-step-coverage.js +1 -1
- package/scripts/flow-step-security.js +1 -1
- package/scripts/flow-story.js +58 -10
- package/scripts/flow-sys.js +204 -0
- package/scripts/flow-task-hierarchy.js +88 -0
- package/scripts/flow-tech-debt.js +2 -1
- package/scripts/flow-test-api.js +1 -1
- package/scripts/flow-utils.js +60 -890
- package/scripts/hooks/core/bugfix-scope-gate.js +5 -4
- package/scripts/hooks/core/deploy-gate.js +1 -1
- package/scripts/hooks/core/pre-tool-helpers.js +72 -0
- package/scripts/hooks/core/pre-tool-orchestrator.js +442 -0
- package/scripts/hooks/core/routing-gate.js +8 -0
- package/scripts/hooks/core/session-end.js +28 -0
- package/scripts/hooks/entry/claude-code/pre-tool-use.js +48 -492
- package/scripts/hooks/entry/shared/hook-runner.js +1 -1
- package/scripts/registries/schema-registry.js +1 -1
- package/scripts/registries/service-registry.js +1 -1
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow - Capture-at-Task-Boundary Gate
|
|
5
|
+
*
|
|
6
|
+
* Quality gate that prevents a task from closing when durable conclusions have
|
|
7
|
+
* been made during the task but not captured to the correct state file.
|
|
8
|
+
*
|
|
9
|
+
* Story: wf-a3cc5f2a (epic-episodic-memory, capture enforcement)
|
|
10
|
+
* Upstream audit: .workflow/audits/state-coverage-2026-04-15.md (G4)
|
|
11
|
+
*
|
|
12
|
+
* Pipeline position: dispatched by flow-done.js via GATE_REGISTRY in
|
|
13
|
+
* flow-done-gates.js. Self-instrumented telemetry (mirrors completionTruthGate).
|
|
14
|
+
*
|
|
15
|
+
* Reuses (no parallel implementations):
|
|
16
|
+
* - flow-conclusion-classifier.js → classifyConclusions, CONCLUSION_KINDS
|
|
17
|
+
* - flow-gate-telemetry.js → recordGateEvent
|
|
18
|
+
* - flow-session-state.js → trackBypassAttempt
|
|
19
|
+
* - flow-utils.js → PATHS, getConfig
|
|
20
|
+
*
|
|
21
|
+
* Usage (programmatic):
|
|
22
|
+
* const { captureGate } = require('./flow-capture-gate');
|
|
23
|
+
* const result = captureGate(ctx);
|
|
24
|
+
* // { passed: boolean, skipped?: true, errorOutput?: string, details?: {...} }
|
|
25
|
+
*
|
|
26
|
+
* CLI (for smoke-testing):
|
|
27
|
+
* node scripts/flow-capture-gate.js smoke <taskId>
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('node:fs');
|
|
31
|
+
const path = require('node:path');
|
|
32
|
+
const { execSync, spawnSync } = require('node:child_process');
|
|
33
|
+
|
|
34
|
+
const { PATHS, getConfig, safeJsonParse, safeJsonParseString } = require('./flow-utils');
|
|
35
|
+
const gateTelemetry = require('./flow-gate-telemetry');
|
|
36
|
+
|
|
37
|
+
// Timeout for the classifier subprocess (Haiku call + parse). Generous but bounded —
|
|
38
|
+
// if it exceeds, the gate SKIPs rather than FAILs (infrastructure issue, not a real miss).
|
|
39
|
+
const CLASSIFIER_TIMEOUT_MS = 25000;
|
|
40
|
+
|
|
41
|
+
const GATE_ID = 'capture-gate';
|
|
42
|
+
const GATE_VERSION = '1.0';
|
|
43
|
+
|
|
44
|
+
// Target files / directories the gate knows how to verify writes against.
|
|
45
|
+
const KNOWN_TARGETS = {
|
|
46
|
+
'.workflow/state/decisions.md': { kind: 'file' },
|
|
47
|
+
'.workflow/state/feedback-patterns.md': { kind: 'file' },
|
|
48
|
+
'.workflow/state/product.md': { kind: 'file' },
|
|
49
|
+
'.workflow/state/adr/': { kind: 'directory' },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// Disabled-mode short-circuit
|
|
54
|
+
// ============================================================================
|
|
55
|
+
|
|
56
|
+
function isGateDisabled(config) {
|
|
57
|
+
const cfg = config || getConfig();
|
|
58
|
+
const cap = cfg?.externalMemory?.capture;
|
|
59
|
+
if (!cap || cap.enabled !== true) {
|
|
60
|
+
return { disabled: true, reason: 'capture-disabled' };
|
|
61
|
+
}
|
|
62
|
+
return { disabled: false, config: cap };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getBlockOnMiss(captureCfg) {
|
|
66
|
+
return captureCfg?.blockOnMiss !== false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getMinLevel(captureCfg) {
|
|
70
|
+
return captureCfg?.minLevel || 'L2';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getMinConfidence(captureCfg) {
|
|
74
|
+
return Number.isFinite(captureCfg?.minConfidence) ? captureCfg.minConfidence : 70;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Level ordering — L0 is largest (epic), L3 is smallest (subtask).
|
|
78
|
+
// Gate "minLevel: L2" means: run on L2 and larger (L2, L1, L0). Skip on L3.
|
|
79
|
+
const LEVEL_ORDER = { L0: 0, L1: 1, L2: 2, L3: 3 };
|
|
80
|
+
function levelIsBelowMin(taskLevel, minLevel) {
|
|
81
|
+
const t = LEVEL_ORDER[String(taskLevel || '').toUpperCase()];
|
|
82
|
+
const m = LEVEL_ORDER[String(minLevel || 'L2').toUpperCase()];
|
|
83
|
+
if (t === undefined || m === undefined) return false; // unknown → run
|
|
84
|
+
return t > m;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ============================================================================
|
|
88
|
+
// Task context helpers
|
|
89
|
+
// ============================================================================
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Load the in-progress task entry from ready.json (or return null).
|
|
93
|
+
*/
|
|
94
|
+
function loadTaskEntry(taskId) {
|
|
95
|
+
try {
|
|
96
|
+
const readyPath = PATHS.ready || path.join(PATHS.state, 'ready.json');
|
|
97
|
+
if (!fs.existsSync(readyPath)) return null;
|
|
98
|
+
const data = safeJsonParse(readyPath, null);
|
|
99
|
+
if (!data || typeof data !== 'object') return null;
|
|
100
|
+
const buckets = ['inProgress', 'ready', 'blocked', 'recentlyCompleted'];
|
|
101
|
+
for (const b of buckets) {
|
|
102
|
+
const arr = Array.isArray(data[b]) ? data[b] : [];
|
|
103
|
+
const hit = arr.find(t => t && t.id === taskId);
|
|
104
|
+
if (hit) return hit;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
} catch (_err) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Read the tail of request-log.md as a plain-text excerpt for the classifier.
|
|
114
|
+
* We only need the last ~2–3 entries for context on what this task did.
|
|
115
|
+
*/
|
|
116
|
+
function readRequestLogTail(maxChars = 6000) {
|
|
117
|
+
try {
|
|
118
|
+
const p = PATHS.requestLog || path.join(PATHS.state, 'request-log.md');
|
|
119
|
+
if (!fs.existsSync(p)) return '';
|
|
120
|
+
const raw = fs.readFileSync(p, 'utf-8');
|
|
121
|
+
if (raw.length <= maxChars) return raw;
|
|
122
|
+
return raw.slice(-maxChars);
|
|
123
|
+
} catch (_err) {
|
|
124
|
+
return '';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Build a textual summary of the task for the classifier.
|
|
130
|
+
*/
|
|
131
|
+
function buildTaskSummary(taskEntry, taskId) {
|
|
132
|
+
if (!taskEntry) return `Task ${taskId} (no summary available).`;
|
|
133
|
+
const bits = [];
|
|
134
|
+
bits.push(`Task: ${taskEntry.id}`);
|
|
135
|
+
if (taskEntry.title) bits.push(`Title: ${taskEntry.title}`);
|
|
136
|
+
if (taskEntry.type) bits.push(`Type: ${taskEntry.type}`);
|
|
137
|
+
if (taskEntry.level) bits.push(`Level: ${taskEntry.level}`);
|
|
138
|
+
if (taskEntry.epic) bits.push(`Epic: ${taskEntry.epic}`);
|
|
139
|
+
if (taskEntry.specPath) {
|
|
140
|
+
try {
|
|
141
|
+
const specFull = path.resolve(process.cwd(), taskEntry.specPath);
|
|
142
|
+
if (fs.existsSync(specFull)) {
|
|
143
|
+
const spec = fs.readFileSync(specFull, 'utf-8').slice(0, 4000);
|
|
144
|
+
bits.push(`\n## Spec\n${spec}`);
|
|
145
|
+
}
|
|
146
|
+
} catch (_err) {
|
|
147
|
+
/* no-op */
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return bits.join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ============================================================================
|
|
154
|
+
// Write verification
|
|
155
|
+
// ============================================================================
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Return an array of target files that have working-tree changes (unstaged + staged + untracked).
|
|
159
|
+
*/
|
|
160
|
+
function getChangedStateFiles() {
|
|
161
|
+
try {
|
|
162
|
+
const porcelain = execSync('git status --porcelain', {
|
|
163
|
+
encoding: 'utf-8',
|
|
164
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
165
|
+
}).trim();
|
|
166
|
+
if (!porcelain) return [];
|
|
167
|
+
const files = [];
|
|
168
|
+
for (const line of porcelain.split('\n')) {
|
|
169
|
+
if (!line || line.length < 3) continue;
|
|
170
|
+
const raw = line.slice(3);
|
|
171
|
+
const name = raw.includes(' -> ') ? raw.split(' -> ')[1] : raw;
|
|
172
|
+
files.push(name);
|
|
173
|
+
}
|
|
174
|
+
return files;
|
|
175
|
+
} catch (_err) {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Return the added-lines diff for a single file relative to HEAD (includes untracked).
|
|
182
|
+
*/
|
|
183
|
+
function getAddedLinesForFile(filePath) {
|
|
184
|
+
try {
|
|
185
|
+
// Handle untracked: cat the file directly. For tracked files: diff against HEAD.
|
|
186
|
+
const status = execSync(`git status --porcelain -- "${filePath}"`, {
|
|
187
|
+
encoding: 'utf-8',
|
|
188
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
189
|
+
}).trim();
|
|
190
|
+
if (!status) return '';
|
|
191
|
+
const indicator = status.slice(0, 2);
|
|
192
|
+
if (indicator.includes('?')) {
|
|
193
|
+
// Untracked — read whole file as "added".
|
|
194
|
+
// Wrap in try-catch per security-patterns.md §1: existsSync→readFileSync race.
|
|
195
|
+
const abs = path.resolve(process.cwd(), filePath);
|
|
196
|
+
if (!fs.existsSync(abs)) return '';
|
|
197
|
+
try {
|
|
198
|
+
return fs.readFileSync(abs, 'utf-8');
|
|
199
|
+
} catch (_err) {
|
|
200
|
+
return '';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const diff = execSync(`git diff -U0 HEAD -- "${filePath}"`, {
|
|
204
|
+
encoding: 'utf-8',
|
|
205
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
206
|
+
});
|
|
207
|
+
// Keep only added lines (leading + but not +++ header)
|
|
208
|
+
return diff
|
|
209
|
+
.split('\n')
|
|
210
|
+
.filter(l => l.startsWith('+') && !l.startsWith('+++'))
|
|
211
|
+
.map(l => l.slice(1))
|
|
212
|
+
.join('\n');
|
|
213
|
+
} catch (_err) {
|
|
214
|
+
return '';
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Normalize a string for exact-substring matching: lowercase + collapse whitespace.
|
|
220
|
+
*/
|
|
221
|
+
function normalizeForMatch(s) {
|
|
222
|
+
return String(s || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Verify whether a conclusion was written. Strategy (per plan P8 resolution):
|
|
227
|
+
* 1. Exact-substring match of normalized excerpt against added lines of the target.
|
|
228
|
+
* 2. For directory targets (ADR), scan all added/untracked files under the dir.
|
|
229
|
+
* 3. Secondary evidence: scan request-log tail for this task's entry citing the target.
|
|
230
|
+
*
|
|
231
|
+
* @returns {{ written: boolean, via: string, matchedFile?: string }}
|
|
232
|
+
*/
|
|
233
|
+
function verifyConclusionWrite(conclusion, ctx) {
|
|
234
|
+
const target = conclusion.targetFile;
|
|
235
|
+
const known = KNOWN_TARGETS[target];
|
|
236
|
+
if (!known) return { written: false, via: 'unknown-target' };
|
|
237
|
+
|
|
238
|
+
const needle = normalizeForMatch(conclusion.excerpt);
|
|
239
|
+
if (!needle || needle.length < 12) {
|
|
240
|
+
// Excerpt too short to do a safe substring check — fall back to file-changed signal.
|
|
241
|
+
const changed = getChangedStateFiles();
|
|
242
|
+
const fileChanged = known.kind === 'file'
|
|
243
|
+
? changed.includes(target)
|
|
244
|
+
: changed.some(f => f.startsWith(target));
|
|
245
|
+
return { written: fileChanged, via: fileChanged ? 'file-changed-short-excerpt' : 'no-match' };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (known.kind === 'file') {
|
|
249
|
+
const added = getAddedLinesForFile(target);
|
|
250
|
+
if (added && normalizeForMatch(added).includes(needle)) {
|
|
251
|
+
return { written: true, via: 'excerpt-in-diff', matchedFile: target };
|
|
252
|
+
}
|
|
253
|
+
} else if (known.kind === 'directory') {
|
|
254
|
+
const changed = getChangedStateFiles().filter(f => f.startsWith(target));
|
|
255
|
+
for (const f of changed) {
|
|
256
|
+
const added = getAddedLinesForFile(f);
|
|
257
|
+
if (added && normalizeForMatch(added).includes(needle)) {
|
|
258
|
+
return { written: true, via: 'excerpt-in-diff', matchedFile: f };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Secondary evidence: request-log entry for this task mentions the target file.
|
|
264
|
+
try {
|
|
265
|
+
const tail = readRequestLogTail(8000);
|
|
266
|
+
if (tail && ctx && ctx.taskId) {
|
|
267
|
+
// Rough heuristic: find the entry block containing the taskId, check its Files field.
|
|
268
|
+
const entryIdx = tail.lastIndexOf(ctx.taskId);
|
|
269
|
+
if (entryIdx >= 0) {
|
|
270
|
+
const window = tail.slice(Math.max(0, entryIdx - 500), Math.min(tail.length, entryIdx + 2000));
|
|
271
|
+
const tgtBase = target.replace(/\/$/, '');
|
|
272
|
+
if (window.includes(tgtBase)) {
|
|
273
|
+
return { written: true, via: 'request-log-cites-target' };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} catch (_err) {
|
|
278
|
+
/* no-op */
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (process.env.DEBUG) {
|
|
282
|
+
// P8 debug: if the file did change but no excerpt match, surface it.
|
|
283
|
+
const changed = getChangedStateFiles();
|
|
284
|
+
if (
|
|
285
|
+
(known.kind === 'file' && changed.includes(target)) ||
|
|
286
|
+
(known.kind === 'directory' && changed.some(f => f.startsWith(target)))
|
|
287
|
+
) {
|
|
288
|
+
console.error(
|
|
289
|
+
`[capture-gate] target=${target} changed but excerpt not matched in diff: "${conclusion.excerpt.slice(0, 80)}"`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { written: false, via: 'no-match' };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ============================================================================
|
|
298
|
+
// Directive rendering
|
|
299
|
+
// ============================================================================
|
|
300
|
+
|
|
301
|
+
function renderDirective(misses) {
|
|
302
|
+
if (!misses.length) return '';
|
|
303
|
+
const lines = [
|
|
304
|
+
'Capture gate: detected durable conclusions that were not captured to a state file.',
|
|
305
|
+
'',
|
|
306
|
+
];
|
|
307
|
+
for (const m of misses) {
|
|
308
|
+
lines.push(` • [${m.kind}] ${m.excerpt}`);
|
|
309
|
+
lines.push(` → write to: ${m.targetFile}`);
|
|
310
|
+
lines.push(` → suggested: ${m.suggestedCommand}`);
|
|
311
|
+
}
|
|
312
|
+
lines.push('');
|
|
313
|
+
lines.push('Capture the above, then re-run `flow done`. To override temporarily, set');
|
|
314
|
+
lines.push(' externalMemory.capture.blockOnMiss: false');
|
|
315
|
+
lines.push('in .workflow/config.json (the bypass is logged to request-log).');
|
|
316
|
+
return lines.join('\n');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ============================================================================
|
|
320
|
+
// Telemetry helper
|
|
321
|
+
// ============================================================================
|
|
322
|
+
|
|
323
|
+
function recordTelemetry(verdict, runCtx = {}) {
|
|
324
|
+
try {
|
|
325
|
+
gateTelemetry.recordGateEvent({
|
|
326
|
+
gateId: GATE_ID,
|
|
327
|
+
gateVersion: GATE_VERSION,
|
|
328
|
+
taskId: runCtx.taskId || null,
|
|
329
|
+
verdict,
|
|
330
|
+
findingCount: runCtx.missCount ?? 0,
|
|
331
|
+
findingSummary: runCtx.findingSummary || [],
|
|
332
|
+
durationMs: runCtx.durationMs,
|
|
333
|
+
metadata: {
|
|
334
|
+
reason: runCtx.reason || null,
|
|
335
|
+
detected: runCtx.detected ?? null,
|
|
336
|
+
written: runCtx.written ?? null,
|
|
337
|
+
missed: runCtx.missCount ?? null,
|
|
338
|
+
blockOnMiss: runCtx.blockOnMiss ?? null,
|
|
339
|
+
minLevel: runCtx.minLevel ?? null,
|
|
340
|
+
taskLevel: runCtx.taskLevel ?? null,
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
} catch (_err) {
|
|
344
|
+
// Telemetry failure must never break the gate.
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ============================================================================
|
|
349
|
+
// Main gate handler — matches flow-done-gates.js interface
|
|
350
|
+
// ============================================================================
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Call the conclusion classifier synchronously via spawnSync so the gate handler
|
|
354
|
+
* stays sync-compatible with flow-done-gates.js runGate (which does not await).
|
|
355
|
+
* @returns {Array} classified conclusions (possibly empty)
|
|
356
|
+
*/
|
|
357
|
+
function callClassifierSync({ taskSummary, requestLogExcerpt, taskId, minConfidence }) {
|
|
358
|
+
try {
|
|
359
|
+
const result = spawnSync(
|
|
360
|
+
process.execPath,
|
|
361
|
+
[path.resolve(__dirname, 'flow-conclusion-classifier.js'), 'classify'],
|
|
362
|
+
{
|
|
363
|
+
input: JSON.stringify({ taskSummary, requestLogExcerpt, taskId, minConfidence }),
|
|
364
|
+
encoding: 'utf-8',
|
|
365
|
+
timeout: CLASSIFIER_TIMEOUT_MS,
|
|
366
|
+
env: {
|
|
367
|
+
PATH: process.env.PATH,
|
|
368
|
+
HOME: process.env.HOME,
|
|
369
|
+
NODE_PATH: process.env.NODE_PATH || '',
|
|
370
|
+
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || '',
|
|
371
|
+
DEBUG: process.env.DEBUG || '',
|
|
372
|
+
},
|
|
373
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
374
|
+
}
|
|
375
|
+
);
|
|
376
|
+
if (result.error) {
|
|
377
|
+
if (process.env.DEBUG) {
|
|
378
|
+
console.error(`[capture-gate] classifier spawn error: ${result.error.message}`);
|
|
379
|
+
}
|
|
380
|
+
return null; // null signals infrastructure error → SKIP
|
|
381
|
+
}
|
|
382
|
+
if (result.status !== 0) {
|
|
383
|
+
if (process.env.DEBUG) {
|
|
384
|
+
console.error(`[capture-gate] classifier exited ${result.status}: ${result.stderr || ''}`);
|
|
385
|
+
}
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
const out = String(result.stdout || '').trim();
|
|
389
|
+
if (!out) return [];
|
|
390
|
+
// Use safeJsonParseString (prototype-pollution guard) instead of raw JSON.parse
|
|
391
|
+
// per security-patterns.md §2.
|
|
392
|
+
const parsed = safeJsonParseString(out, null);
|
|
393
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
394
|
+
} catch (err) {
|
|
395
|
+
if (process.env.DEBUG) {
|
|
396
|
+
console.error(`[capture-gate] classifier call failed: ${err.message}`);
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* @param {Object} ctx - Gate context from flow-done-gates.js runGate
|
|
404
|
+
* @returns {{ passed: boolean, skipped?: boolean, errorOutput?: string, details?: Object }}
|
|
405
|
+
*/
|
|
406
|
+
function captureGate(ctx) {
|
|
407
|
+
const start = Date.now();
|
|
408
|
+
const taskId = ctx?.taskId || null;
|
|
409
|
+
const config = ctx?.config || getConfig();
|
|
410
|
+
|
|
411
|
+
const dis = isGateDisabled(config);
|
|
412
|
+
if (dis.disabled) {
|
|
413
|
+
if (ctx?.color) {
|
|
414
|
+
console.log(` ${ctx.color('yellow', '\u25CB')} captureGate (${dis.reason})`);
|
|
415
|
+
}
|
|
416
|
+
recordTelemetry('SKIP', {
|
|
417
|
+
taskId,
|
|
418
|
+
reason: dis.reason,
|
|
419
|
+
durationMs: Date.now() - start,
|
|
420
|
+
});
|
|
421
|
+
return { passed: true, skipped: true, reason: dis.reason };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const captureCfg = dis.config;
|
|
425
|
+
const minLevel = getMinLevel(captureCfg);
|
|
426
|
+
const minConfidence = getMinConfidence(captureCfg);
|
|
427
|
+
const blockOnMiss = getBlockOnMiss(captureCfg);
|
|
428
|
+
|
|
429
|
+
const taskEntry = taskId ? loadTaskEntry(taskId) : null;
|
|
430
|
+
const taskLevel = taskEntry?.level || null;
|
|
431
|
+
|
|
432
|
+
if (taskLevel && levelIsBelowMin(taskLevel, minLevel)) {
|
|
433
|
+
if (ctx?.color) {
|
|
434
|
+
console.log(` ${ctx.color('yellow', '\u25CB')} captureGate (level ${taskLevel} below ${minLevel})`);
|
|
435
|
+
}
|
|
436
|
+
recordTelemetry('SKIP', {
|
|
437
|
+
taskId,
|
|
438
|
+
reason: 'level-too-low',
|
|
439
|
+
taskLevel,
|
|
440
|
+
minLevel,
|
|
441
|
+
durationMs: Date.now() - start,
|
|
442
|
+
});
|
|
443
|
+
return { passed: true, skipped: true, reason: 'level-too-low' };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (!process.env.ANTHROPIC_API_KEY) {
|
|
447
|
+
if (ctx?.warn) {
|
|
448
|
+
ctx.warn('captureGate skipped — ANTHROPIC_API_KEY not set (classifier unavailable)');
|
|
449
|
+
}
|
|
450
|
+
recordTelemetry('SKIP', {
|
|
451
|
+
taskId,
|
|
452
|
+
reason: 'no-api-key',
|
|
453
|
+
durationMs: Date.now() - start,
|
|
454
|
+
});
|
|
455
|
+
return { passed: true, skipped: true, reason: 'no-api-key' };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Classify (synchronous subprocess call — gate dispatch is sync)
|
|
459
|
+
const taskSummary = buildTaskSummary(taskEntry, taskId);
|
|
460
|
+
const requestLogExcerpt = readRequestLogTail(6000);
|
|
461
|
+
const raw = callClassifierSync({ taskSummary, requestLogExcerpt, taskId, minConfidence });
|
|
462
|
+
if (raw === null) {
|
|
463
|
+
// Infrastructure error → skip, don't block task close on our own failure.
|
|
464
|
+
recordTelemetry('SKIP', {
|
|
465
|
+
taskId,
|
|
466
|
+
reason: 'classifier-error',
|
|
467
|
+
durationMs: Date.now() - start,
|
|
468
|
+
});
|
|
469
|
+
return { passed: true, skipped: true, reason: 'classifier-error' };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Enrich classifier output with canonical target-file / suggested-command metadata.
|
|
473
|
+
// The CLI returns raw classifier records; we reattach KNOWN_TARGETS-derived metadata
|
|
474
|
+
// via the conclusion-classifier's export to keep a single source of truth.
|
|
475
|
+
let CONCLUSION_KINDS;
|
|
476
|
+
try {
|
|
477
|
+
CONCLUSION_KINDS = require('./flow-conclusion-classifier').CONCLUSION_KINDS;
|
|
478
|
+
} catch (_err) {
|
|
479
|
+
CONCLUSION_KINDS = {};
|
|
480
|
+
}
|
|
481
|
+
const conclusions = (Array.isArray(raw) ? raw : [])
|
|
482
|
+
.map(r => {
|
|
483
|
+
const meta = CONCLUSION_KINDS[r?.kind] || {};
|
|
484
|
+
return {
|
|
485
|
+
kind: r?.kind || 'unknown',
|
|
486
|
+
targetFile: r?.targetFile || meta.targetFile || '',
|
|
487
|
+
excerpt: String(r?.excerpt || '').slice(0, 240),
|
|
488
|
+
rationale: String(r?.rationale || '').slice(0, 240),
|
|
489
|
+
confidence: Number(r?.confidence) || 0,
|
|
490
|
+
suggestedCommand: r?.suggestedCommand || meta.suggestedCommand || '',
|
|
491
|
+
taskId,
|
|
492
|
+
};
|
|
493
|
+
})
|
|
494
|
+
.filter(c => c.excerpt && c.targetFile);
|
|
495
|
+
|
|
496
|
+
if (!conclusions.length) {
|
|
497
|
+
if (ctx?.success) {
|
|
498
|
+
ctx.success('captureGate (no durable conclusions detected)');
|
|
499
|
+
}
|
|
500
|
+
recordTelemetry('PASS', {
|
|
501
|
+
taskId,
|
|
502
|
+
detected: 0,
|
|
503
|
+
written: 0,
|
|
504
|
+
missCount: 0,
|
|
505
|
+
blockOnMiss,
|
|
506
|
+
minLevel,
|
|
507
|
+
taskLevel,
|
|
508
|
+
durationMs: Date.now() - start,
|
|
509
|
+
});
|
|
510
|
+
return { passed: true, details: { conclusions: [] } };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Verify each
|
|
514
|
+
const classified = [];
|
|
515
|
+
const misses = [];
|
|
516
|
+
for (const c of conclusions) {
|
|
517
|
+
const v = verifyConclusionWrite(c, { taskId });
|
|
518
|
+
const enriched = { ...c, written: v.written, verifiedVia: v.via, matchedFile: v.matchedFile };
|
|
519
|
+
classified.push(enriched);
|
|
520
|
+
if (!v.written) misses.push(enriched);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const detected = classified.length;
|
|
524
|
+
const written = classified.filter(c => c.written).length;
|
|
525
|
+
const missCount = misses.length;
|
|
526
|
+
|
|
527
|
+
if (missCount === 0) {
|
|
528
|
+
if (ctx?.success) {
|
|
529
|
+
ctx.success(`captureGate (${written}/${detected} conclusions captured)`);
|
|
530
|
+
}
|
|
531
|
+
recordTelemetry('PASS', {
|
|
532
|
+
taskId,
|
|
533
|
+
detected,
|
|
534
|
+
written,
|
|
535
|
+
missCount: 0,
|
|
536
|
+
blockOnMiss,
|
|
537
|
+
minLevel,
|
|
538
|
+
taskLevel,
|
|
539
|
+
durationMs: Date.now() - start,
|
|
540
|
+
});
|
|
541
|
+
return { passed: true, details: { conclusions: classified } };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const directive = renderDirective(misses);
|
|
545
|
+
const findingSummary = misses.slice(0, 5).map(m => `${m.kind}: ${m.excerpt.slice(0, 80)}`);
|
|
546
|
+
|
|
547
|
+
if (!blockOnMiss) {
|
|
548
|
+
// Soft mode: log bypass, warn, but pass.
|
|
549
|
+
try {
|
|
550
|
+
const { trackBypassAttempt } = require('./flow-session-state');
|
|
551
|
+
trackBypassAttempt({
|
|
552
|
+
taskId,
|
|
553
|
+
operation: 'capture-gate',
|
|
554
|
+
reason: `${missCount} conclusion(s) not captured (blockOnMiss=false)`,
|
|
555
|
+
filePath: misses.map(m => m.targetFile).join(','),
|
|
556
|
+
});
|
|
557
|
+
} catch (_err) {
|
|
558
|
+
/* no-op */
|
|
559
|
+
}
|
|
560
|
+
if (ctx?.warn) {
|
|
561
|
+
ctx.warn(`captureGate — soft mode (${missCount}/${detected} conclusions missing)`);
|
|
562
|
+
for (const m of misses.slice(0, 5)) {
|
|
563
|
+
console.log(
|
|
564
|
+
ctx.color
|
|
565
|
+
? ctx.color('dim', ` - ${m.kind}: ${m.excerpt.slice(0, 100)} → ${m.targetFile}`)
|
|
566
|
+
: ` - ${m.kind}: ${m.excerpt.slice(0, 100)} → ${m.targetFile}`
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
recordTelemetry('CONCERN', {
|
|
571
|
+
taskId,
|
|
572
|
+
detected,
|
|
573
|
+
written,
|
|
574
|
+
missCount,
|
|
575
|
+
blockOnMiss: false,
|
|
576
|
+
minLevel,
|
|
577
|
+
taskLevel,
|
|
578
|
+
findingSummary,
|
|
579
|
+
durationMs: Date.now() - start,
|
|
580
|
+
});
|
|
581
|
+
return { passed: true, details: { conclusions: classified, directive } };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// FAIL (block)
|
|
585
|
+
if (ctx?.error) {
|
|
586
|
+
ctx.error(`captureGate (${missCount}/${detected} durable conclusions not captured)`);
|
|
587
|
+
}
|
|
588
|
+
recordTelemetry('FAIL', {
|
|
589
|
+
taskId,
|
|
590
|
+
detected,
|
|
591
|
+
written,
|
|
592
|
+
missCount,
|
|
593
|
+
blockOnMiss: true,
|
|
594
|
+
minLevel,
|
|
595
|
+
taskLevel,
|
|
596
|
+
findingSummary,
|
|
597
|
+
durationMs: Date.now() - start,
|
|
598
|
+
});
|
|
599
|
+
return {
|
|
600
|
+
passed: false,
|
|
601
|
+
errorOutput: directive,
|
|
602
|
+
details: { conclusions: classified, directive },
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ============================================================================
|
|
607
|
+
// Exports
|
|
608
|
+
// ============================================================================
|
|
609
|
+
|
|
610
|
+
module.exports = {
|
|
611
|
+
captureGate,
|
|
612
|
+
isGateDisabled,
|
|
613
|
+
verifyConclusionWrite,
|
|
614
|
+
renderDirective,
|
|
615
|
+
KNOWN_TARGETS,
|
|
616
|
+
// Private helpers exposed for tests
|
|
617
|
+
_levelIsBelowMin: levelIsBelowMin,
|
|
618
|
+
_normalizeForMatch: normalizeForMatch,
|
|
619
|
+
_getChangedStateFiles: getChangedStateFiles,
|
|
620
|
+
_getAddedLinesForFile: getAddedLinesForFile,
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
// ============================================================================
|
|
624
|
+
// CLI for smoke-testing
|
|
625
|
+
// ============================================================================
|
|
626
|
+
|
|
627
|
+
if (require.main === module) {
|
|
628
|
+
const [, , cmd, taskArg] = process.argv;
|
|
629
|
+
if (cmd === 'smoke') {
|
|
630
|
+
const ctx = {
|
|
631
|
+
taskId: taskArg || process.env.WOGI_TASK_ID || null,
|
|
632
|
+
config: getConfig(),
|
|
633
|
+
color: (_c, s) => s,
|
|
634
|
+
success: s => console.log(`PASS: ${s}`),
|
|
635
|
+
warn: s => console.log(`WARN: ${s}`),
|
|
636
|
+
error: s => console.log(`FAIL: ${s}`),
|
|
637
|
+
};
|
|
638
|
+
const res = captureGate(ctx);
|
|
639
|
+
console.log('\n--- result ---');
|
|
640
|
+
console.log(JSON.stringify(res, null, 2));
|
|
641
|
+
} else {
|
|
642
|
+
console.log('Usage: node scripts/flow-capture-gate.js smoke [taskId]');
|
|
643
|
+
}
|
|
644
|
+
}
|
package/scripts/flow-capture.js
CHANGED
|
@@ -23,7 +23,8 @@ const {
|
|
|
23
23
|
outputJson,
|
|
24
24
|
success,
|
|
25
25
|
error,
|
|
26
|
-
getConfig
|
|
26
|
+
getConfig,
|
|
27
|
+
getTodayDate
|
|
27
28
|
} = require('./flow-utils');
|
|
28
29
|
|
|
29
30
|
// Try to load session state for auto-detecting current task
|
|
@@ -31,9 +32,9 @@ let loadSessionState;
|
|
|
31
32
|
try {
|
|
32
33
|
const sessionModule = require('./flow-session-state');
|
|
33
34
|
loadSessionState = sessionModule.loadSessionState;
|
|
34
|
-
} catch (
|
|
35
|
+
} catch (err) {
|
|
35
36
|
if (process.env.DEBUG) {
|
|
36
|
-
console.warn(`[DEBUG] Could not load flow-session-state: ${
|
|
37
|
+
console.warn(`[DEBUG] Could not load flow-session-state: ${err.message}`);
|
|
37
38
|
}
|
|
38
39
|
loadSessionState = () => ({});
|
|
39
40
|
}
|