knoxis-helper 1.9.0 → 1.10.1

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.
@@ -1413,8 +1413,22 @@ function connectRelayWebSocket() {
1413
1413
  // Interactive mode: use multi-turn pair programming script
1414
1414
  const scriptPath = resolveInteractiveScript();
1415
1415
  if (scriptPath) {
1416
- command = buildEnvCommand({ KNOXIS_TASK_FILE: promptFile }, `node "${scriptPath}"`);
1416
+ // Forward QIG attribution from relay message → env vars so
1417
+ // SessionRecorder writes non-null workspace_id / product_slug /
1418
+ // project_slug / task_ids to pair_programmer_sessions.
1419
+ const envVars = { KNOXIS_TASK_FILE: promptFile, KNOXIS_WORKSPACE_PATH: wsDir };
1420
+ if (msg.userId) envVars.KNOXIS_USER_ID = msg.userId;
1421
+ if (msg.workspaceId) envVars.KNOXIS_WORKSPACE_ID = msg.workspaceId;
1422
+ if (msg.productSlug) envVars.KNOXIS_PRODUCT_SLUG = msg.productSlug;
1423
+ if (msg.projectSlug) envVars.KNOXIS_PROJECT_SLUG = msg.projectSlug;
1424
+ if (msg.kitMode) envVars.KNOXIS_KIT_MODE = msg.kitMode;
1425
+ if (Array.isArray(msg.taskIds) && msg.taskIds.length > 0) {
1426
+ envVars.KNOXIS_TASK_IDS = msg.taskIds.filter(Boolean).join(',');
1427
+ }
1428
+ command = buildEnvCommand(envVars, `node "${scriptPath}"`);
1417
1429
  console.log(` 🤝 Interactive mode: ${scriptPath}`);
1430
+ const forwarded = Object.keys(envVars).filter(k => k !== 'KNOXIS_TASK_FILE' && k !== 'KNOXIS_WORKSPACE_PATH');
1431
+ if (forwarded.length > 0) console.log(` 🏷️ Identity vars: ${forwarded.join(', ')}`);
1418
1432
  } else {
1419
1433
  command = buildCatCommand(promptFile) + ' | claude --dangerously-skip-permissions';
1420
1434
  console.warn(` ⚠️ Interactive script not found, falling back to single-shot`);
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
+ const crypto = require('crypto');
6
7
  const { execSync } = require('child_process');
7
8
  const util = require('util');
8
9
  const { exec } = require('child_process');
@@ -65,29 +66,39 @@ const MAX_BAG_BYTES = 2 * 1024 * 1024; // 2MB total — fits Postgres JSO
65
66
  const MAX_FILES_PER_DIR = 50; // Bounds runaway dirs (e.g. archive of old reports).
66
67
  const MAX_WALK_DEPTH = 5;
67
68
 
69
+ /**
70
+ * Walk the workspace's framework markdown docs and return a SPLIT representation:
71
+ * manifest[relPath] = { sha256, size } // small — goes into the JSONB column
72
+ * contents[sha256] = base64(utf8 content) // large — uploaded to Supabase Storage
73
+ *
74
+ * Legacy placeholders (oversized-file notes, truncation marker) stay as string
75
+ * values in the manifest — they're not real files, just metadata the frontend
76
+ * already knows how to render.
77
+ */
68
78
  function readStateFiles(workspace) {
69
- const out = {};
79
+ const manifest = {};
80
+ const contents = {};
70
81
  const skipped = [];
71
82
  const state = { bytes: 0, halted: false };
72
- if (!workspace) return out;
83
+ if (!workspace) return { manifest, contents };
73
84
 
74
85
  for (const dir of STATE_DOC_DIRS) {
75
- walkMdFiles(path.join(workspace, 'docs', dir), `docs/${dir}`, out, 0, state, skipped);
86
+ walkMdFiles(path.join(workspace, 'docs', dir), `docs/${dir}`, manifest, contents, 0, state, skipped);
76
87
  if (state.halted) break;
77
88
  }
78
89
 
79
90
  if (skipped.length > 0) {
80
- out['__truncated__'] = JSON.stringify({
91
+ manifest['__truncated__'] = JSON.stringify({
81
92
  reason: `state_files bag hit ${MAX_BAG_BYTES}-byte budget`,
82
93
  capturedBytes: state.bytes,
83
94
  skippedFiles: skipped.slice(0, 200)
84
95
  }, null, 2);
85
96
  }
86
97
 
87
- return out;
98
+ return { manifest, contents };
88
99
  }
89
100
 
90
- function walkMdFiles(absDir, relDir, out, depth, state, skipped) {
101
+ function walkMdFiles(absDir, relDir, manifest, contents, depth, state, skipped) {
91
102
  if (state.halted || depth > MAX_WALK_DEPTH) return;
92
103
  let entries;
93
104
  try {
@@ -110,7 +121,7 @@ function walkMdFiles(absDir, relDir, out, depth, state, skipped) {
110
121
  const absPath = path.join(absDir, entry.name);
111
122
  const relPath = `${relDir}/${entry.name}`;
112
123
  if (entry.isDirectory()) {
113
- walkMdFiles(absPath, relPath, out, depth + 1, state, skipped);
124
+ walkMdFiles(absPath, relPath, manifest, contents, depth + 1, state, skipped);
114
125
  } else if (entry.isFile()) {
115
126
  if (!entry.name.endsWith('.md')) continue;
116
127
  if (entry.name.includes('.bak-')) continue; // Skip *.bak-<timestamp> backups.
@@ -119,12 +130,12 @@ function walkMdFiles(absDir, relDir, out, depth, state, skipped) {
119
130
  continue;
120
131
  }
121
132
  filesInDir++;
122
- readSafe(absPath, relPath, out, state, skipped);
133
+ readSafe(absPath, relPath, manifest, contents, state, skipped);
123
134
  }
124
135
  }
125
136
  }
126
137
 
127
- function readSafe(absPath, relPath, out, state, skipped) {
138
+ function readSafe(absPath, relPath, manifest, contents, state, skipped) {
128
139
  let stat;
129
140
  try {
130
141
  stat = fs.statSync(absPath);
@@ -133,7 +144,9 @@ function readSafe(absPath, relPath, out, state, skipped) {
133
144
  }
134
145
  if (!stat.isFile()) return;
135
146
  if (stat.size > MAX_STATE_FILE_BYTES) {
136
- out[relPath] = `[File omitted: ${stat.size} bytes exceeds ${MAX_STATE_FILE_BYTES}-byte cap]`;
147
+ // Oversized files stay as inline placeholder strings — the frontend treats
148
+ // these as "metadata, not file content" and shows them as-is.
149
+ manifest[relPath] = `[File omitted: ${stat.size} bytes exceeds ${MAX_STATE_FILE_BYTES}-byte cap]`;
137
150
  return;
138
151
  }
139
152
  if (state.bytes + stat.size > MAX_BAG_BYTES) {
@@ -141,12 +154,20 @@ function readSafe(absPath, relPath, out, state, skipped) {
141
154
  state.halted = true;
142
155
  return;
143
156
  }
157
+ let buf;
144
158
  try {
145
- out[relPath] = fs.readFileSync(absPath, 'utf8');
146
- state.bytes += stat.size;
159
+ buf = fs.readFileSync(absPath);
147
160
  } catch (e) {
148
- // Read failure (perms etc.) — skip silently.
161
+ return; // Read failure (perms etc.) — skip silently.
162
+ }
163
+ const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
164
+ manifest[relPath] = { sha256, size: buf.length };
165
+ // Dedupe: only one base64 payload per unique sha. Multiple paths with the
166
+ // same content reference the same Storage object.
167
+ if (!Object.prototype.hasOwnProperty.call(contents, sha256)) {
168
+ contents[sha256] = buf.toString('base64');
149
169
  }
170
+ state.bytes += buf.length;
150
171
  }
151
172
 
152
173
  // Enumerate feature slugs from the workspace so the frontend can list features
@@ -344,6 +365,10 @@ class SessionRecorder {
344
365
  _buildRecord(finalCommit, totalDiff) {
345
366
  const completedSteps = this.steps.filter(s => s.completedAt && !s.error).length;
346
367
  const closedCleanly = this.steps.length > 0 && completedSteps === this.steps.length;
368
+ // Split the state-files snapshot into a manifest (small, goes to JSONB) and
369
+ // a base64 contents map (large, backend offloads to Supabase Storage and
370
+ // strips before persisting the row).
371
+ const { manifest: stateFilesManifest, contents: stateFileContents } = readStateFiles(this.workspace);
347
372
  return {
348
373
  sessionId: this.sessionId,
349
374
  version: '1.1.0',
@@ -367,13 +392,14 @@ class SessionRecorder {
367
392
  completedSteps,
368
393
  closedCleanly,
369
394
  filesTouched: extractFilesTouched(totalDiff),
370
- // Snapshot of state-pack + architecture + feature files at session-end
371
- // so the QIG frontend can display the latest STATUS / HANDOFF /
372
- // DECISIONS / ROADMAP / etc. without needing live filesystem access
373
- // on the dev's machine. Single bag keyed by relative path; frontend
374
- // filters by prefix (docs/state/*, docs/architecture/*,
375
- // docs/features/<slug>/*).
376
- stateFiles: readStateFiles(this.workspace),
395
+ // Manifest keyed by relative path. Each entry is either a `{sha256, size}`
396
+ // pointer (Supabase Storage), an inline placeholder string (oversized
397
+ // file / truncation marker), or in older agents — the raw content
398
+ // string. The frontend renders all three shapes; see useStateFileBody.
399
+ stateFiles: stateFilesManifest,
400
+ // sha → base64(utf8). Drained server-side into pair-programmer-files
401
+ // bucket; never persisted to the JSONB column.
402
+ stateFileContents,
377
403
  // Quick index of feature slugs found in docs/features/. Lets the
378
404
  // frontend enumerate features without parsing the bag.
379
405
  featureSlugs: listFeatureSlugs(this.workspace),
@@ -15,6 +15,23 @@ You are running in single-shot mode. There is no interactive REPL with the opera
15
15
 
16
16
  The interview happens through \`docs/state/OPEN_QUESTIONS.md\`. You write questions there, the operator fills in answers there, the operator re-runs kickoff, you read the answers and produce the context pack. Same questions, same coverage, just batched.
17
17
 
18
+ ## Step 0a — Reconcile prompt-injected operator answers
19
+
20
+ The QIG frontend stores operator answers in a database and injects them into this prompt as a section titled \`## Operator answers to pending questions\`. Each entry has the shape:
21
+
22
+ \`\`\`
23
+ - Q: <question text>
24
+ A: <answer text>
25
+ \`\`\`
26
+
27
+ If that section is present, treat those answers as authoritative — operators typed them in the frontend after the previous kickoff run and they may not be reflected in the file yet. Before evaluating the branch in Step 0:
28
+
29
+ 1. Read \`docs/state/OPEN_QUESTIONS.md\`. Scaffold it if missing (see State A).
30
+ 2. For each \`Q:\`/\`A:\` pair in the prompt block, find the matching entry in \`## Pending Answers\` by question text (case-insensitive, whitespace-normalized — \`#N.\` prefix is optional in the prompt). When you find a match, replace its \`**Answer:** _(fill in)_\` line with \`**Answer:** <answer>\` (or \`**Answer:** <answer> *(via frontend)*\` if you want the provenance preserved).
31
+ 3. Save the file.
32
+
33
+ If the prompt block is absent or empty, skip this step entirely. After reconciliation (or skip), proceed to Step 0 with the file as the source of truth.
34
+
18
35
  ## Step 0 — Read state and pick a branch
19
36
 
20
37
  Before anything else, read \`docs/state/OPEN_QUESTIONS.md\` (if it doesn't exist, scaffold the layout first). It has three sections: \`## Pending Answers\`, \`## Active\`, \`## Resolved\`. Look only at \`## Pending Answers\` to decide which branch you're in.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knoxis-helper",
3
- "version": "1.9.0",
3
+ "version": "1.10.1",
4
4
  "description": "Local helper for Knoxis pair programming - connects your machine to Knoxis on qig.ai",
5
5
  "bin": {
6
6
  "knoxis-helper": "./bin/knoxis-helper.js"