knoxis-helper 1.4.8 → 1.5.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 +33 -3
- package/lib/knoxis-local-agent.js +156 -15
- package/lib/knoxis-pair-program.js +227 -40
- package/lib/portal-sync.js +149 -0
- package/lib/state-scaffold.js +125 -0
- package/lib/templates/coding-ruleset.js +139 -0
- package/lib/templates/index.js +44 -0
- package/lib/templates/kickoff.js +175 -0
- package/lib/templates/recovery.js +130 -0
- package/lib/templates/resume.js +205 -0
- package/lib/templates/session-end.js +171 -0
- package/package.json +1 -1
package/bin/knoxis-helper.js
CHANGED
|
@@ -81,10 +81,25 @@ function ask(rl, question) {
|
|
|
81
81
|
* Copy agent files to ~/.knoxis/agent/ so they persist across npx cache clears,
|
|
82
82
|
* VPN changes, and reboots. Returns the stable path.
|
|
83
83
|
*/
|
|
84
|
+
function copyDirRecursive(src, dest) {
|
|
85
|
+
if (!fs.existsSync(src)) return;
|
|
86
|
+
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
|
|
87
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
88
|
+
const s = path.join(src, entry.name);
|
|
89
|
+
const d = path.join(dest, entry.name);
|
|
90
|
+
if (entry.isDirectory()) copyDirRecursive(s, d);
|
|
91
|
+
else if (entry.isFile()) fs.copyFileSync(s, d);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
84
95
|
function installAgentLocally(force) {
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
const
|
|
96
|
+
const libDir = path.join(__dirname, '..', 'lib');
|
|
97
|
+
const sourceAgent = path.join(libDir, 'knoxis-local-agent.js');
|
|
98
|
+
const sourcePairProgram = path.join(libDir, 'knoxis-pair-program.js');
|
|
99
|
+
const sourceInteractivePair = path.join(libDir, 'knoxis-interactive-pair.js');
|
|
100
|
+
const sourceStateScaffold = path.join(libDir, 'state-scaffold.js');
|
|
101
|
+
const sourcePortalSync = path.join(libDir, 'portal-sync.js');
|
|
102
|
+
const sourceTemplatesDir = path.join(libDir, 'templates');
|
|
88
103
|
const sourcePackage = path.join(__dirname, '..', 'package.json');
|
|
89
104
|
|
|
90
105
|
if (!fs.existsSync(sourceAgent)) {
|
|
@@ -125,6 +140,21 @@ function installAgentLocally(force) {
|
|
|
125
140
|
console.log(' Installed: knoxis-interactive-pair.js');
|
|
126
141
|
}
|
|
127
142
|
|
|
143
|
+
if (fs.existsSync(sourceStateScaffold)) {
|
|
144
|
+
fs.copyFileSync(sourceStateScaffold, path.join(AGENT_DIR, 'state-scaffold.js'));
|
|
145
|
+
console.log(' Installed: state-scaffold.js');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (fs.existsSync(sourcePortalSync)) {
|
|
149
|
+
fs.copyFileSync(sourcePortalSync, path.join(AGENT_DIR, 'portal-sync.js'));
|
|
150
|
+
console.log(' Installed: portal-sync.js');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (fs.existsSync(sourceTemplatesDir)) {
|
|
154
|
+
copyDirRecursive(sourceTemplatesDir, path.join(AGENT_DIR, 'templates'));
|
|
155
|
+
console.log(' Installed: templates/');
|
|
156
|
+
}
|
|
157
|
+
|
|
128
158
|
if (fs.existsSync(sourcePackage)) {
|
|
129
159
|
fs.copyFileSync(sourcePackage, path.join(AGENT_DIR, 'package.json'));
|
|
130
160
|
}
|
|
@@ -580,6 +580,47 @@ function resolveInteractiveScript() {
|
|
|
580
580
|
return null;
|
|
581
581
|
}
|
|
582
582
|
|
|
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', 'resume', 'session-end', 'recovery']);
|
|
598
|
+
|
|
599
|
+
function buildPairProgramCommand({ scriptPath, workspace, mode, archetype, pattern, productSlug, projectSlug, taskPrompt, userId, workspaceId, taskIds }) {
|
|
600
|
+
const q = v => `"${escapeForDoubleQuotedShellArg(v)}"`;
|
|
601
|
+
// Mode/archetype/pattern are constrained to known short tokens — no need to quote.
|
|
602
|
+
// Slugs and paths can contain spaces or special chars — always quote.
|
|
603
|
+
const parts = [`node ${q(scriptPath)}`, `--workspace ${q(workspace)}`];
|
|
604
|
+
if (mode) parts.push(`--mode ${mode}`);
|
|
605
|
+
if (archetype) parts.push(`--archetype ${archetype}`);
|
|
606
|
+
if (pattern) parts.push(`--pattern ${pattern}`);
|
|
607
|
+
if (productSlug) parts.push(`--product-slug ${q(productSlug)}`);
|
|
608
|
+
if (projectSlug) parts.push(`--project-slug ${q(projectSlug)}`);
|
|
609
|
+
if (userId) parts.push(`--user-id ${q(userId)}`);
|
|
610
|
+
if (workspaceId) parts.push(`--workspace-id ${q(workspaceId)}`);
|
|
611
|
+
if (Array.isArray(taskIds)) {
|
|
612
|
+
for (const t of taskIds) {
|
|
613
|
+
if (t) parts.push(`--task-id ${q(t)}`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if (taskPrompt && taskPrompt.length) {
|
|
617
|
+
// Use base64 to avoid shell-quoting hazards (newlines, quotes, backticks).
|
|
618
|
+
const b64 = Buffer.from(taskPrompt, 'utf8').toString('base64');
|
|
619
|
+
parts.push(`--prompt-base64 ${b64}`);
|
|
620
|
+
}
|
|
621
|
+
return parts.join(' ');
|
|
622
|
+
}
|
|
623
|
+
|
|
583
624
|
// Request handler
|
|
584
625
|
async function handleRequest(req, res) {
|
|
585
626
|
const parsedUrl = url.parse(req.url, true);
|
|
@@ -867,8 +908,22 @@ async function handleRequest(req, res) {
|
|
|
867
908
|
const body = await parseBody(req);
|
|
868
909
|
const { workspace, task, file, provider, headless, sessionId, claudeMdContent } = body;
|
|
869
910
|
const interactive = body.interactive === true || body.interactive === 'true';
|
|
870
|
-
|
|
871
|
-
|
|
911
|
+
const kitMode = typeof body.mode === 'string' && KIT_MODES.has(body.mode) ? body.mode : null;
|
|
912
|
+
const archetype = typeof body.archetype === 'string' ? body.archetype : null;
|
|
913
|
+
const pattern = typeof body.pattern === 'string' ? body.pattern : null;
|
|
914
|
+
const productSlug = typeof body.productSlug === 'string' ? body.productSlug : null;
|
|
915
|
+
const projectSlug = typeof body.projectSlug === 'string' ? body.projectSlug : null;
|
|
916
|
+
// QIG portal linkage — let session records get tied back to the user / workspace / tasks.
|
|
917
|
+
const portalUserId = typeof body.userId === 'string' ? body.userId : null;
|
|
918
|
+
const portalWorkspaceId = typeof body.workspaceId === 'string' ? body.workspaceId : null;
|
|
919
|
+
const portalTaskIds = Array.isArray(body.taskIds)
|
|
920
|
+
? body.taskIds.filter(t => typeof t === 'string' && t)
|
|
921
|
+
: (typeof body.taskId === 'string' ? [body.taskId] : []);
|
|
922
|
+
|
|
923
|
+
// Kickoff and session-end modes don't strictly need a task — the kit prompt
|
|
924
|
+
// drives the interaction. For all other paths a task is still required.
|
|
925
|
+
const taskOptionalForMode = kitMode === 'kickoff' || kitMode === 'session-end';
|
|
926
|
+
if (!task && !taskOptionalForMode) {
|
|
872
927
|
return sendJSON(res, 400, { success: false, error: 'Task description required' }, requestOrigin);
|
|
873
928
|
}
|
|
874
929
|
|
|
@@ -895,16 +950,44 @@ async function handleRequest(req, res) {
|
|
|
895
950
|
}
|
|
896
951
|
}
|
|
897
952
|
|
|
898
|
-
// Write the actual task to a temp file
|
|
953
|
+
// Write the actual task to a temp file (used by single-shot and interactive paths)
|
|
899
954
|
const promptFile = path.join(os.tmpdir(), `knoxis-task-${sessionId || Date.now()}.txt`);
|
|
900
|
-
const promptText = file ? `Working on file: ${file}\n\nTask: ${task}` : task;
|
|
901
|
-
fs.writeFileSync(promptFile, promptText, 'utf8');
|
|
955
|
+
const promptText = task ? (file ? `Working on file: ${file}\n\nTask: ${task}` : task) : '';
|
|
956
|
+
if (promptText) fs.writeFileSync(promptFile, promptText, 'utf8');
|
|
902
957
|
|
|
903
|
-
// Determine the command to run
|
|
958
|
+
// Determine the command to run.
|
|
904
959
|
let command;
|
|
905
960
|
let mode = 'single-shot';
|
|
906
961
|
|
|
907
|
-
|
|
962
|
+
// Route through knoxis-pair-program.js whenever it's installed — gives
|
|
963
|
+
// every task auto-scaffold, the standards systemIntro, auto-context, and
|
|
964
|
+
// portal-sync. Kit mode adds --mode; default mode runs the standard
|
|
965
|
+
// 4-step pipeline. Interactive (Groq) mode and the legacy claude-pipe
|
|
966
|
+
// fallback remain available when the runner isn't present.
|
|
967
|
+
const ppScript = resolvePairProgramScript();
|
|
968
|
+
if (kitMode && !ppScript) {
|
|
969
|
+
return sendJSON(res, 500, { success: false, error: 'knoxis-pair-program.js not found — reinstall knoxis-helper.' }, requestOrigin);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
if (kitMode || (ppScript && !interactive && promptText)) {
|
|
973
|
+
command = buildPairProgramCommand({
|
|
974
|
+
scriptPath: ppScript,
|
|
975
|
+
workspace: workspaceDir,
|
|
976
|
+
mode: kitMode || null,
|
|
977
|
+
archetype,
|
|
978
|
+
pattern,
|
|
979
|
+
productSlug,
|
|
980
|
+
projectSlug,
|
|
981
|
+
taskPrompt: promptText,
|
|
982
|
+
userId: portalUserId,
|
|
983
|
+
workspaceId: portalWorkspaceId,
|
|
984
|
+
taskIds: portalTaskIds
|
|
985
|
+
});
|
|
986
|
+
mode = kitMode
|
|
987
|
+
? `kit:${kitMode}${archetype ? `/${archetype}` : ''}${pattern ? `/${pattern}` : ''}`
|
|
988
|
+
: 'pair-program:default';
|
|
989
|
+
console.log(`🧰 ${kitMode ? 'Kit' : 'Pair-program (default pipeline)'}: ${mode}`);
|
|
990
|
+
} else if (interactive) {
|
|
908
991
|
const scriptPath = resolveInteractiveScript();
|
|
909
992
|
if (scriptPath) {
|
|
910
993
|
// Interactive mode: multi-turn with Groq pair programmer
|
|
@@ -917,17 +1000,19 @@ async function handleRequest(req, res) {
|
|
|
917
1000
|
command = buildCatCommand(promptFile) + ' | claude --dangerously-skip-permissions';
|
|
918
1001
|
}
|
|
919
1002
|
} else {
|
|
920
|
-
//
|
|
1003
|
+
// Legacy fallback when neither pair-program nor interactive scripts are installed.
|
|
921
1004
|
command = buildCatCommand(promptFile) + ' | claude --dangerously-skip-permissions';
|
|
922
1005
|
}
|
|
923
1006
|
|
|
924
1007
|
if (headless) {
|
|
925
|
-
|
|
926
|
-
|
|
1008
|
+
// Anything routed through pair-program.js or knoxis-interactive-pair.js
|
|
1009
|
+
// is a script invocation — run the assembled command verbatim.
|
|
1010
|
+
const isScriptInvocation = mode === 'interactive' || mode === 'pair-program:default' || (kitMode != null);
|
|
1011
|
+
if (isScriptInvocation) {
|
|
927
1012
|
const result = await runHeadlessProcess({
|
|
928
1013
|
workspace: workspaceDir,
|
|
929
1014
|
command,
|
|
930
|
-
sessionLabel: sessionId || 'interactive-pair'
|
|
1015
|
+
sessionLabel: sessionId || (kitMode ? `kit-${kitMode}` : (mode === 'interactive' ? 'interactive-pair' : 'pair-program'))
|
|
931
1016
|
});
|
|
932
1017
|
return sendJSON(res, result.success ? 200 : 500, { ...result, mode }, requestOrigin);
|
|
933
1018
|
}
|
|
@@ -956,10 +1041,15 @@ async function handleRequest(req, res) {
|
|
|
956
1041
|
|
|
957
1042
|
return sendJSON(res, 200, {
|
|
958
1043
|
success: true,
|
|
959
|
-
message:
|
|
1044
|
+
message: kitMode ? `Pair programming session started (kit mode: ${kitMode})`
|
|
1045
|
+
: interactive ? 'Interactive pair programming session started'
|
|
1046
|
+
: 'Pair programming session started',
|
|
960
1047
|
mode,
|
|
1048
|
+
kitMode: kitMode || null,
|
|
1049
|
+
archetype: archetype || null,
|
|
1050
|
+
pattern: pattern || null,
|
|
961
1051
|
workspace: workspaceDir,
|
|
962
|
-
task,
|
|
1052
|
+
task: task || null,
|
|
963
1053
|
file: file || null
|
|
964
1054
|
}, requestOrigin);
|
|
965
1055
|
} catch (error) {
|
|
@@ -1338,8 +1428,37 @@ function connectRelayWebSocket() {
|
|
|
1338
1428
|
}
|
|
1339
1429
|
|
|
1340
1430
|
const interactive = msg.interactive === true;
|
|
1341
|
-
|
|
1342
|
-
|
|
1431
|
+
const kitMode = typeof msg.mode === 'string' && KIT_MODES.has(msg.mode) ? msg.mode : null;
|
|
1432
|
+
const archetype = typeof msg.archetype === 'string' ? msg.archetype : null;
|
|
1433
|
+
const pattern = typeof msg.pattern === 'string' ? msg.pattern : null;
|
|
1434
|
+
const productSlug = typeof msg.productSlug === 'string' ? msg.productSlug : null;
|
|
1435
|
+
const projectSlug = typeof msg.projectSlug === 'string' ? msg.projectSlug : null;
|
|
1436
|
+
const portalUserId = typeof msg.userId === 'string' ? msg.userId : null;
|
|
1437
|
+
const portalWorkspaceId = typeof msg.workspaceId === 'string' ? msg.workspaceId : null;
|
|
1438
|
+
const portalTaskIds = Array.isArray(msg.taskIds)
|
|
1439
|
+
? msg.taskIds.filter(t => typeof t === 'string' && t)
|
|
1440
|
+
: (typeof msg.taskId === 'string' ? [msg.taskId] : []);
|
|
1441
|
+
const ppScript = resolvePairProgramScript();
|
|
1442
|
+
const routeViaPairProgram = (kitMode || (ppScript && !interactive && taskPrompt));
|
|
1443
|
+
|
|
1444
|
+
if (routeViaPairProgram && ppScript) {
|
|
1445
|
+
command = buildPairProgramCommand({
|
|
1446
|
+
scriptPath: ppScript,
|
|
1447
|
+
workspace: wsDir,
|
|
1448
|
+
mode: kitMode || null,
|
|
1449
|
+
archetype,
|
|
1450
|
+
pattern,
|
|
1451
|
+
productSlug,
|
|
1452
|
+
projectSlug,
|
|
1453
|
+
taskPrompt: taskPrompt || '',
|
|
1454
|
+
userId: portalUserId,
|
|
1455
|
+
workspaceId: portalWorkspaceId,
|
|
1456
|
+
taskIds: portalTaskIds
|
|
1457
|
+
});
|
|
1458
|
+
console.log(` 🧰 ${kitMode ? `Kit mode: ${kitMode}` : 'Pair-program (default pipeline)'}${archetype ? `/${archetype}` : ''}${pattern ? `/${pattern}` : ''}`);
|
|
1459
|
+
} else if (kitMode) {
|
|
1460
|
+
console.warn(` ⚠️ knoxis-pair-program.js not found — cannot run kit mode`);
|
|
1461
|
+
} else if (taskPrompt) {
|
|
1343
1462
|
promptFile = path.join(os.tmpdir(), `knoxis-task-${msg.requestId || Date.now()}.txt`);
|
|
1344
1463
|
fs.writeFileSync(promptFile, taskPrompt, 'utf8');
|
|
1345
1464
|
|
|
@@ -1406,6 +1525,28 @@ function connectRelayWebSocket() {
|
|
|
1406
1525
|
}
|
|
1407
1526
|
} else if (msg.type === 'connected') {
|
|
1408
1527
|
console.log(`🤝 Backend acknowledged: ${msg.message}`);
|
|
1528
|
+
} else if (msg.type === 'portal_config') {
|
|
1529
|
+
// Backend is auto-distributing the portal-sync token + URL so devs
|
|
1530
|
+
// don't paste them by hand. Merge into ~/.knoxis/config.json so
|
|
1531
|
+
// portal-sync.js picks them up on the next session save.
|
|
1532
|
+
try {
|
|
1533
|
+
ensureKnoxisDir();
|
|
1534
|
+
const cfgPath = path.join(KNOXIS_DIR, 'config.json');
|
|
1535
|
+
let existing = {};
|
|
1536
|
+
try {
|
|
1537
|
+
if (fs.existsSync(cfgPath)) existing = JSON.parse(fs.readFileSync(cfgPath, 'utf8')) || {};
|
|
1538
|
+
} catch (_) {}
|
|
1539
|
+
if (typeof msg.portalToken === 'string' && msg.portalToken) existing.portalToken = msg.portalToken;
|
|
1540
|
+
if (typeof msg.portalUrl === 'string' && msg.portalUrl) existing.portalUrl = msg.portalUrl;
|
|
1541
|
+
if (typeof msg.portalSessionsPath === 'string' && msg.portalSessionsPath) {
|
|
1542
|
+
existing.portalSessionsPath = msg.portalSessionsPath;
|
|
1543
|
+
}
|
|
1544
|
+
fs.writeFileSync(cfgPath, JSON.stringify(existing, null, 2));
|
|
1545
|
+
// Don't log the token itself.
|
|
1546
|
+
console.log(`🔐 Portal-sync config received (token len ${(msg.portalToken || '').length})`);
|
|
1547
|
+
} catch (cfgErr) {
|
|
1548
|
+
console.warn(`⚠️ Failed to persist portal_config: ${cfgErr.message}`);
|
|
1549
|
+
}
|
|
1409
1550
|
}
|
|
1410
1551
|
} catch (e) {
|
|
1411
1552
|
console.error('❌ Relay message handling error:', e.message);
|
|
@@ -4,6 +4,9 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const { spawn, spawnSync, execSync } = require('child_process');
|
|
7
|
+
const kitTemplates = require('./templates');
|
|
8
|
+
const { scaffoldStateLayout, assertStateLayout } = require('./state-scaffold');
|
|
9
|
+
const { syncSessionToPortal } = require('./portal-sync');
|
|
7
10
|
|
|
8
11
|
// ===== RETRY CONFIGURATION =====
|
|
9
12
|
// Can be overridden via environment variables
|
|
@@ -634,7 +637,7 @@ function slugify(text) {
|
|
|
634
637
|
}
|
|
635
638
|
|
|
636
639
|
class SessionRecorder {
|
|
637
|
-
constructor(task, workspace, aiProvider) {
|
|
640
|
+
constructor(task, workspace, aiProvider, meta = {}) {
|
|
638
641
|
this.sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
639
642
|
this.task = task;
|
|
640
643
|
this.workspace = workspace;
|
|
@@ -642,6 +645,16 @@ class SessionRecorder {
|
|
|
642
645
|
this.startedAt = new Date().toISOString();
|
|
643
646
|
this.steps = [];
|
|
644
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) : [];
|
|
645
658
|
ensureSessionDir();
|
|
646
659
|
}
|
|
647
660
|
|
|
@@ -671,59 +684,99 @@ class SessionRecorder {
|
|
|
671
684
|
}
|
|
672
685
|
|
|
673
686
|
async saveAsync() {
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
startedAt: this.startedAt, completedAt: new Date().toISOString(),
|
|
678
|
-
totalDurationMs: Date.now() - new Date(this.startedAt).getTime(),
|
|
679
|
-
steps: this.steps,
|
|
680
|
-
totalSteps: this.steps.length,
|
|
681
|
-
completedSteps: this.steps.filter(s => s.completedAt && !s.error).length,
|
|
682
|
-
git: {
|
|
683
|
-
initialCommit: this.initialCommit,
|
|
684
|
-
finalCommit: await safeExecAsync('git rev-parse --short HEAD', this.workspace) || '',
|
|
685
|
-
totalDiff: await safeExecAsync('git diff', this.workspace) || ''
|
|
686
|
-
},
|
|
687
|
-
environment: { platform: os.platform(), nodeVersion: process.version }
|
|
688
|
-
};
|
|
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);
|
|
689
690
|
const filename = `${this.sessionId}-${slugify(this.task)}.json`;
|
|
690
691
|
const filepath = path.join(SESSIONS_DIR, filename);
|
|
691
692
|
await fs.promises.writeFile(filepath, JSON.stringify(record, null, 2), 'utf8');
|
|
692
693
|
// Append to index
|
|
693
694
|
try {
|
|
694
695
|
await fs.promises.appendFile(path.join(SESSIONS_DIR, 'index.jsonl'),
|
|
695
|
-
JSON.stringify({ sessionId: record.sessionId, task: record.task, startedAt: record.startedAt, totalDurationMs: record.totalDurationMs, file: filename }) + '\n');
|
|
696
|
+
JSON.stringify({ sessionId: record.sessionId, mode: record.mode, archetype: record.archetype, task: record.task, startedAt: record.startedAt, totalDurationMs: record.totalDurationMs, file: filename }) + '\n');
|
|
696
697
|
} catch (e) {}
|
|
697
698
|
return filepath;
|
|
698
699
|
}
|
|
699
700
|
|
|
700
701
|
// Sync version kept for backward compatibility
|
|
701
702
|
save() {
|
|
702
|
-
const
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
startedAt: this.startedAt, completedAt: new Date().toISOString(),
|
|
706
|
-
totalDurationMs: Date.now() - new Date(this.startedAt).getTime(),
|
|
707
|
-
steps: this.steps,
|
|
708
|
-
totalSteps: this.steps.length,
|
|
709
|
-
completedSteps: this.steps.filter(s => s.completedAt && !s.error).length,
|
|
710
|
-
git: {
|
|
711
|
-
initialCommit: this.initialCommit,
|
|
712
|
-
finalCommit: safeExec('git rev-parse --short HEAD', this.workspace),
|
|
713
|
-
totalDiff: safeExec('git diff', this.workspace) || ''
|
|
714
|
-
},
|
|
715
|
-
environment: { platform: os.platform(), nodeVersion: process.version }
|
|
716
|
-
};
|
|
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);
|
|
717
706
|
const filename = `${this.sessionId}-${slugify(this.task)}.json`;
|
|
718
707
|
const filepath = path.join(SESSIONS_DIR, filename);
|
|
719
708
|
fs.writeFileSync(filepath, JSON.stringify(record, null, 2), 'utf8');
|
|
720
709
|
// Append to index
|
|
721
710
|
try {
|
|
722
711
|
fs.appendFileSync(path.join(SESSIONS_DIR, 'index.jsonl'),
|
|
723
|
-
JSON.stringify({ sessionId: record.sessionId, task: record.task, startedAt: record.startedAt, totalDurationMs: record.totalDurationMs, file: filename }) + '\n');
|
|
712
|
+
JSON.stringify({ sessionId: record.sessionId, mode: record.mode, archetype: record.archetype, task: record.task, startedAt: record.startedAt, totalDurationMs: record.totalDurationMs, file: filename }) + '\n');
|
|
724
713
|
} catch (e) {}
|
|
725
714
|
return filepath;
|
|
726
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);
|
|
727
780
|
}
|
|
728
781
|
|
|
729
782
|
// ===== MAIN =====
|
|
@@ -753,17 +806,61 @@ async function run() {
|
|
|
753
806
|
|
|
754
807
|
const timeline = args['timeline-base64'] ? decodeJsonBase64(args['timeline-base64'], 'timeline') : null;
|
|
755
808
|
|
|
809
|
+
// Kit mode dispatch — when --mode is set, the runner replaces the default
|
|
810
|
+
// 4-step pipeline with a single-step invocation of the matching kit prompt
|
|
811
|
+
// (docs/Pair Programmer Docs/0[1-4]-*.md). Default mode (no --mode flag)
|
|
812
|
+
// preserves existing behavior so callers like knoxis-interactive-pair.js
|
|
813
|
+
// and the local-agent's single-shot path remain unaffected.
|
|
814
|
+
const mode = typeof args.mode === 'string' ? args.mode : null;
|
|
815
|
+
const archetype = typeof args.archetype === 'string' ? args.archetype : null;
|
|
816
|
+
const recoveryPattern = typeof args.pattern === 'string' ? args.pattern : null;
|
|
817
|
+
|
|
818
|
+
if (mode && !kitTemplates.isKitMode(mode)) {
|
|
819
|
+
console.error(`Unknown --mode "${mode}". Supported: ${kitTemplates.listModes().join(', ')}`);
|
|
820
|
+
process.exit(1);
|
|
821
|
+
}
|
|
822
|
+
|
|
756
823
|
let task = args['prompt-base64'] ? decodeBase64(args['prompt-base64']) : args.prompt;
|
|
757
824
|
if ((!task || !task.trim()) && timeline && typeof timeline.task === 'string') {
|
|
758
825
|
task = timeline.task;
|
|
759
826
|
}
|
|
760
827
|
|
|
761
|
-
|
|
762
|
-
|
|
828
|
+
// Kickoff and session-end can run without a prompt — the kit prompt itself
|
|
829
|
+
// drives the interaction. Resume and recovery accept an optional prompt as
|
|
830
|
+
// operator context. Default mode still requires a task.
|
|
831
|
+
const taskOptionalForMode = mode === 'kickoff' || mode === 'session-end';
|
|
832
|
+
if ((!task || !task.trim()) && !taskOptionalForMode) {
|
|
833
|
+
if (mode) {
|
|
834
|
+
console.error(`A --prompt is required for --mode ${mode} (operator context).`);
|
|
835
|
+
} else {
|
|
836
|
+
console.error('A task prompt is required via --prompt, --prompt-base64, or timeline.task.');
|
|
837
|
+
}
|
|
763
838
|
process.exit(1);
|
|
764
839
|
}
|
|
765
840
|
|
|
766
|
-
task
|
|
841
|
+
// Keep the user-provided task separate from the recorder/slug placeholder
|
|
842
|
+
// so the kit prompt header doesn't render `Initial task hint: [kickoff session]`.
|
|
843
|
+
const userTask = (task || '').trim();
|
|
844
|
+
task = userTask || `[${mode || 'default'} session]`;
|
|
845
|
+
|
|
846
|
+
// Always ensure the standardized layout exists. The pair programmer takes
|
|
847
|
+
// over development across the company, so every task — regardless of mode —
|
|
848
|
+
// must land in a workspace that has CODING_RULES.md and the state files.
|
|
849
|
+
// Scaffolding is idempotent; existing files are preserved.
|
|
850
|
+
const scaffoldResult = scaffoldStateLayout(workspace);
|
|
851
|
+
|
|
852
|
+
// Mode-specific preconditions: resume / session-end need real state. If the
|
|
853
|
+
// workspace was just scaffolded for them, the placeholder STATUS/HANDOFF
|
|
854
|
+
// exist — assertStateLayout will pass against the placeholders and the
|
|
855
|
+
// operator gets a useful warning at runtime instead of an error.
|
|
856
|
+
if (mode === 'resume' || mode === 'session-end') {
|
|
857
|
+
try {
|
|
858
|
+
assertStateLayout(workspace, mode);
|
|
859
|
+
} catch (err) {
|
|
860
|
+
console.error(err.message);
|
|
861
|
+
process.exit(1);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
767
864
|
|
|
768
865
|
const aiConfig = resolveAiProvider(args['ai-provider'] || args.provider);
|
|
769
866
|
|
|
@@ -778,11 +875,43 @@ async function run() {
|
|
|
778
875
|
globalContextInputs.push(timeline.sharedContext);
|
|
779
876
|
}
|
|
780
877
|
|
|
878
|
+
// Default mode: auto-include the standards + live state files so every task
|
|
879
|
+
// lands with the rules and prior context already in the prompt. Kit modes
|
|
880
|
+
// skip this — their authored prompts tell Claude which files to read itself,
|
|
881
|
+
// and we don't want to balloon their token budget with redundant content.
|
|
882
|
+
if (!mode && !args['no-auto-context']) {
|
|
883
|
+
for (const rel of ['CODING_RULES.md', 'docs/state/STATUS.md', 'docs/state/HANDOFF.md', 'docs/state/DECISIONS.md']) {
|
|
884
|
+
const abs = path.join(workspace, rel);
|
|
885
|
+
if (fs.existsSync(abs)) globalContextInputs.push(rel);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
781
889
|
const globalContext = await gatherContext(workspace, globalContextInputs);
|
|
782
890
|
const globalContextBlock = globalContext.sections.join('\n\n');
|
|
783
891
|
|
|
784
892
|
let scheduledSteps;
|
|
785
|
-
if (
|
|
893
|
+
if (mode) {
|
|
894
|
+
const tpl = kitTemplates.getTemplate(mode, {
|
|
895
|
+
taskDescription: userTask || null,
|
|
896
|
+
archetype,
|
|
897
|
+
pattern: recoveryPattern,
|
|
898
|
+
productSlug: args['product-slug'] || null,
|
|
899
|
+
projectSlug: args['project-slug'] || null,
|
|
900
|
+
workspace
|
|
901
|
+
});
|
|
902
|
+
scheduledSteps = [{
|
|
903
|
+
key: tpl.key,
|
|
904
|
+
title: tpl.title,
|
|
905
|
+
displayName: aiConfig.label,
|
|
906
|
+
persona: null,
|
|
907
|
+
instruction: tpl.body,
|
|
908
|
+
contextPaths: [],
|
|
909
|
+
includeContext: true,
|
|
910
|
+
// Suppress the generic systemIntro — kit prompts set their own role/framing
|
|
911
|
+
// and the kit's coaching tone shouldn't be overridden.
|
|
912
|
+
suppressSystemIntro: true
|
|
913
|
+
}];
|
|
914
|
+
} else if (timeline && Array.isArray(timeline.steps) && timeline.steps.length) {
|
|
786
915
|
scheduledSteps = timeline.steps.map((step, index) => ({
|
|
787
916
|
key: step.key || `step-${index + 1}`,
|
|
788
917
|
title: step.title || '',
|
|
@@ -804,14 +933,46 @@ async function run() {
|
|
|
804
933
|
process.exit(1);
|
|
805
934
|
}
|
|
806
935
|
|
|
936
|
+
// Resolve operator identity for the session record. CLI flags win, then
|
|
937
|
+
// ~/.knoxis/config.json (where the local-agent stores userId), then null.
|
|
938
|
+
const knoxisConfig = (() => {
|
|
939
|
+
const p = path.join(os.homedir(), '.knoxis', 'config.json');
|
|
940
|
+
try { return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : {}; } catch (e) { return {}; }
|
|
941
|
+
})();
|
|
942
|
+
const engineerId = args['engineer-id'] || knoxisConfig.userId || null;
|
|
943
|
+
const productSlug = args['product-slug'] || knoxisConfig.productSlug || null;
|
|
944
|
+
const projectSlug = args['project-slug'] || knoxisConfig.projectSlug || path.basename(workspace);
|
|
945
|
+
// QIG portal linkage — passed by the local-agent when the caller (e.g.
|
|
946
|
+
// PairProgramSheet) supplies them.
|
|
947
|
+
const userId = args['user-id'] || knoxisConfig.userId || null;
|
|
948
|
+
const workspaceIdArg = args['workspace-id'] || null;
|
|
949
|
+
const taskIdsArg = (() => {
|
|
950
|
+
const raw = args['task-id'];
|
|
951
|
+
if (!raw) return [];
|
|
952
|
+
return Array.isArray(raw) ? raw : [raw];
|
|
953
|
+
})();
|
|
954
|
+
|
|
807
955
|
console.log('==============================================');
|
|
808
956
|
console.log('Knoxis Pair Programming Session');
|
|
809
957
|
console.log(`Workspace: ${workspace}`);
|
|
810
958
|
console.log(`AI Partner: ${aiConfig.label}`);
|
|
959
|
+
if (mode) console.log(`Mode: ${mode}${archetype ? ` (archetype: ${archetype})` : ''}${recoveryPattern ? ` (pattern: ${recoveryPattern})` : ''}`);
|
|
811
960
|
console.log(`Task: ${task}`);
|
|
812
961
|
console.log('==============================================');
|
|
813
962
|
console.log('');
|
|
814
963
|
|
|
964
|
+
if (scaffoldResult) {
|
|
965
|
+
if (scaffoldResult.dirs.length || scaffoldResult.files.length) {
|
|
966
|
+
console.log('Scaffolded standard layout:');
|
|
967
|
+
scaffoldResult.dirs.forEach(d => console.log(` + dir ${d}/`));
|
|
968
|
+
scaffoldResult.files.forEach(f => console.log(` + file ${f}`));
|
|
969
|
+
}
|
|
970
|
+
if (scaffoldResult.skipped.length) {
|
|
971
|
+
console.log(`Existing files preserved: ${scaffoldResult.skipped.join(', ')}`);
|
|
972
|
+
}
|
|
973
|
+
console.log('');
|
|
974
|
+
}
|
|
975
|
+
|
|
815
976
|
if (globalContext.labels.length) {
|
|
816
977
|
console.log(`Shared context files: ${globalContext.labels.join(', ')}`);
|
|
817
978
|
console.log('');
|
|
@@ -821,7 +982,16 @@ async function run() {
|
|
|
821
982
|
const recordingEnabled = !args['no-record'];
|
|
822
983
|
let recorder = null;
|
|
823
984
|
if (recordingEnabled) {
|
|
824
|
-
recorder = new SessionRecorder(task, workspace, aiConfig.label
|
|
985
|
+
recorder = new SessionRecorder(task, workspace, aiConfig.label, {
|
|
986
|
+
mode,
|
|
987
|
+
archetype,
|
|
988
|
+
productSlug,
|
|
989
|
+
projectSlug,
|
|
990
|
+
engineerId,
|
|
991
|
+
userId,
|
|
992
|
+
workspaceId: workspaceIdArg,
|
|
993
|
+
taskIds: taskIdsArg
|
|
994
|
+
});
|
|
825
995
|
console.log(`Recording: ON`);
|
|
826
996
|
console.log('');
|
|
827
997
|
}
|
|
@@ -847,6 +1017,14 @@ Your technical approach:
|
|
|
847
1017
|
- Don't over-engineer - solve the problem at hand
|
|
848
1018
|
- Leave the codebase better than you found it
|
|
849
1019
|
|
|
1020
|
+
Project standards (binding for every task):
|
|
1021
|
+
- Apply the rules in \`CODING_RULES.md\` at the workspace root. They are not optional.
|
|
1022
|
+
- Before substantive work, read \`docs/state/STATUS.md\` and \`docs/state/HANDOFF.md\` to orient on prior context.
|
|
1023
|
+
- When the task is complete, update \`docs/state/STATUS.md\` (Done / In flight / Next) and overwrite \`docs/state/HANDOFF.md\` with a concrete pickup point for next session.
|
|
1024
|
+
- If the task ships or merges code, append a one-line entry to \`docs/state/CHANGELOG.md\` under \`## [Unreleased]\`.
|
|
1025
|
+
- Capture any non-trivial decisions in \`docs/state/DECISIONS.md\` (Context / Decision / Alternatives / Consequences). Architectural decisions go in \`docs/architecture/adr/\` instead.
|
|
1026
|
+
- Add genuinely unresolved questions to \`docs/state/OPEN_QUESTIONS.md\`.
|
|
1027
|
+
|
|
850
1028
|
IMPORTANT: Work autonomously. Do not ask questions or wait for confirmation. Make decisions and implement.
|
|
851
1029
|
Only work inside the provided workspace and preserve user data.`;
|
|
852
1030
|
|
|
@@ -868,7 +1046,7 @@ Only work inside the provided workspace and preserve user data.`;
|
|
|
868
1046
|
|
|
869
1047
|
const includeContext = history.length <= 1 || step.includeContext || stepContext.sections.length > 0;
|
|
870
1048
|
const prompt = buildPrompt({
|
|
871
|
-
systemIntro,
|
|
1049
|
+
systemIntro: step.suppressSystemIntro ? null : systemIntro,
|
|
872
1050
|
personaIntro: step.persona,
|
|
873
1051
|
conversation: conversationLines(),
|
|
874
1052
|
instructionLabel: 'Coordinator',
|
|
@@ -901,11 +1079,20 @@ Only work inside the provided workspace and preserve user data.`;
|
|
|
901
1079
|
}
|
|
902
1080
|
}
|
|
903
1081
|
|
|
904
|
-
// Save session recording
|
|
1082
|
+
// Save session recording, then sync to the portal stub if configured.
|
|
905
1083
|
if (recorder) {
|
|
906
1084
|
const recordPath = recorder.save();
|
|
907
1085
|
console.log(`Session recorded: ${recordPath}`);
|
|
908
1086
|
console.log('');
|
|
1087
|
+
|
|
1088
|
+
if (!args['no-portal-sync']) {
|
|
1089
|
+
try {
|
|
1090
|
+
const finalRecord = recorder.buildFinalRecord();
|
|
1091
|
+
await syncSessionToPortal(finalRecord);
|
|
1092
|
+
} catch (e) {
|
|
1093
|
+
console.warn(`[Portal] Sync errored: ${e.message}`);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
909
1096
|
}
|
|
910
1097
|
|
|
911
1098
|
// Print retry statistics if any retries occurred
|