wogiflow 2.29.2 → 2.29.4
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/docs/intent-grounded-reasoning.md +1 -1
- package/.workflow/templates/partials/methodology-rules.hbs +30 -1
- package/lib/commands/team-connection.js +5 -28
- package/lib/utils.js +12 -26
- package/lib/wogi-claude +40 -1
- package/lib/workspace-channel-server.js +21 -0
- package/lib/workspace-channel-tracking.js +125 -0
- package/lib/workspace.js +6 -13
- package/package.json +2 -2
- package/scripts/flow +4 -0
- package/scripts/flow-autonomous-detector.js +29 -4
- package/scripts/flow-autonomous-mode.js +27 -7
- package/scripts/flow-completion-summary.js +2 -16
- package/scripts/flow-id.js +31 -0
- package/scripts/flow-io.js +78 -0
- package/scripts/flow-long-input-pending.js +110 -0
- package/scripts/flow-long-input-stories.js +8 -0
- package/scripts/flow-orchestrate.js +16 -10
- package/scripts/flow-question-queue.js +73 -7
- package/scripts/flow-scanner-base.js +77 -1
- package/scripts/flow-session-state.js +47 -0
- package/scripts/flow-source-fidelity.js +279 -0
- package/scripts/flow-time-format.js +42 -0
- package/scripts/flow-utils.js +3 -16
- package/scripts/flow-worker-mcp-strip.js +12 -11
- package/scripts/flow-workspace-summary.js +38 -19
- package/scripts/hooks/adapters/claude-code.js +7 -4
- package/scripts/hooks/core/long-input-enforcement.js +311 -0
- package/scripts/hooks/core/pre-tool-deps.js +185 -0
- package/scripts/hooks/core/pre-tool-orchestrator.js +22 -0
- package/scripts/hooks/core/session-context.js +26 -0
- package/scripts/hooks/core/task-boundary-reset.js +13 -0
- package/scripts/hooks/core/worker-boundary-gate.js +67 -16
- package/scripts/hooks/entry/claude-code/pre-tool-use.js +21 -95
- package/scripts/hooks/entry/claude-code/user-prompt-submit.js +33 -0
package/scripts/flow-io.js
CHANGED
|
@@ -246,6 +246,82 @@ function safeJsonParseString(jsonString, defaultValue = null) {
|
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Recursively strip prototype-pollution keys from a parsed object/array.
|
|
251
|
+
* Mutates in place; returns the same reference. Use when the caller wants
|
|
252
|
+
* to filter dangerous content rather than reject the whole payload.
|
|
253
|
+
*
|
|
254
|
+
* Sibling to checkForDangerousKeys (which DETECTS without modifying). This
|
|
255
|
+
* is the strip variant used by lib/* JSON parsers that want to keep
|
|
256
|
+
* structurally-valid content but defang any __proto__/constructor/prototype
|
|
257
|
+
* keys nested anywhere in the tree.
|
|
258
|
+
*/
|
|
259
|
+
// Sentinel returned when stripDangerousKeys hits the depth cap. Distinct from
|
|
260
|
+
// `null` (legitimate JSON value) so callers can distinguish "hit the cap" from
|
|
261
|
+
// "successfully scrubbed null".
|
|
262
|
+
const STRIP_TOO_DEEP = Object.freeze({ __wogiTooDeep: true });
|
|
263
|
+
|
|
264
|
+
const STRIP_MAX_DEPTH = 256;
|
|
265
|
+
|
|
266
|
+
function stripDangerousKeys(value, depth = 0) {
|
|
267
|
+
// SEC-001 fix (2026-04-26): bound recursion AND fail-safe at the cap.
|
|
268
|
+
// Previous impl returned the partially-stripped value, which left dangerous
|
|
269
|
+
// keys live in subtrees past depth 32 — caller could then merge them and
|
|
270
|
+
// pollute Object.prototype. New behavior: return STRIP_TOO_DEEP sentinel so
|
|
271
|
+
// safeJsonParseStringStrip can fall back to defaultValue. Cap raised from
|
|
272
|
+
// 32 → 256 so legitimate nesting never trips it.
|
|
273
|
+
if (depth > STRIP_MAX_DEPTH) return STRIP_TOO_DEEP;
|
|
274
|
+
if (!value || typeof value !== 'object') return value;
|
|
275
|
+
if (Array.isArray(value)) {
|
|
276
|
+
for (let i = 0; i < value.length; i++) {
|
|
277
|
+
const r = stripDangerousKeys(value[i], depth + 1);
|
|
278
|
+
if (r === STRIP_TOO_DEEP) return STRIP_TOO_DEEP;
|
|
279
|
+
}
|
|
280
|
+
return value;
|
|
281
|
+
}
|
|
282
|
+
for (const key of Object.getOwnPropertyNames(value)) {
|
|
283
|
+
if (DANGEROUS_KEYS.has(key)) {
|
|
284
|
+
delete value[key];
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const r = stripDangerousKeys(value[key], depth + 1);
|
|
288
|
+
if (r === STRIP_TOO_DEEP) return STRIP_TOO_DEEP;
|
|
289
|
+
}
|
|
290
|
+
return value;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Parse a JSON string and STRIP any prototype-pollution keys recursively.
|
|
295
|
+
* Returns the sanitized parsed object (or defaultValue on parse error).
|
|
296
|
+
*
|
|
297
|
+
* Differs from safeJsonParseString: that function REJECTS the whole payload
|
|
298
|
+
* if dangerous keys are present (returns defaultValue). This function
|
|
299
|
+
* returns the parsed object with dangerous keys removed. Pick based on
|
|
300
|
+
* threat model:
|
|
301
|
+
* - reject (safeJsonParseString) — fail-loud, refuse hostile content
|
|
302
|
+
* - strip (safeJsonParseStringStrip) — fail-soft, sanitize and proceed
|
|
303
|
+
*
|
|
304
|
+
* Added as part of audit dup-004 consolidation (2026-04-26): unifies the
|
|
305
|
+
* lib/utils.safeJsonParseContent / lib/workspace.safeParseJson /
|
|
306
|
+
* lib/commands/team-connection.safeParseJson trio under a single canonical
|
|
307
|
+
* helper. Preserves the lib/* "strip and proceed" semantic.
|
|
308
|
+
*
|
|
309
|
+
* @param {string} jsonString
|
|
310
|
+
* @param {*} [defaultValue=null]
|
|
311
|
+
* @returns {object|Array|*} sanitized parsed value, or defaultValue
|
|
312
|
+
*/
|
|
313
|
+
function safeJsonParseStringStrip(jsonString, defaultValue = null) {
|
|
314
|
+
try {
|
|
315
|
+
const parsed = JSON.parse(jsonString);
|
|
316
|
+
if (typeof parsed !== 'object' || parsed === null) return defaultValue;
|
|
317
|
+
const stripped = stripDangerousKeys(parsed);
|
|
318
|
+
if (stripped === STRIP_TOO_DEEP) return defaultValue;
|
|
319
|
+
return stripped;
|
|
320
|
+
} catch (_err) {
|
|
321
|
+
return defaultValue;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
249
325
|
// ============================================================
|
|
250
326
|
// Text File Operations
|
|
251
327
|
// ============================================================
|
|
@@ -694,6 +770,8 @@ module.exports = {
|
|
|
694
770
|
writeJson,
|
|
695
771
|
safeJsonParse,
|
|
696
772
|
safeJsonParseString,
|
|
773
|
+
safeJsonParseStringStrip,
|
|
774
|
+
stripDangerousKeys,
|
|
697
775
|
|
|
698
776
|
// Text File Operations
|
|
699
777
|
readFile,
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Wogi Flow — long-input-pending CLI
|
|
7
|
+
*
|
|
8
|
+
* Subcommands:
|
|
9
|
+
* status — show whether the marker is set + payload
|
|
10
|
+
* dismiss [--reason="<text>"] — clear the marker after the AI / user has
|
|
11
|
+
* decided this prompt does NOT create work
|
|
12
|
+
* (escape hatch for the P11.6 gate)
|
|
13
|
+
*
|
|
14
|
+
* The marker file is written by user-prompt-submit when a long-form prompt
|
|
15
|
+
* arrives without a source-link. The PreToolUse `checkLongInputPendingGate`
|
|
16
|
+
* (long-input-enforcement.js) consults it and blocks Edit/Write/Bash/Skill
|
|
17
|
+
* (with a small allow-list) until either /wogi-extract-review runs or this
|
|
18
|
+
* dispatcher's `dismiss` command clears it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
const { PATHS } = require('./flow-utils');
|
|
24
|
+
const {
|
|
25
|
+
isLongInputPending,
|
|
26
|
+
readLongInputPending,
|
|
27
|
+
clearLongInputPending,
|
|
28
|
+
PENDING_PATH
|
|
29
|
+
} = require('./hooks/core/long-input-enforcement');
|
|
30
|
+
|
|
31
|
+
const DISMISS_LOG = path.join(PATHS.state, 'long-input-pending-dismiss.log');
|
|
32
|
+
|
|
33
|
+
function showStatus() {
|
|
34
|
+
if (!isLongInputPending()) {
|
|
35
|
+
process.stdout.write('long-input-pending: not set\n');
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
const payload = readLongInputPending() || {};
|
|
39
|
+
process.stdout.write('long-input-pending: SET\n');
|
|
40
|
+
process.stdout.write(` marker: ${PENDING_PATH}\n`);
|
|
41
|
+
process.stdout.write(` level: ${payload.level || 'unknown'}\n`);
|
|
42
|
+
process.stdout.write(` reason: ${payload.reason || 'unknown'}\n`);
|
|
43
|
+
process.stdout.write(` marked: ${payload.markedAt || 'unknown'}\n`);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function dismiss(args) {
|
|
48
|
+
if (!isLongInputPending()) {
|
|
49
|
+
process.stdout.write('long-input-pending: nothing to dismiss (marker not set)\n');
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
const reasonArg = args.find(a => a.startsWith('--reason='));
|
|
53
|
+
const reason = reasonArg ? reasonArg.slice('--reason='.length).replace(/^['"]|['"]$/g, '').trim() : '';
|
|
54
|
+
if (!reason) {
|
|
55
|
+
process.stderr.write([
|
|
56
|
+
'Usage: flow long-input-pending dismiss --reason="<concrete reason>"',
|
|
57
|
+
'',
|
|
58
|
+
'A reason is required so the dismissal is auditable. Examples:',
|
|
59
|
+
' --reason="log dump, no work created"',
|
|
60
|
+
' --reason="verbatim error trace, already linked to wf-12345678"',
|
|
61
|
+
' --reason="conversational question, not work-creating"'
|
|
62
|
+
].join('\n') + '\n');
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
const payload = readLongInputPending() || {};
|
|
66
|
+
clearLongInputPending();
|
|
67
|
+
try {
|
|
68
|
+
fs.mkdirSync(path.dirname(DISMISS_LOG), { recursive: true });
|
|
69
|
+
fs.appendFileSync(DISMISS_LOG, JSON.stringify({
|
|
70
|
+
dismissedAt: new Date().toISOString(),
|
|
71
|
+
reason,
|
|
72
|
+
markerPayload: payload
|
|
73
|
+
}) + '\n');
|
|
74
|
+
} catch (_err) { /* best-effort log */ }
|
|
75
|
+
process.stdout.write('long-input-pending: dismissed\n');
|
|
76
|
+
process.stdout.write(` reason: ${reason}\n`);
|
|
77
|
+
process.stdout.write(` logged: ${DISMISS_LOG}\n`);
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function showHelp() {
|
|
82
|
+
process.stdout.write([
|
|
83
|
+
'Usage: flow long-input-pending <subcommand> [options]',
|
|
84
|
+
'',
|
|
85
|
+
'Subcommands:',
|
|
86
|
+
' status Show whether the P11.6 long-input-pending marker is set',
|
|
87
|
+
' dismiss --reason="<text>" Clear the marker after deciding the prompt does',
|
|
88
|
+
' NOT create work. A reason is required and is',
|
|
89
|
+
' appended to .workflow/state/long-input-pending-dismiss.log',
|
|
90
|
+
' for telemetry/learning.',
|
|
91
|
+
' help Show this help',
|
|
92
|
+
''
|
|
93
|
+
].join('\n'));
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const [, , sub, ...rest] = process.argv;
|
|
98
|
+
let exitCode = 0;
|
|
99
|
+
switch (sub) {
|
|
100
|
+
case 'status': exitCode = showStatus(); break;
|
|
101
|
+
case 'dismiss': exitCode = dismiss(rest); break;
|
|
102
|
+
case 'help':
|
|
103
|
+
case '--help':
|
|
104
|
+
case '-h':
|
|
105
|
+
case undefined: exitCode = showHelp(); break;
|
|
106
|
+
default:
|
|
107
|
+
process.stderr.write(`Unknown subcommand: ${sub}\nRun 'flow long-input-pending help' for usage.\n`);
|
|
108
|
+
exitCode = 2;
|
|
109
|
+
}
|
|
110
|
+
process.exit(exitCode);
|
|
@@ -2099,6 +2099,14 @@ async function finalizeDigestion(options = {}) {
|
|
|
2099
2099
|
cleanupResult = cleanupTempFiles(activeDigest.session.digest_id);
|
|
2100
2100
|
}
|
|
2101
2101
|
|
|
2102
|
+
// 7. Clear the P11.6 long-input-pending marker — extraction is the
|
|
2103
|
+
// documented "way out" of the gate, so finalize-success means the
|
|
2104
|
+
// forcing reason is now resolved. Best-effort: a missing marker is
|
|
2105
|
+
// expected when finalize was triggered without the gate firing.
|
|
2106
|
+
try {
|
|
2107
|
+
require('./hooks/core/long-input-enforcement').clearLongInputPending();
|
|
2108
|
+
} catch (_err) { /* best-effort */ }
|
|
2109
|
+
|
|
2102
2110
|
return {
|
|
2103
2111
|
success: true,
|
|
2104
2112
|
approved_count: exportResult.summary.total_approved,
|
|
@@ -314,17 +314,23 @@ function autoCorrectCode(code, filePath, projectConfig = null) {
|
|
|
314
314
|
// legacy CLI bootstrap at the bottom of this file.
|
|
315
315
|
const c = require('./flow-orchestrate-corrections');
|
|
316
316
|
|
|
317
|
+
// CL-007 fix (2026-04-26): non-closure data-driven form. The previous
|
|
318
|
+
// array-of-closure form worked correctly under sequential iteration but
|
|
319
|
+
// would silently break under any future parallel/lazy execution because
|
|
320
|
+
// each closure captured `corrected` by reference. Using a plain
|
|
321
|
+
// step-name + args list is equally readable and closure-trap-free.
|
|
317
322
|
let corrected = code;
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
]
|
|
327
|
-
|
|
323
|
+
const steps = [
|
|
324
|
+
['fixForbiddenImports', [ctx.doNotImport]],
|
|
325
|
+
['fixComponentPaths', [ctx.componentPaths]],
|
|
326
|
+
['fixFeatureTypePaths', [filePath, ctx.typePaths]],
|
|
327
|
+
['fixNoExternalUtils', [ctx]],
|
|
328
|
+
['normalizeQuotes', []],
|
|
329
|
+
['cleanupEmptyImports', []],
|
|
330
|
+
['collapseBlankLines', []]
|
|
331
|
+
];
|
|
332
|
+
for (const [fn, args] of steps) {
|
|
333
|
+
const r = c[fn](corrected, ...args);
|
|
328
334
|
corrected = r.corrected;
|
|
329
335
|
if (r.corrections.length) corrections.push(...r.corrections);
|
|
330
336
|
}
|
|
@@ -26,10 +26,17 @@
|
|
|
26
26
|
const path = require('node:path');
|
|
27
27
|
const fs = require('node:fs');
|
|
28
28
|
const { PATHS } = require('./flow-paths');
|
|
29
|
-
const { readJson, writeJson } = require('./flow-io');
|
|
29
|
+
const { readJson, writeJson, withLock } = require('./flow-io');
|
|
30
30
|
|
|
31
31
|
const QUEUE_PATH = path.join(PATHS.state, 'question-queue.json');
|
|
32
32
|
|
|
33
|
+
// SEC-004 caps (2026-04-26): bound disk consumption for buggy classifier
|
|
34
|
+
// over-flag and prompt-injection that intentionally generates many questions.
|
|
35
|
+
// Overflow rotates the current queue to an archive file, never silently drops.
|
|
36
|
+
const MAX_QUESTIONS_PER_FILE = 100;
|
|
37
|
+
const MAX_QUESTION_TEXT_BYTES = 4 * 1024; // 4 KB per question text
|
|
38
|
+
const MAX_QUEUE_FILE_BYTES = 1 * 1024 * 1024; // 1 MB total queue file
|
|
39
|
+
|
|
33
40
|
function emptyQueue() {
|
|
34
41
|
return { questions: [], skippedTasks: [] };
|
|
35
42
|
}
|
|
@@ -52,6 +59,29 @@ function saveQueue(data) {
|
|
|
52
59
|
return data;
|
|
53
60
|
}
|
|
54
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Rotate the current queue to a timestamped archive file, then return an
|
|
64
|
+
* empty queue. Used when overflow caps are hit (SEC-004).
|
|
65
|
+
*/
|
|
66
|
+
function rotateQueue() {
|
|
67
|
+
try {
|
|
68
|
+
if (!fs.existsSync(QUEUE_PATH)) return emptyQueue();
|
|
69
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
70
|
+
const archivePath = path.join(PATHS.state, `question-queue-archive-${ts}.json`);
|
|
71
|
+
fs.renameSync(QUEUE_PATH, archivePath);
|
|
72
|
+
} catch (_err) { /* best-effort archive */ }
|
|
73
|
+
return emptyQueue();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function truncateText(text) {
|
|
77
|
+
if (typeof text !== 'string') return '';
|
|
78
|
+
const buf = Buffer.from(text, 'utf-8');
|
|
79
|
+
if (buf.byteLength <= MAX_QUESTION_TEXT_BYTES) return text;
|
|
80
|
+
// Truncate by bytes, then drop last char to avoid mid-codepoint cuts.
|
|
81
|
+
const truncated = buf.slice(0, MAX_QUESTION_TEXT_BYTES - 1).toString('utf-8');
|
|
82
|
+
return truncated.replace(/.$/, '') + '… [truncated]';
|
|
83
|
+
}
|
|
84
|
+
|
|
55
85
|
function clearQueue() {
|
|
56
86
|
try {
|
|
57
87
|
if (fs.existsSync(QUEUE_PATH)) fs.unlinkSync(QUEUE_PATH);
|
|
@@ -74,10 +104,18 @@ function shortId() {
|
|
|
74
104
|
*/
|
|
75
105
|
function addQuestion(q) {
|
|
76
106
|
if (!q || !q.text) throw new Error('addQuestion: text is required');
|
|
77
|
-
|
|
107
|
+
// SEC-004: cap text size, count, file size with archive rotation on overflow
|
|
108
|
+
const text = truncateText(String(q.text));
|
|
109
|
+
let queue = loadQueue();
|
|
110
|
+
|
|
111
|
+
// Count cap — rotate before append if at limit
|
|
112
|
+
if (queue.questions.length >= MAX_QUESTIONS_PER_FILE) {
|
|
113
|
+
queue = rotateQueue();
|
|
114
|
+
}
|
|
115
|
+
|
|
78
116
|
const entry = {
|
|
79
117
|
id: `q-${shortId()}`,
|
|
80
|
-
text
|
|
118
|
+
text,
|
|
81
119
|
classifiedBucket: q.classifiedBucket || null,
|
|
82
120
|
taskContext: q.taskContext || null,
|
|
83
121
|
dependencies: Array.isArray(q.dependencies) ? q.dependencies : [],
|
|
@@ -86,10 +124,27 @@ function addQuestion(q) {
|
|
|
86
124
|
answered: false
|
|
87
125
|
};
|
|
88
126
|
queue.questions.push(entry);
|
|
127
|
+
|
|
128
|
+
// File-size cap (defense-in-depth — covers degenerate per-question payloads).
|
|
129
|
+
const candidate = JSON.stringify(queue);
|
|
130
|
+
if (Buffer.byteLength(candidate, 'utf-8') > MAX_QUEUE_FILE_BYTES) {
|
|
131
|
+
queue = rotateQueue();
|
|
132
|
+
queue.questions.push(entry);
|
|
133
|
+
}
|
|
134
|
+
|
|
89
135
|
saveQueue(queue);
|
|
90
136
|
return entry;
|
|
91
137
|
}
|
|
92
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Async variant of addQuestion that holds an inter-process lock around the
|
|
141
|
+
* read-modify-write cycle (CL-002 fix). Prefer this in concurrent contexts
|
|
142
|
+
* (autonomous worker + manager dispatch handler running in parallel).
|
|
143
|
+
*/
|
|
144
|
+
async function addQuestionAsync(q) {
|
|
145
|
+
return withLock(QUEUE_PATH, async () => addQuestion(q));
|
|
146
|
+
}
|
|
147
|
+
|
|
93
148
|
/**
|
|
94
149
|
* Mark a task as skipped, recording the reason and (optionally) the question
|
|
95
150
|
* blocking it.
|
|
@@ -113,12 +168,17 @@ function skipTask({ taskId, reason, blockingQuestionId } = {}) {
|
|
|
113
168
|
return record;
|
|
114
169
|
}
|
|
115
170
|
|
|
171
|
+
async function skipTaskAsync(args) {
|
|
172
|
+
return withLock(QUEUE_PATH, async () => skipTask(args));
|
|
173
|
+
}
|
|
174
|
+
|
|
116
175
|
/**
|
|
117
176
|
* Conservative dependency classifier — text-match only.
|
|
118
|
-
* AI classifier
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
177
|
+
* AI classifier integration is handled by `classifyDependenciesSafe(text,
|
|
178
|
+
* tasks, aiClassifier)` below — callers pass an injected classifier
|
|
179
|
+
* function. This keeps the hot path classifier-free for tests and for
|
|
180
|
+
* environments without Anthropic credentials. (CL-008 fix 2026-04-26 —
|
|
181
|
+
* removed misleading reference to a non-existent classifyDependenciesWithAi.)
|
|
122
182
|
*
|
|
123
183
|
* Rules:
|
|
124
184
|
* 1. Exact task ID match (wf-XXXXXXXX) → flag dependency.
|
|
@@ -216,12 +276,18 @@ function listSkippedTasks() {
|
|
|
216
276
|
|
|
217
277
|
module.exports = {
|
|
218
278
|
QUEUE_PATH,
|
|
279
|
+
MAX_QUESTIONS_PER_FILE,
|
|
280
|
+
MAX_QUESTION_TEXT_BYTES,
|
|
281
|
+
MAX_QUEUE_FILE_BYTES,
|
|
219
282
|
emptyQueue,
|
|
220
283
|
loadQueue,
|
|
221
284
|
saveQueue,
|
|
285
|
+
rotateQueue,
|
|
222
286
|
clearQueue,
|
|
223
287
|
addQuestion,
|
|
288
|
+
addQuestionAsync,
|
|
224
289
|
skipTask,
|
|
290
|
+
skipTaskAsync,
|
|
225
291
|
classifyDependencies,
|
|
226
292
|
classifyDependenciesSafe,
|
|
227
293
|
unionDependencies,
|
|
@@ -387,9 +387,63 @@ class BaseScanner {
|
|
|
387
387
|
}
|
|
388
388
|
});
|
|
389
389
|
|
|
390
|
-
// Pass 2: Collect all exported names
|
|
390
|
+
// Pass 2: Collect all exported names (handles BOTH ESM `export` and
|
|
391
|
+
// CommonJS `module.exports`/`exports` patterns).
|
|
392
|
+
// arch-005 fix (2026-04-26): the original Babel scanner only handled
|
|
393
|
+
// ESM. wogi-flow's source is CommonJS, so all exports were invisible
|
|
394
|
+
// and function-map.md was empty after every scan.
|
|
391
395
|
const exported = new Map();
|
|
392
396
|
|
|
397
|
+
// CJS helper: handle `module.exports = { ... }` and
|
|
398
|
+
// `module.exports.x = ...` and `exports.x = ...`.
|
|
399
|
+
const handleCjsExportAssignment = (assignNode) => {
|
|
400
|
+
const left = assignNode.left;
|
|
401
|
+
const right = assignNode.right;
|
|
402
|
+
if (!left || !right) return;
|
|
403
|
+
|
|
404
|
+
// Pattern: module.exports = { foo, bar, baz }
|
|
405
|
+
// or: exports = { foo, bar, baz } (rare)
|
|
406
|
+
const isModuleExports =
|
|
407
|
+
left.type === 'MemberExpression' &&
|
|
408
|
+
left.object?.name === 'module' &&
|
|
409
|
+
left.property?.name === 'exports' &&
|
|
410
|
+
!left.computed;
|
|
411
|
+
const isBareExports =
|
|
412
|
+
left.type === 'Identifier' && left.name === 'exports';
|
|
413
|
+
|
|
414
|
+
if ((isModuleExports || isBareExports) && right.type === 'ObjectExpression') {
|
|
415
|
+
for (const prop of right.properties) {
|
|
416
|
+
if (prop.type === 'ObjectProperty' || prop.type === 'Property') {
|
|
417
|
+
// Shorthand: { foo } → key = foo (identifier), value = foo
|
|
418
|
+
// Long form: { foo: bar } → key = foo, value = bar (identifier)
|
|
419
|
+
const keyName = prop.key?.name || prop.key?.value;
|
|
420
|
+
if (typeof keyName === 'string' && keyName) {
|
|
421
|
+
exported.set(keyName, { isDefault: false });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Pattern: module.exports.foo = bar
|
|
429
|
+
// exports.foo = bar
|
|
430
|
+
// (left is MemberExpression where the property is the export name)
|
|
431
|
+
const isModuleExportsDotX =
|
|
432
|
+
left.type === 'MemberExpression' &&
|
|
433
|
+
left.object?.type === 'MemberExpression' &&
|
|
434
|
+
left.object.object?.name === 'module' &&
|
|
435
|
+
left.object.property?.name === 'exports';
|
|
436
|
+
const isExportsDotX =
|
|
437
|
+
left.type === 'MemberExpression' &&
|
|
438
|
+
left.object?.name === 'exports';
|
|
439
|
+
if (isModuleExportsDotX || isExportsDotX) {
|
|
440
|
+
const name = left.property?.name;
|
|
441
|
+
if (typeof name === 'string' && name) {
|
|
442
|
+
exported.set(name, { isDefault: false });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
|
|
393
447
|
this.traverse(ast, {
|
|
394
448
|
ExportNamedDeclaration: (nodePath) => {
|
|
395
449
|
const decl = nodePath.node.declaration;
|
|
@@ -415,6 +469,10 @@ class BaseScanner {
|
|
|
415
469
|
} else if (decl.type === 'Identifier') {
|
|
416
470
|
exported.set(decl.name, { isDefault: true });
|
|
417
471
|
}
|
|
472
|
+
},
|
|
473
|
+
// CommonJS exports — `module.exports = { ... }`, `exports.x = ...`
|
|
474
|
+
AssignmentExpression: (nodePath) => {
|
|
475
|
+
handleCjsExportAssignment(nodePath.node);
|
|
418
476
|
}
|
|
419
477
|
});
|
|
420
478
|
|
|
@@ -456,6 +514,24 @@ class BaseScanner {
|
|
|
456
514
|
}
|
|
457
515
|
}
|
|
458
516
|
|
|
517
|
+
// CommonJS: module.exports = { foo, bar, baz }
|
|
518
|
+
// arch-005 fix (2026-04-26): regex fallback was ESM-only, missing all
|
|
519
|
+
// CJS exports. Same gap as the Babel scanner had.
|
|
520
|
+
const cjsObjectExportRegex = /module\.exports\s*=\s*\{([^}]+)\}/g;
|
|
521
|
+
while ((match = cjsObjectExportRegex.exec(content)) !== null) {
|
|
522
|
+
const specifiers = match[1].split(',');
|
|
523
|
+
for (const spec of specifiers) {
|
|
524
|
+
const name = spec.trim().split(/[:\s]/)[0].trim();
|
|
525
|
+
if (name && /^[a-zA-Z_$][\w$]*$/.test(name)) exported.add(name);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// CommonJS: module.exports.foo = ... or exports.foo = ...
|
|
530
|
+
const cjsDotExportRegex = /(?:module\.exports|^exports)\s*\.\s*([a-zA-Z_$][\w$]*)\s*=/gm;
|
|
531
|
+
while ((match = cjsDotExportRegex.exec(content)) !== null) {
|
|
532
|
+
exported.add(match[1]);
|
|
533
|
+
}
|
|
534
|
+
|
|
459
535
|
return exported;
|
|
460
536
|
}
|
|
461
537
|
|
|
@@ -986,6 +986,18 @@ function rehydrateAutonomousFromDisk() {
|
|
|
986
986
|
return { hydrated: Boolean(mode && mode.active), mode };
|
|
987
987
|
}
|
|
988
988
|
|
|
989
|
+
/**
|
|
990
|
+
* Increment the shared adversary-invocation counter.
|
|
991
|
+
*
|
|
992
|
+
* NOTE on concurrency (CL-005, 2026-04-26): the sync variant is the original
|
|
993
|
+
* single-flight call site. It performs read-modify-write through the cache
|
|
994
|
+
* + saveSessionState, which itself uses atomic writeJson. Two parallel
|
|
995
|
+
* invocations on the same session would race (both read used=N, both write
|
|
996
|
+
* N+1, last writer wins, cap bypassed by 1). This is acceptable in
|
|
997
|
+
* single-process autonomous-mode runs, but parallel sub-agent contexts MUST
|
|
998
|
+
* use `incrementAdversaryInvocationAsync` which holds an inter-process lock
|
|
999
|
+
* and re-reads from disk inside the lock to defeat cache staleness.
|
|
1000
|
+
*/
|
|
989
1001
|
function incrementAdversaryInvocation(source = 'manual') {
|
|
990
1002
|
const mode = getAutonomousMode();
|
|
991
1003
|
if (!mode || !mode.active) return { allowed: true, used: 0, cap: 0 };
|
|
@@ -1002,6 +1014,40 @@ function incrementAdversaryInvocation(source = 'manual') {
|
|
|
1002
1014
|
return { allowed: used <= cap, used, cap };
|
|
1003
1015
|
}
|
|
1004
1016
|
|
|
1017
|
+
/**
|
|
1018
|
+
* Concurrency-safe variant of incrementAdversaryInvocation. Wraps the
|
|
1019
|
+
* read-modify-write in withLock(SESSION_PATH) and bypasses the in-process
|
|
1020
|
+
* cache during the lock window so the value read is always disk-fresh.
|
|
1021
|
+
* Use this from any context that may run in parallel with another adversary
|
|
1022
|
+
* invocation (parallel sub-agents, IGR + manual /wogi-challenge co-firing).
|
|
1023
|
+
*/
|
|
1024
|
+
async function incrementAdversaryInvocationAsync(source = 'manual') {
|
|
1025
|
+
return withLock(SESSION_PATH, async () => {
|
|
1026
|
+
// Read directly from disk, bypassing the cache
|
|
1027
|
+
const fresh = loadSessionState();
|
|
1028
|
+
const mode = fresh.autonomousMode;
|
|
1029
|
+
if (!mode || !mode.active) return { allowed: true, used: 0, cap: 0 };
|
|
1030
|
+
const cap = getAutonomousConfig().maxAdversaryInvocations;
|
|
1031
|
+
const used = (mode.adversaryInvocations?.used ?? 0) + 1;
|
|
1032
|
+
const breakdown = { ...(mode.adversaryInvocations?.breakdown || {}) };
|
|
1033
|
+
const key = source === 'igr' ? 'igrArchitect'
|
|
1034
|
+
: source === 'lowConfidence' ? 'autonomousLowConfidence'
|
|
1035
|
+
: 'manual';
|
|
1036
|
+
breakdown[key] = (breakdown[key] || 0) + 1;
|
|
1037
|
+
const updated = { ...mode, adversaryInvocations: { used, breakdown } };
|
|
1038
|
+
// Write atomically via writeJson (already atomic, lock prevents
|
|
1039
|
+
// overlapping read-modify-write).
|
|
1040
|
+
const newState = {
|
|
1041
|
+
...fresh,
|
|
1042
|
+
autonomousMode: updated,
|
|
1043
|
+
lastActive: new Date().toISOString()
|
|
1044
|
+
};
|
|
1045
|
+
writeJson(SESSION_PATH, newState);
|
|
1046
|
+
autonomousModeCache = updated;
|
|
1047
|
+
return { allowed: used <= cap, used, cap };
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1005
1051
|
function _resetAutonomousCacheForTests() {
|
|
1006
1052
|
autonomousModeCache = undefined;
|
|
1007
1053
|
}
|
|
@@ -1078,6 +1124,7 @@ module.exports = {
|
|
|
1078
1124
|
isAutonomousStale,
|
|
1079
1125
|
rehydrateAutonomousFromDisk,
|
|
1080
1126
|
incrementAdversaryInvocation,
|
|
1127
|
+
incrementAdversaryInvocationAsync,
|
|
1081
1128
|
getAutonomousConfig,
|
|
1082
1129
|
_resetAutonomousCacheForTests,
|
|
1083
1130
|
|