knoxis-helper 1.5.0 → 1.6.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.
- package/bin/knoxis-helper.js +6 -0
- package/lib/knoxis-interactive-pair.js +110 -22
- package/lib/knoxis-local-agent.js +20 -4
- package/lib/knoxis-pair-program.js +10 -155
- package/lib/session-recorder.js +265 -0
- package/package.json +1 -1
package/bin/knoxis-helper.js
CHANGED
|
@@ -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:
|
|
291
|
-
console.log(' Session:
|
|
292
|
-
console.log('
|
|
293
|
-
console.log('
|
|
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
|
|
381
|
-
|
|
382
|
-
'Claude
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
457
|
-
|
|
458
|
-
'Claude
|
|
459
|
-
|
|
460
|
-
|
|
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
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
498
|
-
|
|
499
|
-
|
|
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,265 @@
|
|
|
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
|
+
// Snapshot the 6 well-known state files (docs/state/*.md) so the
|
|
36
|
+
// session record carries the latest STATUS / HANDOFF / DECISIONS / etc.
|
|
37
|
+
// up to the portal for display on qig.ai.
|
|
38
|
+
const STATE_FILES_TO_CAPTURE = [
|
|
39
|
+
'docs/state/STATUS.md',
|
|
40
|
+
'docs/state/HANDOFF.md',
|
|
41
|
+
'docs/state/DECISIONS.md',
|
|
42
|
+
'docs/state/CHANGELOG.md',
|
|
43
|
+
'docs/state/OPEN_QUESTIONS.md',
|
|
44
|
+
'docs/state/RISKS.md'
|
|
45
|
+
];
|
|
46
|
+
const MAX_STATE_FILE_BYTES = 256 * 1024; // 256KB per file
|
|
47
|
+
|
|
48
|
+
function readStateFiles(workspace) {
|
|
49
|
+
const out = {};
|
|
50
|
+
if (!workspace) return out;
|
|
51
|
+
for (const rel of STATE_FILES_TO_CAPTURE) {
|
|
52
|
+
const abs = path.join(workspace, rel);
|
|
53
|
+
try {
|
|
54
|
+
const stat = fs.statSync(abs);
|
|
55
|
+
if (!stat.isFile()) continue;
|
|
56
|
+
if (stat.size > MAX_STATE_FILE_BYTES) {
|
|
57
|
+
out[rel] = `[File omitted: ${stat.size} bytes exceeds ${MAX_STATE_FILE_BYTES}-byte cap]`;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
out[rel] = fs.readFileSync(abs, 'utf8');
|
|
61
|
+
} catch (e) {
|
|
62
|
+
// Missing file is normal — skip silently.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Default git-call helpers — return null on any failure (callers want a string or null).
|
|
69
|
+
function defaultSafeExec(cmd, cwd) {
|
|
70
|
+
try {
|
|
71
|
+
return execSync(cmd, { cwd, encoding: 'utf8', timeout: 30000 }).trim();
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const execAsync = util.promisify(exec);
|
|
78
|
+
async function defaultSafeExecAsync(cmd, cwd) {
|
|
79
|
+
try {
|
|
80
|
+
const { stdout } = await execAsync(cmd, { cwd, encoding: 'utf8', timeout: 30000, maxBuffer: 10 * 1024 * 1024 });
|
|
81
|
+
return stdout.trim();
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Records a multi-step pair-programming session — prompts, responses, git
|
|
89
|
+
* diffs, timings — and emits a JSON record on save() that is schema-aligned
|
|
90
|
+
* with the portal contract §3.4 SESSION shape.
|
|
91
|
+
*
|
|
92
|
+
* Used by both:
|
|
93
|
+
* - knoxis-pair-program.js (Claude-only pipelines, default + kit modes)
|
|
94
|
+
* - knoxis-interactive-pair.js (Groq-reviewed multi-phase flow)
|
|
95
|
+
*
|
|
96
|
+
* `meta.safeExec` / `meta.safeExecAsync` may be injected to reuse a caller's
|
|
97
|
+
* retry-aware exec wrappers; otherwise simple defaults run with no retries.
|
|
98
|
+
*/
|
|
99
|
+
class SessionRecorder {
|
|
100
|
+
constructor(task, workspace, aiProvider, meta = {}) {
|
|
101
|
+
this.sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
102
|
+
this.task = task;
|
|
103
|
+
this.workspace = workspace;
|
|
104
|
+
this.aiProvider = aiProvider;
|
|
105
|
+
this.startedAt = new Date().toISOString();
|
|
106
|
+
this.steps = [];
|
|
107
|
+
|
|
108
|
+
this._safeExec = meta.safeExec || defaultSafeExec;
|
|
109
|
+
this._safeExecAsync = meta.safeExecAsync || defaultSafeExecAsync;
|
|
110
|
+
|
|
111
|
+
this.initialCommit = this._safeExec('git rev-parse --short HEAD', workspace);
|
|
112
|
+
|
|
113
|
+
// Schema-aligned metadata — maps to portal contract §3.4 SESSION fields.
|
|
114
|
+
this.mode = meta.mode || null;
|
|
115
|
+
this.archetype = meta.archetype || null;
|
|
116
|
+
this.productSlug = meta.productSlug || null;
|
|
117
|
+
this.projectSlug = meta.projectSlug || null;
|
|
118
|
+
this.engineerId = meta.engineerId || null;
|
|
119
|
+
// QIG portal linkage (set when launching from collaboration/tasks UI).
|
|
120
|
+
this.userId = meta.userId || null;
|
|
121
|
+
this.workspaceId = meta.workspaceId || null;
|
|
122
|
+
this.taskIds = Array.isArray(meta.taskIds) ? meta.taskIds.filter(Boolean) : [];
|
|
123
|
+
|
|
124
|
+
ensureSessionDir();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
startStep(key, agent, instruction) {
|
|
128
|
+
const step = {
|
|
129
|
+
stepKey: key,
|
|
130
|
+
agentName: agent,
|
|
131
|
+
instruction,
|
|
132
|
+
prompt: null,
|
|
133
|
+
response: null,
|
|
134
|
+
startedAt: new Date().toISOString(),
|
|
135
|
+
completedAt: null,
|
|
136
|
+
durationMs: null,
|
|
137
|
+
gitDiffBefore: this._safeExec('git diff', this.workspace) || '',
|
|
138
|
+
gitDiffAfter: null,
|
|
139
|
+
codeChanged: false,
|
|
140
|
+
error: null
|
|
141
|
+
};
|
|
142
|
+
this.steps.push(step);
|
|
143
|
+
return this.steps.length - 1;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
setStepPrompt(idx, prompt) {
|
|
147
|
+
if (this.steps[idx]) this.steps[idx].prompt = prompt;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
completeStep(idx, response, error) {
|
|
151
|
+
const s = this.steps[idx];
|
|
152
|
+
if (!s) return;
|
|
153
|
+
s.response = response;
|
|
154
|
+
s.completedAt = new Date().toISOString();
|
|
155
|
+
s.durationMs = new Date(s.completedAt) - new Date(s.startedAt);
|
|
156
|
+
s.gitDiffAfter = this._safeExec('git diff', this.workspace) || '';
|
|
157
|
+
s.codeChanged = s.gitDiffBefore !== s.gitDiffAfter;
|
|
158
|
+
s.error = error || null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
buildFinalRecord() {
|
|
162
|
+
const finalCommit = this._safeExec('git rev-parse --short HEAD', this.workspace);
|
|
163
|
+
const totalDiff = this._safeExec('git diff', this.workspace) || '';
|
|
164
|
+
return this._buildRecord(finalCommit, totalDiff);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async saveAsync() {
|
|
168
|
+
const finalCommit = await this._safeExecAsync('git rev-parse --short HEAD', this.workspace) || '';
|
|
169
|
+
const totalDiff = await this._safeExecAsync('git diff', this.workspace) || '';
|
|
170
|
+
const record = this._buildRecord(finalCommit, totalDiff);
|
|
171
|
+
const filename = `${this.sessionId}-${slugify(this.task)}.json`;
|
|
172
|
+
const filepath = path.join(SESSIONS_DIR, filename);
|
|
173
|
+
await fs.promises.writeFile(filepath, JSON.stringify(record, null, 2), 'utf8');
|
|
174
|
+
try {
|
|
175
|
+
await fs.promises.appendFile(
|
|
176
|
+
path.join(SESSIONS_DIR, 'index.jsonl'),
|
|
177
|
+
JSON.stringify({
|
|
178
|
+
sessionId: record.sessionId,
|
|
179
|
+
mode: record.mode,
|
|
180
|
+
archetype: record.archetype,
|
|
181
|
+
task: record.task,
|
|
182
|
+
startedAt: record.startedAt,
|
|
183
|
+
totalDurationMs: record.totalDurationMs,
|
|
184
|
+
file: filename
|
|
185
|
+
}) + '\n'
|
|
186
|
+
);
|
|
187
|
+
} catch (_) {}
|
|
188
|
+
return filepath;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
save() {
|
|
192
|
+
const finalCommit = this._safeExec('git rev-parse --short HEAD', this.workspace);
|
|
193
|
+
const totalDiff = this._safeExec('git diff', this.workspace) || '';
|
|
194
|
+
const record = this._buildRecord(finalCommit, totalDiff);
|
|
195
|
+
const filename = `${this.sessionId}-${slugify(this.task)}.json`;
|
|
196
|
+
const filepath = path.join(SESSIONS_DIR, filename);
|
|
197
|
+
fs.writeFileSync(filepath, JSON.stringify(record, null, 2), 'utf8');
|
|
198
|
+
try {
|
|
199
|
+
fs.appendFileSync(
|
|
200
|
+
path.join(SESSIONS_DIR, 'index.jsonl'),
|
|
201
|
+
JSON.stringify({
|
|
202
|
+
sessionId: record.sessionId,
|
|
203
|
+
mode: record.mode,
|
|
204
|
+
archetype: record.archetype,
|
|
205
|
+
task: record.task,
|
|
206
|
+
startedAt: record.startedAt,
|
|
207
|
+
totalDurationMs: record.totalDurationMs,
|
|
208
|
+
file: filename
|
|
209
|
+
}) + '\n'
|
|
210
|
+
);
|
|
211
|
+
} catch (_) {}
|
|
212
|
+
return filepath;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
_buildRecord(finalCommit, totalDiff) {
|
|
216
|
+
const completedSteps = this.steps.filter(s => s.completedAt && !s.error).length;
|
|
217
|
+
const closedCleanly = this.steps.length > 0 && completedSteps === this.steps.length;
|
|
218
|
+
return {
|
|
219
|
+
sessionId: this.sessionId,
|
|
220
|
+
version: '1.1.0',
|
|
221
|
+
mode: this.mode,
|
|
222
|
+
archetype: this.archetype,
|
|
223
|
+
productSlug: this.productSlug,
|
|
224
|
+
projectSlug: this.projectSlug,
|
|
225
|
+
engineerId: this.engineerId,
|
|
226
|
+
userId: this.userId,
|
|
227
|
+
workspaceId: this.workspaceId,
|
|
228
|
+
taskIds: this.taskIds,
|
|
229
|
+
task: this.task,
|
|
230
|
+
workspace: this.workspace,
|
|
231
|
+
aiProvider: this.aiProvider,
|
|
232
|
+
startedAt: this.startedAt,
|
|
233
|
+
completedAt: new Date().toISOString(),
|
|
234
|
+
totalDurationMs: Date.now() - new Date(this.startedAt).getTime(),
|
|
235
|
+
steps: this.steps,
|
|
236
|
+
totalSteps: this.steps.length,
|
|
237
|
+
completedSteps,
|
|
238
|
+
closedCleanly,
|
|
239
|
+
filesTouched: extractFilesTouched(totalDiff),
|
|
240
|
+
// Snapshot of docs/state/*.md at session-end so the QIG frontend can
|
|
241
|
+
// display the latest STATUS / HANDOFF / DECISIONS / etc. without
|
|
242
|
+
// needing live filesystem access on the dev's machine.
|
|
243
|
+
stateFiles: readStateFiles(this.workspace),
|
|
244
|
+
decisionsLogged: [],
|
|
245
|
+
waiversRequested: [],
|
|
246
|
+
incidentsFlagged: [],
|
|
247
|
+
ruleViolations: [],
|
|
248
|
+
archetypeSpecificData: {},
|
|
249
|
+
git: {
|
|
250
|
+
initialCommit: this.initialCommit,
|
|
251
|
+
finalCommit,
|
|
252
|
+
totalDiff
|
|
253
|
+
},
|
|
254
|
+
environment: { platform: os.platform(), nodeVersion: process.version }
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = {
|
|
260
|
+
SessionRecorder,
|
|
261
|
+
SESSIONS_DIR,
|
|
262
|
+
ensureSessionDir,
|
|
263
|
+
slugify,
|
|
264
|
+
extractFilesTouched
|
|
265
|
+
};
|