@qa-gentic/stlc-agents 1.0.16 → 1.0.17

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.
Files changed (41) hide show
  1. package/README.md +59 -314
  2. package/bin/postinstall.js +17 -1
  3. package/bin/qa-stlc.js +23 -0
  4. package/package.json +1 -1
  5. package/skills/write-helix-files/SKILL.md +6 -0
  6. package/src/cli/cmd-cost.js +253 -0
  7. package/src/cli/cmd-mcp-config.js +124 -59
  8. package/src/stlc_agents/agent_gherkin_generator/server.py +88 -4
  9. package/src/stlc_agents/agent_helix_writer/tools/helix_write.py +60 -28
  10. package/src/stlc_agents/agent_jira_manager/server.py +209 -2
  11. package/src/stlc_agents/agent_jira_manager/tools/jira_workitem.py +36 -0
  12. package/src/stlc_agents/agent_playwright_generator/server.py +968 -105
  13. package/src/stlc_agents/agent_test_case_manager/server.py +121 -2
  14. package/src/stlc_agents/shared/cost_tracker.py +395 -0
  15. package/src/stlc_agents/shared/pricing.py +72 -0
  16. package/src/stlc_agents/__pycache__/__init__.cpython-310.pyc +0 -0
  17. package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-310.pyc +0 -0
  18. package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-310.pyc +0 -0
  19. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-310.pyc +0 -0
  20. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-310.pyc +0 -0
  21. package/src/stlc_agents/agent_helix_writer/__pycache__/__init__.cpython-310.pyc +0 -0
  22. package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-310.pyc +0 -0
  23. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/__init__.cpython-310.pyc +0 -0
  24. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/boilerplate.cpython-310.pyc +0 -0
  25. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/helix_write.cpython-310.pyc +0 -0
  26. package/src/stlc_agents/agent_jira_manager/__pycache__/__init__.cpython-310.pyc +0 -0
  27. package/src/stlc_agents/agent_jira_manager/__pycache__/server.cpython-310.pyc +0 -0
  28. package/src/stlc_agents/agent_jira_manager/tools/__pycache__/__init__.cpython-310.pyc +0 -0
  29. package/src/stlc_agents/agent_jira_manager/tools/__pycache__/jira_workitem.cpython-310.pyc +0 -0
  30. package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-310.pyc +0 -0
  31. package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-310.pyc +0 -0
  32. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-310.pyc +0 -0
  33. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-310.pyc +0 -0
  34. package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-310.pyc +0 -0
  35. package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-310.pyc +0 -0
  36. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-310.pyc +0 -0
  37. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-310.pyc +0 -0
  38. package/src/stlc_agents/shared/__pycache__/__init__.cpython-310.pyc +0 -0
  39. package/src/stlc_agents/shared/__pycache__/auth.cpython-310.pyc +0 -0
  40. package/src/stlc_agents/shared_jira/__pycache__/__init__.cpython-310.pyc +0 -0
  41. package/src/stlc_agents/shared_jira/__pycache__/auth.cpython-310.pyc +0 -0
@@ -0,0 +1,253 @@
1
+ /**
2
+ * cmd-cost.js — `qa-stlc cost`
3
+ *
4
+ * Read session cost logs from ~/.qa-stlc/cost-*.jsonl and print a report.
5
+ * Also handles --set-model to persist the coding agent model preference.
6
+ *
7
+ * Usage:
8
+ * qa-stlc cost # last session
9
+ * qa-stlc cost --all # all sessions
10
+ * qa-stlc cost --session <id> # specific session
11
+ * qa-stlc cost --json # raw JSON output
12
+ * qa-stlc cost --set-model <model-id> # save model for Cursor/Windsurf users
13
+ *
14
+ * Model detection order (mirrors cost_tracker.py):
15
+ * STLC_CODING_AGENT_MODEL env var
16
+ * → ANTHROPIC_MODEL env var (Claude Code sets this automatically)
17
+ * → GITHUB_COPILOT_MODEL env var
18
+ * → ~/.qa-stlc/agent-model (saved by --set-model)
19
+ * → "claude-sonnet-4-6" (default)
20
+ */
21
+
22
+ "use strict";
23
+
24
+ const fs = require("fs");
25
+ const path = require("path");
26
+ const os = require("os");
27
+
28
+ const LOG_DIR = path.join(os.homedir(), ".qa-stlc");
29
+ const PREF_FILE = path.join(LOG_DIR, "agent-model");
30
+
31
+ const C = {
32
+ reset: "\x1b[0m", bold: "\x1b[1m",
33
+ dim: "\x1b[2m", cyan: "\x1b[36m",
34
+ green: "\x1b[32m", yellow: "\x1b[33m",
35
+ };
36
+ const b = (s) => `${C.bold}${s}${C.reset}`;
37
+ const dim = (s) => `${C.dim}${s}${C.reset}`;
38
+ const grn = (s) => `${C.green}${s}${C.reset}`;
39
+ const cyn = (s) => `${C.cyan}${s}${C.reset}`;
40
+
41
+ function fmtTok(n) { return n >= 1000 ? `${(n/1000).toFixed(1)}K` : String(n); }
42
+ function fmtUsd(n) { return `$${n.toFixed(6)}`; }
43
+ function fmtMs(n) { return `${n}ms`; }
44
+
45
+ // ── Model preference helpers ───────────────────────────────────────────────
46
+
47
+ const KNOWN_MODELS = {
48
+ // Anthropic
49
+ "claude-sonnet-4-6": "$3/$15 per MTok",
50
+ "claude-opus-4-6": "$5/$25 per MTok",
51
+ "claude-opus-4-7": "$5/$25 per MTok",
52
+ "claude-haiku-4-5-20251001": "$1/$5 per MTok",
53
+ "claude-sonnet-4-20250514": "$3/$15 per MTok",
54
+ // OpenAI / Copilot
55
+ "gpt-4o": "$2.50/$10 per MTok",
56
+ "gpt-4o-mini": "$0.15/$0.60 per MTok",
57
+ };
58
+
59
+ function saveModelPref(modelId) {
60
+ fs.mkdirSync(LOG_DIR, { recursive: true });
61
+ fs.writeFileSync(PREF_FILE, modelId.trim(), "utf8");
62
+ }
63
+
64
+ function readModelPref() {
65
+ try {
66
+ if (fs.existsSync(PREF_FILE)) return fs.readFileSync(PREF_FILE, "utf8").trim();
67
+ } catch (_) {}
68
+ return null;
69
+ }
70
+
71
+ function printModelHelp() {
72
+ console.log(`\n ${b("Coding agent model for cost tracking")}\n`);
73
+ console.log(` ${dim("Model is detected automatically in this order:")}`);
74
+ console.log(` 1. STLC_CODING_AGENT_MODEL env var ${dim("(explicit override, always wins)")}`);
75
+ console.log(` 2. ANTHROPIC_MODEL env var ${dim("(set automatically by Claude Code ✓ zero config)")}`);
76
+ console.log(` 3. GITHUB_COPILOT_MODEL env var ${dim("(set by Copilot if configured)")}`);
77
+ console.log(` 4. ~/.qa-stlc/agent-model file ${dim("(saved by qa-stlc cost --set-model)")}`);
78
+ console.log(` 5. claude-sonnet-4-6 ${dim("(default fallback)")}\n`);
79
+
80
+ const saved = readModelPref();
81
+ if (saved) {
82
+ console.log(` ${grn("✓")} Currently saved: ${b(saved)} ${dim("(~/.qa-stlc/agent-model)")}\n`);
83
+ }
84
+
85
+ console.log(` ${b("Known models:")}`);
86
+ for (const [id, rate] of Object.entries(KNOWN_MODELS)) {
87
+ console.log(` ${id.padEnd(36)} ${dim(rate)}`);
88
+ }
89
+
90
+ console.log(`\n ${b("To set for Cursor / Windsurf / any agent:")}`);
91
+ console.log(` ${cyn("Option A — save once (recommended):")}`);
92
+ console.log(` qa-stlc cost --set-model claude-opus-4-6\n`);
93
+ console.log(` ${cyn("Option B — shell profile (applies to all projects):")}`);
94
+ console.log(` echo 'export STLC_CODING_AGENT_MODEL=claude-opus-4-6' >> ~/.zshrc\n`);
95
+ console.log(` ${cyn("Option C — .mcp.json env block (project-level):")}`);
96
+ console.log(` "qa-gherkin-generator": {`);
97
+ console.log(` "command": "/path/to/qa-gherkin-generator",`);
98
+ console.log(` "env": { "STLC_CODING_AGENT_MODEL": "claude-opus-4-6" }`);
99
+ console.log(` }\n`);
100
+ console.log(` ${cyn("Option D — .env file in project root:")}`);
101
+ console.log(` STLC_CODING_AGENT_MODEL=claude-opus-4-6\n`);
102
+ console.log(` ${dim("Claude Code users: no action needed.")}`);
103
+ console.log(` ${dim("ANTHROPIC_MODEL is passed through automatically via qa-stlc mcp-config.")}\n`);
104
+ }
105
+
106
+ // ── Log reading ────────────────────────────────────────────────────────────
107
+
108
+ function readLogs() {
109
+ if (!fs.existsSync(LOG_DIR)) return [];
110
+ return fs.readdirSync(LOG_DIR)
111
+ .filter((f) => f.startsWith("cost-") && f.endsWith(".jsonl"))
112
+ .sort()
113
+ .map((f) => {
114
+ const file = path.join(LOG_DIR, f);
115
+ const records = fs.readFileSync(file, "utf8")
116
+ .split("\n").filter(Boolean).map((l) => JSON.parse(l));
117
+ return {
118
+ file,
119
+ sessionId: f.replace(/^cost-/, "").replace(/\.jsonl$/, ""),
120
+ records,
121
+ };
122
+ });
123
+ }
124
+
125
+ // ── Printing ───────────────────────────────────────────────────────────────
126
+
127
+ function printSession(sess) {
128
+ const { sessionId, records } = sess;
129
+ if (!records.length) return;
130
+
131
+ const byServer = {};
132
+ let totalCost = 0, totalTokens = 0;
133
+ for (const r of records) {
134
+ const k = r.server || "unknown";
135
+ if (!byServer[k]) byServer[k] = { calls: 0, tokens: 0, cost: 0 };
136
+ byServer[k].calls++;
137
+ byServer[k].tokens += r.estimated_tokens || 0;
138
+ byServer[k].cost += r.cost_usd || 0;
139
+ totalCost += r.cost_usd || 0;
140
+ totalTokens += r.estimated_tokens || 0;
141
+ }
142
+
143
+ const model = records[0]?.model || "unknown";
144
+ const source = records[0]?.model_source || "";
145
+ const method = records.some((r) => r.token_method === "estimated")
146
+ ? "estimated (payload chars÷4)" : "exact";
147
+ const ts = records[0]?.timestamp?.slice(0, 19).replace("T", " ") || "";
148
+ const tsEnd = records[records.length - 1]?.timestamp?.slice(0, 19).replace("T", " ") || "";
149
+
150
+ console.log(`\n${"═".repeat(68)}`);
151
+ console.log(b(` stlc-agents · Cost Report · ${sessionId}`));
152
+ console.log(`${"═".repeat(68)}`);
153
+ console.log(dim(` ${ts} → ${tsEnd}`));
154
+ console.log(dim(` Model: ${model} (detected via: ${source})`));
155
+ console.log(dim(` Token method: ${method}`));
156
+
157
+ // Per-server
158
+ console.log(`\n ${"Server".padEnd(30)} ${"Calls".padStart(6)} ${"~Tokens".padStart(10)} ${"Cost (USD)".padStart(14)}`);
159
+ console.log(` ${"─".repeat(60)}`);
160
+ for (const [svr, d] of Object.entries(byServer)) {
161
+ console.log(
162
+ ` ${cyn(svr.padEnd(30))} ${String(d.calls).padStart(6)} ` +
163
+ `${fmtTok(d.tokens).padStart(10)} ${grn(fmtUsd(d.cost).padStart(14))}`
164
+ );
165
+ }
166
+
167
+ // Per-step
168
+ console.log(`\n ${"Step detail"}`);
169
+ console.log(` ${"─".repeat(68)}`);
170
+ for (const r of records) {
171
+ console.log(
172
+ ` ${(r.server || "?").padEnd(26)} ${(r.tool || "?").padEnd(36)} ` +
173
+ `${fmtTok(r.estimated_tokens || 0).padStart(6)} ` +
174
+ `${grn(fmtUsd(r.cost_usd || 0))} ${fmtMs(r.latency_ms || 0)} ` +
175
+ dim(`[${r.token_method || "?"}]`)
176
+ );
177
+ }
178
+
179
+ // Totals
180
+ console.log(`\n ${"─".repeat(68)}`);
181
+ console.log(` ${"Total tokens".padEnd(40)} ${fmtTok(totalTokens).padStart(10)}`);
182
+ console.log(` ${b("Total cost".padEnd(40))} ${grn(fmtUsd(totalCost))}`);
183
+ console.log(dim(` Log: ${sess.file}`));
184
+ console.log(`${"═".repeat(68)}\n`);
185
+ }
186
+
187
+ // ── Main ───────────────────────────────────────────────────────────────────
188
+
189
+ module.exports = async function cost(opts) {
190
+
191
+ // --set-model: save model preference for Cursor/Windsurf users
192
+ if (opts.setModel) {
193
+ const modelId = opts.setModel.trim();
194
+ saveModelPref(modelId);
195
+ const rate = KNOWN_MODELS[modelId];
196
+ console.log(`\n ${grn("✓")} Model saved: ${b(modelId)}`);
197
+ if (rate) console.log(` Pricing: ${dim(rate)}`);
198
+ else console.log(` ${dim("(unknown model — pricing may show $0.000000, add to pricing.py)")}`);
199
+ console.log(` Written to: ${dim(PREF_FILE)}`);
200
+ console.log(`\n This preference is used by all stlc-agents servers in all projects.`);
201
+ console.log(` Override per-project via STLC_CODING_AGENT_MODEL in .env or .mcp.json.\n`);
202
+ return;
203
+ }
204
+
205
+ // --model-help: explain how model detection works
206
+ if (opts.modelHelp) {
207
+ printModelHelp();
208
+ return;
209
+ }
210
+
211
+ const sessions = readLogs();
212
+
213
+ if (!sessions.length) {
214
+ console.log(`\n No cost logs found in ${LOG_DIR}`);
215
+ console.log(` Run any qa-stlc MCP tool call to start tracking.\n`);
216
+ return;
217
+ }
218
+
219
+ if (opts.json) {
220
+ const all = opts.all ? sessions : [sessions[sessions.length - 1]];
221
+ console.log(JSON.stringify(all, null, 2));
222
+ return;
223
+ }
224
+
225
+ if (opts.session) {
226
+ const found = sessions.find((s) => s.sessionId === opts.session);
227
+ if (!found) {
228
+ console.log(`\n Session not found: ${opts.session}`);
229
+ console.log(` Available: ${sessions.map((s) => s.sessionId).join(", ")}\n`);
230
+ return;
231
+ }
232
+ printSession(found);
233
+ return;
234
+ }
235
+
236
+ if (opts.all) {
237
+ for (const s of sessions) printSession(s);
238
+ const allRecords = sessions.flatMap((s) => s.records);
239
+ const grandTotal = allRecords.reduce((a, r) => a + (r.cost_usd || 0), 0);
240
+ const grandTokens = allRecords.reduce((a, r) => a + (r.estimated_tokens || 0), 0);
241
+ console.log(`${"═".repeat(68)}`);
242
+ console.log(b(` All sessions — grand total`));
243
+ console.log(`${"═".repeat(68)}`);
244
+ console.log(` Sessions : ${sessions.length}`);
245
+ console.log(` Total tokens: ${fmtTok(grandTokens)}`);
246
+ console.log(b(` TOTAL COST : ${grn(fmtUsd(grandTotal))}`));
247
+ console.log(`${"═".repeat(68)}\n`);
248
+ return;
249
+ }
250
+
251
+ // Default: last session
252
+ printSession(sessions[sessions.length - 1]);
253
+ };
@@ -1,12 +1,40 @@
1
1
  /**
2
- * cmd-mcp-config.js — `qa-stlc mcp-config`
2
+ * cmd-mcp-config.js — `qa-stlc mcp-config` (updated with cost tracking env injection)
3
3
  *
4
- * Generates .mcp.json (Claude Code) or .vscode/mcp.json (GitHub Copilot / VS Code).
4
+ * Change from original: both buildClaudeConfig() and buildVscodeConfig() now
5
+ * inject STLC cost tracking env vars into every MCP server entry.
6
+ *
7
+ * What gets injected and why:
8
+ *
9
+ * STLC_COST_TRACKING "true" — enables cost tracking (opt-out via "false")
10
+ *
11
+ * ANTHROPIC_MODEL "${env:ANTHROPIC_MODEL}" — Claude Code sets this
12
+ * automatically on the process that launches Claude.
13
+ * Passing it through to the MCP subprocess lets the
14
+ * cost tracker auto-detect the model without any
15
+ * manual configuration.
16
+ *
17
+ * GITHUB_COPILOT_MODEL "${env:GITHUB_COPILOT_MODEL}" — VS Code / Copilot
18
+ * may set this; passed through for the same reason.
19
+ *
20
+ * STLC_CODING_AGENT_MODEL "${env:STLC_CODING_AGENT_MODEL}" — explicit user
21
+ * override. Empty by default; set in shell profile
22
+ * or .env to hard-pin the model regardless of agent.
23
+ *
24
+ * The detection order inside cost_tracker.py is:
25
+ * STLC_CODING_AGENT_MODEL → ANTHROPIC_MODEL → CLAUDE_MODEL →
26
+ * GITHUB_COPILOT_MODEL → ~/.qa-stlc/agent-model → "claude-sonnet-4-6"
27
+ *
28
+ * RESULT: zero configuration for Claude Code users — the model is detected
29
+ * automatically. Copilot / Cursor / Windsurf users set STLC_CODING_AGENT_MODEL
30
+ * once in their shell profile or run `qa-stlc cost --set-model <model>`.
5
31
  */
32
+
6
33
  "use strict";
7
34
 
8
- const path = require("path");
9
- const fs = require("fs");
35
+ const path = require("path");
36
+ const fs = require("fs");
37
+ const os = require("os");
10
38
  const { spawnSync } = require("child_process");
11
39
 
12
40
  const C = {
@@ -15,45 +43,54 @@ const C = {
15
43
  yellow: "\x1b[33m", red: "\x1b[31m", dim: "\x1b[2m",
16
44
  };
17
45
  const ok = (m) => console.log(`${C.green}✓${C.reset} ${m}`);
18
- const info = (m) => console.log(`${C.cyan}→${C.reset} ${m}`);
19
46
  const warn = (m) => console.log(`${C.yellow}⚠${C.reset} ${m}`);
20
47
 
21
- const os = require("os");
22
- const fs_ = fs; // alias — fs already required above
23
-
24
- const PREF_FILE_MCP = require("path").join(os.homedir(), ".qa-stlc", "integration");
48
+ const PREF_FILE_MCP = path.join(os.homedir(), ".qa-stlc", "integration");
25
49
 
26
50
  function readIntegrationPref() {
27
51
  try {
28
- if (fs_.existsSync(PREF_FILE_MCP))
29
- return fs_.readFileSync(PREF_FILE_MCP, "utf8").trim();
52
+ if (fs.existsSync(PREF_FILE_MCP))
53
+ return fs.readFileSync(PREF_FILE_MCP, "utf8").trim();
30
54
  } catch (_) {}
31
55
  return "ado";
32
56
  }
33
57
 
34
- const CWD = process.cwd();
58
+ const CWD = process.cwd();
35
59
  const IS_WIN = process.platform === "win32";
36
- const EXT = IS_WIN ? ".exe" : "";
60
+ const EXT = IS_WIN ? ".exe" : "";
37
61
 
38
- const AGENT_NAMES = [
62
+ const AGENT_NAMES = [
39
63
  "qa-test-case-manager",
40
64
  "qa-gherkin-generator",
41
65
  "qa-playwright-generator",
42
66
  "qa-helix-writer",
43
67
  ];
44
-
45
- // Jira agent — added separately because it needs env var injection
46
68
  const JIRA_AGENT_NAME = "qa-jira-manager";
69
+
70
+ // ── Cost tracking env vars injected into every server entry ───────────────
71
+ //
72
+ // These are passed as env block items in both .mcp.json and .vscode/mcp.json.
73
+ // The "${env:X}" syntax is evaluated by Claude Code / VS Code at server start,
74
+ // substituting the actual value from the coding agent's own environment.
75
+ //
76
+ // ANTHROPIC_MODEL is the key one: Claude Code sets it automatically, so
77
+ // Claude Code users get correct pricing with zero manual configuration.
78
+ const COST_ENV = {
79
+ // Carry the coding agent's model ID into the MCP subprocess
80
+ "ANTHROPIC_MODEL": "${env:ANTHROPIC_MODEL}",
81
+ "GITHUB_COPILOT_MODEL": "${env:GITHUB_COPILOT_MODEL}",
82
+ // Explicit override — user sets this in shell profile if auto-detect is wrong
83
+ "STLC_CODING_AGENT_MODEL": "${env:STLC_CODING_AGENT_MODEL}",
84
+ // Opt-out flag — user sets STLC_COST_TRACKING=false in their shell to silence output
85
+ "STLC_COST_TRACKING": "${env:STLC_COST_TRACKING}",
86
+ };
87
+
47
88
  const JIRA_ENV_VARS = {
48
89
  JIRA_CLIENT_ID: "${env:JIRA_CLIENT_ID}",
49
90
  JIRA_CLIENT_SECRET: "${env:JIRA_CLIENT_SECRET}",
50
91
  JIRA_CLOUD_ID: "${env:JIRA_CLOUD_ID}",
51
92
  };
52
93
 
53
- /**
54
- * Locate the binary for an agent.
55
- * Priority: .venv → --python dir → system PATH → python sys.executable dir → macOS framework dirs
56
- */
57
94
  function findBinary(name, pythonBin) {
58
95
  // 1. .venv in cwd
59
96
  const venvBin = IS_WIN
@@ -67,108 +104,132 @@ function findBinary(name, pythonBin) {
67
104
  if (fs.existsSync(candidate)) return candidate;
68
105
  }
69
106
 
70
- // 3. system PATH via which/where
107
+ // 3. system PATH
71
108
  const which = spawnSync(IS_WIN ? "where" : "which", [name], { encoding: "utf8" });
72
- if (which.status === 0 && which.stdout.trim()) return which.stdout.trim().split("\n")[0].trim();
109
+ if (which.status === 0 && which.stdout.trim())
110
+ return which.stdout.trim().split("\n")[0].trim();
73
111
 
74
- // 4. ask Python where its own bin dir is (catches framework installs not on PATH)
112
+ // 4. Python's own bin dir (framework installs not on PATH)
75
113
  for (const py of ["python3", "python"]) {
76
- const r = spawnSync(py, ["-c", "import sys, os; print(os.path.dirname(sys.executable))"], { encoding: "utf8" });
114
+ const r = spawnSync(py, ["-c", "import sys,os;print(os.path.dirname(sys.executable))"], { encoding: "utf8" });
77
115
  if (r.status === 0 && r.stdout.trim()) {
78
116
  const candidate = path.join(r.stdout.trim(), name + EXT);
79
117
  if (fs.existsSync(candidate)) return candidate;
80
118
  }
81
119
  }
82
120
 
83
- // 5. macOS Python.org framework fallback
121
+ // 5. macOS framework fallback
84
122
  if (!IS_WIN) {
85
- const frameworkDirs = [
86
- "/Library/Frameworks/Python.framework/Versions/3.13/bin",
87
- "/Library/Frameworks/Python.framework/Versions/3.12/bin",
88
- "/Library/Frameworks/Python.framework/Versions/3.11/bin",
89
- "/Library/Frameworks/Python.framework/Versions/3.10/bin",
90
- ];
91
- for (const dir of frameworkDirs) {
92
- const candidate = path.join(dir, name);
123
+ for (const v of ["3.13", "3.12", "3.11", "3.10"]) {
124
+ const candidate = `/Library/Frameworks/Python.framework/Versions/${v}/bin/${name}`;
93
125
  if (fs.existsSync(candidate)) return candidate;
94
126
  }
95
127
  }
96
-
97
128
  return null;
98
129
  }
99
130
 
131
+ // ── Claude Code config (.mcp.json) ────────────────────────────────────────
132
+
100
133
  function buildClaudeConfig(pythonBin, playwrightPort, integration) {
101
134
  const servers = {};
102
135
  const missing = [];
103
136
  const needsAdo = !integration || integration === "ado" || integration === "both";
104
137
  const needsJira = integration === "jira" || integration === "both";
105
-
106
- // ADO agents — always included for ado/both; also included for jira because
107
- // qa-gherkin-generator, qa-playwright-generator, and qa-helix-writer are shared
108
- // by the Jira pipeline (Gherkin → Playwright → Helix steps run the same agents).
109
138
  const activeNames = (needsAdo || needsJira) ? AGENT_NAMES : [];
110
139
 
111
140
  for (const name of activeNames) {
112
141
  const bin = findBinary(name, pythonBin);
113
142
  if (bin) {
114
- servers[name] = { command: bin };
143
+ // Every server gets the cost tracking env block
144
+ servers[name] = { command: bin, env: { ...COST_ENV } };
115
145
  } else {
116
146
  missing.push(name);
117
- servers[name] = { command: `/path/to/.venv/bin/${name}`, "_comment": "NOT FOUND — run: pip install qa-gentic-stlc-agents" };
147
+ servers[name] = {
148
+ command: `/path/to/.venv/bin/${name}`,
149
+ env: { ...COST_ENV },
150
+ "_comment": "NOT FOUND — run: pip install qa-gentic-stlc-agents",
151
+ };
118
152
  }
119
153
  }
120
154
 
121
- // Jira agent — only when integration includes jira
122
155
  if (needsJira) {
123
156
  const jiraBin = findBinary(JIRA_AGENT_NAME, pythonBin);
124
157
  if (jiraBin) {
125
- servers[JIRA_AGENT_NAME] = { command: jiraBin, env: JIRA_ENV_VARS };
158
+ servers[JIRA_AGENT_NAME] = {
159
+ command: jiraBin,
160
+ env: { ...COST_ENV, ...JIRA_ENV_VARS },
161
+ };
126
162
  } else {
127
163
  missing.push(JIRA_AGENT_NAME);
128
164
  servers[JIRA_AGENT_NAME] = {
129
165
  command: `/path/to/.venv/bin/${JIRA_AGENT_NAME}`,
130
- env: JIRA_ENV_VARS,
166
+ env: { ...COST_ENV, ...JIRA_ENV_VARS },
131
167
  "_comment": "NOT FOUND — run: pip install qa-gentic-stlc-agents",
132
168
  };
133
169
  }
134
170
  }
135
171
 
136
- servers["playwright"] = {
137
- type: "url",
138
- url: `ws://localhost:${playwrightPort}`,
139
- };
172
+ servers["playwright"] = { type: "url", url: `ws://localhost:${playwrightPort}` };
140
173
 
141
174
  return { config: { mcpServers: servers }, missing };
142
175
  }
143
176
 
177
+ // ── VS Code config (.vscode/mcp.json) ─────────────────────────────────────
178
+
144
179
  function buildVscodeConfig(pythonBin, playwrightPort, integration) {
145
180
  const servers = {};
146
181
  const missing = [];
147
182
  const needsAdo = !integration || integration === "ado" || integration === "both";
148
183
  const needsJira = integration === "jira" || integration === "both";
149
- // Shared agents (Gherkin, Playwright, Helix) are needed by both ADO and Jira pipelines.
150
184
  const activeNames = (needsAdo || needsJira) ? AGENT_NAMES : [];
151
185
 
152
186
  for (const name of activeNames) {
153
187
  const bin = findBinary(name, pythonBin);
154
188
  if (bin) {
155
- servers[name] = { command: bin };
189
+ servers[name] = {
190
+ type: "stdio",
191
+ command: bin,
192
+ args: [],
193
+ env: {
194
+ // Azure DevOps auth passthrough (original)
195
+ "AZURE_TENANT_ID": "${env:AZURE_TENANT_ID}",
196
+ "AZURE_CLIENT_ID": "${env:AZURE_CLIENT_ID}",
197
+ "AZURE_CLIENT_SECRET": "${env:AZURE_CLIENT_SECRET}",
198
+ // Cost tracking passthrough (new)
199
+ ...COST_ENV,
200
+ },
201
+ };
156
202
  } else {
157
203
  missing.push(name);
158
- servers[name] = { command: `/path/to/.venv/bin/${name}`, "_comment": "NOT FOUND — run: pip install qa-gentic-stlc-agents" };
204
+ servers[name] = {
205
+ type: "stdio",
206
+ command: `/path/to/.venv/bin/${name}`,
207
+ args: [],
208
+ env: { ...COST_ENV },
209
+ "_comment": "NOT FOUND — run: pip install qa-gentic-stlc-agents",
210
+ };
159
211
  }
160
212
  }
161
213
 
162
- // Jira agent — only when integration includes jira
163
214
  if (needsJira) {
164
- const jiraBinV = findBinary(JIRA_AGENT_NAME, pythonBin);
165
- if (jiraBinV) {
166
- servers[JIRA_AGENT_NAME] = { command: jiraBinV, env: JIRA_ENV_VARS };
215
+ const jiraBin = findBinary(JIRA_AGENT_NAME, pythonBin);
216
+ if (jiraBin) {
217
+ servers[JIRA_AGENT_NAME] = {
218
+ type: "stdio",
219
+ command: jiraBin,
220
+ args: [],
221
+ env: {
222
+ ...COST_ENV,
223
+ ...JIRA_ENV_VARS,
224
+ },
225
+ };
167
226
  } else {
168
227
  missing.push(JIRA_AGENT_NAME);
169
228
  servers[JIRA_AGENT_NAME] = {
229
+ type: "stdio",
170
230
  command: `/path/to/.venv/bin/${JIRA_AGENT_NAME}`,
171
- env: JIRA_ENV_VARS,
231
+ args: [],
232
+ env: { ...COST_ENV, ...JIRA_ENV_VARS },
172
233
  "_comment": "NOT FOUND — run: pip install qa-gentic-stlc-agents",
173
234
  };
174
235
  }
@@ -190,9 +251,13 @@ function printNextSteps(mode, playwrightPort) {
190
251
  ${C.dim}headless (CI): npx @playwright/mcp@latest --headless --port ${playwrightPort}${C.reset}
191
252
 
192
253
  ${isVscode
193
- ? `Reload VS Code window — all 5 MCP servers will appear in the MCP panel.`
194
- : `In Claude Code, run /mcp to verify all 5 servers are loaded.`
254
+ ? `Reload VS Code window — all MCP servers will appear in the MCP panel.`
255
+ : `In Claude Code, run /mcp to verify all servers are loaded.`
195
256
  }
257
+
258
+ ${C.dim}Cost tracking is active on all servers.
259
+ Each tool call logs tokens + cost to ~/.qa-stlc/cost-<session>.jsonl
260
+ View reports: qa-stlc cost${C.reset}
196
261
  `);
197
262
  }
198
263
 
@@ -220,7 +285,7 @@ module.exports = async function mcpConfig(opts) {
220
285
  fs.mkdirSync(dir, { recursive: true });
221
286
  const out = path.join(dir, "mcp.json");
222
287
  fs.writeFileSync(out, JSON.stringify(config, null, 2) + "\n", "utf8");
223
- ok(`Written → .vscode/mcp.json`);
288
+ ok(`Written → .vscode/mcp.json (cost tracking env vars included)`);
224
289
  if (missing.length) {
225
290
  warn(`${missing.length} agent(s) not found — run: pip install qa-gentic-stlc-agents`);
226
291
  missing.forEach((m) => warn(` missing: ${m}`));
@@ -230,7 +295,7 @@ module.exports = async function mcpConfig(opts) {
230
295
  const { config, missing } = buildClaudeConfig(pythonBin, playwrightPort, integration);
231
296
  const out = path.join(CWD, ".mcp.json");
232
297
  fs.writeFileSync(out, JSON.stringify(config, null, 2) + "\n", "utf8");
233
- ok(`Written → .mcp.json`);
298
+ ok(`Written → .mcp.json (cost tracking env vars included)`);
234
299
  if (missing.length) {
235
300
  warn(`${missing.length} agent(s) not found — run: pip install qa-gentic-stlc-agents`);
236
301
  missing.forEach((m) => warn(` missing: ${m}`));
@@ -323,6 +323,55 @@ async def list_tools() -> list[types.Tool]:
323
323
  "required": ["gherkin_content"],
324
324
  },
325
325
  ),
326
+
327
+ # ── NEW: Headless composite — fetch + validate + attach with retry ───
328
+ types.Tool(
329
+ name="generate_and_attach_gherkin",
330
+ description=(
331
+ "Headless composite tool: fetch work item data, validate Gherkin content, "
332
+ "and attach to ADO — retrying once internally if validation fails. "
333
+ "Use this from the pipeline or CI runner instead of calling "
334
+ "validate_gherkin_content + attach_gherkin_to_feature/work_item separately. "
335
+ "Accepts pre-generated gherkin_content from the LLM caller. "
336
+ "If validation fails on the first pass the errors are returned as "
337
+ "validation_failed (no retry is possible without a new LLM call — "
338
+ "the pipeline must re-generate and call this tool again). "
339
+ "Returns {status, gherkin_content, attached} on success, "
340
+ "or {status: validation_failed, errors, gherkin_content} on failure."
341
+ ),
342
+ inputSchema={
343
+ "type": "object",
344
+ "properties": {
345
+ "work_item_id": {
346
+ "type": "integer",
347
+ "description": "ADO Feature, PBI, or Bug ID",
348
+ },
349
+ "work_item_title": {
350
+ "type": "string",
351
+ "description": "Title of the work item (used in filename)",
352
+ },
353
+ "organization_url": {"type": "string"},
354
+ "project_name": {"type": "string"},
355
+ "gherkin_content": {
356
+ "type": "string",
357
+ "description": "Pre-generated .feature file content from LLM caller",
358
+ },
359
+ "scope": {
360
+ "type": "string",
361
+ "enum": ["feature", "work_item"],
362
+ "description": (
363
+ "'feature' attaches to a Feature work item (5–10 scenarios). "
364
+ "'work_item' attaches to a PBI or Bug (3–9 scenarios). "
365
+ "Default: 'work_item'."
366
+ ),
367
+ },
368
+ },
369
+ "required": [
370
+ "work_item_id", "work_item_title", "organization_url",
371
+ "project_name", "gherkin_content",
372
+ ],
373
+ },
374
+ ),
326
375
  ]
327
376
 
328
377
 
@@ -416,6 +465,44 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
416
465
  arguments.get("scope", "feature"),
417
466
  )
418
467
 
468
+ elif name == "generate_and_attach_gherkin":
469
+ org = arguments["organization_url"]
470
+ project = arguments["project_name"]
471
+ wi_id = arguments["work_item_id"]
472
+ wi_title = arguments["work_item_title"]
473
+ gherkin = arguments["gherkin_content"]
474
+ scope = arguments.get("scope", "work_item")
475
+
476
+ # Step 1: validate
477
+ validation = await asyncio.to_thread(_validate_gherkin, gherkin, scope)
478
+ if not validation["valid"]:
479
+ result = {
480
+ "status": "validation_failed",
481
+ "errors": validation.get("errors", []),
482
+ "gherkin_content": gherkin,
483
+ "message": (
484
+ "Gherkin content failed structural validation. "
485
+ "The pipeline must re-generate and call this tool again "
486
+ "with corrected content. No file was attached to ADO."
487
+ ),
488
+ }
489
+ else:
490
+ # Step 2: attach
491
+ if scope == "feature":
492
+ attach = await asyncio.to_thread(
493
+ _attach_feature, org, project, wi_id, wi_title, gherkin
494
+ )
495
+ else:
496
+ attach = await asyncio.to_thread(
497
+ _attach_wi_file, org, project, wi_id, wi_title, gherkin
498
+ )
499
+ attach["_validation"] = _validate_attach_response(attach, scope)
500
+ result = {
501
+ "status": "ok",
502
+ "gherkin_content": gherkin,
503
+ "attached": attach,
504
+ }
505
+
419
506
  else:
420
507
  result = {"error": f"Unknown tool: {name}"}
421
508
 
@@ -448,7 +535,4 @@ def main():
448
535
 
449
536
 
450
537
  if __name__ == "__main__":
451
- main()
452
-
453
-
454
-
538
+ main()