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.
@@ -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 sourceAgent = path.join(__dirname, '..', 'lib', 'knoxis-local-agent.js');
86
- const sourcePairProgram = path.join(__dirname, '..', 'lib', 'knoxis-pair-program.js');
87
- const sourceInteractivePair = path.join(__dirname, '..', 'lib', 'knoxis-interactive-pair.js');
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
- if (!task) {
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
- if (interactive) {
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
- // Standard single-shot mode: pipe task to Claude
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
- if (interactive && mode === 'interactive') {
926
- // Headless interactive: run the script directly as a process
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: interactive ? 'Interactive pair programming session started' : 'Pair programming session started',
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
- if (taskPrompt) {
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 record = {
675
- sessionId: this.sessionId, version: '1.0.0',
676
- task: this.task, workspace: this.workspace, aiProvider: this.aiProvider,
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 record = {
703
- sessionId: this.sessionId, version: '1.0.0',
704
- task: this.task, workspace: this.workspace, aiProvider: this.aiProvider,
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
- if (!task || !task.trim()) {
762
- console.error('A task prompt is required via --prompt, --prompt-base64, or timeline.task.');
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 = task.trim();
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 (timeline && Array.isArray(timeline.steps) && timeline.steps.length) {
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