@syndicalt/snow-cli 2.0.0 → 2.0.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.
Files changed (3) hide show
  1. package/README.md +62 -1
  2. package/dist/index.js +230 -14
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -28,6 +28,10 @@ A portable CLI for ServiceNow. Query tables, inspect schemas, edit and search sc
28
28
 
29
29
  ---
30
30
 
31
+ ## Full Documentation
32
+
33
+ The full documentation is available on the [documentation page](https://syndicalt.github.io/snow_cli/). This README provides a quick overview and examples to get started.
34
+
31
35
  ## Installation
32
36
 
33
37
  **From npm (when published):**
@@ -89,6 +93,9 @@ snow diff all --against test --scripts --scope x_myco_myapp
89
93
  # 11. Run the full factory pipeline: plan → build → test → promote
90
94
  snow factory "Build a hardware asset request app with approval workflow" --envs test,prod
91
95
 
96
+ # 11b. Run tests and auto-fix failures with the LLM optimization loop
97
+ snow factory "Create a Script Include named SLACalculator with risk level logic" --run-tests --optimize
98
+
92
99
  # 12. Start an interactive session to build iteratively
93
100
  snow ai chat
94
101
 
@@ -845,6 +852,12 @@ snow factory "Build an incident escalation app" --dry-run
845
852
  # Generate and immediately run ATF tests
846
853
  snow factory "Build a change approval workflow" --run-tests
847
854
 
855
+ # Generate ATF tests, run them, and auto-fix failures with the LLM (up to 3 retries)
856
+ snow factory "Create a priority calculator script include" --run-tests --optimize
857
+
858
+ # Same, but allow up to 5 fix iterations
859
+ snow factory "Create a priority calculator script include" --run-tests --optimize --max-retries 5
860
+
848
861
  # Skip ATF test generation (faster)
849
862
  snow factory "Add a business rule to auto-assign P1 incidents" --skip-tests
850
863
 
@@ -863,6 +876,8 @@ snow factory "" --list
863
876
  | `--scope <prefix>` | Override the application scope prefix for all artifacts (e.g. `x_myco_myapp`) |
864
877
  | `--skip-tests` | Skip ATF test generation |
865
878
  | `--run-tests` | Execute the generated ATF test suite immediately after pushing |
879
+ | `--optimize` | After running tests, auto-fix failures via LLM feedback loop (implies `--run-tests`) |
880
+ | `--max-retries <n>` | Max optimization iterations when using `--optimize` (default: `3`) |
866
881
  | `--dry-run` | Show the generated plan only — no builds, no deployments |
867
882
  | `--resume <id>` | Resume a previous run from its checkpoint (see `--list`) |
868
883
  | `--list` | Show recent factory runs and their component completion status |
@@ -886,7 +901,13 @@ snow factory "" --list
886
901
  [4/N] Generating ATF tests (unless --skip-tests)
887
902
  LLM generates a test suite with server-side script assertions
888
903
  Tests pushed to the instance as sys_atf_test + sys_atf_step records
889
- Optionally executed if --run-tests is set
904
+ If --run-tests or --optimize: suite is executed via the CICD API
905
+ Pass/fail counts and per-test results are displayed
906
+
907
+ If --optimize and tests failed:
908
+ → LLM receives the failing test names + error messages + artifact source
909
+ → Generates corrected artifacts, re-pushes them, re-runs the suite
910
+ → Repeats until all pass or --max-retries is exhausted
890
911
 
891
912
  [5/N] Promoting to <env> (once per additional env in --envs)
892
913
  Checks for pre-existing artifacts on the target
@@ -906,6 +927,40 @@ snow factory "" --resume abc12345 # resume from last successful phase
906
927
 
907
928
  The resume prompt argument is ignored when `--resume` is provided — the original prompt and plan are loaded from the checkpoint.
908
929
 
930
+ #### Auto-optimization loop
931
+
932
+ When `--optimize` is set, the factory runs an automated fix cycle after the initial ATF test run. For each iteration:
933
+
934
+ 1. Failing test names and error messages are collected from the `sys_atf_result` table
935
+ 2. The current source code of every script-bearing artifact is extracted from the local manifest
936
+ 3. A structured fix prompt is sent to the LLM — no questions, just a corrected build
937
+ 4. Fixed artifacts are merged into the build (replaced by `type + name`), re-pushed to the instance
938
+ 5. The existing ATF suite is re-run; if all tests pass, the loop exits early
939
+
940
+ ```
941
+ [Optimize 1/3] 2 test(s) failing — asking LLM to fix...
942
+ ────────────────────────────────────────────────
943
+ Patching:
944
+ ~ script_include SLACalculator
945
+ Re-pushing fixed artifacts to instance...
946
+ Re-running ATF tests...
947
+ 3/3 tests passed
948
+ ✓ Test low risk level
949
+ ✓ Test medium risk level
950
+ ✓ Test high risk level
951
+
952
+ ✔ All tests passing after optimization!
953
+ ```
954
+
955
+ If failures persist after all retries, a warning is shown and the best-effort build is kept on disk.
956
+
957
+ **Requirements for ATF test execution:**
958
+ - The `sn_cicd` plugin must be active on the instance
959
+ - The authenticated user must have the `sn_cicd` role
960
+ - ATF must be enabled (`System ATF` → `Properties` → `Enable ATF`)
961
+
962
+ The optimization loop uses the same LLM provider as the build. Each iteration costs one LLM call, so `--max-retries 3` (default) means at most 3 additional calls beyond the initial build.
963
+
909
964
  #### Example output
910
965
 
911
966
  ```
@@ -968,6 +1023,12 @@ The resume prompt argument is ignored when `--resume` is provided — the origin
968
1023
  • Test manager notification trigger (2 steps)
969
1024
  ✓ Test suite created
970
1025
  https://dev12345.service-now.com/nav_to.do?uri=sys_atf_test_suite.do?sys_id=...
1026
+ Running ATF tests...
1027
+ 4/4 tests passed
1028
+ ✓ Test employee record creation and field defaults
1029
+ ✓ Test OnboardingUtils task generation
1030
+ ✓ Test business rule fires on insert
1031
+ ✓ Test manager notification trigger
971
1032
  ```
972
1033
 
973
1034
  #### Permission-denied errors
package/dist/index.js CHANGED
@@ -1075,18 +1075,83 @@ async function pushATFSuite(client, suite, stepConfigSysId) {
1075
1075
  return { suiteSysId, suiteUrl, testCount: suite.tests.length, stepCount };
1076
1076
  }
1077
1077
  async function runATFSuite(client, suiteSysId) {
1078
- const res = await client.post("/api/now/atf/test_suite/run", { id: suiteSysId });
1079
- const r = res?.result?.test_suite_result ?? {};
1078
+ const startRes = await client.post("/api/sn_cicd/testsuite/run", void 0, {
1079
+ params: { test_suite_sys_id: suiteSysId }
1080
+ });
1081
+ const resData = startRes;
1082
+ const inner = resData["result"] ?? resData;
1083
+ const links = inner["links"];
1084
+ const progressId = links?.["progress"]?.["id"] ?? links?.["results"]?.["id"];
1085
+ if (!progressId) {
1086
+ throw new Error(
1087
+ "CICD API did not return a progress link.\nRaw response: " + JSON.stringify(startRes, null, 2) + "\nEnsure the sn_cicd plugin is active and your user has the sn_cicd role."
1088
+ );
1089
+ }
1090
+ const resultsId = links?.["results"]?.["id"] ?? progressId;
1091
+ const TERMINAL = /* @__PURE__ */ new Set(["2", "3", "4"]);
1092
+ const POLL_MS = 5e3;
1093
+ const MAX_POLLS = 120;
1094
+ let done = false;
1095
+ let lastProgressRes = {};
1096
+ for (let i = 0; i < MAX_POLLS; i++) {
1097
+ await new Promise((resolve2) => setTimeout(resolve2, POLL_MS));
1098
+ const progress = await client.get(
1099
+ `/api/sn_cicd/progress/${progressId}`
1100
+ );
1101
+ lastProgressRes = progress["result"] ?? progress;
1102
+ if (TERMINAL.has(String(lastProgressRes["status"] ?? ""))) {
1103
+ done = true;
1104
+ break;
1105
+ }
1106
+ }
1107
+ if (!done) {
1108
+ throw new Error("ATF test suite timed out after 10 minutes");
1109
+ }
1110
+ const progressLinks = lastProgressRes["links"];
1111
+ const finalResultsId = progressLinks?.["results"]?.["id"] ?? resultsId;
1112
+ const resultsRes = await client.get(
1113
+ `/api/sn_cicd/testsuite/results/${finalResultsId}`
1114
+ );
1115
+ const resultData = resultsRes["result"] ?? resultsRes;
1116
+ const successCount = parseInt(String(resultData["rolledup_test_success_count"] ?? 0), 10);
1117
+ const failureCount = parseInt(String(resultData["rolledup_test_failure_count"] ?? 0), 10) + parseInt(String(resultData["rolledup_test_error_count"] ?? 0), 10);
1118
+ const skipCount = parseInt(String(resultData["rolledup_test_skip_count"] ?? 0), 10);
1119
+ const total = successCount + failureCount + skipCount;
1120
+ const suiteResultLinks = resultData["links"];
1121
+ const suiteResultSysId = suiteResultLinks?.["results"]?.["id"] ?? finalResultsId;
1122
+ let testResults = [];
1123
+ try {
1124
+ const rows = await client.queryTable("sys_atf_result", {
1125
+ sysparmQuery: `test_suite_result=${suiteResultSysId}`,
1126
+ sysparmFields: "test,status,output",
1127
+ sysparmLimit: 200,
1128
+ sysparmDisplayValue: true
1129
+ });
1130
+ testResults = rows.map((r) => {
1131
+ const testField = r["test"];
1132
+ const name = testField && typeof testField === "object" ? String(testField["display_value"] ?? "(unknown)") : String(testField ?? "(unknown)");
1133
+ const statusStr = String(r["status"] ?? "");
1134
+ return {
1135
+ name,
1136
+ status: statusStr === "success" || statusStr === "pass" ? "success" : "failure",
1137
+ message: r["output"] ? String(r["output"]) : void 0
1138
+ };
1139
+ });
1140
+ } catch {
1141
+ testResults = Array.from({ length: successCount }, (_, i) => ({
1142
+ name: `Test ${i + 1}`,
1143
+ status: "success"
1144
+ }));
1145
+ for (let i = 0; i < failureCount; i++) {
1146
+ testResults.push({ name: `Test ${successCount + i + 1}`, status: "failure" });
1147
+ }
1148
+ }
1080
1149
  return {
1081
- status: res?.result?.status ?? "unknown",
1082
- passed: parseInt(r.tests_passed ?? "0", 10),
1083
- failed: parseInt(r.tests_failed ?? "0", 10),
1084
- total: parseInt(r.total_tests ?? "0", 10),
1085
- testResults: (r.test_results ?? []).map((t) => ({
1086
- name: t.test_name ?? "(unknown)",
1087
- status: t.status ?? "unknown",
1088
- message: t.message
1089
- }))
1150
+ status: String(resultData["test_suite_status"] ?? resultData["status"] ?? "unknown"),
1151
+ passed: successCount,
1152
+ failed: failureCount,
1153
+ total,
1154
+ testResults
1090
1155
  };
1091
1156
  }
1092
1157
  var ATF_SYSTEM_PROMPT;
@@ -5627,8 +5692,148 @@ Scope prefix for all custom names: ${plan.scope.prefix}`;
5627
5692
  writeFileSync8(join7(compDir, `${base}.manifest.json`), JSON.stringify(build, null, 2), "utf-8");
5628
5693
  return build;
5629
5694
  }
5695
+ async function runOptimizationLoop(opts) {
5696
+ const { provider, client, atfSuiteSysId, maxRetries, runDir } = opts;
5697
+ let currentBuild = opts.build;
5698
+ let runResult = opts.initialRunResult;
5699
+ const { SN_SYSTEM_PROMPT: SN_SYSTEM_PROMPT2 } = await Promise.resolve().then(() => (init_sn_context(), sn_context_exports));
5700
+ for (let iteration = 1; iteration <= maxRetries; iteration++) {
5701
+ const failures = runResult.testResults.filter((t) => t.status !== "success");
5702
+ if (failures.length === 0) break;
5703
+ console.log();
5704
+ printDivider();
5705
+ console.log(` ${chalk13.bold.magenta(`[Optimize ${iteration}/${maxRetries}]`)} ${chalk13.red(`${failures.length} test(s) failing`)} \u2014 asking LLM to fix...`);
5706
+ printDivider();
5707
+ const failureLines = failures.map((t) => ` - "${t.name}": ${t.message ?? "no details"}`).join("\n");
5708
+ const artifactLines = currentBuild.artifacts.filter((a) => a.fields["script"] ?? a.fields["condition"]).map((a) => {
5709
+ const name = String(a.fields["name"] ?? "");
5710
+ const script = String(a.fields["script"] ?? "");
5711
+ const condition = String(a.fields["condition"] ?? "");
5712
+ let out = `[${a.type}: ${name}]`;
5713
+ if (script) out += `
5714
+ script:
5715
+ ${script}`;
5716
+ if (condition) out += `
5717
+ condition:
5718
+ ${condition}`;
5719
+ return out;
5720
+ }).join("\n\n---\n\n");
5721
+ const fixPrompt = `OPTIMIZATION LOOP \u2014 Iteration ${iteration} of ${maxRetries}
5722
+
5723
+ The following ATF tests failed after deploying the generated artifacts:
5724
+
5725
+ FAILING TESTS:
5726
+ ${failureLines}
5727
+
5728
+ CURRENT ARTIFACT CODE:
5729
+ ${artifactLines}
5730
+
5731
+ Your task: analyze the failures, identify root causes, and return corrected artifacts.
5732
+ Rules:
5733
+ - Return ONLY a JSON object wrapped in a \`\`\`json code fence
5734
+ - Use the same schema: { "name": "...", "description": "...", "artifacts": [...] }
5735
+ - Only include artifacts that need changes
5736
+ - All scripts must be ES5 (var only, no arrow functions, no const/let)
5737
+ - Do NOT ask clarifying questions \u2014 provide the fix immediately`;
5738
+ const messages = [
5739
+ { role: "system", content: SN_SYSTEM_PROMPT2 },
5740
+ { role: "user", content: fixPrompt }
5741
+ ];
5742
+ const llmSpinner = ora12(` Calling LLM for fix...`).start();
5743
+ let fixedArtifacts;
5744
+ try {
5745
+ const raw = await provider.complete(messages);
5746
+ llmSpinner.stop();
5747
+ const json = extractJSON(raw);
5748
+ const parsed = JSON.parse(json);
5749
+ const rawArtifacts = Array.isArray(parsed["artifacts"]) ? parsed["artifacts"] : [];
5750
+ fixedArtifacts = rawArtifacts.map((a) => {
5751
+ const art = a;
5752
+ const type = String(art["type"] ?? "");
5753
+ let fields;
5754
+ if (art["fields"] && typeof art["fields"] === "object" && !Array.isArray(art["fields"])) {
5755
+ fields = art["fields"];
5756
+ } else {
5757
+ const { type: _t, ...rest } = art;
5758
+ fields = rest;
5759
+ }
5760
+ return { type, fields };
5761
+ });
5762
+ } catch (err) {
5763
+ llmSpinner.fail(chalk13.yellow(` LLM call failed on iteration ${iteration} \u2014 stopping optimization`));
5764
+ console.error(chalk13.dim(` ${err instanceof Error ? err.message : String(err)}`));
5765
+ break;
5766
+ }
5767
+ const mergedArtifacts = currentBuild.artifacts.map((orig) => {
5768
+ const fix = fixedArtifacts.find(
5769
+ (f) => f.type === orig.type && String(f.fields["name"]) === String(orig.fields["name"])
5770
+ );
5771
+ return fix ?? orig;
5772
+ });
5773
+ const addedArtifacts = fixedArtifacts.filter(
5774
+ (f) => !currentBuild.artifacts.some(
5775
+ (orig) => orig.type === f.type && String(orig.fields["name"]) === String(f.fields["name"])
5776
+ )
5777
+ );
5778
+ const updatedBuild = {
5779
+ ...currentBuild,
5780
+ artifacts: [...mergedArtifacts, ...addedArtifacts]
5781
+ };
5782
+ const changed = fixedArtifacts.filter(
5783
+ (f) => currentBuild.artifacts.some(
5784
+ (orig) => orig.type === f.type && String(orig.fields["name"]) === String(f.fields["name"])
5785
+ )
5786
+ );
5787
+ if (changed.length > 0) {
5788
+ console.log(` ${chalk13.dim("Patching:")}`);
5789
+ for (const a of changed) {
5790
+ console.log(` ${chalk13.yellow("~")} ${chalk13.cyan(a.type.padEnd(16))} ${String(a.fields["name"] ?? "")}`);
5791
+ }
5792
+ }
5793
+ const pushSpinner = ora12(` Re-pushing fixed artifacts to instance...`).start();
5794
+ try {
5795
+ await pushArtifacts(client, updatedBuild);
5796
+ pushSpinner.stop();
5797
+ } catch (err) {
5798
+ pushSpinner.fail(chalk13.yellow("Re-push failed \u2014 stopping optimization"));
5799
+ console.error(chalk13.dim(` ${err instanceof Error ? err.message : String(err)}`));
5800
+ break;
5801
+ }
5802
+ const base = slugify2(updatedBuild.name);
5803
+ writeFileSync8(join7(runDir, `${base}.xml`), generateUpdateSetXML(updatedBuild), "utf-8");
5804
+ writeFileSync8(join7(runDir, `${base}.manifest.json`), JSON.stringify(updatedBuild, null, 2), "utf-8");
5805
+ const testSpinner = ora12(` Re-running ATF tests...`).start();
5806
+ try {
5807
+ runResult = await runATFSuite(client, atfSuiteSysId);
5808
+ testSpinner.stop();
5809
+ } catch (err) {
5810
+ testSpinner.fail(chalk13.yellow("ATF re-run failed \u2014 stopping optimization"));
5811
+ console.error(chalk13.dim(` ${err instanceof Error ? err.message : String(err)}`));
5812
+ break;
5813
+ }
5814
+ const passColor = runResult.failed === 0 ? chalk13.green : chalk13.yellow;
5815
+ console.log(` ${passColor(`${runResult.passed}/${runResult.total} tests passed`)}`);
5816
+ for (const t of runResult.testResults) {
5817
+ const icon = t.status === "success" ? chalk13.green("\u2713") : chalk13.red("\u2717");
5818
+ const msg = t.message ? chalk13.dim(` (${t.message})`) : "";
5819
+ console.log(` ${icon} ${t.name}${msg}`);
5820
+ }
5821
+ currentBuild = updatedBuild;
5822
+ if (runResult.failed === 0) {
5823
+ console.log();
5824
+ console.log(chalk13.green(" \u2714 All tests passing after optimization!"));
5825
+ break;
5826
+ }
5827
+ }
5828
+ if (runResult.failed > 0) {
5829
+ console.log();
5830
+ console.log(chalk13.yellow(` \u26A0 ${runResult.failed} test(s) still failing after ${maxRetries} optimization iteration(s).`));
5831
+ console.log(chalk13.dim(" Review the ATF suite in ServiceNow for details."));
5832
+ }
5833
+ return currentBuild;
5834
+ }
5630
5835
  function factoryCommand() {
5631
- const cmd = new Command13("factory").description("AI-orchestrated multi-component ServiceNow app pipeline: plan \u2192 build \u2192 test \u2192 promote").argument("<prompt>", "Natural language description of the application to build").option("--envs <aliases>", "Comma-separated instance aliases to deploy to in order (default: active instance only)", "").option("--scope <prefix>", "Override the application scope prefix (e.g. x_myco_myapp)").option("--skip-tests", "Skip ATF test generation").option("--run-tests", "Execute ATF tests on the source instance after generating them").option("--dry-run", "Show the plan only \u2014 do not build or deploy").option("--resume <id>", "Resume a previous factory run from its checkpoint").option("--list", "List recent factory runs and their status").action(async (prompt, opts) => {
5836
+ const cmd = new Command13("factory").description("AI-orchestrated multi-component ServiceNow app pipeline: plan \u2192 build \u2192 test \u2192 promote").argument("<prompt>", "Natural language description of the application to build").option("--envs <aliases>", "Comma-separated instance aliases to deploy to in order (default: active instance only)", "").option("--scope <prefix>", "Override the application scope prefix (e.g. x_myco_myapp)").option("--skip-tests", "Skip ATF test generation").option("--run-tests", "Execute ATF tests on the source instance after generating them").option("--optimize", "Auto-fix failing ATF tests via LLM feedback loop (implies --run-tests)").option("--max-retries <n>", "Max optimization iterations (used with --optimize)", "3").option("--dry-run", "Show the plan only \u2014 do not build or deploy").option("--resume <id>", "Resume a previous factory run from its checkpoint").option("--list", "List recent factory runs and their status").action(async (prompt, opts) => {
5632
5837
  if (opts.list) {
5633
5838
  const runs = listCheckpoints();
5634
5839
  if (runs.length === 0) {
@@ -5819,7 +6024,7 @@ function factoryCommand() {
5819
6024
  console.log(` ${chalk13.green("+")} ${chalk13.cyan(a.type.padEnd(16))} ${name}`);
5820
6025
  }
5821
6026
  }
5822
- const combinedBuild = {
6027
+ let combinedBuild = {
5823
6028
  name: plan.name,
5824
6029
  description: plan.description,
5825
6030
  scope: plan.scope,
@@ -5906,7 +6111,7 @@ function factoryCommand() {
5906
6111
  console.error(chalk13.dim(` ${err instanceof Error ? err.message : String(err)}`));
5907
6112
  atfResult = null;
5908
6113
  }
5909
- if (opts.runTests && atfResult) {
6114
+ if ((opts.runTests || opts.optimize) && atfResult) {
5910
6115
  const runSpinner = ora12(" Running ATF tests...").start();
5911
6116
  try {
5912
6117
  const runResult = await runATFSuite(sourceClient, atfResult.suiteSysId);
@@ -5918,6 +6123,17 @@ function factoryCommand() {
5918
6123
  const msg = t.message ? chalk13.dim(` (${t.message})`) : "";
5919
6124
  console.log(` ${icon} ${t.name}${msg}`);
5920
6125
  }
6126
+ if (opts.optimize && runResult.failed > 0) {
6127
+ combinedBuild = await runOptimizationLoop({
6128
+ provider,
6129
+ client: sourceClient,
6130
+ build: combinedBuild,
6131
+ atfSuiteSysId: atfResult.suiteSysId,
6132
+ initialRunResult: runResult,
6133
+ maxRetries: parseInt(opts.maxRetries ?? "3", 10),
6134
+ runDir
6135
+ });
6136
+ }
5921
6137
  } catch (err) {
5922
6138
  runSpinner.fail(chalk13.yellow("ATF run failed (non-fatal)"));
5923
6139
  console.error(chalk13.dim(` ${err instanceof Error ? err.message : String(err)}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syndicalt/snow-cli",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "CLI tool for ServiceNow automated build and instance management",
5
5
  "type": "module",
6
6
  "bin": {