knoxis-helper 1.5.0 → 1.5.2

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.
@@ -99,6 +99,7 @@ function installAgentLocally(force) {
99
99
  const sourceInteractivePair = path.join(libDir, 'knoxis-interactive-pair.js');
100
100
  const sourceStateScaffold = path.join(libDir, 'state-scaffold.js');
101
101
  const sourcePortalSync = path.join(libDir, 'portal-sync.js');
102
+ const sourceSessionRecorder = path.join(libDir, 'session-recorder.js');
102
103
  const sourceTemplatesDir = path.join(libDir, 'templates');
103
104
  const sourcePackage = path.join(__dirname, '..', 'package.json');
104
105
 
@@ -150,6 +151,11 @@ function installAgentLocally(force) {
150
151
  console.log(' Installed: portal-sync.js');
151
152
  }
152
153
 
154
+ if (fs.existsSync(sourceSessionRecorder)) {
155
+ fs.copyFileSync(sourceSessionRecorder, path.join(AGENT_DIR, 'session-recorder.js'));
156
+ console.log(' Installed: session-recorder.js');
157
+ }
158
+
153
159
  if (fs.existsSync(sourceTemplatesDir)) {
154
160
  copyDirRecursive(sourceTemplatesDir, path.join(AGENT_DIR, 'templates'));
155
161
  console.log(' Installed: templates/');
@@ -33,6 +33,9 @@ const https = require('https');
33
33
  const fs = require('fs');
34
34
  const path = require('path');
35
35
  const os = require('os');
36
+ const { SessionRecorder } = require('./session-recorder');
37
+ const { scaffoldStateLayout } = require('./state-scaffold');
38
+ const { syncSessionToPortal } = require('./portal-sync');
36
39
 
37
40
  // === CONFIG ===
38
41
  const CONFIG_PATH = path.join(os.homedir(), '.knoxis', 'config.json');
@@ -269,6 +272,42 @@ async function runSingleShot(task) {
269
272
  return result.code || 0;
270
273
  }
271
274
 
275
+ // === IDENTITY (passed by knoxis-local-agent via env vars) ===
276
+ function readIdentityFromEnv() {
277
+ const taskIds = (process.env.KNOXIS_TASK_IDS || '')
278
+ .split(',')
279
+ .map(s => s.trim())
280
+ .filter(Boolean);
281
+ return {
282
+ workspace: process.env.KNOXIS_WORKSPACE_PATH || process.cwd(),
283
+ userId: process.env.KNOXIS_USER_ID || (config && config.userId) || null,
284
+ workspaceId: process.env.KNOXIS_WORKSPACE_ID || null,
285
+ taskIds,
286
+ productSlug: process.env.KNOXIS_PRODUCT_SLUG || null,
287
+ projectSlug: process.env.KNOXIS_PROJECT_SLUG || null,
288
+ engineerId: process.env.KNOXIS_USER_ID || (config && config.userId) || null
289
+ };
290
+ }
291
+
292
+ // === RECORDER + PORTAL FINALIZATION ===
293
+ // Saves the session JSON locally and POSTs to the portal stub. Called from
294
+ // every exit path so partial / fallback sessions still surface in the UI.
295
+ async function finalizeSession(recorder) {
296
+ if (!recorder) return;
297
+ try {
298
+ const filepath = await recorder.saveAsync();
299
+ console.log(' Session JSON: ' + filepath);
300
+ } catch (e) {
301
+ console.warn(' Could not save session JSON: ' + e.message);
302
+ }
303
+ try {
304
+ const record = recorder.buildFinalRecord();
305
+ await syncSessionToPortal(record);
306
+ } catch (e) {
307
+ console.warn(' Portal-sync errored: ' + e.message);
308
+ }
309
+ }
310
+
272
311
  // === MAIN ===
273
312
  async function main() {
274
313
  const task = loadTask();
@@ -282,15 +321,40 @@ async function main() {
282
321
 
283
322
  initSessionLog();
284
323
 
324
+ // Auto-scaffold + recorder. Idempotent — pre-existing files preserved.
325
+ const identity = readIdentityFromEnv();
326
+ let scaffoldResult = null;
327
+ try {
328
+ scaffoldResult = scaffoldStateLayout(identity.workspace);
329
+ } catch (e) {
330
+ console.warn(' Scaffold failed: ' + e.message);
331
+ }
332
+
333
+ const recorder = new SessionRecorder(task, identity.workspace, 'Claude Code + Knoxis (Groq)', {
334
+ mode: null, // interactive flow isn't a kit mode
335
+ archetype: null,
336
+ productSlug: identity.productSlug,
337
+ projectSlug: identity.projectSlug,
338
+ engineerId: identity.engineerId,
339
+ userId: identity.userId,
340
+ workspaceId: identity.workspaceId,
341
+ taskIds: identity.taskIds
342
+ });
343
+
285
344
  console.log('');
286
345
  console.log('╔══════════════════════════════════════════════════════════════╗');
287
346
  console.log('║ KNOXIS INTERACTIVE PAIR PROGRAMMING ║');
288
347
  console.log('╚══════════════════════════════════════════════════════════════╝');
289
348
  console.log('');
290
- console.log(' Task: ' + task.substring(0, 100) + (task.length > 100 ? '...' : ''));
291
- console.log(' Session: ' + SESSION_ID);
292
- console.log(' Pair: ' + (hasGroq ? 'Groq (' + GROQ_MODEL + ')' : 'Disabled (no GROQ_API_KEY)'));
293
- console.log(' Timeout: ' + (MAX_PHASE_MS / 60000).toFixed(0) + ' min per phase');
349
+ console.log(' Task: ' + task.substring(0, 100) + (task.length > 100 ? '...' : ''));
350
+ console.log(' Session: ' + SESSION_ID);
351
+ console.log(' Recorded: ' + recorder.sessionId);
352
+ console.log(' Workspace: ' + identity.workspace);
353
+ console.log(' Pair: ' + (hasGroq ? 'Groq (' + GROQ_MODEL + ')' : 'Disabled (no GROQ_API_KEY)'));
354
+ console.log(' Timeout: ' + (MAX_PHASE_MS / 60000).toFixed(0) + ' min per phase');
355
+ if (scaffoldResult && (scaffoldResult.dirs.length || scaffoldResult.files.length)) {
356
+ console.log(' Scaffolded: ' + (scaffoldResult.dirs.length + scaffoldResult.files.length) + ' new entries (CODING_RULES + docs/state/)');
357
+ }
294
358
  console.log('');
295
359
 
296
360
  appendLog('# Knoxis Interactive Pair Programming Session');
@@ -301,7 +365,11 @@ async function main() {
301
365
 
302
366
  // If no Groq, fall back to enhanced single-shot
303
367
  if (!hasGroq) {
368
+ const idx = recorder.startStep('single-shot', 'Claude Code', task);
369
+ recorder.setStepPrompt(idx, task);
304
370
  const code = await runSingleShot(task);
371
+ recorder.completeStep(idx, '(single-shot mode; output streamed live)', code !== 0 ? `exit ${code}` : null);
372
+ await finalizeSession(recorder);
305
373
  process.exit(code);
306
374
  }
307
375
 
@@ -352,8 +420,11 @@ async function main() {
352
420
  'Share your plan and then STOP. Do not implement yet. I will review it first.',
353
421
  ].join('\n');
354
422
 
423
+ const phase1Idx = recorder.startStep('understand-plan', 'Claude Code', planPrompt);
424
+ recorder.setStepPrompt(phase1Idx, planPrompt);
355
425
  const phase1 = await runClaudeTurn(planPrompt, false);
356
426
  appendLog(phase1.stdout + '\n');
427
+ recorder.completeStep(phase1Idx, phase1.stdout, phase1.code !== 0 && !phase1.stdout ? `exit ${phase1.code}: ${phase1.stderr || '(empty)'}` : null);
357
428
 
358
429
  if (phase1.code !== 0 && !phase1.stdout) {
359
430
  console.log('');
@@ -362,9 +433,13 @@ async function main() {
362
433
  console.log(' stderr: ' + phase1.stderr.split('\n').slice(0, 5).join('\n '));
363
434
  }
364
435
  appendLog('Phase 1 failed (exit ' + phase1.code + '). stderr: ' + (phase1.stderr || '(empty)') + '\n');
436
+ const fallbackIdx = recorder.startStep('single-shot-fallback', 'Claude Code', task);
437
+ recorder.setStepPrompt(fallbackIdx, task);
365
438
  const code = await runSingleShot(task);
439
+ recorder.completeStep(fallbackIdx, '(single-shot fallback; output streamed live)', code !== 0 ? `exit ${code}` : null);
366
440
  const logFile = saveSessionLog();
367
441
  if (logFile) console.log(' Log: ' + logFile);
442
+ await finalizeSession(recorder);
368
443
  process.exit(code);
369
444
  }
370
445
 
@@ -377,12 +452,13 @@ async function main() {
377
452
  console.log(' KNOXIS: Reviewing plan...');
378
453
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
379
454
 
380
- const planReview = await callGroq(
381
- groqSystem,
382
- 'Claude produced the following plan:\n\n'
383
- + phase1.stdout.substring(0, 8000)
384
- + '\n\nReview this plan. Answer any questions Claude asked. Approve or suggest specific changes. Then tell Claude to proceed with implementation.'
385
- );
455
+ const planReviewPrompt = 'Claude produced the following plan:\n\n'
456
+ + phase1.stdout.substring(0, 8000)
457
+ + '\n\nReview this plan. Answer any questions Claude asked. Approve or suggest specific changes. Then tell Claude to proceed with implementation.';
458
+ const planReviewIdx = recorder.startStep('knoxis-plan-review', 'Knoxis (Groq)', planReviewPrompt);
459
+ recorder.setStepPrompt(planReviewIdx, planReviewPrompt);
460
+ const planReview = await callGroq(groqSystem, planReviewPrompt);
461
+ recorder.completeStep(planReviewIdx, planReview, null);
386
462
 
387
463
  console.log('');
388
464
  console.log(' Knoxis: ' + planReview);
@@ -399,8 +475,11 @@ async function main() {
399
475
  console.log('');
400
476
  appendLog('## Phase 2: Implementation\n');
401
477
 
478
+ const phase2Idx = recorder.startStep('implement', 'Claude Code', planReview);
479
+ recorder.setStepPrompt(phase2Idx, planReview);
402
480
  const phase2 = await runClaudeTurn(planReview, true);
403
481
  appendLog(phase2.stdout.substring(0, 10000) + '\n');
482
+ recorder.completeStep(phase2Idx, phase2.stdout, phase2.code !== 0 ? `exit ${phase2.code}: ${(phase2.stderr || '').slice(0, 200)}` : null);
404
483
 
405
484
  // If resume failed (session not found), try context accumulation fallback
406
485
  if (phase2.code !== 0 && phase2.stderr && phase2.stderr.includes('session')) {
@@ -418,9 +497,11 @@ async function main() {
418
497
  'Now implement the solution. Follow existing patterns in the codebase.',
419
498
  ].join('\n');
420
499
 
500
+ const phase2bIdx = recorder.startStep('implement-fallback', 'Claude Code', fallbackPrompt);
501
+ recorder.setStepPrompt(phase2bIdx, fallbackPrompt);
421
502
  const phase2b = await runClaudeTurn(fallbackPrompt, false);
422
- // Use new session for subsequent turns
423
503
  appendLog(phase2b.stdout.substring(0, 10000) + '\n');
504
+ recorder.completeStep(phase2bIdx, phase2b.stdout, phase2b.code !== 0 ? `exit ${phase2b.code}` : null);
424
505
 
425
506
  // Skip to verification with this result
426
507
  const verifyPrompt = 'Verify the changes compile/build correctly. Run the most relevant test or build command. Give a brief summary of what was done.';
@@ -430,8 +511,11 @@ async function main() {
430
511
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
431
512
  console.log('');
432
513
 
514
+ const phase3bIdx = recorder.startStep('verify', 'Claude Code', verifyPrompt);
515
+ recorder.setStepPrompt(phase3bIdx, verifyPrompt);
433
516
  const phase3b = await runClaudeTurn(verifyPrompt, true);
434
517
  appendLog('## Phase 3: Verification\n' + phase3b.stdout.substring(0, 5000) + '\n');
518
+ recorder.completeStep(phase3bIdx, phase3b.stdout, phase3b.code !== 0 ? `exit ${phase3b.code}` : null);
435
519
 
436
520
  console.log('');
437
521
  console.log('╔══════════════════════════════════════════════════════════════╗');
@@ -441,6 +525,7 @@ async function main() {
441
525
  if (logFile) console.log(' Log: ' + logFile);
442
526
  console.log(' Resume: claude --resume ' + SESSION_ID);
443
527
  console.log('');
528
+ await finalizeSession(recorder);
444
529
  process.exit(phase3b.code || 0);
445
530
  }
446
531
 
@@ -453,12 +538,13 @@ async function main() {
453
538
  console.log(' KNOXIS: Reviewing implementation...');
454
539
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
455
540
 
456
- const implReview = await callGroq(
457
- groqSystem,
458
- 'Claude implemented the following:\n\n'
459
- + phase2.stdout.substring(0, 8000)
460
- + '\n\nReview the implementation. If there are issues, describe specifically what needs fixing. If it looks correct, tell Claude to verify the build/tests and summarize what was done.'
461
- );
541
+ const implReviewPrompt = 'Claude implemented the following:\n\n'
542
+ + phase2.stdout.substring(0, 8000)
543
+ + '\n\nReview the implementation. If there are issues, describe specifically what needs fixing. If it looks correct, tell Claude to verify the build/tests and summarize what was done.';
544
+ const implReviewIdx = recorder.startStep('knoxis-impl-review', 'Knoxis (Groq)', implReviewPrompt);
545
+ recorder.setStepPrompt(implReviewIdx, implReviewPrompt);
546
+ const implReview = await callGroq(groqSystem, implReviewPrompt);
547
+ recorder.completeStep(implReviewIdx, implReview, null);
462
548
 
463
549
  console.log('');
464
550
  console.log(' Knoxis: ' + implReview);
@@ -475,12 +561,13 @@ async function main() {
475
561
  console.log('');
476
562
  appendLog('## Phase 3: Verification\n');
477
563
 
478
- const phase3 = await runClaudeTurn(
479
- implReview
480
- + '\n\nAfter addressing any feedback above, verify the changes compile/build correctly. Run the most relevant test or build command. Give a brief summary of everything that was done.',
481
- true
482
- );
564
+ const phase3Prompt = implReview
565
+ + '\n\nAfter addressing any feedback above, verify the changes compile/build correctly. Run the most relevant test or build command. Give a brief summary of everything that was done.';
566
+ const phase3Idx = recorder.startStep('verify', 'Claude Code', phase3Prompt);
567
+ recorder.setStepPrompt(phase3Idx, phase3Prompt);
568
+ const phase3 = await runClaudeTurn(phase3Prompt, true);
483
569
  appendLog(phase3.stdout.substring(0, 5000) + '\n');
570
+ recorder.completeStep(phase3Idx, phase3.stdout, phase3.code !== 0 ? `exit ${phase3.code}` : null);
484
571
 
485
572
 
486
573
  // ═══════════════════════════════════════════
@@ -497,6 +584,7 @@ async function main() {
497
584
  if (logFile) console.log(' Log: ' + logFile);
498
585
  console.log('');
499
586
 
587
+ await finalizeSession(recorder);
500
588
  process.exit(phase3.code || 0);
501
589
  }
502
590
 
@@ -990,8 +990,16 @@ async function handleRequest(req, res) {
990
990
  } else if (interactive) {
991
991
  const scriptPath = resolveInteractiveScript();
992
992
  if (scriptPath) {
993
- // Interactive mode: multi-turn with Groq pair programmer
994
- command = buildEnvCommand({ KNOXIS_TASK_FILE: promptFile }, `node "${scriptPath}"`);
993
+ // Interactive mode: multi-turn with Groq pair programmer.
994
+ // Pass identity env vars so the script can build a schema-aligned
995
+ // session record and POST it via portal-sync.
996
+ const interactiveEnv = { KNOXIS_TASK_FILE: promptFile, KNOXIS_WORKSPACE_PATH: workspaceDir };
997
+ if (portalUserId) interactiveEnv.KNOXIS_USER_ID = portalUserId;
998
+ if (portalWorkspaceId) interactiveEnv.KNOXIS_WORKSPACE_ID = portalWorkspaceId;
999
+ if (portalTaskIds && portalTaskIds.length) interactiveEnv.KNOXIS_TASK_IDS = portalTaskIds.join(',');
1000
+ if (productSlug) interactiveEnv.KNOXIS_PRODUCT_SLUG = productSlug;
1001
+ if (projectSlug) interactiveEnv.KNOXIS_PROJECT_SLUG = projectSlug;
1002
+ command = buildEnvCommand(interactiveEnv, `node "${scriptPath}"`);
995
1003
  mode = 'interactive';
996
1004
  console.log(`🤝 Interactive mode: ${scriptPath}`);
997
1005
  } else {
@@ -1463,10 +1471,18 @@ function connectRelayWebSocket() {
1463
1471
  fs.writeFileSync(promptFile, taskPrompt, 'utf8');
1464
1472
 
1465
1473
  if (interactive) {
1466
- // Interactive mode: use multi-turn pair programming script
1474
+ // Interactive mode: use multi-turn pair programming script.
1475
+ // Pass identity env vars so the script can build a schema-aligned
1476
+ // session record and POST it via portal-sync.
1467
1477
  const scriptPath = resolveInteractiveScript();
1468
1478
  if (scriptPath) {
1469
- command = buildEnvCommand({ KNOXIS_TASK_FILE: promptFile }, `node "${scriptPath}"`);
1479
+ const interactiveEnv = { KNOXIS_TASK_FILE: promptFile, KNOXIS_WORKSPACE_PATH: wsDir };
1480
+ if (portalUserId) interactiveEnv.KNOXIS_USER_ID = portalUserId;
1481
+ if (portalWorkspaceId) interactiveEnv.KNOXIS_WORKSPACE_ID = portalWorkspaceId;
1482
+ if (portalTaskIds && portalTaskIds.length) interactiveEnv.KNOXIS_TASK_IDS = portalTaskIds.join(',');
1483
+ if (productSlug) interactiveEnv.KNOXIS_PRODUCT_SLUG = productSlug;
1484
+ if (projectSlug) interactiveEnv.KNOXIS_PROJECT_SLUG = projectSlug;
1485
+ command = buildEnvCommand(interactiveEnv, `node "${scriptPath}"`);
1470
1486
  console.log(` 🤝 Interactive mode: ${scriptPath}`);
1471
1487
  } else {
1472
1488
  command = buildCatCommand(promptFile) + ' | claude --dangerously-skip-permissions';
@@ -7,6 +7,7 @@ const { spawn, spawnSync, execSync } = require('child_process');
7
7
  const kitTemplates = require('./templates');
8
8
  const { scaffoldStateLayout, assertStateLayout } = require('./state-scaffold');
9
9
  const { syncSessionToPortal } = require('./portal-sync');
10
+ const { SessionRecorder } = require('./session-recorder');
10
11
 
11
12
  // ===== RETRY CONFIGURATION =====
12
13
  // Can be overridden via environment variables
@@ -494,13 +495,10 @@ async function callAi(aiConfig, prompt, livePrinter, options = {}) {
494
495
  }
495
496
 
496
497
  // ===== SESSION RECORDING =====
497
- // Records full prompts, responses, git diffs, and timing for model training
498
-
499
- const SESSIONS_DIR = path.join(os.homedir(), '.knoxis', 'sessions');
500
-
501
- function ensureSessionDir() {
502
- if (!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true });
503
- }
498
+ // SessionRecorder + helpers live in ./session-recorder.js (shared with
499
+ // knoxis-interactive-pair.js). The retry-aware safeExec/safeExecAsync below
500
+ // are still defined here and injected into SessionRecorder via constructor
501
+ // meta so this script keeps its existing retry behavior on git calls.
504
502
 
505
503
  // Async version with proper setTimeout
506
504
  async function safeExecAsync(cmd, cwd, options = {}) {
@@ -632,153 +630,6 @@ function safeExec(cmd, cwd, options = {}) {
632
630
  return null;
633
631
  }
634
632
 
635
- function slugify(text) {
636
- return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60);
637
- }
638
-
639
- class SessionRecorder {
640
- constructor(task, workspace, aiProvider, meta = {}) {
641
- this.sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
642
- this.task = task;
643
- this.workspace = workspace;
644
- this.aiProvider = aiProvider;
645
- this.startedAt = new Date().toISOString();
646
- this.steps = [];
647
- this.initialCommit = safeExec('git rev-parse --short HEAD', workspace);
648
- // Schema-aligned metadata — maps to portal contract §3.4 SESSION fields.
649
- this.mode = meta.mode || null;
650
- this.archetype = meta.archetype || null;
651
- this.productSlug = meta.productSlug || null;
652
- this.projectSlug = meta.projectSlug || null;
653
- this.engineerId = meta.engineerId || null;
654
- // QIG portal linkage (set when launching from collaboration/tasks UI).
655
- this.userId = meta.userId || null; // profiles.id (auth user)
656
- this.workspaceId = meta.workspaceId || null; // collab_workspaces.id
657
- this.taskIds = Array.isArray(meta.taskIds) ? meta.taskIds.filter(Boolean) : [];
658
- ensureSessionDir();
659
- }
660
-
661
- startStep(key, agent, instruction) {
662
- const step = {
663
- stepKey: key, agentName: agent, instruction,
664
- prompt: null, response: null,
665
- startedAt: new Date().toISOString(), completedAt: null, durationMs: null,
666
- gitDiffBefore: safeExec('git diff', this.workspace) || '',
667
- gitDiffAfter: null, error: null
668
- };
669
- this.steps.push(step);
670
- return this.steps.length - 1;
671
- }
672
-
673
- setStepPrompt(idx, prompt) { if (this.steps[idx]) this.steps[idx].prompt = prompt; }
674
-
675
- completeStep(idx, response, error) {
676
- const s = this.steps[idx];
677
- if (!s) return;
678
- s.response = response;
679
- s.completedAt = new Date().toISOString();
680
- s.durationMs = new Date(s.completedAt) - new Date(s.startedAt);
681
- s.gitDiffAfter = safeExec('git diff', this.workspace) || '';
682
- s.codeChanged = s.gitDiffBefore !== s.gitDiffAfter;
683
- s.error = error || null;
684
- }
685
-
686
- async saveAsync() {
687
- const finalCommit = await safeExecAsync('git rev-parse --short HEAD', this.workspace) || '';
688
- const totalDiff = await safeExecAsync('git diff', this.workspace) || '';
689
- const record = this._buildRecord(finalCommit, totalDiff);
690
- const filename = `${this.sessionId}-${slugify(this.task)}.json`;
691
- const filepath = path.join(SESSIONS_DIR, filename);
692
- await fs.promises.writeFile(filepath, JSON.stringify(record, null, 2), 'utf8');
693
- // Append to index
694
- try {
695
- await fs.promises.appendFile(path.join(SESSIONS_DIR, 'index.jsonl'),
696
- JSON.stringify({ sessionId: record.sessionId, mode: record.mode, archetype: record.archetype, task: record.task, startedAt: record.startedAt, totalDurationMs: record.totalDurationMs, file: filename }) + '\n');
697
- } catch (e) {}
698
- return filepath;
699
- }
700
-
701
- // Sync version kept for backward compatibility
702
- save() {
703
- const finalCommit = safeExec('git rev-parse --short HEAD', this.workspace);
704
- const totalDiff = safeExec('git diff', this.workspace) || '';
705
- const record = this._buildRecord(finalCommit, totalDiff);
706
- const filename = `${this.sessionId}-${slugify(this.task)}.json`;
707
- const filepath = path.join(SESSIONS_DIR, filename);
708
- fs.writeFileSync(filepath, JSON.stringify(record, null, 2), 'utf8');
709
- // Append to index
710
- try {
711
- fs.appendFileSync(path.join(SESSIONS_DIR, 'index.jsonl'),
712
- JSON.stringify({ sessionId: record.sessionId, mode: record.mode, archetype: record.archetype, task: record.task, startedAt: record.startedAt, totalDurationMs: record.totalDurationMs, file: filename }) + '\n');
713
- } catch (e) {}
714
- return filepath;
715
- }
716
-
717
- buildFinalRecord() {
718
- const finalCommit = safeExec('git rev-parse --short HEAD', this.workspace);
719
- const totalDiff = safeExec('git diff', this.workspace) || '';
720
- return this._buildRecord(finalCommit, totalDiff);
721
- }
722
-
723
- _buildRecord(finalCommit, totalDiff) {
724
- const completedSteps = this.steps.filter(s => s.completedAt && !s.error).length;
725
- const closedCleanly = this.steps.length > 0 && completedSteps === this.steps.length;
726
- return {
727
- // Identification
728
- sessionId: this.sessionId,
729
- version: '1.1.0',
730
- // Portal contract §3.4 SESSION shape
731
- mode: this.mode,
732
- archetype: this.archetype,
733
- productSlug: this.productSlug,
734
- projectSlug: this.projectSlug,
735
- engineerId: this.engineerId,
736
- // QIG portal linkage
737
- userId: this.userId,
738
- workspaceId: this.workspaceId,
739
- taskIds: this.taskIds,
740
- task: this.task,
741
- workspace: this.workspace,
742
- aiProvider: this.aiProvider,
743
- startedAt: this.startedAt,
744
- completedAt: new Date().toISOString(),
745
- totalDurationMs: Date.now() - new Date(this.startedAt).getTime(),
746
- // Step-level detail (preserved from v1.0.0)
747
- steps: this.steps,
748
- totalSteps: this.steps.length,
749
- completedSteps,
750
- closedCleanly,
751
- // Derived collections — populated initially as empty arrays so the
752
- // portal can index them; structured extraction from Claude responses
753
- // is a follow-up. Maps to portal contract fields decisions_logged,
754
- // waivers_requested, incidents_flagged, rule_violations.
755
- filesTouched: extractFilesTouched(totalDiff),
756
- decisionsLogged: [],
757
- waiversRequested: [],
758
- incidentsFlagged: [],
759
- ruleViolations: [],
760
- archetypeSpecificData: {},
761
- git: {
762
- initialCommit: this.initialCommit,
763
- finalCommit,
764
- totalDiff
765
- },
766
- environment: { platform: os.platform(), nodeVersion: process.version }
767
- };
768
- }
769
- }
770
-
771
- function extractFilesTouched(diff) {
772
- if (!diff || typeof diff !== 'string') return [];
773
- const seen = new Set();
774
- const re = /^diff --git a\/(\S+) b\/(\S+)/gm;
775
- let match;
776
- while ((match = re.exec(diff)) !== null) {
777
- seen.add(match[2] || match[1]);
778
- }
779
- return Array.from(seen);
780
- }
781
-
782
633
  // ===== MAIN =====
783
634
 
784
635
  async function run() {
@@ -990,7 +841,11 @@ async function run() {
990
841
  engineerId,
991
842
  userId,
992
843
  workspaceId: workspaceIdArg,
993
- taskIds: taskIdsArg
844
+ taskIds: taskIdsArg,
845
+ // Inject this script's retry-aware exec helpers so git timeouts
846
+ // don't lose recordings.
847
+ safeExec,
848
+ safeExecAsync
994
849
  });
995
850
  console.log(`Recording: ON`);
996
851
  console.log('');
@@ -0,0 +1,228 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const { execSync } = require('child_process');
7
+ const util = require('util');
8
+ const { exec } = require('child_process');
9
+
10
+ const SESSIONS_DIR = path.join(os.homedir(), '.knoxis', 'sessions');
11
+
12
+ function ensureSessionDir() {
13
+ if (!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true });
14
+ }
15
+
16
+ function slugify(text) {
17
+ return String(text || '')
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9]+/g, '-')
20
+ .replace(/^-|-$/g, '')
21
+ .slice(0, 60);
22
+ }
23
+
24
+ function extractFilesTouched(diff) {
25
+ if (!diff || typeof diff !== 'string') return [];
26
+ const seen = new Set();
27
+ const re = /^diff --git a\/(\S+) b\/(\S+)/gm;
28
+ let match;
29
+ while ((match = re.exec(diff)) !== null) {
30
+ seen.add(match[2] || match[1]);
31
+ }
32
+ return Array.from(seen);
33
+ }
34
+
35
+ // Default git-call helpers — return null on any failure (callers want a string or null).
36
+ function defaultSafeExec(cmd, cwd) {
37
+ try {
38
+ return execSync(cmd, { cwd, encoding: 'utf8', timeout: 30000 }).trim();
39
+ } catch (e) {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ const execAsync = util.promisify(exec);
45
+ async function defaultSafeExecAsync(cmd, cwd) {
46
+ try {
47
+ const { stdout } = await execAsync(cmd, { cwd, encoding: 'utf8', timeout: 30000, maxBuffer: 10 * 1024 * 1024 });
48
+ return stdout.trim();
49
+ } catch (e) {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Records a multi-step pair-programming session — prompts, responses, git
56
+ * diffs, timings — and emits a JSON record on save() that is schema-aligned
57
+ * with the portal contract §3.4 SESSION shape.
58
+ *
59
+ * Used by both:
60
+ * - knoxis-pair-program.js (Claude-only pipelines, default + kit modes)
61
+ * - knoxis-interactive-pair.js (Groq-reviewed multi-phase flow)
62
+ *
63
+ * `meta.safeExec` / `meta.safeExecAsync` may be injected to reuse a caller's
64
+ * retry-aware exec wrappers; otherwise simple defaults run with no retries.
65
+ */
66
+ class SessionRecorder {
67
+ constructor(task, workspace, aiProvider, meta = {}) {
68
+ this.sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
69
+ this.task = task;
70
+ this.workspace = workspace;
71
+ this.aiProvider = aiProvider;
72
+ this.startedAt = new Date().toISOString();
73
+ this.steps = [];
74
+
75
+ this._safeExec = meta.safeExec || defaultSafeExec;
76
+ this._safeExecAsync = meta.safeExecAsync || defaultSafeExecAsync;
77
+
78
+ this.initialCommit = this._safeExec('git rev-parse --short HEAD', workspace);
79
+
80
+ // Schema-aligned metadata — maps to portal contract §3.4 SESSION fields.
81
+ this.mode = meta.mode || null;
82
+ this.archetype = meta.archetype || null;
83
+ this.productSlug = meta.productSlug || null;
84
+ this.projectSlug = meta.projectSlug || null;
85
+ this.engineerId = meta.engineerId || null;
86
+ // QIG portal linkage (set when launching from collaboration/tasks UI).
87
+ this.userId = meta.userId || null;
88
+ this.workspaceId = meta.workspaceId || null;
89
+ this.taskIds = Array.isArray(meta.taskIds) ? meta.taskIds.filter(Boolean) : [];
90
+
91
+ ensureSessionDir();
92
+ }
93
+
94
+ startStep(key, agent, instruction) {
95
+ const step = {
96
+ stepKey: key,
97
+ agentName: agent,
98
+ instruction,
99
+ prompt: null,
100
+ response: null,
101
+ startedAt: new Date().toISOString(),
102
+ completedAt: null,
103
+ durationMs: null,
104
+ gitDiffBefore: this._safeExec('git diff', this.workspace) || '',
105
+ gitDiffAfter: null,
106
+ codeChanged: false,
107
+ error: null
108
+ };
109
+ this.steps.push(step);
110
+ return this.steps.length - 1;
111
+ }
112
+
113
+ setStepPrompt(idx, prompt) {
114
+ if (this.steps[idx]) this.steps[idx].prompt = prompt;
115
+ }
116
+
117
+ completeStep(idx, response, error) {
118
+ const s = this.steps[idx];
119
+ if (!s) return;
120
+ s.response = response;
121
+ s.completedAt = new Date().toISOString();
122
+ s.durationMs = new Date(s.completedAt) - new Date(s.startedAt);
123
+ s.gitDiffAfter = this._safeExec('git diff', this.workspace) || '';
124
+ s.codeChanged = s.gitDiffBefore !== s.gitDiffAfter;
125
+ s.error = error || null;
126
+ }
127
+
128
+ buildFinalRecord() {
129
+ const finalCommit = this._safeExec('git rev-parse --short HEAD', this.workspace);
130
+ const totalDiff = this._safeExec('git diff', this.workspace) || '';
131
+ return this._buildRecord(finalCommit, totalDiff);
132
+ }
133
+
134
+ async saveAsync() {
135
+ const finalCommit = await this._safeExecAsync('git rev-parse --short HEAD', this.workspace) || '';
136
+ const totalDiff = await this._safeExecAsync('git diff', this.workspace) || '';
137
+ const record = this._buildRecord(finalCommit, totalDiff);
138
+ const filename = `${this.sessionId}-${slugify(this.task)}.json`;
139
+ const filepath = path.join(SESSIONS_DIR, filename);
140
+ await fs.promises.writeFile(filepath, JSON.stringify(record, null, 2), 'utf8');
141
+ try {
142
+ await fs.promises.appendFile(
143
+ path.join(SESSIONS_DIR, 'index.jsonl'),
144
+ JSON.stringify({
145
+ sessionId: record.sessionId,
146
+ mode: record.mode,
147
+ archetype: record.archetype,
148
+ task: record.task,
149
+ startedAt: record.startedAt,
150
+ totalDurationMs: record.totalDurationMs,
151
+ file: filename
152
+ }) + '\n'
153
+ );
154
+ } catch (_) {}
155
+ return filepath;
156
+ }
157
+
158
+ save() {
159
+ const finalCommit = this._safeExec('git rev-parse --short HEAD', this.workspace);
160
+ const totalDiff = this._safeExec('git diff', this.workspace) || '';
161
+ const record = this._buildRecord(finalCommit, totalDiff);
162
+ const filename = `${this.sessionId}-${slugify(this.task)}.json`;
163
+ const filepath = path.join(SESSIONS_DIR, filename);
164
+ fs.writeFileSync(filepath, JSON.stringify(record, null, 2), 'utf8');
165
+ try {
166
+ fs.appendFileSync(
167
+ path.join(SESSIONS_DIR, 'index.jsonl'),
168
+ JSON.stringify({
169
+ sessionId: record.sessionId,
170
+ mode: record.mode,
171
+ archetype: record.archetype,
172
+ task: record.task,
173
+ startedAt: record.startedAt,
174
+ totalDurationMs: record.totalDurationMs,
175
+ file: filename
176
+ }) + '\n'
177
+ );
178
+ } catch (_) {}
179
+ return filepath;
180
+ }
181
+
182
+ _buildRecord(finalCommit, totalDiff) {
183
+ const completedSteps = this.steps.filter(s => s.completedAt && !s.error).length;
184
+ const closedCleanly = this.steps.length > 0 && completedSteps === this.steps.length;
185
+ return {
186
+ sessionId: this.sessionId,
187
+ version: '1.1.0',
188
+ mode: this.mode,
189
+ archetype: this.archetype,
190
+ productSlug: this.productSlug,
191
+ projectSlug: this.projectSlug,
192
+ engineerId: this.engineerId,
193
+ userId: this.userId,
194
+ workspaceId: this.workspaceId,
195
+ taskIds: this.taskIds,
196
+ task: this.task,
197
+ workspace: this.workspace,
198
+ aiProvider: this.aiProvider,
199
+ startedAt: this.startedAt,
200
+ completedAt: new Date().toISOString(),
201
+ totalDurationMs: Date.now() - new Date(this.startedAt).getTime(),
202
+ steps: this.steps,
203
+ totalSteps: this.steps.length,
204
+ completedSteps,
205
+ closedCleanly,
206
+ filesTouched: extractFilesTouched(totalDiff),
207
+ decisionsLogged: [],
208
+ waiversRequested: [],
209
+ incidentsFlagged: [],
210
+ ruleViolations: [],
211
+ archetypeSpecificData: {},
212
+ git: {
213
+ initialCommit: this.initialCommit,
214
+ finalCommit,
215
+ totalDiff
216
+ },
217
+ environment: { platform: os.platform(), nodeVersion: process.version }
218
+ };
219
+ }
220
+ }
221
+
222
+ module.exports = {
223
+ SessionRecorder,
224
+ SESSIONS_DIR,
225
+ ensureSessionDir,
226
+ slugify,
227
+ extractFilesTouched
228
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knoxis-helper",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
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"