wogiflow 1.5.13 → 1.5.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wogiflow",
3
- "version": "1.5.13",
3
+ "version": "1.5.14",
4
4
  "description": "AI-powered development workflow management system with multi-model support",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -13,11 +13,11 @@
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
15
  const https = require('https');
16
- const http = require('http');
17
16
  const crypto = require('crypto');
18
17
  const os = require('os');
18
+ const { execFileSync } = require('child_process');
19
19
 
20
- const { getConfig, PATHS, safeJsonParse } = require('./flow-utils');
20
+ const { PATHS, safeJsonParse, safeJsonParseString } = require('./flow-utils');
21
21
 
22
22
  // ~/.wogiflow/ directory for user-level state (persists across projects)
23
23
  const WOGIFLOW_HOME = path.join(os.homedir(), '.wogiflow');
@@ -28,6 +28,7 @@ const LAST_PUSH_PATH = path.join(WOGIFLOW_HOME, 'last-community-push');
28
28
  const CONSENT_PATH = path.join(WOGIFLOW_HOME, 'consent-acknowledged');
29
29
 
30
30
  const REQUEST_TIMEOUT_MS = 5000;
31
+ const MAX_RESPONSE_BYTES = 512 * 1024; // 512KB max response size
31
32
 
32
33
  // ──────────────────────────────────────────────
33
34
  // Anonymous ID
@@ -146,7 +147,6 @@ function stripPII(data, config) {
146
147
  let gitUser = '';
147
148
  let gitEmail = '';
148
149
  try {
149
- const { execFileSync } = require('child_process');
150
150
  gitUser = execFileSync('git', ['config', 'user.name'], { encoding: 'utf-8', timeout: 2000 }).trim();
151
151
  gitEmail = execFileSync('git', ['config', 'user.email'], { encoding: 'utf-8', timeout: 2000 }).trim();
152
152
  } catch {
@@ -159,7 +159,7 @@ function stripPII(data, config) {
159
159
  let result = str;
160
160
 
161
161
  // Replace absolute paths (Unix and Windows)
162
- result = result.replace(/(?:\/(?:Users|home|var|tmp|opt|etc|usr)\/[^\s,;:'")\]}>]+)/g, '[PATH]');
162
+ result = result.replace(/(?:\/(?:Users|home|var|tmp|opt|etc|usr|root|app|srv|run|mnt|media|proc|data)\/[^\s,;:'")\]}>]+)/g, '[PATH]');
163
163
  result = result.replace(/(?:[A-Z]:\\[^\s,;:'")\]}>]+)/gi, '[PATH]');
164
164
 
165
165
  // Replace home directory references
@@ -266,13 +266,17 @@ function collectShareableData(config) {
266
266
  * Get WogiFlow version from package.json.
267
267
  * @returns {string}
268
268
  */
269
+ let _cachedVersion = null;
269
270
  function getWogiFlowVersion() {
271
+ if (_cachedVersion) return _cachedVersion;
270
272
  try {
271
273
  const pkgPath = path.join(__dirname, '..', 'package.json');
272
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
273
- return pkg.version || 'unknown';
274
+ const pkg = safeJsonParse(pkgPath, {});
275
+ _cachedVersion = pkg.version || 'unknown';
276
+ return _cachedVersion;
274
277
  } catch {
275
- return 'unknown';
278
+ _cachedVersion = 'unknown';
279
+ return _cachedVersion;
276
280
  }
277
281
  }
278
282
 
@@ -505,6 +509,42 @@ function collectSkillLearnings() {
505
509
  // HTTP Helpers
506
510
  // ──────────────────────────────────────────────
507
511
 
512
+ /**
513
+ * Validate server URL to prevent SSRF attacks.
514
+ * Enforces HTTPS-only and blocks private/internal addresses.
515
+ * @param {string} urlStr
516
+ * @returns {boolean}
517
+ */
518
+ function isAllowedServerUrl(urlStr) {
519
+ try {
520
+ const url = new URL(urlStr);
521
+ // Enforce HTTPS only
522
+ if (url.protocol !== 'https:') return false;
523
+ // Block localhost and loopback
524
+ // URL.hostname returns IPv6 with bracket delimiters (e.g., '[::1]') — strip them
525
+ const rawHostname = url.hostname.toLowerCase();
526
+ const hostname = rawHostname.startsWith('[') ? rawHostname.slice(1, -1) : rawHostname;
527
+ if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return false;
528
+ // Block IPv6 private/loopback ranges
529
+ if (hostname.startsWith('::ffff:')) return false; // IPv4-mapped IPv6
530
+ if (hostname.startsWith('fe80:') || hostname.startsWith('fe80::')) return false; // Link-local
531
+ if (hostname.startsWith('fc00:') || hostname.startsWith('fd00:')) return false; // Unique local (RFC 4193)
532
+ // Block private IP ranges (RFC-1918 + link-local)
533
+ const ipMatch = hostname.match(/^(\d+)\.(\d+)\.\d+\.\d+$/);
534
+ if (ipMatch) {
535
+ const a = parseInt(ipMatch[1], 10);
536
+ const b = parseInt(ipMatch[2], 10);
537
+ if (a === 10) return false; // 10.0.0.0/8
538
+ if (a === 172 && b >= 16 && b <= 31) return false; // 172.16.0.0/12
539
+ if (a === 192 && b === 168) return false; // 192.168.0.0/16
540
+ if (a === 169 && b === 254) return false; // 169.254.0.0/16 (link-local)
541
+ }
542
+ return true;
543
+ } catch {
544
+ return false;
545
+ }
546
+ }
547
+
508
548
  /**
509
549
  * Make an HTTPS request with timeout. Fire-and-forget pattern.
510
550
  * @param {string} method - HTTP method
@@ -516,13 +556,19 @@ function collectSkillLearnings() {
516
556
  function httpRequest(method, urlStr, body = null, timeoutMs = REQUEST_TIMEOUT_MS) {
517
557
  return new Promise((resolve) => {
518
558
  try {
519
- const url = new URL(urlStr);
520
- const isHttps = url.protocol === 'https:';
521
- const transport = isHttps ? https : http;
559
+ if (!isAllowedServerUrl(urlStr)) {
560
+ if (process.env.DEBUG) {
561
+ console.error(`[flow-community] Blocked request to disallowed URL: ${urlStr}`);
562
+ }
563
+ resolve(null);
564
+ return;
565
+ }
522
566
 
567
+ const url = new URL(urlStr);
568
+ // isAllowedServerUrl already enforces HTTPS — use https directly
523
569
  const options = {
524
570
  hostname: url.hostname,
525
- port: url.port || (isHttps ? 443 : 80),
571
+ port: url.port || 443,
526
572
  path: url.pathname + url.search,
527
573
  method,
528
574
  headers: {
@@ -533,9 +579,17 @@ function httpRequest(method, urlStr, body = null, timeoutMs = REQUEST_TIMEOUT_MS
533
579
  timeout: timeoutMs
534
580
  };
535
581
 
536
- const req = transport.request(options, (res) => {
582
+ const req = https.request(options, (res) => {
537
583
  let data = '';
538
- res.on('data', (chunk) => { data += chunk; });
584
+ res.on('data', (chunk) => {
585
+ // Check size BEFORE appending to prevent single oversized chunk from buffering
586
+ if (data.length + chunk.length > MAX_RESPONSE_BYTES) {
587
+ req.destroy();
588
+ resolve(null);
589
+ return;
590
+ }
591
+ data += chunk;
592
+ });
539
593
  res.on('end', () => {
540
594
  resolve({ statusCode: res.statusCode, body: data });
541
595
  });
@@ -626,7 +680,8 @@ async function pullFromServer(config) {
626
680
 
627
681
  if (result && result.statusCode >= 200 && result.statusCode < 300) {
628
682
  try {
629
- const knowledge = JSON.parse(result.body);
683
+ const knowledge = safeJsonParseString(result.body);
684
+ if (!knowledge || typeof knowledge !== 'object') return cached || null;
630
685
  knowledge._cachedAt = new Date().toISOString();
631
686
  saveCommunityCache(knowledge);
632
687
  return knowledge;
@@ -663,10 +718,13 @@ async function submitSuggestion(text, type, config) {
663
718
  const validTypes = ['idea', 'bug', 'improvement'];
664
719
  const suggestionType = validTypes.includes(type) ? type : 'idea';
665
720
 
721
+ // Strip PII from suggestion text before sending
722
+ const strippedText = stripPII(text.trim(), config);
723
+
666
724
  const suggestion = {
667
725
  anonId: getOrCreateAnonId(),
668
726
  type: suggestionType,
669
- content: text.trim(),
727
+ content: typeof strippedText === 'string' ? strippedText : text.trim(),
670
728
  wogiflowVersion: getWogiFlowVersion(),
671
729
  submittedAt: new Date().toISOString()
672
730
  };
@@ -701,7 +759,7 @@ function queuePendingSuggestion(suggestion) {
701
759
  if (fs.existsSync(PENDING_SUGGESTIONS_PATH)) {
702
760
  try {
703
761
  const content = fs.readFileSync(PENDING_SUGGESTIONS_PATH, 'utf-8');
704
- const parsed = JSON.parse(content);
762
+ const parsed = safeJsonParseString(content, []);
705
763
  if (Array.isArray(parsed)) {
706
764
  pending = parsed;
707
765
  }
@@ -737,7 +795,7 @@ async function retryPendingSuggestions(config) {
737
795
  const content = fs.readFileSync(PENDING_SUGGESTIONS_PATH, 'utf-8');
738
796
  let pending;
739
797
  try {
740
- pending = JSON.parse(content);
798
+ pending = safeJsonParseString(content, null);
741
799
  } catch {
742
800
  return;
743
801
  }
@@ -785,7 +843,7 @@ function loadCommunityCache() {
785
843
  try {
786
844
  if (!fs.existsSync(COMMUNITY_CACHE_PATH)) return null;
787
845
  const content = fs.readFileSync(COMMUNITY_CACHE_PATH, 'utf-8');
788
- return JSON.parse(content);
846
+ return safeJsonParseString(content, null);
789
847
  } catch {
790
848
  return null;
791
849
  }
@@ -887,25 +945,29 @@ function mergeModelIntelligence(items) {
887
945
  if (!fs.existsSync(filePath)) continue;
888
946
 
889
947
  const content = fs.readFileSync(filePath, 'utf-8');
948
+ const detail = (item.adjustments || item.strengths || item.weaknesses || '').slice(0, 500);
949
+ if (!detail) continue;
890
950
 
891
951
  // Check if community section already exists
892
952
  if (content.includes(COMMUNITY_MARKER)) {
893
- // Section exists check for this specific item
894
- const detail = item.adjustments || item.strengths || item.weaknesses || '';
895
- if (!detail || content.includes(detail.slice(0, 80))) {
953
+ // Scope dedup check to community section only (not full file)
954
+ const markerIndex = content.indexOf(COMMUNITY_MARKER);
955
+ const communitySection = content.slice(markerIndex);
956
+ if (communitySection.includes(detail)) {
896
957
  continue; // Already merged
897
958
  }
898
- // Append to existing section
899
- const markerIndex = content.indexOf(COMMUNITY_MARKER);
900
- const insertPoint = content.indexOf('\n', markerIndex) + 1;
959
+ // Append at END of community section (next ## heading or EOF) for chronological order
960
+ const afterMarker = content.slice(markerIndex);
961
+ const nextHeadingMatch = afterMarker.match(/\n## /);
962
+ const insertPoint = nextHeadingMatch
963
+ ? markerIndex + nextHeadingMatch.index
964
+ : content.length;
901
965
  const newLine = `- ${detail}\n`;
902
966
  const updated = content.slice(0, insertPoint) + newLine + content.slice(insertPoint);
903
967
  fs.writeFileSync(filePath, updated, 'utf-8');
904
968
  merged++;
905
969
  } else {
906
970
  // Add new community section at end of file
907
- const detail = item.adjustments || item.strengths || item.weaknesses || '';
908
- if (!detail) continue;
909
971
  const section = `\n\n## Community Learnings\n${COMMUNITY_MARKER}\n- ${detail}\n`;
910
972
  fs.writeFileSync(filePath, content.trimEnd() + section, 'utf-8');
911
973
  merged++;
@@ -1013,7 +1075,7 @@ function mergePatterns(items) {
1013
1075
  // Check if this community pattern already exists
1014
1076
  if (content.includes(patternName)) continue;
1015
1077
 
1016
- const description = item.description.replace(/\|/g, '/'); // Escape pipes for table
1078
+ const description = item.description.slice(0, 500).replace(/\|/g, '/'); // Escape pipes for table, cap length
1017
1079
  const occurrences = item.occurrences || 1;
1018
1080
  newRows.push(`| ${today} | ${patternName} | Community: ${description} | ${occurrences} | Informational |`);
1019
1081
  merged++;
@@ -16,9 +16,9 @@
16
16
 
17
17
  const fs = require('fs');
18
18
  const path = require('path');
19
- const { execSync } = require('child_process');
20
- const { getProjectRoot, colors, getConfig } = require('./flow-utils');
21
- const { getExec, getCommand } = require('./flow-script-resolver');
19
+ const { execFileSync } = require('child_process');
20
+ const { getProjectRoot, colors, getConfig, safeJsonParse } = require('./flow-utils');
21
+ const { getExecParts, getCommand } = require('./flow-script-resolver');
22
22
 
23
23
  const PROJECT_ROOT = getProjectRoot();
24
24
  const STATE_DIR = path.join(PROJECT_ROOT, '.workflow', 'state');
@@ -37,7 +37,7 @@ function getCompletedTasks() {
37
37
  }
38
38
 
39
39
  try {
40
- const ready = JSON.parse(fs.readFileSync(READY_PATH, 'utf8'));
40
+ const ready = safeJsonParse(READY_PATH, {});
41
41
  return ready.recentlyCompleted || [];
42
42
  } catch (err) {
43
43
  log('yellow', `Warning: Could not parse ready.json: ${err.message}`);
@@ -51,9 +51,17 @@ function getCompletedTasks() {
51
51
  function findTestFiles(taskId, taskData) {
52
52
  const testFiles = [];
53
53
 
54
- // Check if task has explicit test files
54
+ // Check if task has explicit test files (validate paths to prevent injection)
55
55
  if (taskData?.testFiles) {
56
- testFiles.push(...taskData.testFiles);
56
+ const SAFE_PATH = /^[a-zA-Z0-9_.\-/]+$/;
57
+ for (const tf of taskData.testFiles) {
58
+ if (typeof tf === 'string' && SAFE_PATH.test(tf) && !tf.includes('..')) {
59
+ const resolved = path.resolve(PROJECT_ROOT, tf);
60
+ if (resolved.startsWith(PROJECT_ROOT + path.sep) || resolved === PROJECT_ROOT) {
61
+ testFiles.push(tf);
62
+ }
63
+ }
64
+ }
57
65
  return testFiles;
58
66
  }
59
67
 
@@ -81,7 +89,12 @@ function findTestFiles(taskId, taskData) {
81
89
  // Also look in request-log for files changed
82
90
  const logPath = path.join(STATE_DIR, 'request-log.md');
83
91
  if (fs.existsSync(logPath)) {
84
- const logContent = fs.readFileSync(logPath, 'utf8');
92
+ let logContent;
93
+ try {
94
+ logContent = fs.readFileSync(logPath, 'utf8');
95
+ } catch {
96
+ return [...new Set(testFiles)]; // Dedupe and return what we have
97
+ }
85
98
  // Escape special regex characters in taskId
86
99
  const escapedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
87
100
  const taskPattern = new RegExp(`### R-\\d+.*${escapedTaskId}[\\s\\S]*?Files:\\s*([^\\n]+)`, 'i');
@@ -127,9 +140,9 @@ function runTaskTests(taskId, taskData) {
127
140
  log('white', ` Running tests: ${testFiles.join(', ')}`);
128
141
 
129
142
  try {
130
- // Try to run tests with common test runners
131
- const testCommand = detectTestRunner(testFiles);
132
- execSync(testCommand, {
143
+ // Try to run tests with common test runners (using execFileSync for injection safety)
144
+ const { cmd, args } = detectTestRunner(testFiles);
145
+ execFileSync(cmd, args, {
133
146
  cwd: PROJECT_ROOT,
134
147
  stdio: 'pipe',
135
148
  timeout: 60000 // 1 minute timeout per task
@@ -151,44 +164,49 @@ function runTaskTests(taskId, taskData) {
151
164
  }
152
165
 
153
166
  /**
154
- * Detect which test runner to use
167
+ * Detect which test runner to use.
168
+ * Returns { cmd, args } for use with execFileSync (prevents shell injection).
169
+ * @returns {{ cmd: string, args: string[] }}
155
170
  */
156
171
  function detectTestRunner(testFiles) {
157
172
  const packageJson = path.join(PROJECT_ROOT, 'package.json');
158
173
 
159
174
  if (fs.existsSync(packageJson)) {
160
175
  try {
161
- const pkg = JSON.parse(fs.readFileSync(packageJson, 'utf8'));
176
+ const pkg = safeJsonParse(packageJson, {});
162
177
 
163
178
  // Check scripts
164
179
  if (pkg.scripts?.test) {
165
180
  // If specific files, pass them
166
181
  if (testFiles.length > 0) {
167
- const fileArgs = testFiles.join(' ');
168
182
  // Jest-style
169
183
  if (pkg.devDependencies?.jest || pkg.dependencies?.jest) {
170
- return `${getExec('jest')} ${fileArgs} --passWithNoTests`;
184
+ return getExecParts('jest', [...testFiles, '--passWithNoTests']);
171
185
  }
172
186
  // Vitest
173
187
  if (pkg.devDependencies?.vitest || pkg.dependencies?.vitest) {
174
- return `${getExec('vitest')} run ${fileArgs}`;
188
+ return getExecParts('vitest', ['run', ...testFiles]);
175
189
  }
176
190
  // Mocha
177
191
  if (pkg.devDependencies?.mocha || pkg.dependencies?.mocha) {
178
- return `${getExec('mocha')} ${fileArgs}`;
192
+ return getExecParts('mocha', testFiles);
179
193
  }
180
194
  }
181
- // Fallback to resolved test command
182
- const testCmd = getCommand('test') || 'npm test';
183
- return `${testCmd} -- --passWithNoTests`;
195
+ // Fallback to resolved test command via package manager
196
+ const testCmd = getCommand('test');
197
+ if (testCmd) {
198
+ const parts = testCmd.split(/\s+/);
199
+ return { cmd: parts[0], args: [...parts.slice(1), '--', '--passWithNoTests'] };
200
+ }
201
+ return { cmd: 'npm', args: ['test', '--', '--passWithNoTests'] };
184
202
  }
185
- } catch (err) {
203
+ } catch {
186
204
  // package.json is malformed, fall through to default
187
205
  }
188
206
  }
189
207
 
190
208
  // Default to jest via exec resolver
191
- return `${getExec('jest')} ${testFiles.join(' ')} --passWithNoTests`;
209
+ return getExecParts('jest', [...testFiles, '--passWithNoTests']);
192
210
  }
193
211
 
194
212
  /**
@@ -27,7 +27,7 @@ const path = require('path');
27
27
  * Validate a script name is safe for shell usage.
28
28
  * Rejects names containing shell metacharacters.
29
29
  */
30
- const UNSAFE_CHARS = /[;&|$`()"'\\<>!\n\r]/;
30
+ const UNSAFE_CHARS = /[;&|$`()"'\\<>!\n\r/]/;
31
31
  function isSafeScriptName(name) {
32
32
  return typeof name === 'string' && name.length > 0 && name.length < 100 && !UNSAFE_CHARS.test(name);
33
33
  }
@@ -183,8 +183,9 @@ function getCommand(name, options = {}) {
183
183
  // 1. Check config override
184
184
  const configScripts = config.scripts || {};
185
185
  if (configScripts[name] && typeof configScripts[name] === 'string') {
186
- // Validate config override is safe
187
- if (!isSafeScriptName(configScripts[name].split(' ')[0])) return null;
186
+ // Validate entire config override reject if any part contains shell metacharacters
187
+ const overrideParts = configScripts[name].trim().split(/\s+/).filter(Boolean);
188
+ if (!overrideParts.every(part => isSafeScriptName(part))) return null;
188
189
  return configScripts[name];
189
190
  }
190
191
 
@@ -750,17 +750,19 @@ function safeJsonParse(filePath, defaultValue = null) {
750
750
  }
751
751
 
752
752
  /**
753
- * Safely parse a JSON string with prototype pollution protection
754
- * Use this when you already have the JSON content as a string
753
+ * Safely parse a JSON string with prototype pollution protection.
754
+ * Use this when you already have the JSON content as a string.
755
+ * Note: Unlike safeJsonParse (file-based), this allows arrays through
756
+ * since it validates typeof === 'object' which is true for arrays.
755
757
  * @param {string} jsonString - JSON string to parse
756
758
  * @param {*} [defaultValue=null] - Default value if parsing fails
757
- * @returns {object|null} Parsed JSON or defaultValue on error
759
+ * @returns {object|Array|null} Parsed JSON (object or array) or defaultValue on error
758
760
  */
759
761
  function safeJsonParseString(jsonString, defaultValue = null) {
760
762
  try {
761
763
  const parsed = JSON.parse(jsonString);
762
764
 
763
- // Validate it's an object (not array or primitive for config files)
765
+ // Validate it's an object or array (not primitive for config files)
764
766
  if (typeof parsed !== 'object' || parsed === null) {
765
767
  return defaultValue;
766
768
  }
@@ -17,14 +17,21 @@
17
17
 
18
18
  const registeredExtensions = new Map();
19
19
 
20
+ // Extension names must be DNS-label-like: lowercase alphanumeric + hyphens, no trailing hyphens, max 64 chars
21
+ const VALID_EXTENSION_NAME = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/;
22
+
20
23
  /**
21
24
  * Register an extension's hook module.
25
+ * Note: This registry is in-process only — each hook invocation runs in a fresh
26
+ * Node.js process, so registrations do not persist across hook calls.
27
+ * For cross-invocation extension state, use settings.json (as postinstall.js does).
28
+ *
22
29
  * @param {string} name - Extension name (e.g., 'teams')
23
30
  * @param {Object} hookModule - Module object with hook functions
24
31
  * @returns {boolean} True if registered, false if already exists
25
32
  */
26
33
  function register(name, hookModule) {
27
- if (!name || typeof name !== 'string') {
34
+ if (!name || typeof name !== 'string' || !VALID_EXTENSION_NAME.test(name)) {
28
35
  if (process.env.DEBUG) {
29
36
  console.error(`[extension-registry] Invalid extension name: ${name}`);
30
37
  }
@@ -11,6 +11,7 @@ const { gatherSessionContext } = require('../../core/session-context');
11
11
  const { claudeCodeAdapter } = require('../../adapters/claude-code');
12
12
  const { setCliSessionId, clearStaleCurrentTaskAsync } = require('../../../flow-session-state');
13
13
  const { checkAndResetStalePhase } = require('../../core/phase-gate');
14
+ const { safeJsonParseString } = require('../../../flow-utils');
14
15
 
15
16
  // Lazy-load bridge state to avoid circular dependencies
16
17
  let autoSyncBridge = null;
@@ -42,7 +43,7 @@ async function main() {
42
43
  inputData += chunk;
43
44
  }
44
45
 
45
- const input = inputData ? JSON.parse(inputData) : {};
46
+ const input = inputData ? (safeJsonParseString(inputData, {}) || {}) : {};
46
47
  const parsedInput = claudeCodeAdapter.parseInput(input);
47
48
 
48
49
  // Store CLI session ID for tracking (CLI-agnostic via session-state)
@@ -109,17 +110,20 @@ async function main() {
109
110
  // Retry pending suggestions (fire-and-forget)
110
111
  community.retryPendingSuggestions(config).catch(() => {});
111
112
 
112
- // Pull community knowledge (fire-and-forget, updates cache)
113
- const knowledge = await community.pullFromServer(config);
114
- if (knowledge && coreResult && coreResult.context) {
115
- coreResult.context.communityKnowledge = knowledge;
116
-
117
- // Merge community knowledge into local state files (Phase C2)
118
- try {
119
- community.mergeCommunityKnowledge(knowledge, config);
120
- } catch (mergeErr) {
121
- if (process.env.DEBUG) {
122
- console.error(`[session-start] Community merge failed: ${mergeErr.message}`);
113
+ // Pull community knowledge (respects pullOnSessionStart toggle)
114
+ if (config.community?.pullOnSessionStart !== false) {
115
+ // Non-blocking pull with 5s timeout — uses cache if unavailable
116
+ const knowledge = await community.pullFromServer(config);
117
+ if (knowledge && coreResult && coreResult.context) {
118
+ coreResult.context.communityKnowledge = knowledge;
119
+
120
+ // Merge community knowledge into local state files (Phase C2)
121
+ try {
122
+ community.mergeCommunityKnowledge(knowledge, config);
123
+ } catch (mergeErr) {
124
+ if (process.env.DEBUG) {
125
+ console.error(`[session-start] Community merge failed: ${mergeErr.message}`);
126
+ }
123
127
  }
124
128
  }
125
129
  }