wogiflow 2.29.1 → 2.29.3

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 (36) hide show
  1. package/.claude/docs/intent-grounded-reasoning.md +1 -1
  2. package/.workflow/templates/partials/methodology-rules.hbs +60 -0
  3. package/lib/commands/team-connection.js +5 -28
  4. package/lib/mode-schema.js +2 -1
  5. package/lib/utils.js +12 -26
  6. package/lib/wogi-claude +40 -1
  7. package/lib/workspace-messages.js +2 -1
  8. package/lib/workspace.js +7 -14
  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-corrections.js +158 -0
  19. package/scripts/flow-orchestrate.js +22 -97
  20. package/scripts/flow-question-queue.js +73 -7
  21. package/scripts/flow-scanner-base.js +77 -1
  22. package/scripts/flow-session-state.js +47 -0
  23. package/scripts/flow-source-fidelity.js +279 -0
  24. package/scripts/flow-time-format.js +42 -0
  25. package/scripts/flow-utils.js +3 -16
  26. package/scripts/flow-worker-mcp-strip.js +12 -11
  27. package/scripts/flow-workspace-summary.js +38 -19
  28. package/scripts/hooks/adapters/claude-code.js +7 -4
  29. package/scripts/hooks/core/long-input-enforcement.js +311 -0
  30. package/scripts/hooks/core/pre-tool-deps.js +185 -0
  31. package/scripts/hooks/core/pre-tool-orchestrator.js +22 -0
  32. package/scripts/hooks/core/session-context.js +26 -0
  33. package/scripts/hooks/core/task-boundary-reset.js +13 -0
  34. package/scripts/hooks/core/worker-boundary-gate.js +67 -16
  35. package/scripts/hooks/entry/claude-code/pre-tool-use.js +21 -95
  36. package/scripts/hooks/entry/claude-code/user-prompt-submit.js +33 -0
@@ -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,
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow — Auto-Correction Helpers (Story 14 / wf-d0937c83)
5
+ *
6
+ * Splits flow-orchestrate's autoCorrectCode into focused helpers:
7
+ *
8
+ * - fixForbiddenImports (section 1: doNotImport, default/combined/namespace forms)
9
+ * - fixComponentPaths (section 2: shadcn @/components/ui/X mapping)
10
+ * - fixFeatureTypePaths (section 3: ../types in /features/ files)
11
+ * - fixNoExternalUtils (section 4: @/lib/utils removal + formatCurrency inline + cn() unwrap)
12
+ * - normalizeQuotes (section 5: double-quote → single-quote when single dominates)
13
+ * - cleanupEmptyImports (section 6: empty `import {} from "..."` cleanup)
14
+ * - collapseBlankLines (section 7: 3+ blanks → 2)
15
+ *
16
+ * Each helper takes a `(code, ...args)` shape and returns
17
+ * `{ corrected, corrections }`. The orchestrator (flow-orchestrate.js
18
+ * `autoCorrectCode`) chains them in section order.
19
+ *
20
+ * Behavior is preserved verbatim from the pre-extraction implementation;
21
+ * pinned by characterization tests in
22
+ * `tests/flow-orchestrate-corrections.test.js` (Tier-3 integration test
23
+ * per the Cross-Story Integration Tier-3 Rule — feeds real input through
24
+ * the public `autoCorrectCode` API and asserts the output).
25
+ *
26
+ * Programmatic:
27
+ * const c = require('./flow-orchestrate-corrections');
28
+ * const { corrected, corrections } = c.fixForbiddenImports(code, ['React']);
29
+ */
30
+
31
+ 'use strict';
32
+
33
+ function fixForbiddenImports(code, doNotImport) {
34
+ let corrected = code;
35
+ const corrections = [];
36
+ const list = Array.isArray(doNotImport) && doNotImport.length ? doNotImport : ['React'];
37
+ for (const forbidden of list) {
38
+ const defaultImportRegex = new RegExp(`^import ${forbidden} from ['"][^'"]+['"];?\\s*\\n?`, 'gm');
39
+ if (defaultImportRegex.test(corrected)) {
40
+ corrected = corrected.replace(defaultImportRegex, '');
41
+ corrections.push(`Removed forbidden import: ${forbidden}`);
42
+ }
43
+
44
+ const combinedImportRegex = new RegExp(`^import ${forbidden},\\s*(\\{[^}]+\\})\\s+from\\s+(['"][^'"]+['"])`, 'gm');
45
+ if (combinedImportRegex.test(corrected)) {
46
+ corrected = corrected.replace(combinedImportRegex, 'import $1 from $2');
47
+ corrections.push(`Removed ${forbidden} from combined import`);
48
+ }
49
+
50
+ const namespaceImportRegex = new RegExp(`^import \\* as ${forbidden} from ['"][^'"]+['"];?\\s*\\n?`, 'gm');
51
+ if (namespaceImportRegex.test(corrected)) {
52
+ corrected = corrected.replace(namespaceImportRegex, '');
53
+ corrections.push(`Removed namespace import: ${forbidden}`);
54
+ }
55
+ }
56
+ return { corrected, corrections };
57
+ }
58
+
59
+ function fixComponentPaths(code, componentPaths) {
60
+ let corrected = code;
61
+ const corrections = [];
62
+ const map = (componentPaths && typeof componentPaths === 'object') ? componentPaths : {};
63
+ const shadcnPattern = /@\/components\/ui\/(\w+)/g;
64
+ corrected = corrected.replace(shadcnPattern, (match, component) => {
65
+ const capitalName = component.charAt(0).toUpperCase() + component.slice(1);
66
+ const configPath = map[capitalName];
67
+ if (configPath) {
68
+ corrections.push(`Fixed import: ${match} → ${configPath}`);
69
+ return configPath;
70
+ }
71
+ return match;
72
+ });
73
+ return { corrected, corrections };
74
+ }
75
+
76
+ function fixFeatureTypePaths(code, filePath, typePaths) {
77
+ let corrected = code;
78
+ const corrections = [];
79
+ const paths = (typePaths && typeof typePaths === 'object') ? typePaths : { features: '../api/types' };
80
+ if (filePath && filePath.includes('/features/') && paths.features) {
81
+ const wrongPaths = ["'../types'", '"../types"', "'./types'", '"./types"'];
82
+ for (const wrong of wrongPaths) {
83
+ if (corrected.includes(wrong)) {
84
+ corrected = corrected.replace(new RegExp(wrong.replace(/['"]/g, '[\'"]'), 'g'), `'${paths.features}'`);
85
+ corrections.push('Fixed type import path');
86
+ }
87
+ }
88
+ }
89
+ return { corrected, corrections };
90
+ }
91
+
92
+ function fixNoExternalUtils(code, ctx) {
93
+ let corrected = code;
94
+ const corrections = [];
95
+ if (!(ctx && ctx.noExternalUtils && corrected.includes('@/lib/utils'))) {
96
+ return { corrected, corrections };
97
+ }
98
+
99
+ const hadFormatCurrency = corrected.includes('formatCurrency');
100
+ const hadCn = corrected.includes(' cn(') || corrected.includes(' cn`');
101
+
102
+ corrected = corrected.replace(/^import.*from ['"]@\/lib\/utils['"];?\s*\n?/gm, '');
103
+ corrections.push('Removed @/lib/utils import');
104
+
105
+ if (hadFormatCurrency) {
106
+ const formatCurrencyFn = `\nconst formatCurrency = (amount: number) =>\n new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);\n`;
107
+ const lastImportMatch = corrected.match(/^import[^;]+;?\s*\n/gm);
108
+ if (lastImportMatch) {
109
+ const lastImport = lastImportMatch[lastImportMatch.length - 1];
110
+ const insertPos = corrected.lastIndexOf(lastImport) + lastImport.length;
111
+ corrected = corrected.slice(0, insertPos) + formatCurrencyFn + corrected.slice(insertPos);
112
+ }
113
+ corrections.push('Inlined formatCurrency');
114
+ }
115
+
116
+ if (hadCn) {
117
+ corrected = corrected.replace(/cn\((['"`][^'"`]+['"`])\)/g, '$1');
118
+ corrections.push('Removed cn() wrapper');
119
+ }
120
+
121
+ return { corrected, corrections };
122
+ }
123
+
124
+ function normalizeQuotes(code) {
125
+ let corrected = code;
126
+ const corrections = [];
127
+ const singleQuoteCount = (corrected.match(/from '/g) || []).length;
128
+ const doubleQuoteCount = (corrected.match(/from "/g) || []).length;
129
+ if (singleQuoteCount > doubleQuoteCount && doubleQuoteCount > 0) {
130
+ corrected = corrected.replace(/from "([^"]+)"/g, "from '$1'");
131
+ corrections.push('Normalized import quotes to single quotes');
132
+ }
133
+ return { corrected, corrections };
134
+ }
135
+
136
+ function cleanupEmptyImports(code) {
137
+ return {
138
+ corrected: code.replace(/^import\s*\{\s*\}\s*from\s*['"][^'"]+['"];?\s*\n?/gm, ''),
139
+ corrections: []
140
+ };
141
+ }
142
+
143
+ function collapseBlankLines(code) {
144
+ return {
145
+ corrected: code.replace(/\n{3,}/g, '\n\n'),
146
+ corrections: []
147
+ };
148
+ }
149
+
150
+ module.exports = {
151
+ fixForbiddenImports,
152
+ fixComponentPaths,
153
+ fixFeatureTypePaths,
154
+ fixNoExternalUtils,
155
+ normalizeQuotes,
156
+ cleanupEmptyImports,
157
+ collapseBlankLines
158
+ };
@@ -307,109 +307,34 @@ function autoCorrectCode(code, filePath, projectConfig = null) {
307
307
  return { corrected: code, corrections: [] };
308
308
  }
309
309
 
310
- // Load project context from config if not provided
311
310
  const ctx = projectConfig?.projectContext ?? getProjectContext();
312
-
313
- let corrected = code;
314
311
  const corrections = [];
315
312
 
316
- // 1. Remove forbidden imports (from config, defaults to ['React'])
317
- const doNotImport = ctx.doNotImport || ['React'];
318
- for (const forbidden of doNotImport) {
319
- // Case A: Default import - "import X from '...'"
320
- const defaultImportRegex = new RegExp(`^import ${forbidden} from ['"][^'"]+['"];?\\s*\\n?`, 'gm');
321
- if (defaultImportRegex.test(corrected)) {
322
- corrected = corrected.replace(defaultImportRegex, '');
323
- corrections.push(`Removed forbidden import: ${forbidden}`);
324
- }
325
-
326
- // Case B: Combined with named imports - "import X, { y, z } from '...'"
327
- const combinedImportRegex = new RegExp(`^import ${forbidden},\\s*(\\{[^}]+\\})\\s+from\\s+(['"][^'"]+['"])`, 'gm');
328
- if (combinedImportRegex.test(corrected)) {
329
- corrected = corrected.replace(combinedImportRegex, 'import $1 from $2');
330
- corrections.push(`Removed ${forbidden} from combined import`);
331
- }
332
-
333
- // Case C: Namespace import - "import * as X from '...'"
334
- const namespaceImportRegex = new RegExp(`^import \\* as ${forbidden} from ['"][^'"]+['"];?\\s*\\n?`, 'gm');
335
- if (namespaceImportRegex.test(corrected)) {
336
- corrected = corrected.replace(namespaceImportRegex, '');
337
- corrections.push(`Removed namespace import: ${forbidden}`);
338
- }
339
- }
340
-
341
- // 2. Fix component paths based on config mappings
342
- const componentPaths = ctx.componentPaths ?? {};
343
-
344
- // Build reverse mapping from shadcn-style to project paths
345
- // @/components/ui/button → project's Button path
346
- const shadcnPattern = /@\/components\/ui\/(\w+)/g;
347
- corrected = corrected.replace(shadcnPattern, (match, component) => {
348
- const capitalName = component.charAt(0).toUpperCase() + component.slice(1);
349
- const configPath = componentPaths[capitalName];
350
- if (configPath) {
351
- corrections.push(`Fixed import: ${match} → ${configPath}`);
352
- return configPath;
353
- }
354
- return match; // Leave as-is if no mapping
355
- });
356
-
357
- // 3. Fix type paths for features (from config)
358
- const typePaths = ctx.typePaths || { features: '../api/types' };
359
- if (filePath && filePath.includes('/features/') && typePaths.features) {
360
- const wrongPaths = ["'../types'", '"../types"', "'./types'", '"./types"'];
361
- for (const wrong of wrongPaths) {
362
- if (corrected.includes(wrong)) {
363
- corrected = corrected.replace(new RegExp(wrong.replace(/['"]/g, '[\'"]'), 'g'), `'${typePaths.features}'`);
364
- corrections.push('Fixed type import path');
365
- }
366
- }
367
- }
368
-
369
- // 4. Remove external utils if configured (noExternalUtils: true)
370
- if (ctx.noExternalUtils && corrected.includes('@/lib/utils')) {
371
- const hadFormatCurrency = corrected.includes('formatCurrency');
372
- const hadCn = corrected.includes(' cn(') || corrected.includes(' cn`');
373
-
374
- // Remove the import
375
- corrected = corrected.replace(/^import.*from ['"]@\/lib\/utils['"];?\s*\n?/gm, '');
376
- corrections.push('Removed @/lib/utils import');
377
-
378
- // Inline formatCurrency if it was used
379
- if (hadFormatCurrency) {
380
- const formatCurrencyFn = `\nconst formatCurrency = (amount: number) =>\n new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);\n`;
381
- // Insert after imports
382
- const lastImportMatch = corrected.match(/^import[^;]+;?\s*\n/gm);
383
- if (lastImportMatch) {
384
- const lastImport = lastImportMatch[lastImportMatch.length - 1];
385
- const insertPos = corrected.lastIndexOf(lastImport) + lastImport.length;
386
- corrected = corrected.slice(0, insertPos) + formatCurrencyFn + corrected.slice(insertPos);
387
- }
388
- corrections.push('Inlined formatCurrency');
389
- }
313
+ // Lazy-load the helpers avoids module-load-order issues with the
314
+ // legacy CLI bootstrap at the bottom of this file.
315
+ const c = require('./flow-orchestrate-corrections');
390
316
 
391
- // Remove cn() usage - just use template literals or className directly
392
- if (hadCn) {
393
- corrected = corrected.replace(/cn\((['"`][^'"`]+['"`])\)/g, '$1');
394
- corrections.push('Removed cn() wrapper');
395
- }
396
- }
397
-
398
- // 5. Fix double-quoted imports to single quotes (style consistency)
399
- const singleQuoteCount = (corrected.match(/from '/g) || []).length;
400
- const doubleQuoteCount = (corrected.match(/from "/g) || []).length;
401
- if (singleQuoteCount > doubleQuoteCount && doubleQuoteCount > 0) {
402
- corrected = corrected.replace(/from "([^"]+)"/g, "from '$1'");
403
- corrections.push('Normalized import quotes to single quotes');
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.
322
+ let corrected = code;
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);
334
+ corrected = r.corrected;
335
+ if (r.corrections.length) corrections.push(...r.corrections);
404
336
  }
405
337
 
406
- // 6. Remove empty import statements (artifact of removing imports)
407
- corrected = corrected.replace(/^import\s*\{\s*\}\s*from\s*['"][^'"]+['"];?\s*\n?/gm, '');
408
-
409
- // 7. Fix multiple consecutive blank lines (cleanup)
410
- corrected = corrected.replace(/\n{3,}/g, '\n\n');
411
-
412
- // Log corrections if any
413
338
  if (corrections.length > 0 && typeof log === 'function') {
414
339
  log('dim', ` 🔧 Auto-corrected: ${corrections.join(', ')}`);
415
340
  }
@@ -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