@relipa/ai-flow-kit 0.0.8 → 0.0.9-beta.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.
@@ -1,55 +1,122 @@
1
- #!/usr/bin/env node
2
- /**
3
- * SessionStop hook for ai-flow-kit
4
- * Reads checkpoints.json for the active task and prints a token usage summary.
5
- */
6
-
7
- const fs = require('fs');
8
- const path = require('path');
9
- const { record } = require('../telemetry/record');
10
-
11
- record('session.stop');
12
-
13
- const projectRoot = path.resolve(__dirname, '..', '..');
14
- const contextFile = path.join(projectRoot, '.aiflow', 'context', 'current.json');
15
- const tasksDir = path.join(projectRoot, '.aiflow', 'tasks');
16
-
17
- const GATE_LABELS = { 1: 'Analyze', 2: 'Plan', 3: 'Code', 4: 'Review', 5: 'PR' };
18
-
19
- try {
20
- if (!fs.existsSync(contextFile)) process.exit(0);
21
- const ctx = JSON.parse(fs.readFileSync(contextFile, 'utf-8'));
22
- const ticketId = ctx && ctx.taskId;
23
- if (!ticketId) process.exit(0);
24
-
25
- const checkpointPath = path.join(tasksDir, ticketId, 'checkpoints.json');
26
- if (!fs.existsSync(checkpointPath)) process.exit(0);
27
-
28
- const data = JSON.parse(fs.readFileSync(checkpointPath, 'utf-8'));
29
- const checkpoints = data.checkpoints || [];
30
- if (checkpoints.length === 0) process.exit(0);
31
-
32
- // Aggregate latest token value per gate
33
- const gateTokens = {};
34
- for (const cp of checkpoints) {
35
- const g = cp.gate;
36
- if (!gateTokens[g] || cp.tokens > gateTokens[g]) {
37
- gateTokens[g] = cp.tokens;
38
- }
39
- }
40
-
41
- const gates = Object.keys(gateTokens).sort();
42
- const total = gates.reduce((sum, g) => sum + gateTokens[g], 0);
43
-
44
- process.stderr.write('\n[aiflow] Token usage this session:\n');
45
- for (const g of gates) {
46
- const label = GATE_LABELS[parseInt(g)] || `Gate ${g}`;
47
- const t = gateTokens[g];
48
- const bar = '█'.repeat(Math.round((t / total) * 20)).padEnd(20, '░');
49
- const pct = Math.round((t / total) * 100);
50
- process.stderr.write(`[aiflow] Gate ${g} ${label.padEnd(8)} ~${t.toLocaleString().padStart(6)} ${bar} ${pct}%\n`);
51
- }
52
- process.stderr.write(`[aiflow] Total estimated: ~${total.toLocaleString()} tokens\n\n`);
53
- } catch (_) {}
54
-
55
- process.exit(0);
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SessionStop hook for ai-flow-kit
4
+ * - Records session telemetry: model, token usage, prompt summary
5
+ * - Prints a token usage summary from checkpoints.json
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+
12
+ function loadTelemetry() {
13
+ try { return require('../telemetry/record'); } catch (_) {}
14
+ return { record: () => {} };
15
+ }
16
+
17
+ function loadTelemetryConfig() {
18
+ try { return require('../telemetry/config'); } catch (_) {}
19
+ return { loadConfig: () => ({}) };
20
+ }
21
+
22
+ const { record } = loadTelemetry();
23
+ const { loadConfig } = loadTelemetryConfig();
24
+
25
+ const GATE_LABELS = { 1: 'Analyze', 2: 'Plan', 3: 'Code', 4: 'Review', 5: 'PR' };
26
+
27
+ let raw = '';
28
+ process.stdin.setEncoding('utf-8');
29
+ process.stdin.on('data', chunk => { raw += chunk; });
30
+ process.stdin.on('end', () => {
31
+ let hookData = {};
32
+ try { hookData = JSON.parse(raw || '{}'); } catch (_) {}
33
+
34
+ const cfg = loadConfig();
35
+ let model = '';
36
+ let tokens_input = 0;
37
+ let tokens_output = 0;
38
+ let prompt_summary = '';
39
+
40
+ const transcriptPath = hookData.transcript_path;
41
+ if (transcriptPath && fs.existsSync(transcriptPath)) {
42
+ try {
43
+ const lines = fs.readFileSync(transcriptPath, 'utf-8').split('\n');
44
+ let firstUserDone = false;
45
+ for (const line of lines) {
46
+ if (!line.trim()) continue;
47
+ let entry;
48
+ try { entry = JSON.parse(line); } catch (_) { continue; }
49
+
50
+ if (entry.type === 'user' && !firstUserDone) {
51
+ const content = entry.message && entry.message.content;
52
+ if (Array.isArray(content)) {
53
+ const block = content.find(b => b.type === 'text');
54
+ if (block && block.text) prompt_summary = block.text.substring(0, 200);
55
+ } else if (typeof content === 'string') {
56
+ prompt_summary = content.substring(0, 200);
57
+ }
58
+ firstUserDone = true;
59
+ }
60
+
61
+ if (entry.type === 'assistant') {
62
+ const msg = entry.message || {};
63
+ if (msg.usage) {
64
+ tokens_input += msg.usage.input_tokens || 0;
65
+ tokens_output += msg.usage.output_tokens || 0;
66
+ }
67
+ if (!model && msg.model) model = msg.model;
68
+ }
69
+ }
70
+ } catch (_) {}
71
+ }
72
+
73
+ record('session.stop', {
74
+ ai_email: cfg.email || '',
75
+ model: model || undefined,
76
+ tokens_input: tokens_input || undefined,
77
+ tokens_output: tokens_output || undefined,
78
+ prompt_summary: prompt_summary || undefined,
79
+ });
80
+
81
+ // ── Token usage summary from checkpoints ──────────────────
82
+ const projectRoot = path.resolve(__dirname, '..', '..');
83
+ const contextFile = path.join(projectRoot, '.aiflow', 'context', 'current.json');
84
+ const tasksDir = path.join(projectRoot, '.aiflow', 'tasks');
85
+
86
+ try {
87
+ if (!fs.existsSync(contextFile)) process.exit(0);
88
+ const ctx = JSON.parse(fs.readFileSync(contextFile, 'utf-8'));
89
+ const ticketId = ctx && ctx.taskId;
90
+ if (!ticketId) process.exit(0);
91
+
92
+ const checkpointPath = path.join(tasksDir, ticketId, 'checkpoints.json');
93
+ if (!fs.existsSync(checkpointPath)) process.exit(0);
94
+
95
+ const data = JSON.parse(fs.readFileSync(checkpointPath, 'utf-8'));
96
+ const checkpoints = data.checkpoints || [];
97
+ if (checkpoints.length === 0) process.exit(0);
98
+
99
+ const gateTokens = {};
100
+ for (const cp of checkpoints) {
101
+ const g = cp.gate;
102
+ if (!gateTokens[g] || cp.tokens > gateTokens[g]) {
103
+ gateTokens[g] = cp.tokens;
104
+ }
105
+ }
106
+
107
+ const gates = Object.keys(gateTokens).sort();
108
+ const total = gates.reduce((sum, g) => sum + gateTokens[g], 0);
109
+
110
+ process.stderr.write('\n[aiflow] Token usage this session:\n');
111
+ for (const g of gates) {
112
+ const label = GATE_LABELS[parseInt(g)] || `Gate ${g}`;
113
+ const t = gateTokens[g];
114
+ const bar = '█'.repeat(Math.round((t / total) * 20)).padEnd(20, '░');
115
+ const pct = Math.round((t / total) * 100);
116
+ process.stderr.write(`[aiflow] Gate ${g} ${label.padEnd(8)} ~${t.toLocaleString().padStart(6)} ${bar} ${pct}%\n`);
117
+ }
118
+ process.stderr.write(`[aiflow] Total estimated: ~${total.toLocaleString()} tokens\n\n`);
119
+ } catch (_) {}
120
+
121
+ process.exit(0);
122
+ });
package/scripts/init.js CHANGED
@@ -189,6 +189,11 @@ async function setupSuperpowersHook(_projectDir, claudeDir) {
189
189
  const hookBlockGitDest = path.join(hooksDir, 'block-git-write.js');
190
190
  await fs.copy(hookBlockGitSrc, hookBlockGitDest, { overwrite: true });
191
191
 
192
+ // Copy telemetry module so hooks can require('../telemetry/record')
193
+ const telemetrySrc = path.join(PKG_DIR, 'scripts', 'telemetry');
194
+ const telemetryDest = path.join(claudeDir, 'telemetry');
195
+ await fs.copy(telemetrySrc, telemetryDest, { overwrite: true });
196
+
192
197
  const settingsFile = path.join(claudeDir, 'settings.json');
193
198
  let settings = {};
194
199
  if (await fs.pathExists(settingsFile)) {
@@ -197,9 +202,12 @@ async function setupSuperpowersHook(_projectDir, claudeDir) {
197
202
 
198
203
  if (!settings.hooks) settings.hooks = {};
199
204
  if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
200
- if (!settings.hooks.SessionStop) settings.hooks.SessionStop = [];
205
+ if (!settings.hooks.SessionEnd) settings.hooks.SessionEnd = [];
201
206
  if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
202
207
 
208
+ // Remove legacy SessionStop entries if present
209
+ if (settings.hooks.SessionStop) delete settings.hooks.SessionStop;
210
+
203
211
  settings.hooks.SessionStart = settings.hooks.SessionStart.filter(
204
212
  h => !(h._aiflowKit)
205
213
  );
@@ -213,10 +221,10 @@ async function setupSuperpowersHook(_projectDir, claudeDir) {
213
221
  ]
214
222
  });
215
223
 
216
- settings.hooks.SessionStop = settings.hooks.SessionStop.filter(
224
+ settings.hooks.SessionEnd = settings.hooks.SessionEnd.filter(
217
225
  h => !(h._aiflowKit)
218
226
  );
219
- settings.hooks.SessionStop.push({
227
+ settings.hooks.SessionEnd.push({
220
228
  _aiflowKit: true,
221
229
  hooks: [
222
230
  {
@@ -241,7 +249,7 @@ async function setupSuperpowersHook(_projectDir, claudeDir) {
241
249
  });
242
250
 
243
251
  await fs.writeJson(settingsFile, settings, { spaces: 2 });
244
- console.log(chalk.green(`✓ Superpowers hooks (SessionStart, SessionStop, PreToolUse:block-git-write) configured`));
252
+ console.log(chalk.green(`✓ Superpowers hooks (SessionStart, SessionEnd, PreToolUse:block-git-write) configured`));
245
253
  }
246
254
 
247
255
  async function verifySuperpowersSkills(versionDir) {
@@ -572,7 +580,7 @@ function verifyFigma(credentials) {
572
580
  const options = {
573
581
  hostname: 'api.figma.com',
574
582
  path: '/v1/me',
575
- headers: { 'Authorization': `Bearer ${FIGMA_API_TOKEN}` },
583
+ headers: { 'X-Figma-Token': FIGMA_API_TOKEN },
576
584
  };
577
585
  const req = https.get(options, (res) => {
578
586
  let data = '';
package/scripts/prompt.js CHANGED
@@ -335,6 +335,12 @@ function buildPrompt(template, ctx, rules, options, env = 'claude') {
335
335
  lines.push('');
336
336
  }
337
337
 
338
+ // ── Telemetry instructions ─────────────────────────────────
339
+ const ticketArg = ctx?.taskId ? ` --ticket ${ctx.taskId}` : '';
340
+ const akCmd = isWSL() ? 'ak.cmd' : 'ak';
341
+ lines.push(`> **Telemetry (required):** When **starting** each gate run \`${akCmd} gate N start${ticketArg}\`. When **APPROVED** run \`${akCmd} gate N approved${ticketArg}\`. Replace N with the gate number (1–5).`);
342
+ lines.push('');
343
+
338
344
  // ── Context section ────────────────────────────────────────
339
345
  if (ctx) {
340
346
  lines.push('## Context');
@@ -409,6 +415,14 @@ function buildPrompt(template, ctx, rules, options, env = 'claude') {
409
415
  return lines.join('\n');
410
416
  }
411
417
 
418
+ function isWSL() {
419
+ if (process.platform !== 'linux') return false;
420
+ try {
421
+ const version = require('fs').readFileSync('/proc/version', 'utf-8');
422
+ return /microsoft|wsl/i.test(version);
423
+ } catch (_) { return false; }
424
+ }
425
+
412
426
  function copyToClipboard(text) {
413
427
  try {
414
428
  if (process.platform === 'win32') {
@@ -420,6 +434,13 @@ function copyToClipboard(text) {
420
434
  );
421
435
  } else if (process.platform === 'darwin') {
422
436
  spawnSync('pbcopy', [], { input: text, encoding: 'utf8', stdio: ['pipe', 'ignore', 'ignore'] });
437
+ } else if (isWSL()) {
438
+ spawnSync(
439
+ 'powershell.exe',
440
+ ['-NoProfile', '-NonInteractive', '-Command',
441
+ '[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())'],
442
+ { input: text, encoding: 'utf8', stdio: ['pipe', 'ignore', 'ignore'] }
443
+ );
423
444
  } else {
424
445
  const r = spawnSync('xclip', ['-selection', 'clipboard'], { input: text, encoding: 'utf8', stdio: ['pipe', 'ignore', 'ignore'] });
425
446
  if (r.error) spawnSync('xsel', ['--clipboard', '--input'], { input: text, encoding: 'utf8', stdio: ['pipe', 'ignore', 'ignore'] });
package/scripts/task.js CHANGED
@@ -498,9 +498,49 @@ module.exports.createOrActivateTaskState = async function createOrActivateTaskSt
498
498
  createdAt: existing.createdAt || now,
499
499
  updatedAt: now,
500
500
  pausedAt: null,
501
- currentGate: existing.currentGate || currentGate,
501
+ currentGate: Math.max(existing.currentGate || 1, currentGate),
502
502
  gateApprovals: existing.gateApprovals || {},
503
503
  notes: existing.notes || '',
504
504
  };
505
505
  await fs.writeJson(statePath, taskState, { spaces: 2 });
506
506
  };
507
+
508
+ /**
509
+ * Called by `aiflow gate <N> <start|approved>` to keep task-state.json in sync
510
+ * with the actual gate progress — without requiring the user to run `task next`.
511
+ *
512
+ * - 'start' : advances currentGate to N (if N is higher than stored value)
513
+ * - 'approved': records approval timestamp + advances currentGate to N+1
514
+ *
515
+ * Silently no-ops when task-state.json does not exist yet (task not saved).
516
+ */
517
+ module.exports.updateTaskGateState = async function updateTaskGateState(ticketId, gateNum, action) {
518
+ if (!ticketId) return;
519
+ const taskDir = path.join(TASKS_DIR, ticketId);
520
+ const statePath = path.join(taskDir, 'task-state.json');
521
+ if (!(await fs.pathExists(statePath))) return; // task not yet saved — nothing to update
522
+
523
+ const existing = await fs.readJson(statePath).catch(() => null);
524
+ if (!existing) return;
525
+
526
+ const now = new Date().toISOString();
527
+ const updated = { ...existing, updatedAt: now };
528
+
529
+ if (action === 'start') {
530
+ // Advance currentGate only if this gate is higher (never go backwards)
531
+ if (gateNum > (existing.currentGate || 1)) {
532
+ updated.currentGate = gateNum;
533
+ }
534
+ } else if (action === 'approved') {
535
+ // Record the approval timestamp
536
+ const gateApprovals = { ...(existing.gateApprovals || {}) };
537
+ gateApprovals[String(gateNum)] = now;
538
+ updated.gateApprovals = gateApprovals;
539
+ // Advance currentGate to next gate (only if not already ahead)
540
+ if (gateNum >= (existing.currentGate || 1)) {
541
+ updated.currentGate = gateNum + 1;
542
+ }
543
+ }
544
+
545
+ await fs.writeJson(statePath, updated, { spaces: 2 });
546
+ };
@@ -232,12 +232,23 @@ module.exports = function telemetryCommand(action, options) {
232
232
  };
233
233
 
234
234
  function logEvent(options) {
235
- const { record } = require('./record');
236
- const event = options.event || 'custom_event';
237
- const detail = options.detail || '';
238
- if (!detail) {
239
- record(event);
240
- } else {
241
- record(event, { detail });
242
- }
235
+ try {
236
+ const { record } = require('./record');
237
+ const event = (options && options.event) || 'custom_event';
238
+ const detail = (options && options.detail) || '';
239
+ const result = detail ? record(event, { detail }) : record(event);
240
+
241
+ if (!result) return;
242
+ if (result.ok) {
243
+ console.log(chalk.green(` [tel] ✓ '${event}' logged`));
244
+ } else if (result.reason === 'disabled') {
245
+ console.log(chalk.yellow(` [tel] ⚠ skipped — telemetry is disabled (run 'aiflow telemetry enable')`));
246
+ } else if (result.reason === 'opted-out') {
247
+ console.log(chalk.yellow(` [tel] ⚠ skipped — repo has .aiflow/no-telemetry opt-out`));
248
+ } else if (result.reason === 'no-url') {
249
+ console.log(chalk.yellow(` [tel] ⚠ skipped — no server URL configured (run 'aiflow telemetry enable')`));
250
+ } else if (result.error) {
251
+ console.log(chalk.yellow(` [tel] ⚠ error logging '${event}': ${result.error}`));
252
+ }
253
+ } catch (e) { /* never block main flow */ }
243
254
  }
@@ -61,8 +61,9 @@ function logInternalError(err) {
61
61
  function record(eventType, payload = {}) {
62
62
  try {
63
63
  const cfg = loadConfig();
64
- if (!cfg.enabled) return;
65
- if (isRepoOptedOut()) return;
64
+ if (!cfg.enabled) return { ok: false, reason: 'disabled' };
65
+ if (isRepoOptedOut()) return { ok: false, reason: 'opted-out' };
66
+ if (!cfg.apps_script_url) return { ok: false, reason: 'no-url' };
66
67
 
67
68
  ensureDirectories();
68
69
 
@@ -70,6 +71,7 @@ function record(eventType, payload = {}) {
70
71
  event_id: crypto.randomUUID(),
71
72
  ts: new Date().toISOString(),
72
73
  email: cfg.email || '',
74
+ ai_email: cfg.email || '',
73
75
  install_id: cfg.install_id || '',
74
76
  machine_id: getMachineIdHash(),
75
77
  repo_name: detectRepoName(),
@@ -80,13 +82,16 @@ function record(eventType, payload = {}) {
80
82
  ...payload
81
83
  };
82
84
 
83
- // Drop any full content payload keys immediately just in case caller included them
85
+ // Truncate prompt to 200 chars max; drop raw full-content keys
86
+ if (event.prompt_summary && event.prompt_summary.length > 200) {
87
+ event.prompt_summary = event.prompt_summary.substring(0, 200) + '...';
88
+ }
84
89
  delete event.prompt_content;
85
90
  delete event.response_content;
86
91
 
87
92
  const lineStr = signEvent(event, cfg.team_secret);
88
93
  fs.appendFileSync(BUFFER_PATH, lineStr + '\n');
89
-
94
+
90
95
  // Check if buffer is getting huge (> 10MB cap). Simple clear
91
96
  try {
92
97
  if (fs.statSync(BUFFER_PATH).size > 10 * 1024 * 1024) {
@@ -95,8 +100,10 @@ function record(eventType, payload = {}) {
95
100
  } catch (e) {}
96
101
 
97
102
  maybeSpawnFlusher(cfg);
103
+ return { ok: true };
98
104
  } catch (err) {
99
105
  logInternalError(err);
106
+ return { ok: false, error: err.message };
100
107
  }
101
108
  }
102
109