@qa-gentic/stlc-agents 1.0.16 → 1.0.18

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 (45) hide show
  1. package/ORCHESTRATION_RULES.md +283 -0
  2. package/README.md +246 -321
  3. package/bin/postinstall.js +26 -2
  4. package/bin/qa-stlc.js +23 -0
  5. package/package.json +15 -2
  6. package/skills/write-helix-files/SKILL.md +6 -0
  7. package/src/cli/cmd-cost.js +253 -0
  8. package/src/cli/cmd-init.js +19 -2
  9. package/src/cli/cmd-mcp-config.js +123 -62
  10. package/src/cli/cmd-skills.js +21 -4
  11. package/src/stlc_agents/agent_gherkin_generator/server.py +88 -4
  12. package/src/stlc_agents/agent_helix_writer/tools/helix_write.py +60 -28
  13. package/src/stlc_agents/agent_jira_manager/server.py +209 -2
  14. package/src/stlc_agents/agent_jira_manager/tools/jira_workitem.py +36 -0
  15. package/src/stlc_agents/agent_playwright_generator/server.py +968 -105
  16. package/src/stlc_agents/agent_test_case_manager/server.py +121 -2
  17. package/src/stlc_agents/shared/cost_tracker.py +395 -0
  18. package/src/stlc_agents/shared/install_hook.py +154 -0
  19. package/src/stlc_agents/shared/pricing.py +72 -0
  20. package/src/stlc_agents/__pycache__/__init__.cpython-310.pyc +0 -0
  21. package/src/stlc_agents/agent_gherkin_generator/__pycache__/__init__.cpython-310.pyc +0 -0
  22. package/src/stlc_agents/agent_gherkin_generator/__pycache__/server.cpython-310.pyc +0 -0
  23. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/__init__.cpython-310.pyc +0 -0
  24. package/src/stlc_agents/agent_gherkin_generator/tools/__pycache__/ado_gherkin.cpython-310.pyc +0 -0
  25. package/src/stlc_agents/agent_helix_writer/__pycache__/__init__.cpython-310.pyc +0 -0
  26. package/src/stlc_agents/agent_helix_writer/__pycache__/server.cpython-310.pyc +0 -0
  27. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/__init__.cpython-310.pyc +0 -0
  28. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/boilerplate.cpython-310.pyc +0 -0
  29. package/src/stlc_agents/agent_helix_writer/tools/__pycache__/helix_write.cpython-310.pyc +0 -0
  30. package/src/stlc_agents/agent_jira_manager/__pycache__/__init__.cpython-310.pyc +0 -0
  31. package/src/stlc_agents/agent_jira_manager/__pycache__/server.cpython-310.pyc +0 -0
  32. package/src/stlc_agents/agent_jira_manager/tools/__pycache__/__init__.cpython-310.pyc +0 -0
  33. package/src/stlc_agents/agent_jira_manager/tools/__pycache__/jira_workitem.cpython-310.pyc +0 -0
  34. package/src/stlc_agents/agent_playwright_generator/__pycache__/__init__.cpython-310.pyc +0 -0
  35. package/src/stlc_agents/agent_playwright_generator/__pycache__/server.cpython-310.pyc +0 -0
  36. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/__init__.cpython-310.pyc +0 -0
  37. package/src/stlc_agents/agent_playwright_generator/tools/__pycache__/ado_attach.cpython-310.pyc +0 -0
  38. package/src/stlc_agents/agent_test_case_manager/__pycache__/__init__.cpython-310.pyc +0 -0
  39. package/src/stlc_agents/agent_test_case_manager/__pycache__/server.cpython-310.pyc +0 -0
  40. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/__init__.cpython-310.pyc +0 -0
  41. package/src/stlc_agents/agent_test_case_manager/tools/__pycache__/ado_workitem.cpython-310.pyc +0 -0
  42. package/src/stlc_agents/shared/__pycache__/__init__.cpython-310.pyc +0 -0
  43. package/src/stlc_agents/shared/__pycache__/auth.cpython-310.pyc +0 -0
  44. package/src/stlc_agents/shared_jira/__pycache__/__init__.cpython-310.pyc +0 -0
  45. package/src/stlc_agents/shared_jira/__pycache__/auth.cpython-310.pyc +0 -0
@@ -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,116 +104,137 @@ 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
172
  servers["playwright"] = {
137
- type: "url",
138
- url: `ws://localhost:${playwrightPort}`,
173
+ command: "npx",
174
+ args: ["@playwright/mcp@latest", "--isolated"],
139
175
  };
140
176
 
141
177
  return { config: { mcpServers: servers }, missing };
142
178
  }
143
179
 
180
+ // ── VS Code config (.vscode/mcp.json) ─────────────────────────────────────
181
+
144
182
  function buildVscodeConfig(pythonBin, playwrightPort, integration) {
145
183
  const servers = {};
146
184
  const missing = [];
147
185
  const needsAdo = !integration || integration === "ado" || integration === "both";
148
186
  const needsJira = integration === "jira" || integration === "both";
149
- // Shared agents (Gherkin, Playwright, Helix) are needed by both ADO and Jira pipelines.
150
187
  const activeNames = (needsAdo || needsJira) ? AGENT_NAMES : [];
151
188
 
152
189
  for (const name of activeNames) {
153
190
  const bin = findBinary(name, pythonBin);
154
191
  if (bin) {
155
- servers[name] = { command: bin };
192
+ servers[name] = {
193
+ type: "stdio",
194
+ command: bin,
195
+ args: [],
196
+ env: { ...COST_ENV },
197
+ };
156
198
  } else {
157
199
  missing.push(name);
158
- servers[name] = { command: `/path/to/.venv/bin/${name}`, "_comment": "NOT FOUND — run: pip install qa-gentic-stlc-agents" };
200
+ servers[name] = {
201
+ type: "stdio",
202
+ command: `/path/to/.venv/bin/${name}`,
203
+ args: [],
204
+ env: { ...COST_ENV },
205
+ "_comment": "NOT FOUND — run: pip install qa-gentic-stlc-agents",
206
+ };
159
207
  }
160
208
  }
161
209
 
162
- // Jira agent — only when integration includes jira
163
210
  if (needsJira) {
164
- const jiraBinV = findBinary(JIRA_AGENT_NAME, pythonBin);
165
- if (jiraBinV) {
166
- servers[JIRA_AGENT_NAME] = { command: jiraBinV, env: JIRA_ENV_VARS };
211
+ const jiraBin = findBinary(JIRA_AGENT_NAME, pythonBin);
212
+ if (jiraBin) {
213
+ servers[JIRA_AGENT_NAME] = {
214
+ type: "stdio",
215
+ command: jiraBin,
216
+ args: [],
217
+ env: {
218
+ ...COST_ENV,
219
+ ...JIRA_ENV_VARS,
220
+ },
221
+ };
167
222
  } else {
168
223
  missing.push(JIRA_AGENT_NAME);
169
224
  servers[JIRA_AGENT_NAME] = {
225
+ type: "stdio",
170
226
  command: `/path/to/.venv/bin/${JIRA_AGENT_NAME}`,
171
- env: JIRA_ENV_VARS,
227
+ args: [],
228
+ env: { ...COST_ENV, ...JIRA_ENV_VARS },
172
229
  "_comment": "NOT FOUND — run: pip install qa-gentic-stlc-agents",
173
230
  };
174
231
  }
175
232
  }
176
233
 
177
234
  servers["playwright"] = {
178
- type: "http",
179
- url: `http://localhost:${playwrightPort}/mcp`,
235
+ type: "stdio",
236
+ command: "npx",
237
+ args: ["@playwright/mcp@latest", "--isolated"],
180
238
  };
181
239
 
182
240
  return { config: { servers }, missing };
@@ -185,14 +243,17 @@ function buildVscodeConfig(pythonBin, playwrightPort, integration) {
185
243
  function printNextSteps(mode, playwrightPort) {
186
244
  const isVscode = mode === "vscode";
187
245
  console.log(`
188
- ${C.dim}Start Playwright MCP before running generation workflows:${C.reset}
189
- npx @playwright/mcp@latest --port ${playwrightPort}
190
- ${C.dim}headless (CI): npx @playwright/mcp@latest --headless --port ${playwrightPort}${C.reset}
246
+ ${C.dim}Playwright MCP is auto-started by the MCP framework (--isolated, no manual start needed).
247
+ For CI/headless: set PLAYWRIGHT_MCP_URL or start manually with --headless --isolated --port ${playwrightPort}${C.reset}
191
248
 
192
249
  ${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.`
250
+ ? `Reload VS Code window — all MCP servers will appear in the MCP panel.`
251
+ : `In Claude Code, run /mcp to verify all servers are loaded.`
195
252
  }
253
+
254
+ ${C.dim}Cost tracking is active on all servers.
255
+ Each tool call logs tokens + cost to ~/.qa-stlc/cost-<session>.jsonl
256
+ View reports: qa-stlc cost${C.reset}
196
257
  `);
197
258
  }
198
259
 
@@ -220,7 +281,7 @@ module.exports = async function mcpConfig(opts) {
220
281
  fs.mkdirSync(dir, { recursive: true });
221
282
  const out = path.join(dir, "mcp.json");
222
283
  fs.writeFileSync(out, JSON.stringify(config, null, 2) + "\n", "utf8");
223
- ok(`Written → .vscode/mcp.json`);
284
+ ok(`Written → .vscode/mcp.json (cost tracking env vars included)`);
224
285
  if (missing.length) {
225
286
  warn(`${missing.length} agent(s) not found — run: pip install qa-gentic-stlc-agents`);
226
287
  missing.forEach((m) => warn(` missing: ${m}`));
@@ -230,7 +291,7 @@ module.exports = async function mcpConfig(opts) {
230
291
  const { config, missing } = buildClaudeConfig(pythonBin, playwrightPort, integration);
231
292
  const out = path.join(CWD, ".mcp.json");
232
293
  fs.writeFileSync(out, JSON.stringify(config, null, 2) + "\n", "utf8");
233
- ok(`Written → .mcp.json`);
294
+ ok(`Written → .mcp.json (cost tracking env vars included)`);
234
295
  if (missing.length) {
235
296
  warn(`${missing.length} agent(s) not found — run: pip install qa-gentic-stlc-agents`);
236
297
  missing.forEach((m) => warn(` missing: ${m}`));
@@ -9,6 +9,8 @@
9
9
  * windsurf → .windsurf/rules/
10
10
  * both → claude + vscode
11
11
  * print → stdout only
12
+ *
13
+ * Also installs ORCHESTRATION_RULES.md to project root (multi-step workflow reference).
12
14
  */
13
15
  "use strict";
14
16
 
@@ -35,10 +37,11 @@ function readIntegrationPrefSk() {
35
37
  }
36
38
 
37
39
  // Resolve the skills bundled with this npm package
38
- const PKG_ROOT = path.resolve(__dirname, "../..");
39
- const SKILLS_DIR = path.join(PKG_ROOT, "skills");
40
- const BEHAVIOR_MD = path.join(SKILLS_DIR, "AGENT-BEHAVIOR.md");
41
- const AGENTS_DIR = path.join(PKG_ROOT, ".github", "agents");
40
+ const PKG_ROOT = path.resolve(__dirname, "../..");
41
+ const SKILLS_DIR = path.join(PKG_ROOT, "skills");
42
+ const BEHAVIOR_MD = path.join(SKILLS_DIR, "AGENT-BEHAVIOR.md");
43
+ const ORCHESTRATION_MD = path.join(PKG_ROOT, "ORCHESTRATION_RULES.md");
44
+ const AGENTS_DIR = path.join(PKG_ROOT, ".github", "agents");
42
45
 
43
46
  /** Copy a file, creating parent dirs as needed. */
44
47
  function cp(src, dest) {
@@ -105,6 +108,15 @@ function installAgents(integration) {
105
108
  agentFiles(integration).forEach((f) => info(path.basename(f)));
106
109
  }
107
110
 
111
+ /** Install ORCHESTRATION_RULES.md to project root for multi-step workflow reference. */
112
+ function installOrchestrationRules() {
113
+ if (!fs.existsSync(ORCHESTRATION_MD)) return;
114
+ const dest = path.join(CWD, "ORCHESTRATION_RULES.md");
115
+ cp(ORCHESTRATION_MD, dest);
116
+ ok(`ORCHESTRATION_RULES.md installed → project root`);
117
+ info("Reference this file for multi-step QA workflow best practices");
118
+ }
119
+
108
120
  function installClaude(integration) {
109
121
  const dest = path.join(CWD, ".claude", "skills");
110
122
  // Copy entire skill directory (preserves references/ subdirectory)
@@ -118,6 +130,7 @@ function installClaude(integration) {
118
130
  info("AGENT-BEHAVIOR.md → .claude/AGENT-BEHAVIOR.md");
119
131
  skillEntries(integration).forEach((e) => info(`${e.name}/SKILL.md`));
120
132
  installAgents(integration);
133
+ installOrchestrationRules();
121
134
  printPlaywrightHint();
122
135
  }
123
136
 
@@ -134,6 +147,7 @@ function installVscode(integration) {
134
147
  info("AGENT-BEHAVIOR.md → .github/copilot-instructions/AGENT-BEHAVIOR.md");
135
148
  skillEntries(integration).forEach((e) => info(`${e.name}/SKILL.md`));
136
149
  installAgents(integration);
150
+ installOrchestrationRules();
137
151
  printPlaywrightHint();
138
152
  }
139
153
 
@@ -146,6 +160,7 @@ function installCursor(integration) {
146
160
  cp(BEHAVIOR_MD, path.join(dest, "AGENT-BEHAVIOR.md"));
147
161
  ok(`Skills installed → .cursor/rules/`);
148
162
  installAgents(integration);
163
+ installOrchestrationRules();
149
164
  printPlaywrightHint();
150
165
  }
151
166
 
@@ -158,6 +173,7 @@ function installWindsurf(integration) {
158
173
  cp(BEHAVIOR_MD, path.join(dest, "AGENT-BEHAVIOR.md"));
159
174
  ok(`Skills installed → .windsurf/rules/`);
160
175
  installAgents(integration);
176
+ installOrchestrationRules();
161
177
  printPlaywrightHint();
162
178
  }
163
179
 
@@ -165,6 +181,7 @@ function printSkills() {
165
181
  console.log("\nAvailable skills:\n");
166
182
  skillEntries().forEach((e) => console.log(` ${e.name}/SKILL.md`));
167
183
  console.log(` AGENT-BEHAVIOR.md`);
184
+ console.log(` ORCHESTRATION_RULES.md (workflow reference)`);
168
185
  }
169
186
 
170
187
  /** Recursively copy a directory tree. */
@@ -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()
@@ -40,6 +40,10 @@ Within-file deduplication
40
40
  src/test/features/ are scanned for existing scenario titles.
41
41
  A title already present in any sibling file is treated as a
42
42
  duplicate and dropped from the incoming content.
43
+ For cucumber.config.ts : new profiles are appended ONLY if the file already exists.
44
+ If the file does not exist, the profile is skipped entirely.
45
+ This prevents orphaned config files from being created when
46
+ no Cucumber config yet exists on disk.
43
47
 
44
48
  Interface adapter
45
49
  The generator emits repo.updateHealed / repo.incrementSuccess / repo.getBBox etc.
@@ -140,11 +144,26 @@ def _adapt_to_helix_interface(content: str) -> str:
140
144
  'import { EnvironmentManager } from "@helper/environment/environmentManager.util";',
141
145
  'import { environment } from "@config/environment";',
142
146
  )
147
+ # If content uses environment.getConfig() but lacks the import, add it after the last import line
148
+ if "environment.getConfig()" in content and 'from "@config/environment"' not in content:
149
+ import_insert = 'import { environment } from "@config/environment";'
150
+ # Insert after the last import statement
151
+ last_import = content.rfind("\nimport ")
152
+ if last_import != -1:
153
+ end_of_line = content.find("\n", last_import + 1)
154
+ if end_of_line != -1:
155
+ content = content[:end_of_line + 1] + import_insert + "\n" + content[end_of_line + 1:]
143
156
  content = re.sub(r"\s*this\.env\s*=\s*new EnvironmentManager\(\);?\s*\n", "\n", content)
144
157
  content = content.replace("new EnvironmentManager()", "environment")
145
158
  content = content.replace("this.env.getBaseUrl()", "environment.getConfig().baseUrl")
146
159
  content = re.sub(r"this\.env\.getPath\(['\"]([^'\"]+)['\"]\)", r'"\1"', content)
147
160
 
161
+ # Fix super() — cannot access this before super() in TS
162
+ content = content.replace(
163
+ "super(page ?? this.page);",
164
+ "super(page ?? fixture().page);",
165
+ )
166
+
148
167
  return content
149
168
 
150
169
 
@@ -162,11 +181,22 @@ def _resolve_destination(helix_root: Path, file_key: str) -> Path:
162
181
  if _STEPS_RE.search(key):
163
182
  return helix_root / "src" / "test" / "steps" / Path(key).name
164
183
  if _LOCATOR_RE.search(key):
165
- parts = Path(key).parts
166
- stem = next(
167
- (p for p in parts if p not in ("src", "pages", "locators", "utils") and not p.endswith(".ts")),
168
- "page",
169
- )
184
+ # Extract the page/feature name from the path
185
+ # Input: src/pages/login-page/locators.ts or src/locators/login-page.locators.ts
186
+ # Output stem: login-page
187
+ path_obj = Path(key)
188
+
189
+ # First try: if filename is exactly 'locators.ts', use parent directory name
190
+ if path_obj.name == "locators.ts" and path_obj.parent.name:
191
+ stem = path_obj.parent.name
192
+ else:
193
+ # Otherwise extract stem from filename
194
+ fname = path_obj.name
195
+ stem = re.sub(r"\.locators\.ts$", "", fname)
196
+ stem = re.sub(r"\.ts$", "", stem)
197
+ if not stem or stem in ("src", "locators", "pages", "utils"):
198
+ stem = "page"
199
+
170
200
  return helix_root / "src" / "locators" / f"{stem}.locators.ts"
171
201
  if _PAGE_RE.search(key):
172
202
  return helix_root / "src" / "pages" / Path(key).name
@@ -242,7 +272,7 @@ def _collect_all_scenario_titles(features_dir: Path, exclude_file: Path | None =
242
272
  return titles
243
273
 
244
274
 
245
-
275
+ def _collect_all_step_patterns(steps_dir: Path, exclude_file: Path | None = None) -> set[str]:
246
276
  """Return every /^pattern$/ defined in all *.steps.ts files in steps_dir,
247
277
  optionally excluding one file (the one currently being written)."""
248
278
  patterns: set[str] = set()
@@ -627,29 +657,31 @@ def write_files_to_helix(
627
657
  skipped.append({"file_key": file_key, "dest": dest_rel, "reason": str(exc)})
628
658
  continue
629
659
 
630
- # ── Cucumber config: append profile, skip duplicate ────────────────
660
+ # ── Cucumber config: append profile only if file exists, never create ────────────
631
661
  if _CUCUMBER_RE.search(file_key):
662
+ if not dest.exists():
663
+ # RULE: if cucumber config does not exist, skip creation
664
+ skipped.append({
665
+ "file_key": file_key, "dest": dest_rel,
666
+ "reason": "cucumber.config.ts does not exist; only append to existing configs",
667
+ })
668
+ continue
632
669
  try:
633
- if dest.exists():
634
- existing_text = dest.read_text(encoding="utf-8")
635
- profile_match = re.match(r"\s*(\w+)\s*:", content.strip())
636
- profile_name = profile_match.group(1) if profile_match else None
637
- if profile_name and profile_name in existing_text:
638
- skipped.append({
639
- "file_key": file_key, "dest": dest_rel,
640
- "reason": f"profile '{profile_name}' already exists in cucumber.config.ts",
641
- })
642
- continue
643
- dest.write_text(
644
- existing_text.rstrip() + "\n\n// --- generated profile ---\n" + content,
645
- encoding="utf-8",
646
- )
647
- written.append({"file_key": file_key, "dest": dest_rel,
648
- "bytes": len(content.encode()), "action": "appended"})
649
- else:
650
- dest.write_text(content, encoding="utf-8")
651
- written.append({"file_key": file_key, "dest": dest_rel,
652
- "bytes": len(content.encode()), "action": "created"})
670
+ existing_text = dest.read_text(encoding="utf-8")
671
+ profile_match = re.match(r"\s*(\w+)\s*:", content.strip())
672
+ profile_name = profile_match.group(1) if profile_match else None
673
+ if profile_name and profile_name in existing_text:
674
+ skipped.append({
675
+ "file_key": file_key, "dest": dest_rel,
676
+ "reason": f"profile '{profile_name}' already exists in cucumber.config.ts",
677
+ })
678
+ continue
679
+ dest.write_text(
680
+ existing_text.rstrip() + "\n\n// --- generated profile ---\n" + content,
681
+ encoding="utf-8",
682
+ )
683
+ written.append({"file_key": file_key, "dest": dest_rel,
684
+ "bytes": len(content.encode()), "action": "appended"})
653
685
  except OSError as exc:
654
686
  skipped.append({"file_key": file_key, "dest": dest_rel, "reason": str(exc)})
655
687
  continue
@@ -867,4 +899,4 @@ def list_helix_tree(helix_root: str) -> dict[str, Any]:
867
899
  else:
868
900
  tree["other"].append(rel)
869
901
 
870
- return {"success": True, "helix_root": str(root), "tree": tree}
902
+ return {"success": True, "helix_root": str(root), "tree": tree}