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.
Files changed (35) hide show
  1. package/.claude/docs/intent-grounded-reasoning.md +1 -1
  2. package/.workflow/templates/partials/methodology-rules.hbs +30 -1
  3. package/lib/commands/team-connection.js +5 -28
  4. package/lib/utils.js +12 -26
  5. package/lib/wogi-claude +40 -1
  6. package/lib/workspace-channel-server.js +21 -0
  7. package/lib/workspace-channel-tracking.js +125 -0
  8. package/lib/workspace.js +6 -13
  9. package/package.json +2 -2
  10. package/scripts/flow +4 -0
  11. package/scripts/flow-autonomous-detector.js +29 -4
  12. package/scripts/flow-autonomous-mode.js +27 -7
  13. package/scripts/flow-completion-summary.js +2 -16
  14. package/scripts/flow-id.js +31 -0
  15. package/scripts/flow-io.js +78 -0
  16. package/scripts/flow-long-input-pending.js +110 -0
  17. package/scripts/flow-long-input-stories.js +8 -0
  18. package/scripts/flow-orchestrate.js +16 -10
  19. package/scripts/flow-question-queue.js +73 -7
  20. package/scripts/flow-scanner-base.js +77 -1
  21. package/scripts/flow-session-state.js +47 -0
  22. package/scripts/flow-source-fidelity.js +279 -0
  23. package/scripts/flow-time-format.js +42 -0
  24. package/scripts/flow-utils.js +3 -16
  25. package/scripts/flow-worker-mcp-strip.js +12 -11
  26. package/scripts/flow-workspace-summary.js +38 -19
  27. package/scripts/hooks/adapters/claude-code.js +7 -4
  28. package/scripts/hooks/core/long-input-enforcement.js +311 -0
  29. package/scripts/hooks/core/pre-tool-deps.js +185 -0
  30. package/scripts/hooks/core/pre-tool-orchestrator.js +22 -0
  31. package/scripts/hooks/core/session-context.js +26 -0
  32. package/scripts/hooks/core/task-boundary-reset.js +13 -0
  33. package/scripts/hooks/core/worker-boundary-gate.js +67 -16
  34. package/scripts/hooks/entry/claude-code/pre-tool-use.js +21 -95
  35. package/scripts/hooks/entry/claude-code/user-prompt-submit.js +33 -0
@@ -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
- for (const step of [
319
- () => c.fixForbiddenImports(corrected, ctx.doNotImport),
320
- () => c.fixComponentPaths(corrected, ctx.componentPaths),
321
- () => c.fixFeatureTypePaths(corrected, filePath, ctx.typePaths),
322
- () => c.fixNoExternalUtils(corrected, ctx),
323
- () => c.normalizeQuotes(corrected),
324
- () => c.cleanupEmptyImports(corrected),
325
- () => c.collapseBlankLines(corrected)
326
- ]) {
327
- const r = step();
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
- const queue = loadQueue();
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: q.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 (Haiku) fallback is intentionally NOT inlined here; callers
119
- * that have access to Haiku invoke `classifyDependenciesWithAi()` and merge
120
- * results with `unionDependencies()`. This keeps the hot path classifier-free
121
- * for tests and for environments without Anthropic credentials.
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