bosun 0.41.4 → 0.41.6

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.
package/infra/monitor.mjs CHANGED
@@ -663,6 +663,7 @@ async function ensureWorkflowAutomationEngine() {
663
663
  "template-task-lifecycle",
664
664
  "template-task-finalization-guard",
665
665
  "template-agent-session-monitor",
666
+ "template-github-kanban-sync",
666
667
  ],
667
668
  });
668
669
  if (Number(reconcile?.autoUpdated || 0) > 0) {
@@ -673,6 +674,15 @@ async function ensureWorkflowAutomationEngine() {
673
674
  : ""),
674
675
  );
675
676
  }
677
+ if (
678
+ typeof engine.load === "function" &&
679
+ (Number(reconcile?.autoUpdated || 0) > 0 ||
680
+ Number(reconcile?.metadataUpdated || 0) > 0 ||
681
+ (Array.isArray(reconcile?.updatedWorkflowIds) &&
682
+ reconcile.updatedWorkflowIds.length > 0))
683
+ ) {
684
+ engine.load();
685
+ }
676
686
  }
677
687
  for (const summary of engine.list?.() || []) {
678
688
  const installedFrom = String(summary?.metadata?.installedFrom || "").trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.41.4",
3
+ "version": "0.41.6",
4
4
  "description": "Bosun Autonomous Engineering — manages AI agent executors with failover, extremely powerful workflow builder, and a massive amount of included default workflow templates for autonomous engineering, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -18432,7 +18432,7 @@
18432
18432
  "defaultTargetBranch": "origin/main",
18433
18433
  "taskTimeoutMs": 21600000,
18434
18434
  "prePrValidationEnabled": true,
18435
- "prePrValidationCommand": "npm run prepush:check",
18435
+ "prePrValidationCommand": "auto",
18436
18436
  "autoMergeOnCreate": false,
18437
18437
  "autoMergeMethod": "squash",
18438
18438
  "maxRetries": 2,
@@ -18836,6 +18836,7 @@
18836
18836
  "label": "Pre-PR Validation",
18837
18837
  "config": {
18838
18838
  "command": "{{prePrValidationCommand}}",
18839
+ "commandType": "qualityGate",
18839
18840
  "cwd": "{{worktreePath}}",
18840
18841
  "failOnError": false
18841
18842
  },
@@ -38231,7 +38232,7 @@
38231
38232
  "defaultTargetBranch": "origin/main",
38232
38233
  "taskTimeoutMs": 21600000,
38233
38234
  "prePrValidationEnabled": true,
38234
- "prePrValidationCommand": "npm run prepush:check",
38235
+ "prePrValidationCommand": "auto",
38235
38236
  "autoMergeOnCreate": false,
38236
38237
  "autoMergeMethod": "squash",
38237
38238
  "maxRetries": 2,
@@ -38604,6 +38605,7 @@
38604
38605
  "label": "Pre-PR Validation",
38605
38606
  "config": {
38606
38607
  "command": "{{prePrValidationCommand}}",
38608
+ "commandType": "qualityGate",
38607
38609
  "cwd": "{{worktreePath}}",
38608
38610
  "failOnError": false
38609
38611
  },
@@ -2,7 +2,8 @@
2
2
  * project-detection.mjs — Auto-detect project type, build tools, and commands.
3
3
  *
4
4
  * Scans a directory for manifest files and infers the project's language,
5
- * package manager, and standard commands (test, build, lint, syntax-check).
5
+ * package manager, and standard commands (test, build, lint, syntax-check,
6
+ * quality gate).
6
7
  * Handles mono-repos by detecting multiple stacks in a single root.
7
8
  */
8
9
 
@@ -360,6 +361,58 @@ function markerExists(rootDir, marker) {
360
361
  return existsSync(resolve(rootDir, marker));
361
362
  }
362
363
 
364
+ function buildPackageScriptCommand(packageManager, scriptName) {
365
+ const pm = String(packageManager || "npm").trim().toLowerCase();
366
+ if (pm === "yarn") return `yarn ${scriptName}`;
367
+ if (pm === "pnpm") return `pnpm ${scriptName}`;
368
+ if (pm === "bun") return `bun run ${scriptName}`;
369
+ return `npm run ${scriptName}`;
370
+ }
371
+
372
+ function readMakefile(rootDir) {
373
+ for (const name of ["Makefile", "makefile", "GNUmakefile"]) {
374
+ try {
375
+ return readFileSync(resolve(rootDir, name), "utf8");
376
+ } catch {}
377
+ }
378
+ return "";
379
+ }
380
+
381
+ function escapeRegExp(value) {
382
+ return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
383
+ }
384
+
385
+ function findMakeTarget(rootDir, targetNames = []) {
386
+ const makefile = readMakefile(rootDir);
387
+ if (!makefile) return "";
388
+ for (const name of targetNames) {
389
+ const pattern = new RegExp(`^${escapeRegExp(name)}\\s*:`, "m");
390
+ if (pattern.test(makefile)) return name;
391
+ }
392
+ return "";
393
+ }
394
+
395
+ function detectQualityGateCommand(rootDir, commands = {}, options = {}) {
396
+ const packageManager = String(options.packageManager || "").trim().toLowerCase();
397
+ const scripts = readPackageJsonScripts(rootDir);
398
+ for (const scriptName of ["prepush:check", "prepush-check", "prepush", "pre-push", "verify", "validate", "check"]) {
399
+ if (typeof scripts[scriptName] === "string" && scripts[scriptName].trim()) {
400
+ return buildPackageScriptCommand(packageManager, scriptName);
401
+ }
402
+ }
403
+
404
+ if (existsSync(resolve(rootDir, ".githooks", "pre-push"))) {
405
+ return "bash .githooks/pre-push";
406
+ }
407
+
408
+ const makeTarget = findMakeTarget(rootDir, ["prepush", "pre-push", "verify", "validate", "check"]);
409
+ if (makeTarget) {
410
+ return `make ${makeTarget}`;
411
+ }
412
+
413
+ return commands.test || commands.lint || commands.build || commands.syntaxCheck || "";
414
+ }
415
+
363
416
  // ── Public API ───────────────────────────────────────────────────────────────
364
417
 
365
418
  /**
@@ -388,6 +441,7 @@ function markerExists(rootDir, marker) {
388
441
  * @property {string} lint - Lint command
389
442
  * @property {string} syntaxCheck - Syntax/compile check command
390
443
  * @property {string} [typeCheck] - Type-check command
444
+ * @property {string} [qualityGate] - Pre-push / pre-PR validation command
391
445
  * @property {string} [testFramework] - Detected test framework name
392
446
  */
393
447
  export function detectProjectStack(rootDir) {
@@ -402,6 +456,7 @@ export function detectProjectStack(rootDir) {
402
456
 
403
457
  const pm = def.detectPackageManager(rootDir);
404
458
  const commands = def.detectCommands(rootDir);
459
+ commands.qualityGate = detectQualityGateCommand(rootDir, commands, { packageManager: pm });
405
460
  const frameworks = def.detectFrameworks(rootDir);
406
461
  stacks.push({
407
462
  id: def.id,
@@ -438,6 +493,7 @@ export function getCommandPresets(detected) {
438
493
  build: [],
439
494
  lint: [],
440
495
  syntaxCheck: [],
496
+ qualityGate: [],
441
497
  };
442
498
 
443
499
  // If we have a detected stack, put its commands first as "Detected" options
@@ -448,6 +504,7 @@ export function getCommandPresets(detected) {
448
504
  if (cmds.build) presets.build.push({ label: `${label}: ${cmds.build}`, value: cmds.build, detected: true });
449
505
  if (cmds.lint) presets.lint.push({ label: `${label}: ${cmds.lint}`, value: cmds.lint, detected: true });
450
506
  if (cmds.syntaxCheck) presets.syntaxCheck.push({ label: `${label}: ${cmds.syntaxCheck}`, value: cmds.syntaxCheck, detected: true });
507
+ if (cmds.qualityGate) presets.qualityGate.push({ label: `${label}: ${cmds.qualityGate}`, value: cmds.qualityGate, detected: true });
451
508
  }
452
509
 
453
510
  // Add additional detected stacks (monorepo)
@@ -459,6 +516,7 @@ export function getCommandPresets(detected) {
459
516
  if (cmds.build) presets.build.push({ label: `${label}: ${cmds.build}`, value: cmds.build, detected: true });
460
517
  if (cmds.lint) presets.lint.push({ label: `${label}: ${cmds.lint}`, value: cmds.lint, detected: true });
461
518
  if (cmds.syntaxCheck) presets.syntaxCheck.push({ label: `${label}: ${cmds.syntaxCheck}`, value: cmds.syntaxCheck, detected: true });
519
+ if (cmds.qualityGate) presets.qualityGate.push({ label: `${label}: ${cmds.qualityGate}`, value: cmds.qualityGate, detected: true });
462
520
  }
463
521
  }
464
522
 
@@ -521,6 +579,13 @@ export function getCommandPresets(detected) {
521
579
  { label: "Ruby — ruby -c", value: "ruby -c" },
522
580
  { label: "PHP — php -l", value: "php -l" },
523
581
  ],
582
+ qualityGate: [
583
+ { label: "Node.js — npm run prepush:check", value: "npm run prepush:check" },
584
+ { label: "Node.js — pnpm prepush:check", value: "pnpm prepush:check" },
585
+ { label: "Repository hook — bash .githooks/pre-push", value: "bash .githooks/pre-push" },
586
+ { label: "Go — go test ./...", value: "go test ./..." },
587
+ { label: "Make — make test", value: "make test" },
588
+ ],
524
589
  };
525
590
 
526
591
  // Merge: skip any universal presets whose value already appears in detected
@@ -541,7 +606,7 @@ export function getCommandPresets(detected) {
541
606
  * If the value isn't "auto", returns it unchanged.
542
607
  *
543
608
  * @param {string} value - The command value (may be "auto" or an actual command)
544
- * @param {string} commandType - One of "test", "build", "lint", "syntaxCheck"
609
+ * @param {string} commandType - One of "test", "build", "lint", "syntaxCheck", "qualityGate"
545
610
  * @param {string} rootDir - Project root for detection
546
611
  * @returns {string} The resolved command
547
612
  */
@@ -552,7 +617,7 @@ export function resolveAutoCommand(value, commandType, rootDir) {
552
617
  }
553
618
 
554
619
  function emptyCommands() {
555
- return { test: "", build: "", lint: "", syntaxCheck: "", typeCheck: "" };
620
+ return { test: "", build: "", lint: "", syntaxCheck: "", typeCheck: "", qualityGate: "" };
556
621
  }
557
622
 
558
623
  // Re-export the stack definitions for introspection
@@ -41,6 +41,7 @@ import {
41
41
  ensureTestRuntimeSandbox,
42
42
  resolvePathForTestRuntime,
43
43
  } from "../infra/test-runtime.mjs";
44
+ import { getTemplate } from "./workflow-templates.mjs";
44
45
 
45
46
  // Lazy-loaded workspace manager for workspace-aware scheduling
46
47
  let _workspaceManagerMod = null;
@@ -744,6 +745,34 @@ export class WorkflowEngine extends EventEmitter {
744
745
  return normalized || "";
745
746
  }
746
747
 
748
+ _resolveTemplateDefaultVariable(def, key) {
749
+ if (!def || typeof def !== "object") return undefined;
750
+ if (def?.metadata?.templateState?.isCustomized === true) return undefined;
751
+ const templateId = String(def?.metadata?.installedFrom || "").trim();
752
+ if (!templateId) return undefined;
753
+ const template = getTemplate(templateId);
754
+ if (!template?.variables || typeof template.variables !== "object") return undefined;
755
+ return Object.prototype.hasOwnProperty.call(template.variables, key)
756
+ ? template.variables[key]
757
+ : undefined;
758
+ }
759
+
760
+ _applyResumeInputMigrations(def, data = {}) {
761
+ if (!data || typeof data !== "object") return data;
762
+ const next = { ...data };
763
+ const templateId = String(def?.metadata?.installedFrom || "").trim();
764
+ if (templateId === "template-task-lifecycle") {
765
+ const currentValue = next.prePrValidationCommand;
766
+ const nextDefault =
767
+ this._resolveTemplateDefaultVariable(def, "prePrValidationCommand")
768
+ ?? def?.variables?.prePrValidationCommand;
769
+ if (currentValue === "npm run prepush:check" && nextDefault === "auto") {
770
+ next.prePrValidationCommand = nextDefault;
771
+ }
772
+ }
773
+ return next;
774
+ }
775
+
747
776
  _resolveTaskTraceContext(ctx, node = null, result = null) {
748
777
  const nodeTaskIdCandidate = (() => {
749
778
  if (!node?.config || typeof node.config !== "object") return "";
@@ -1220,10 +1249,13 @@ export class WorkflowEngine extends EventEmitter {
1220
1249
  * @private
1221
1250
  */
1222
1251
  async _executeInner(def, workflowId, inputData, opts) {
1223
-
1224
- const ctx = new WorkflowContext({
1252
+ const initialData = this._applyResumeInputMigrations(def, {
1225
1253
  ...def.variables,
1226
1254
  ...inputData,
1255
+ });
1256
+
1257
+ const ctx = new WorkflowContext({
1258
+ ...initialData,
1227
1259
  _workflowId: workflowId,
1228
1260
  _workflowName: def.name,
1229
1261
  });
@@ -1347,6 +1379,7 @@ export class WorkflowEngine extends EventEmitter {
1347
1379
  const originalData = { ...(originalRun.detail?.data || {}) };
1348
1380
  delete originalData._workflowId;
1349
1381
  delete originalData._workflowName;
1382
+ const retryData = this._applyResumeInputMigrations(def, originalData);
1350
1383
 
1351
1384
  this.emit("run:retry", {
1352
1385
  originalRunId: runId,
@@ -1356,7 +1389,7 @@ export class WorkflowEngine extends EventEmitter {
1356
1389
  });
1357
1390
 
1358
1391
  if (mode === "from_scratch") {
1359
- const ctx = await this.execute(workflowId, originalData, {
1392
+ const ctx = await this.execute(workflowId, retryData, {
1360
1393
  ...retryOpts,
1361
1394
  _isRetry: true,
1362
1395
  _originalRunId: runId,
@@ -1373,7 +1406,7 @@ export class WorkflowEngine extends EventEmitter {
1373
1406
  // Build a fresh context but pre-seed completed node outputs.
1374
1407
  const ctx = new WorkflowContext({
1375
1408
  ...def.variables,
1376
- ...originalData,
1409
+ ...retryData,
1377
1410
  _workflowId: workflowId,
1378
1411
  _workflowName: def.name,
1379
1412
  _retryOf: runId,
@@ -3240,7 +3273,9 @@ export class WorkflowEngine extends EventEmitter {
3240
3273
  const runDetailCache = new Map(); // runId → parsed detail
3241
3274
  const latestByTaskId = new Map(); // taskId → run entry (highest startedAt)
3242
3275
 
3243
- for (const run of runs) {
3276
+ const allRuns = this._readRunIndex();
3277
+
3278
+ for (const run of allRuns) {
3244
3279
  const dp = resolve(this.runsDir, `${run.runId}.json`);
3245
3280
  if (!existsSync(dp)) continue;
3246
3281
  try {
@@ -3435,7 +3470,34 @@ export function getWorkflowEngine(opts = {}) {
3435
3470
  _defaultEngine = new WorkflowEngine(engineOpts);
3436
3471
  _defaultEngine.load();
3437
3472
  } else if (opts && typeof opts === "object") {
3438
- if (opts.services && typeof opts.services === "object") {
3473
+ const workflowDir = typeof opts.workflowDir === "string" && opts.workflowDir
3474
+ ? resolve(opts.workflowDir)
3475
+ : null;
3476
+ const runsDir = typeof opts.runsDir === "string" && opts.runsDir
3477
+ ? resolve(opts.runsDir)
3478
+ : null;
3479
+ const configDir = typeof opts.configDir === "string" && opts.configDir
3480
+ ? resolve(opts.configDir)
3481
+ : null;
3482
+ const shouldReinitialize =
3483
+ (workflowDir && workflowDir !== _defaultEngine.workflowDir) ||
3484
+ (runsDir && runsDir !== _defaultEngine.runsDir) ||
3485
+ (configDir && configDir !== resolve(_defaultEngine._configDir || process.cwd()));
3486
+
3487
+ if (shouldReinitialize) {
3488
+ const engineOpts = {
3489
+ ...opts,
3490
+ services: mergeWorkflowServices(_defaultEngine.services, opts.services || {}),
3491
+ };
3492
+ if (
3493
+ engineOpts.detectInterruptedRuns === undefined &&
3494
+ shouldDisableDefaultInterruptedRunDetection(engineOpts)
3495
+ ) {
3496
+ engineOpts.detectInterruptedRuns = false;
3497
+ }
3498
+ _defaultEngine = new WorkflowEngine(engineOpts);
3499
+ _defaultEngine.load();
3500
+ } else if (opts.services && typeof opts.services === "object") {
3439
3501
  _defaultEngine.services = mergeWorkflowServices(
3440
3502
  _defaultEngine.services,
3441
3503
  opts.services,
@@ -44,6 +44,7 @@ import {
44
44
  loadWorkflowContract,
45
45
  validateWorkflowContract,
46
46
  } from "./workflow-contract.mjs";
47
+ import { resolveAutoCommand } from "./project-detection.mjs";
47
48
  import { loadConfig } from "../config/config.mjs";
48
49
  import { fixGitConfigCorruption } from "../workspace/worktree-manager.mjs";
49
50
  import { clearBlockedWorktreeIdentity, normalizeBaseBranch } from "../git/git-safety.mjs";
@@ -2509,10 +2510,15 @@ registerBuiltinNodeType("action.run_agent", {
2509
2510
  ) || "",
2510
2511
  ).trim();
2511
2512
  const sessionId = resolvedSessionId || null;
2513
+ const storedSessionOwnerNodeId = String(ctx.data?._agentSessionNodeId || "").trim() || null;
2514
+ const hasExplicitSessionOverride =
2515
+ options.sessionId != null
2516
+ || node.config?.sessionId != null
2517
+ || (sessionId && !storedSessionOwnerNodeId);
2518
+ const canContinueStoredSession =
2519
+ !!sessionId && (hasExplicitSessionOverride || storedSessionOwnerNodeId === node.id);
2512
2520
  const explicitTaskKey = String(ctx.resolve(node.config?.taskKey || "") || "").trim();
2513
- const fallbackTaskKey =
2514
- sessionId ||
2515
- `${ctx.data?._workflowId || "workflow"}:${ctx.id}:${node.id}`;
2521
+ const fallbackTaskKey = `${ctx.data?._workflowId || "workflow"}:${ctx.id}:${node.id}`;
2516
2522
  const recoveryTaskKey = options.taskKey || explicitTaskKey || fallbackTaskKey;
2517
2523
  const autoRecover = options.autoRecover ?? (node.config?.autoRecover !== false);
2518
2524
  const continueOnSession =
@@ -2612,7 +2618,7 @@ registerBuiltinNodeType("action.run_agent", {
2612
2618
  if (
2613
2619
  autoRecover &&
2614
2620
  continueOnSession &&
2615
- sessionId &&
2621
+ canContinueStoredSession &&
2616
2622
  typeof agentPool.continueSession === "function"
2617
2623
  ) {
2618
2624
  ctx.log(node.id, `${passLabel} Recovery: continuing existing session ${sessionId}`.trim());
@@ -2706,6 +2712,8 @@ registerBuiltinNodeType("action.run_agent", {
2706
2712
  if (persistSession && threadId) {
2707
2713
  ctx.data.sessionId = threadId;
2708
2714
  ctx.data.threadId = threadId;
2715
+ ctx.data._agentSessionNodeId = node.id;
2716
+ ctx.data._agentSessionTaskKey = recoveryTaskKey;
2709
2717
  }
2710
2718
  const digest = buildAgentExecutionDigest(result, streamLines, maxRetainedEvents);
2711
2719
 
@@ -2988,6 +2996,11 @@ registerBuiltinNodeType("action.run_command", {
2988
2996
  env: { type: "object", description: "Environment variables passed to the command (supports templates)", additionalProperties: true },
2989
2997
  timeoutMs: { type: "number", default: 300000 },
2990
2998
  shell: { type: "string", default: "auto", enum: ["auto", "bash", "pwsh", "cmd"] },
2999
+ commandType: {
3000
+ type: "string",
3001
+ enum: ["test", "build", "lint", "syntaxCheck", "qualityGate"],
3002
+ description: "Optional auto-resolution category when command is set to 'auto'",
3003
+ },
2991
3004
  captureOutput: { type: "boolean", default: true },
2992
3005
  parseJson: { type: "boolean", default: false, description: "Parse JSON output automatically" },
2993
3006
  failOnError: { type: "boolean", default: false, description: "Throw on non-zero exit status (enables workflow retries)" },
@@ -2996,8 +3009,13 @@ registerBuiltinNodeType("action.run_command", {
2996
3009
  },
2997
3010
  async execute(node, ctx) {
2998
3011
  const resolvedCommand = ctx.resolve(node.config?.command || "");
2999
- const command = normalizeLegacyWorkflowCommand(resolvedCommand);
3000
3012
  const cwd = ctx.resolve(node.config?.cwd || ctx.data?.worktreePath || process.cwd());
3013
+ const commandType = typeof node.config?.commandType === "string" ? node.config.commandType.trim() : "";
3014
+ const autoCommandRoot = ctx.resolve(ctx.data?.repoRoot || cwd);
3015
+ const autoResolvedCommand = commandType
3016
+ ? resolveAutoCommand(String(resolvedCommand || ""), commandType, autoCommandRoot)
3017
+ : resolvedCommand;
3018
+ const command = normalizeLegacyWorkflowCommand(autoResolvedCommand);
3001
3019
  const resolvedEnvConfig = resolveWorkflowNodeValue(node.config?.env ?? {}, ctx);
3002
3020
  const commandEnv = { ...process.env };
3003
3021
  if (resolvedEnvConfig && typeof resolvedEnvConfig === "object" && !Array.isArray(resolvedEnvConfig)) {
@@ -3040,7 +3058,19 @@ registerBuiltinNodeType("action.run_command", {
3040
3058
  };
3041
3059
  const usedArgv = commandArgs.length > 0;
3042
3060
 
3043
- if (command !== resolvedCommand) {
3061
+ if (!command.trim()) {
3062
+ const reason =
3063
+ String(resolvedCommand || "").trim().toLowerCase() === "auto" && commandType
3064
+ ? `No ${commandType} command detected for ${autoCommandRoot || cwd}`
3065
+ : "No command configured";
3066
+ ctx.log(node.id, reason);
3067
+ return { success: false, output: "", stderr: "", exitCode: null, error: reason };
3068
+ }
3069
+
3070
+ if (autoResolvedCommand !== resolvedCommand) {
3071
+ ctx.log(node.id, `Resolved auto ${commandType} command: ${autoResolvedCommand}`);
3072
+ }
3073
+ if (command !== autoResolvedCommand) {
3044
3074
  ctx.log(node.id, `Normalized legacy command for portability: ${command}`);
3045
3075
  }
3046
3076
  ctx.log(node.id, `Running: ${usedArgv ? `${command} ${commandArgs.join(" ")}`.trim() : command}`);
@@ -454,13 +454,32 @@ function createWorkflowTemplateState({ getTemplate, cloneTemplateDefinition }) {
454
454
  return def;
455
455
  }
456
456
 
457
- function makeUpdatedWorkflowFromTemplate(existing, template, mode = "replace") {
457
+ function applyLegacyTemplateVariableMigrations(existing, template, variables, opts = {}) {
458
+ if (!variables || typeof variables !== "object") return variables;
459
+ const assumeUncustomized = opts.assumeUncustomized === true;
460
+ if (!assumeUncustomized && existing?.metadata?.templateState?.isCustomized === true) {
461
+ return variables;
462
+ }
463
+
464
+ const templateId = String(template?.id || existing?.metadata?.installedFrom || "").trim();
465
+ if (templateId === "template-task-lifecycle") {
466
+ const currentValue = existing?.variables?.prePrValidationCommand;
467
+ const nextDefault = template?.variables?.prePrValidationCommand;
468
+ if (currentValue === "npm run prepush:check" && nextDefault === "auto") {
469
+ variables.prePrValidationCommand = nextDefault;
470
+ }
471
+ }
472
+
473
+ return variables;
474
+ }
475
+
476
+ function makeUpdatedWorkflowFromTemplate(existing, template, mode = "replace", opts = {}) {
458
477
  const templateClone = cloneTemplateDefinition(template);
459
478
  const nowIso = new Date().toISOString();
460
- const mergedVariables = {
479
+ const mergedVariables = applyLegacyTemplateVariableMigrations(existing, templateClone, {
461
480
  ...(templateClone.variables || {}),
462
481
  ...(existing.variables || {}),
463
- };
482
+ }, opts);
464
483
  const next = {
465
484
  ...templateClone,
466
485
  id: mode === "copy" ? randomUUID() : existing.id,
@@ -500,7 +519,9 @@ function createWorkflowTemplateState({ getTemplate, cloneTemplateDefinition }) {
500
519
  throw new Error("Workflow has custom changes; pass force=true to replace it");
501
520
  }
502
521
 
503
- const next = makeUpdatedWorkflowFromTemplate(hydrated, template, mode);
522
+ const next = makeUpdatedWorkflowFromTemplate(hydrated, template, mode, {
523
+ assumeUncustomized: opts.assumeUncustomized === true,
524
+ });
504
525
  return engine.save(next);
505
526
  }
506
527
 
@@ -563,8 +584,13 @@ function createWorkflowTemplateState({ getTemplate, cloneTemplateDefinition }) {
563
584
 
564
585
  const templateId = String(state.templateId || "").trim();
565
586
  const shouldForceUpdate = templateId && forceUpdateTemplateIds.has(templateId);
587
+ const assumeUncustomized = previousState?.isCustomized !== true;
566
588
  if (shouldForceUpdate) {
567
- const saved = updateWorkflowFromTemplate(engine, def.id, { mode: "replace", force: true });
589
+ const saved = updateWorkflowFromTemplate(engine, def.id, {
590
+ mode: "replace",
591
+ force: true,
592
+ assumeUncustomized,
593
+ });
568
594
  result.autoUpdated += 1;
569
595
  result.updatedWorkflowIds.push(saved.id);
570
596
  result.forceUpdated.push(saved.id);
@@ -573,7 +599,11 @@ function createWorkflowTemplateState({ getTemplate, cloneTemplateDefinition }) {
573
599
 
574
600
  const wasCustomized = previousState?.isCustomized === true;
575
601
  if (autoUpdateUnmodified && state.updateAvailable === true && !wasCustomized) {
576
- const saved = updateWorkflowFromTemplate(engine, def.id, { mode: "replace", force: true });
602
+ const saved = updateWorkflowFromTemplate(engine, def.id, {
603
+ mode: "replace",
604
+ force: true,
605
+ assumeUncustomized: true,
606
+ });
577
607
  result.autoUpdated += 1;
578
608
  result.updatedWorkflowIds.push(saved.id);
579
609
  }
@@ -967,7 +997,7 @@ export function expandTemplateGroups(templateIds) {
967
997
  */
968
998
  /**
969
999
  * Classify a variable key into a command category if it matches.
970
- * Returns "test" | "build" | "lint" | "syntaxCheck" | null.
1000
+ * Returns "test" | "build" | "lint" | "syntaxCheck" | "qualityGate" | null.
971
1001
  */
972
1002
  function classifyCommandVariable(key) {
973
1003
  const k = String(key || "").toLowerCase();
@@ -975,6 +1005,7 @@ function classifyCommandVariable(key) {
975
1005
  if (k.includes("buildcommand") || k.includes("build_command")) return "build";
976
1006
  if (k.includes("lintcommand") || k.includes("lint_command") || k.includes("lintcmd")) return "lint";
977
1007
  if (k.includes("syntaxcheck") || k.includes("syntax_check") || k.includes("typecheckcommand") || k.includes("type_check")) return "syntaxCheck";
1008
+ if (k.includes("preprvalidationcommand") || k.includes("pre_pr_validation_command") || k.includes("qualitygatecommand") || k.includes("quality_gate_command")) return "qualityGate";
978
1009
  return null;
979
1010
  }
980
1011
 
@@ -1058,6 +1089,7 @@ function inferVariableDescription(key, defaultValue) {
1058
1089
  if (classifyCommandVariable(normalized) === "build") return "Build command for your project. Auto-detected from project files when available.";
1059
1090
  if (classifyCommandVariable(normalized) === "lint") return "Lint/style check command. Auto-detected from project files when available.";
1060
1091
  if (classifyCommandVariable(normalized) === "syntaxCheck") return "Syntax/compile check command. Auto-detected from project files when available.";
1092
+ if (classifyCommandVariable(normalized) === "qualityGate") return "Pre-PR validation command. Auto-detected from project files and repo hooks when available.";
1061
1093
  if (typeof defaultValue === "boolean") return "Toggle this setting on or off.";
1062
1094
  if (typeof defaultValue === "number") return "Numeric workflow setting.";
1063
1095
  return "";
@@ -1395,5 +1427,3 @@ export function installRecommendedTemplates(engine, overridesById = {}) {
1395
1427
 
1396
1428
 
1397
1429
 
1398
-
1399
-
@@ -66,7 +66,7 @@ export const TASK_LIFECYCLE_TEMPLATE = {
66
66
  defaultTargetBranch: "origin/main",
67
67
  taskTimeoutMs: 21600000, // 6 hours
68
68
  prePrValidationEnabled: true,
69
- prePrValidationCommand: "npm run prepush:check",
69
+ prePrValidationCommand: "auto",
70
70
  autoMergeOnCreate: false,
71
71
  autoMergeMethod: "squash",
72
72
  maxRetries: 2,
@@ -235,6 +235,7 @@ export const TASK_LIFECYCLE_TEMPLATE = {
235
235
  // ── SUCCESS PATH: Local quality gate before push/PR ──────────────────
236
236
  node("pre-pr-validation", "action.run_command", "Pre-PR Validation", {
237
237
  command: "{{prePrValidationCommand}}",
238
+ commandType: "qualityGate",
238
239
  cwd: "{{worktreePath}}",
239
240
  failOnError: false,
240
241
  }, { x: -120, y: 1940 }),
@@ -826,4 +827,3 @@ export const VE_ORCHESTRATOR_LITE_TEMPLATE = {
826
827
  },
827
828
  },
828
829
  };
829
-