knoxis-helper 1.8.6 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -46,7 +46,52 @@ const ALLOWED_ORIGINS = (process.env.KNOXIS_ALLOWED_ORIGINS || '')
46
46
  .map(origin => origin.trim())
47
47
  .filter(Boolean);
48
48
  // Merge custom origins with trusted ones
49
- const ALL_ALLOWED_ORIGINS = [...new Set([...TRUSTED_ORIGINS, ...ALLOWED_ORIGINS])];
49
+ const ALL_ALLOWED_ORIGINS = Array.from(new Set([...TRUSTED_ORIGINS, ...ALLOWED_ORIGINS]));
50
+
51
+ /**
52
+ * Collect Maker-related Markdown documents for persistence and syncing.
53
+ * These include backlog, PRD, glossary, product, and strategy files.
54
+ */
55
+ function collectMakerDocs(workspaceDir) {
56
+ try {
57
+ if (!workspaceDir || !fs.existsSync(workspaceDir)) return [];
58
+ const makerFiles = [];
59
+ const candidates = [
60
+ 'BACKLOG.md',
61
+ 'GLOSSARY.md',
62
+ 'PRD.md',
63
+ 'PRODUCT.md',
64
+ 'STRATEGY.md'
65
+ ];
66
+ candidates.forEach(name => {
67
+ const full = path.join(workspaceDir, name);
68
+ if (fs.existsSync(full)) makerFiles.push(full);
69
+ });
70
+ const docsDir = path.join(workspaceDir, 'docs');
71
+ if (fs.existsSync(docsDir)) {
72
+ fs.readdirSync(docsDir).forEach(f => {
73
+ if (f.endsWith('.md')) makerFiles.push(path.join(docsDir, f));
74
+ });
75
+ }
76
+ const mvpDir = path.join(workspaceDir, '.mvp');
77
+ if (fs.existsSync(mvpDir)) {
78
+ const walk = dir => {
79
+ fs.readdirSync(dir, { withFileTypes: true }).forEach(entry => {
80
+ const res = path.join(dir, entry.name);
81
+ if (entry.isDirectory()) walk(res);
82
+ else if (entry.name.endsWith('.md')) makerFiles.push(res);
83
+ });
84
+ };
85
+ walk(mvpDir);
86
+ }
87
+ console.log(`📁 Maker-aware discovery found ${makerFiles.length} planning docs.`);
88
+ return makerFiles;
89
+ } catch (err) {
90
+ console.warn('⚠️ collectMakerDocs failed:', err.message);
91
+ return [];
92
+ }
93
+ }
94
+
50
95
 
51
96
  const serverMeta = { secure: false, port: DEFAULT_PORT };
52
97
 
@@ -580,55 +625,6 @@ function resolveInteractiveScript() {
580
625
  return null;
581
626
  }
582
627
 
583
- // Resolve the kit-aware pair-program runner (used when caller specifies --mode)
584
- function resolvePairProgramScript() {
585
- const candidates = [
586
- path.join(__dirname, 'knoxis-pair-program.js'),
587
- path.join(__dirname, '..', 'knoxis-pair-program.js'),
588
- path.join(os.homedir(), '.knoxis', 'agent', 'knoxis-pair-program.js'),
589
- path.join(__dirname, '..', '..', 'knoxis-pair-program.js'),
590
- ];
591
- for (const candidate of candidates) {
592
- if (fs.existsSync(candidate)) return candidate;
593
- }
594
- return null;
595
- }
596
-
597
- const KIT_MODES = new Set(['kickoff', 'feature-kickoff', 'resume', 'session-end', 'recovery']);
598
- // Every kit mode rewrites files on disk — STATUS / HANDOFF / DECISIONS for
599
- // session-end, the docs/state/* + docs/features/<slug>/* bag for kickoff /
600
- // feature-kickoff / resume, and recovery's triage notes. The interactive
601
- // runner invokes `claude -p --session-id`, which gives Claude proper tool
602
- // access; the older pair-program.js pipes to `claude` without -p and silently
603
- // skips the file writes. Route every kit mode through the interactive runner
604
- // so their kit prompts actually do their work.
605
- const KIT_MODES_VIA_INTERACTIVE = new Set(['kickoff', 'feature-kickoff', 'resume', 'session-end', 'recovery']);
606
-
607
- function buildPairProgramCommand({ scriptPath, workspace, mode, archetype, pattern, productSlug, projectSlug, taskPrompt, userId, workspaceId, taskIds }) {
608
- const q = v => `"${escapeForDoubleQuotedShellArg(v)}"`;
609
- // Mode/archetype/pattern are constrained to known short tokens — no need to quote.
610
- // Slugs and paths can contain spaces or special chars — always quote.
611
- const parts = [`node ${q(scriptPath)}`, `--workspace ${q(workspace)}`];
612
- if (mode) parts.push(`--mode ${mode}`);
613
- if (archetype) parts.push(`--archetype ${archetype}`);
614
- if (pattern) parts.push(`--pattern ${pattern}`);
615
- if (productSlug) parts.push(`--product-slug ${q(productSlug)}`);
616
- if (projectSlug) parts.push(`--project-slug ${q(projectSlug)}`);
617
- if (userId) parts.push(`--user-id ${q(userId)}`);
618
- if (workspaceId) parts.push(`--workspace-id ${q(workspaceId)}`);
619
- if (Array.isArray(taskIds)) {
620
- for (const t of taskIds) {
621
- if (t) parts.push(`--task-id ${q(t)}`);
622
- }
623
- }
624
- if (taskPrompt && taskPrompt.length) {
625
- // Use base64 to avoid shell-quoting hazards (newlines, quotes, backticks).
626
- const b64 = Buffer.from(taskPrompt, 'utf8').toString('base64');
627
- parts.push(`--prompt-base64 ${b64}`);
628
- }
629
- return parts.join(' ');
630
- }
631
-
632
628
  // Request handler
633
629
  async function handleRequest(req, res) {
634
630
  const parsedUrl = url.parse(req.url, true);
@@ -685,7 +681,14 @@ async function handleRequest(req, res) {
685
681
  if (body.claudeMdContent && workspace && fs.existsSync(workspace)) {
686
682
  try {
687
683
  fs.writeFileSync(path.join(workspace, 'CLAUDE.md'), body.claudeMdContent, 'utf8');
688
- console.log('📄 Wrote CLAUDE.md (' + body.claudeMdContent.length + ' chars)');
684
+ console.log('📄 Wrote CLAUDE.md (' + body.claudeMdContent.length + ' chars)');
685
+ // Maker-aware discovery integration
686
+ try {
687
+ const makerDocs = collectMakerDocs(workspace);
688
+ console.log(` Maker doc sync: ${makerDocs.length} files found.`);
689
+ } catch (err) {
690
+ console.warn('⚠️ Maker doc sync failed:', err.message);
691
+ }
689
692
  } catch (writeErr) {
690
693
  console.warn('⚠️ Failed to write CLAUDE.md:', writeErr.message);
691
694
  }
@@ -916,22 +919,8 @@ async function handleRequest(req, res) {
916
919
  const body = await parseBody(req);
917
920
  const { workspace, task, file, provider, headless, sessionId, claudeMdContent } = body;
918
921
  const interactive = body.interactive === true || body.interactive === 'true';
919
- const kitMode = typeof body.mode === 'string' && KIT_MODES.has(body.mode) ? body.mode : null;
920
- const archetype = typeof body.archetype === 'string' ? body.archetype : null;
921
- const pattern = typeof body.pattern === 'string' ? body.pattern : null;
922
- const productSlug = typeof body.productSlug === 'string' ? body.productSlug : null;
923
- const projectSlug = typeof body.projectSlug === 'string' ? body.projectSlug : null;
924
- // QIG portal linkage — let session records get tied back to the user / workspace / tasks.
925
- const portalUserId = typeof body.userId === 'string' ? body.userId : null;
926
- const portalWorkspaceId = typeof body.workspaceId === 'string' ? body.workspaceId : null;
927
- const portalTaskIds = Array.isArray(body.taskIds)
928
- ? body.taskIds.filter(t => typeof t === 'string' && t)
929
- : (typeof body.taskId === 'string' ? [body.taskId] : []);
930
-
931
- // Kickoff and session-end modes don't strictly need a task — the kit prompt
932
- // drives the interaction. For all other paths a task is still required.
933
- const taskOptionalForMode = kitMode === 'kickoff' || kitMode === 'session-end';
934
- if (!task && !taskOptionalForMode) {
922
+
923
+ if (!task) {
935
924
  return sendJSON(res, 400, { success: false, error: 'Task description required' }, requestOrigin);
936
925
  }
937
926
 
@@ -953,80 +942,32 @@ async function handleRequest(req, res) {
953
942
  try {
954
943
  fs.writeFileSync(path.join(workspaceDir, 'CLAUDE.md'), effectiveClaudeMd, 'utf8');
955
944
  console.log(`📄 Wrote CLAUDE.md to ${workspaceDir} (${effectiveClaudeMd.length} chars)`);
945
+ // Maker-aware discovery integration
946
+ try {
947
+ const makerDocs = collectMakerDocs(workspaceDir);
948
+ console.log(` Maker doc sync: ${makerDocs.length} files found.`);
949
+ } catch (err) {
950
+ console.warn('⚠️ Maker doc sync failed:', err.message);
951
+ }
956
952
  } catch (e) {
957
953
  console.warn(`⚠️ Failed to write CLAUDE.md: ${e.message}`);
958
954
  }
959
955
  }
960
956
 
961
- // Write the actual task to a temp file (used by single-shot and interactive paths)
957
+ // Write the actual task to a temp file
962
958
  const promptFile = path.join(os.tmpdir(), `knoxis-task-${sessionId || Date.now()}.txt`);
963
- const promptText = task ? (file ? `Working on file: ${file}\n\nTask: ${task}` : task) : '';
964
- if (promptText) fs.writeFileSync(promptFile, promptText, 'utf8');
959
+ const promptText = file ? `Working on file: ${file}\n\nTask: ${task}` : task;
960
+ fs.writeFileSync(promptFile, promptText, 'utf8');
965
961
 
966
- // Determine the command to run.
962
+ // Determine the command to run
967
963
  let command;
968
964
  let mode = 'single-shot';
969
965
 
970
- // Route through knoxis-pair-program.js whenever it's installed — gives
971
- // every task auto-scaffold, the standards systemIntro, auto-context, and
972
- // portal-sync. Kit mode adds --mode; default mode runs the standard
973
- // 4-step pipeline. Interactive (Groq) mode and the legacy claude-pipe
974
- // fallback remain available when the runner isn't present.
975
- const ppScript = resolvePairProgramScript();
976
- const kitViaInteractive = kitMode && KIT_MODES_VIA_INTERACTIVE.has(kitMode);
977
- if (kitMode && !kitViaInteractive && !ppScript) {
978
- return sendJSON(res, 500, { success: false, error: 'knoxis-pair-program.js not found — reinstall knoxis-helper.' }, requestOrigin);
979
- }
980
- if (kitViaInteractive) {
981
- const scriptPath = resolveInteractiveScript();
982
- if (!scriptPath) {
983
- return sendJSON(res, 500, { success: false, error: 'knoxis-interactive-pair.js not found — reinstall knoxis-helper.' }, requestOrigin);
984
- }
985
- const kitEnv = { KNOXIS_WORKSPACE_PATH: workspaceDir, KNOXIS_KIT_MODE: kitMode };
986
- if (promptText) kitEnv.KNOXIS_TASK_FILE = promptFile;
987
- if (archetype) kitEnv.KNOXIS_KIT_ARCHETYPE = archetype;
988
- if (pattern) kitEnv.KNOXIS_KIT_PATTERN = pattern;
989
- if (productSlug) kitEnv.KNOXIS_PRODUCT_SLUG = productSlug;
990
- if (projectSlug) kitEnv.KNOXIS_PROJECT_SLUG = projectSlug;
991
- if (portalUserId) kitEnv.KNOXIS_USER_ID = portalUserId;
992
- if (portalWorkspaceId) kitEnv.KNOXIS_WORKSPACE_ID = portalWorkspaceId;
993
- if (portalTaskIds && portalTaskIds.length) kitEnv.KNOXIS_TASK_IDS = portalTaskIds.join(',');
994
- if (sessionId) kitEnv.KNOXIS_BACKEND_SESSION_ID = sessionId;
995
- command = buildEnvCommand(kitEnv, `node "${scriptPath}"`);
996
- mode = `kit:${kitMode}${archetype ? `/${archetype}` : ''}`;
997
- console.log(`🧰 Kit (interactive runner): ${mode} — ${scriptPath}`);
998
- } else if (kitMode || (ppScript && !interactive && promptText)) {
999
- command = buildPairProgramCommand({
1000
- scriptPath: ppScript,
1001
- workspace: workspaceDir,
1002
- mode: kitMode || null,
1003
- archetype,
1004
- pattern,
1005
- productSlug,
1006
- projectSlug,
1007
- taskPrompt: promptText,
1008
- userId: portalUserId,
1009
- workspaceId: portalWorkspaceId,
1010
- taskIds: portalTaskIds
1011
- });
1012
- mode = kitMode
1013
- ? `kit:${kitMode}${archetype ? `/${archetype}` : ''}${pattern ? `/${pattern}` : ''}`
1014
- : 'pair-program:default';
1015
- console.log(`🧰 ${kitMode ? 'Kit' : 'Pair-program (default pipeline)'}: ${mode}`);
1016
- } else if (interactive) {
966
+ if (interactive) {
1017
967
  const scriptPath = resolveInteractiveScript();
1018
968
  if (scriptPath) {
1019
- // Interactive mode: multi-turn with Groq pair programmer.
1020
- // Pass identity env vars so the script can build a schema-aligned
1021
- // session record and POST it via portal-sync.
1022
- const interactiveEnv = { KNOXIS_TASK_FILE: promptFile, KNOXIS_WORKSPACE_PATH: workspaceDir };
1023
- if (portalUserId) interactiveEnv.KNOXIS_USER_ID = portalUserId;
1024
- if (portalWorkspaceId) interactiveEnv.KNOXIS_WORKSPACE_ID = portalWorkspaceId;
1025
- if (portalTaskIds && portalTaskIds.length) interactiveEnv.KNOXIS_TASK_IDS = portalTaskIds.join(',');
1026
- if (productSlug) interactiveEnv.KNOXIS_PRODUCT_SLUG = productSlug;
1027
- if (projectSlug) interactiveEnv.KNOXIS_PROJECT_SLUG = projectSlug;
1028
- if (sessionId) interactiveEnv.KNOXIS_BACKEND_SESSION_ID = sessionId;
1029
- command = buildEnvCommand(interactiveEnv, `node "${scriptPath}"`);
969
+ // Interactive mode: multi-turn with Groq pair programmer
970
+ command = buildEnvCommand({ KNOXIS_TASK_FILE: promptFile }, `node "${scriptPath}"`);
1030
971
  mode = 'interactive';
1031
972
  console.log(`🤝 Interactive mode: ${scriptPath}`);
1032
973
  } else {
@@ -1035,19 +976,17 @@ async function handleRequest(req, res) {
1035
976
  command = buildCatCommand(promptFile) + ' | claude --dangerously-skip-permissions';
1036
977
  }
1037
978
  } else {
1038
- // Legacy fallback when neither pair-program nor interactive scripts are installed.
979
+ // Standard single-shot mode: pipe task to Claude
1039
980
  command = buildCatCommand(promptFile) + ' | claude --dangerously-skip-permissions';
1040
981
  }
1041
982
 
1042
983
  if (headless) {
1043
- // Anything routed through pair-program.js or knoxis-interactive-pair.js
1044
- // is a script invocation run the assembled command verbatim.
1045
- const isScriptInvocation = mode === 'interactive' || mode === 'pair-program:default' || (kitMode != null);
1046
- if (isScriptInvocation) {
984
+ if (interactive && mode === 'interactive') {
985
+ // Headless interactive: run the script directly as a process
1047
986
  const result = await runHeadlessProcess({
1048
987
  workspace: workspaceDir,
1049
988
  command,
1050
- sessionLabel: sessionId || (kitMode ? `kit-${kitMode}` : (mode === 'interactive' ? 'interactive-pair' : 'pair-program'))
989
+ sessionLabel: sessionId || 'interactive-pair'
1051
990
  });
1052
991
  return sendJSON(res, result.success ? 200 : 500, { ...result, mode }, requestOrigin);
1053
992
  }
@@ -1076,15 +1015,10 @@ async function handleRequest(req, res) {
1076
1015
 
1077
1016
  return sendJSON(res, 200, {
1078
1017
  success: true,
1079
- message: kitMode ? `Pair programming session started (kit mode: ${kitMode})`
1080
- : interactive ? 'Interactive pair programming session started'
1081
- : 'Pair programming session started',
1018
+ message: interactive ? 'Interactive pair programming session started' : 'Pair programming session started',
1082
1019
  mode,
1083
- kitMode: kitMode || null,
1084
- archetype: archetype || null,
1085
- pattern: pattern || null,
1086
1020
  workspace: workspaceDir,
1087
- task: task || null,
1021
+ task,
1088
1022
  file: file || null
1089
1023
  }, requestOrigin);
1090
1024
  } catch (error) {
@@ -1437,6 +1371,13 @@ function connectRelayWebSocket() {
1437
1371
  try {
1438
1372
  fs.writeFileSync(path.join(wsDir, 'CLAUDE.md'), msg.claudeMdContent, 'utf8');
1439
1373
  console.log(` 📄 Wrote CLAUDE.md (${msg.claudeMdContent.length} chars)`);
1374
+ // Maker-aware discovery integration
1375
+ try {
1376
+ const makerDocs = collectMakerDocs(wsDir);
1377
+ console.log(` Maker doc sync: ${makerDocs.length} files found.`);
1378
+ } catch (err) {
1379
+ console.warn('⚠️ Maker doc sync failed:', err.message);
1380
+ }
1440
1381
  } catch (writeErr) {
1441
1382
  console.warn(` ⚠️ Failed to write CLAUDE.md: ${writeErr.message}`);
1442
1383
  }
@@ -1463,84 +1404,31 @@ function connectRelayWebSocket() {
1463
1404
  }
1464
1405
 
1465
1406
  const interactive = msg.interactive === true;
1466
- const kitMode = typeof msg.mode === 'string' && KIT_MODES.has(msg.mode) ? msg.mode : null;
1467
- const archetype = typeof msg.archetype === 'string' ? msg.archetype : null;
1468
- const pattern = typeof msg.pattern === 'string' ? msg.pattern : null;
1469
- const productSlug = typeof msg.productSlug === 'string' ? msg.productSlug : null;
1470
- const projectSlug = typeof msg.projectSlug === 'string' ? msg.projectSlug : null;
1471
- const portalUserId = typeof msg.userId === 'string' ? msg.userId : null;
1472
- const portalWorkspaceId = typeof msg.workspaceId === 'string' ? msg.workspaceId : null;
1473
- const portalTaskIds = Array.isArray(msg.taskIds)
1474
- ? msg.taskIds.filter(t => typeof t === 'string' && t)
1475
- : (typeof msg.taskId === 'string' ? [msg.taskId] : []);
1476
- const ppScript = resolvePairProgramScript();
1477
- const kitViaInteractive = kitMode && KIT_MODES_VIA_INTERACTIVE.has(kitMode);
1478
- const routeViaPairProgram = !kitViaInteractive && (kitMode || (ppScript && !interactive && taskPrompt));
1479
-
1480
- if (kitViaInteractive) {
1481
- const scriptPath = resolveInteractiveScript();
1482
- if (scriptPath) {
1483
- // kickoff/resume need Claude's tool path (claude -p --session-id),
1484
- // which the interactive runner provides. Pass the kit mode + identity
1485
- // via env so the runner picks the kit branch instead of the 4-phase
1486
- // Groq flow.
1487
- if (taskPrompt) {
1488
- promptFile = path.join(os.tmpdir(), `knoxis-task-${msg.requestId || Date.now()}.txt`);
1489
- fs.writeFileSync(promptFile, taskPrompt, 'utf8');
1490
- }
1491
- const kitEnv = { KNOXIS_WORKSPACE_PATH: wsDir, KNOXIS_KIT_MODE: kitMode };
1492
- if (promptFile) kitEnv.KNOXIS_TASK_FILE = promptFile;
1493
- if (archetype) kitEnv.KNOXIS_KIT_ARCHETYPE = archetype;
1494
- if (pattern) kitEnv.KNOXIS_KIT_PATTERN = pattern;
1495
- if (productSlug) kitEnv.KNOXIS_PRODUCT_SLUG = productSlug;
1496
- if (projectSlug) kitEnv.KNOXIS_PROJECT_SLUG = projectSlug;
1497
- if (portalUserId) kitEnv.KNOXIS_USER_ID = portalUserId;
1498
- if (portalWorkspaceId) kitEnv.KNOXIS_WORKSPACE_ID = portalWorkspaceId;
1499
- if (portalTaskIds && portalTaskIds.length) kitEnv.KNOXIS_TASK_IDS = portalTaskIds.join(',');
1500
- const relaySessionId = typeof msg.sessionId === 'string' ? msg.sessionId : null;
1501
- if (relaySessionId) kitEnv.KNOXIS_BACKEND_SESSION_ID = relaySessionId;
1502
- command = buildEnvCommand(kitEnv, `node "${scriptPath}"`);
1503
- console.log(` 🧰 Kit (interactive runner): ${kitMode}${archetype ? `/${archetype}` : ''} — ${scriptPath}`);
1504
- } else {
1505
- console.warn(` ⚠️ knoxis-interactive-pair.js not found — cannot run kit mode ${kitMode}`);
1506
- }
1507
- } else if (routeViaPairProgram && ppScript) {
1508
- command = buildPairProgramCommand({
1509
- scriptPath: ppScript,
1510
- workspace: wsDir,
1511
- mode: kitMode || null,
1512
- archetype,
1513
- pattern,
1514
- productSlug,
1515
- projectSlug,
1516
- taskPrompt: taskPrompt || '',
1517
- userId: portalUserId,
1518
- workspaceId: portalWorkspaceId,
1519
- taskIds: portalTaskIds
1520
- });
1521
- console.log(` 🧰 ${kitMode ? `Kit mode: ${kitMode}` : 'Pair-program (default pipeline)'}${archetype ? `/${archetype}` : ''}${pattern ? `/${pattern}` : ''}`);
1522
- } else if (kitMode) {
1523
- console.warn(` ⚠️ knoxis-pair-program.js not found — cannot run kit mode`);
1524
- } else if (taskPrompt) {
1407
+
1408
+ if (taskPrompt) {
1525
1409
  promptFile = path.join(os.tmpdir(), `knoxis-task-${msg.requestId || Date.now()}.txt`);
1526
1410
  fs.writeFileSync(promptFile, taskPrompt, 'utf8');
1527
1411
 
1528
1412
  if (interactive) {
1529
- // Interactive mode: use multi-turn pair programming script.
1530
- // Pass identity env vars so the script can build a schema-aligned
1531
- // session record and POST it via portal-sync.
1413
+ // Interactive mode: use multi-turn pair programming script
1532
1414
  const scriptPath = resolveInteractiveScript();
1533
1415
  if (scriptPath) {
1534
- const interactiveEnv = { KNOXIS_TASK_FILE: promptFile, KNOXIS_WORKSPACE_PATH: wsDir };
1535
- if (portalUserId) interactiveEnv.KNOXIS_USER_ID = portalUserId;
1536
- if (portalWorkspaceId) interactiveEnv.KNOXIS_WORKSPACE_ID = portalWorkspaceId;
1537
- if (portalTaskIds && portalTaskIds.length) interactiveEnv.KNOXIS_TASK_IDS = portalTaskIds.join(',');
1538
- if (productSlug) interactiveEnv.KNOXIS_PRODUCT_SLUG = productSlug;
1539
- if (projectSlug) interactiveEnv.KNOXIS_PROJECT_SLUG = projectSlug;
1540
- const relaySessionId = typeof msg.sessionId === 'string' ? msg.sessionId : null;
1541
- if (relaySessionId) interactiveEnv.KNOXIS_BACKEND_SESSION_ID = relaySessionId;
1542
- command = buildEnvCommand(interactiveEnv, `node "${scriptPath}"`);
1416
+ // Forward QIG attribution from relay message env vars so
1417
+ // SessionRecorder writes non-null workspace_id / product_slug /
1418
+ // project_slug / task_ids to pair_programmer_sessions.
1419
+ const envVars = { KNOXIS_TASK_FILE: promptFile, KNOXIS_WORKSPACE_PATH: wsDir };
1420
+ if (msg.userId) envVars.KNOXIS_USER_ID = msg.userId;
1421
+ if (msg.workspaceId) envVars.KNOXIS_WORKSPACE_ID = msg.workspaceId;
1422
+ if (msg.productSlug) envVars.KNOXIS_PRODUCT_SLUG = msg.productSlug;
1423
+ if (msg.projectSlug) envVars.KNOXIS_PROJECT_SLUG = msg.projectSlug;
1424
+ if (msg.kitMode) envVars.KNOXIS_KIT_MODE = msg.kitMode;
1425
+ if (Array.isArray(msg.taskIds) && msg.taskIds.length > 0) {
1426
+ envVars.KNOXIS_TASK_IDS = msg.taskIds.filter(Boolean).join(',');
1427
+ }
1428
+ command = buildEnvCommand(envVars, `node "${scriptPath}"`);
1543
1429
  console.log(` 🤝 Interactive mode: ${scriptPath}`);
1430
+ const forwarded = Object.keys(envVars).filter(k => k !== 'KNOXIS_TASK_FILE' && k !== 'KNOXIS_WORKSPACE_PATH');
1431
+ if (forwarded.length > 0) console.log(` 🏷️ Identity vars: ${forwarded.join(', ')}`);
1544
1432
  } else {
1545
1433
  command = buildCatCommand(promptFile) + ' | claude --dangerously-skip-permissions';
1546
1434
  console.warn(` ⚠️ Interactive script not found, falling back to single-shot`);
@@ -1598,28 +1486,6 @@ function connectRelayWebSocket() {
1598
1486
  }
1599
1487
  } else if (msg.type === 'connected') {
1600
1488
  console.log(`🤝 Backend acknowledged: ${msg.message}`);
1601
- } else if (msg.type === 'portal_config') {
1602
- // Backend is auto-distributing the portal-sync token + URL so devs
1603
- // don't paste them by hand. Merge into ~/.knoxis/config.json so
1604
- // portal-sync.js picks them up on the next session save.
1605
- try {
1606
- ensureKnoxisDir();
1607
- const cfgPath = path.join(KNOXIS_DIR, 'config.json');
1608
- let existing = {};
1609
- try {
1610
- if (fs.existsSync(cfgPath)) existing = JSON.parse(fs.readFileSync(cfgPath, 'utf8')) || {};
1611
- } catch (_) {}
1612
- if (typeof msg.portalToken === 'string' && msg.portalToken) existing.portalToken = msg.portalToken;
1613
- if (typeof msg.portalUrl === 'string' && msg.portalUrl) existing.portalUrl = msg.portalUrl;
1614
- if (typeof msg.portalSessionsPath === 'string' && msg.portalSessionsPath) {
1615
- existing.portalSessionsPath = msg.portalSessionsPath;
1616
- }
1617
- fs.writeFileSync(cfgPath, JSON.stringify(existing, null, 2));
1618
- // Don't log the token itself.
1619
- console.log(`🔐 Portal-sync config received (token len ${(msg.portalToken || '').length})`);
1620
- } catch (cfgErr) {
1621
- console.warn(`⚠️ Failed to persist portal_config: ${cfgErr.message}`);
1622
- }
1623
1489
  }
1624
1490
  } catch (e) {
1625
1491
  console.error('❌ Relay message handling error:', e.message);
@@ -1803,4 +1669,4 @@ server.listen(serverMeta.port, () => {
1803
1669
  console.log('✅ HTTPS enabled - ready for secure connections from deployed frontends');
1804
1670
  console.log('');
1805
1671
  }
1806
- });
1672
+ });
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
+ const crypto = require('crypto');
6
7
  const { execSync } = require('child_process');
7
8
  const util = require('util');
8
9
  const { exec } = require('child_process');
@@ -34,89 +35,139 @@ function extractFilesTouched(diff) {
34
35
  return Array.from(seen);
35
36
  }
36
37
 
37
- // Snapshot the canonical state files so the session record carries the
38
- // latest STATUS / HANDOFF / DECISIONS / etc. up to the portal for display
39
- // on qig.ai. Three groups are captured into a single bag keyed by relative
40
- // path; the frontend distinguishes them by path prefix.
38
+ // Snapshot the project's maker-framework markdown docs so the session record
39
+ // carries the latest STATUS / HANDOFF / ARCHITECTURE / PRD / etc. up to the
40
+ // portal for display on qig.ai. Captured as a single bag keyed by relative
41
+ // path; the frontend distinguishes by path prefix.
41
42
  //
42
- // docs/state/*.md — project-level state (always present after scaffold)
43
- // docs/architecture/*.md — ARCHITECTURE.md + ADRs from project-kickoff
44
- // docs/features/<slug>/* — per-feature ROADMAP / PHASES / STATUS / manifest
45
- // from feature-kickoff (one entry per feature)
43
+ // All 11 dirs under `docs/` listed in the maker framework are walked
44
+ // recursively for `.md` files in priority order, until a total-bag budget
45
+ // is hit. The Java PairProgrammerSessionService persists the bag to the
46
+ // `state_files` JSONB column.
46
47
  //
47
- // All three flow through `stateFiles` on the session record. The Java
48
- // PairProgrammerSessionService persists the bag to the `state_files` JSONB
49
- // column; no schema change is needed to add new path prefixes.
50
- const STATE_FILES_TO_CAPTURE = [
51
- 'docs/state/STATUS.md',
52
- 'docs/state/HANDOFF.md',
53
- 'docs/state/DECISIONS.md',
54
- 'docs/state/CHANGELOG.md',
55
- 'docs/state/OPEN_QUESTIONS.md',
56
- 'docs/state/RISKS.md',
57
- // Project architecture pack from kickoff v7.
58
- 'docs/architecture/ARCHITECTURE.md'
48
+ // Priority order matters: when the budget is exhausted, the high-value docs
49
+ // must already be in. State first, then architecture, then product / planning,
50
+ // then features, then the rest alphabetical.
51
+ const STATE_DOC_DIRS = [
52
+ 'state',
53
+ 'architecture',
54
+ 'product',
55
+ 'planning',
56
+ 'features',
57
+ 'compliance',
58
+ 'design',
59
+ 'operations',
60
+ 'reports',
61
+ 'requirements',
62
+ 'standards'
59
63
  ];
60
- const FEATURE_FILE_NAMES = [
61
- 'ROADMAP.md',
62
- 'PHASES.md',
63
- 'STATUS.md',
64
- 'OPEN_QUESTIONS.md',
65
- 'manifest.json'
66
- ];
67
- const MAX_STATE_FILE_BYTES = 256 * 1024; // 256KB per file
64
+ const MAX_STATE_FILE_BYTES = 256 * 1024; // 256KB per file
65
+ const MAX_BAG_BYTES = 2 * 1024 * 1024; // 2MB total — fits Postgres JSONB row comfortably + keeps frontend fast.
66
+ const MAX_FILES_PER_DIR = 50; // Bounds runaway dirs (e.g. archive of old reports).
67
+ const MAX_WALK_DEPTH = 5;
68
+
69
+ /**
70
+ * Walk the workspace's framework markdown docs and return a SPLIT representation:
71
+ * manifest[relPath] = { sha256, size } // small goes into the JSONB column
72
+ * contents[sha256] = base64(utf8 content) // large — uploaded to Supabase Storage
73
+ *
74
+ * Legacy placeholders (oversized-file notes, truncation marker) stay as string
75
+ * values in the manifest — they're not real files, just metadata the frontend
76
+ * already knows how to render.
77
+ */
78
+ function readStateFiles(workspace) {
79
+ const manifest = {};
80
+ const contents = {};
81
+ const skipped = [];
82
+ const state = { bytes: 0, halted: false };
83
+ if (!workspace) return { manifest, contents };
84
+
85
+ for (const dir of STATE_DOC_DIRS) {
86
+ walkMdFiles(path.join(workspace, 'docs', dir), `docs/${dir}`, manifest, contents, 0, state, skipped);
87
+ if (state.halted) break;
88
+ }
89
+
90
+ if (skipped.length > 0) {
91
+ manifest['__truncated__'] = JSON.stringify({
92
+ reason: `state_files bag hit ${MAX_BAG_BYTES}-byte budget`,
93
+ capturedBytes: state.bytes,
94
+ skippedFiles: skipped.slice(0, 200)
95
+ }, null, 2);
96
+ }
68
97
 
69
- function readSafe(absPath, relPath, out) {
98
+ return { manifest, contents };
99
+ }
100
+
101
+ function walkMdFiles(absDir, relDir, manifest, contents, depth, state, skipped) {
102
+ if (state.halted || depth > MAX_WALK_DEPTH) return;
103
+ let entries;
70
104
  try {
71
- const stat = fs.statSync(absPath);
72
- if (!stat.isFile()) return;
73
- if (stat.size > MAX_STATE_FILE_BYTES) {
74
- out[relPath] = `[File omitted: ${stat.size} bytes exceeds ${MAX_STATE_FILE_BYTES}-byte cap]`;
75
- return;
76
- }
77
- out[relPath] = fs.readFileSync(absPath, 'utf8');
105
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
78
106
  } catch (e) {
79
- // Missing file is normal — skip silently.
107
+ return; // Missing or unreadable dir — skip silently.
80
108
  }
81
- }
82
109
 
83
- function readStateFiles(workspace) {
84
- const out = {};
85
- if (!workspace) return out;
110
+ // Sort: directories last, files alphabetically. Gives stable, predictable
111
+ // capture order so truncation skips the same low-priority files each run.
112
+ entries.sort((a, b) => {
113
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? 1 : -1;
114
+ return a.name.localeCompare(b.name);
115
+ });
86
116
 
87
- // Fixed list (state pack + ARCHITECTURE).
88
- for (const rel of STATE_FILES_TO_CAPTURE) {
89
- readSafe(path.join(workspace, rel), rel, out);
117
+ let filesInDir = 0;
118
+ for (const entry of entries) {
119
+ if (state.halted) return;
120
+ if (entry.name.startsWith('.')) continue; // Skip hidden entries.
121
+ const absPath = path.join(absDir, entry.name);
122
+ const relPath = `${relDir}/${entry.name}`;
123
+ if (entry.isDirectory()) {
124
+ walkMdFiles(absPath, relPath, manifest, contents, depth + 1, state, skipped);
125
+ } else if (entry.isFile()) {
126
+ if (!entry.name.endsWith('.md')) continue;
127
+ if (entry.name.includes('.bak-')) continue; // Skip *.bak-<timestamp> backups.
128
+ if (filesInDir >= MAX_FILES_PER_DIR) {
129
+ skipped.push(`${relPath} (per-dir cap)`);
130
+ continue;
131
+ }
132
+ filesInDir++;
133
+ readSafe(absPath, relPath, manifest, contents, state, skipped);
134
+ }
90
135
  }
136
+ }
91
137
 
92
- // ADRs docs/architecture/adr/*.md.
93
- const adrDir = path.join(workspace, 'docs', 'architecture', 'adr');
138
+ function readSafe(absPath, relPath, manifest, contents, state, skipped) {
139
+ let stat;
94
140
  try {
95
- for (const entry of fs.readdirSync(adrDir, { withFileTypes: true })) {
96
- if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
97
- const rel = `docs/architecture/adr/${entry.name}`;
98
- readSafe(path.join(adrDir, entry.name), rel, out);
99
- }
141
+ stat = fs.statSync(absPath);
100
142
  } catch (e) {
101
- // No ADR dir is normal.
143
+ return; // Missing file is normal.
102
144
  }
103
-
104
- // Per-feature files docs/features/<slug>/{ROADMAP,PHASES,STATUS,OPEN_QUESTIONS}.md
105
- // and manifest.json. One slug = one folder.
106
- const featuresDir = path.join(workspace, 'docs', 'features');
145
+ if (!stat.isFile()) return;
146
+ if (stat.size > MAX_STATE_FILE_BYTES) {
147
+ // Oversized files stay as inline placeholder strings — the frontend treats
148
+ // these as "metadata, not file content" and shows them as-is.
149
+ manifest[relPath] = `[File omitted: ${stat.size} bytes exceeds ${MAX_STATE_FILE_BYTES}-byte cap]`;
150
+ return;
151
+ }
152
+ if (state.bytes + stat.size > MAX_BAG_BYTES) {
153
+ skipped.push(`${relPath} (bag budget)`);
154
+ state.halted = true;
155
+ return;
156
+ }
157
+ let buf;
107
158
  try {
108
- for (const entry of fs.readdirSync(featuresDir, { withFileTypes: true })) {
109
- if (!entry.isDirectory()) continue;
110
- for (const filename of FEATURE_FILE_NAMES) {
111
- const rel = `docs/features/${entry.name}/${filename}`;
112
- readSafe(path.join(featuresDir, entry.name, filename), rel, out);
113
- }
114
- }
159
+ buf = fs.readFileSync(absPath);
115
160
  } catch (e) {
116
- // No features dir is normal.
161
+ return; // Read failure (perms etc.) — skip silently.
117
162
  }
118
-
119
- return out;
163
+ const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
164
+ manifest[relPath] = { sha256, size: buf.length };
165
+ // Dedupe: only one base64 payload per unique sha. Multiple paths with the
166
+ // same content reference the same Storage object.
167
+ if (!Object.prototype.hasOwnProperty.call(contents, sha256)) {
168
+ contents[sha256] = buf.toString('base64');
169
+ }
170
+ state.bytes += buf.length;
120
171
  }
121
172
 
122
173
  // Enumerate feature slugs from the workspace so the frontend can list features
@@ -314,6 +365,10 @@ class SessionRecorder {
314
365
  _buildRecord(finalCommit, totalDiff) {
315
366
  const completedSteps = this.steps.filter(s => s.completedAt && !s.error).length;
316
367
  const closedCleanly = this.steps.length > 0 && completedSteps === this.steps.length;
368
+ // Split the state-files snapshot into a manifest (small, goes to JSONB) and
369
+ // a base64 contents map (large, backend offloads to Supabase Storage and
370
+ // strips before persisting the row).
371
+ const { manifest: stateFilesManifest, contents: stateFileContents } = readStateFiles(this.workspace);
317
372
  return {
318
373
  sessionId: this.sessionId,
319
374
  version: '1.1.0',
@@ -337,13 +392,14 @@ class SessionRecorder {
337
392
  completedSteps,
338
393
  closedCleanly,
339
394
  filesTouched: extractFilesTouched(totalDiff),
340
- // Snapshot of state-pack + architecture + feature files at session-end
341
- // so the QIG frontend can display the latest STATUS / HANDOFF /
342
- // DECISIONS / ROADMAP / etc. without needing live filesystem access
343
- // on the dev's machine. Single bag keyed by relative path; frontend
344
- // filters by prefix (docs/state/*, docs/architecture/*,
345
- // docs/features/<slug>/*).
346
- stateFiles: readStateFiles(this.workspace),
395
+ // Manifest keyed by relative path. Each entry is either a `{sha256, size}`
396
+ // pointer (Supabase Storage), an inline placeholder string (oversized
397
+ // file / truncation marker), or in older agents — the raw content
398
+ // string. The frontend renders all three shapes; see useStateFileBody.
399
+ stateFiles: stateFilesManifest,
400
+ // sha → base64(utf8). Drained server-side into pair-programmer-files
401
+ // bucket; never persisted to the JSONB column.
402
+ stateFileContents,
347
403
  // Quick index of feature slugs found in docs/features/. Lets the
348
404
  // frontend enumerate features without parsing the bag.
349
405
  featureSlugs: listFeatureSlugs(this.workspace),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knoxis-helper",
3
- "version": "1.8.6",
3
+ "version": "1.10.0",
4
4
  "description": "Local helper for Knoxis pair programming - connects your machine to Knoxis on qig.ai",
5
5
  "bin": {
6
6
  "knoxis-helper": "./bin/knoxis-helper.js"