bosun 0.40.0 → 0.40.2

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.
@@ -741,6 +741,114 @@ const FALLBACK_ORDER = [
741
741
  "opencode-sdk",
742
742
  ];
743
743
 
744
+ const FAILOVER_CONSECUTIVE_INFRA_ERRORS = Math.max(
745
+ 1,
746
+ Number(process.env.PRIMARY_AGENT_FAILOVER_CONSECUTIVE_INFRA_ERRORS) || 3,
747
+ );
748
+ const FAILOVER_ERROR_WINDOW_MS = Math.max(
749
+ 10_000,
750
+ Number(process.env.PRIMARY_AGENT_FAILOVER_ERROR_WINDOW_MS) ||
751
+ 10 * 60 * 1000,
752
+ );
753
+ const _primaryRecoveryRetryEnv = Number(
754
+ process.env.PRIMARY_AGENT_RECOVERY_RETRY_ATTEMPTS,
755
+ );
756
+ const PRIMARY_RECOVERY_RETRY_ATTEMPTS = Number.isFinite(
757
+ _primaryRecoveryRetryEnv,
758
+ )
759
+ ? Math.max(0, _primaryRecoveryRetryEnv)
760
+ : 1;
761
+
762
+ const _adapterFailureState = new Map();
763
+
764
+ function adapterErrorText(err) {
765
+ const message = String(err?.message || err || "");
766
+ const code = String(err?.code || "");
767
+ return `${code} ${message}`.trim();
768
+ }
769
+
770
+ function isSessionScopedAdapterError(err) {
771
+ const text = adapterErrorText(err).toLowerCase();
772
+ if (!text) return false;
773
+ return (
774
+ /\b(session|thread|conversation|context)\b.*\b(not found|expired|invalid|closed|corrupt)\b/.test(
775
+ text,
776
+ ) ||
777
+ /\bfailed to resume session\b/.test(text) ||
778
+ /\bsession does not exist\b/.test(text)
779
+ );
780
+ }
781
+
782
+ function isInfrastructureAdapterError(err) {
783
+ const text = adapterErrorText(err).toLowerCase();
784
+ if (!text) return false;
785
+ return (
786
+ /\bagent_timeout\b/.test(text) ||
787
+ /\bcodex exec exited with code\b/.test(text) ||
788
+ /\btransport channel closed\b/.test(text) ||
789
+ /\bstream disconnected\b/.test(text) ||
790
+ /\brate limit|too many requests|429\b/.test(text) ||
791
+ /\bservice unavailable|temporarily unavailable|overloaded\b/.test(text) ||
792
+ /\bcannot find module\b/.test(text) ||
793
+ /\bsdk not available|failed to load sdk\b/.test(text) ||
794
+ /\beconnreset|econnrefused|etimedout|network error\b/.test(text) ||
795
+ /\bsegfault|crash|killed\b/.test(text)
796
+ );
797
+ }
798
+
799
+ function clearAdapterFailureState(adapterName) {
800
+ if (!adapterName) return;
801
+ _adapterFailureState.delete(adapterName);
802
+ }
803
+
804
+ function noteAdapterFailure(adapterName, err) {
805
+ const now = Date.now();
806
+ const infrastructure = isInfrastructureAdapterError(err);
807
+ const previous = _adapterFailureState.get(adapterName) || {
808
+ streak: 0,
809
+ lastAt: 0,
810
+ lastError: "",
811
+ infrastructure: false,
812
+ };
813
+
814
+ const next = {
815
+ streak: 0,
816
+ lastAt: now,
817
+ lastError: adapterErrorText(err),
818
+ infrastructure,
819
+ };
820
+
821
+ if (infrastructure) {
822
+ const withinWindow =
823
+ now - Number(previous.lastAt || 0) <= FAILOVER_ERROR_WINDOW_MS;
824
+ next.streak =
825
+ withinWindow && previous.infrastructure ? previous.streak + 1 : 1;
826
+ }
827
+
828
+ _adapterFailureState.set(adapterName, next);
829
+ return {
830
+ ...next,
831
+ allowFailover:
832
+ infrastructure && next.streak >= FAILOVER_CONSECUTIVE_INFRA_ERRORS,
833
+ };
834
+ }
835
+
836
+ async function recoverAdapterSession(adapter, adapterName) {
837
+ if (!adapter) return;
838
+ if (typeof adapter.reset === "function") {
839
+ try {
840
+ await adapter.reset();
841
+ } catch (err) {
842
+ console.warn(
843
+ `[primary-agent] recovery reset failed for ${adapterName}: ${err?.message || err}`,
844
+ );
845
+ }
846
+ }
847
+ if (typeof adapter.init === "function") {
848
+ await adapter.init();
849
+ }
850
+ }
851
+
744
852
  function mapAdapterToPoolSdk(adapterName) {
745
853
  const normalized = String(adapterName || "").trim().toLowerCase();
746
854
  if (normalized === "copilot-sdk") return "copilot";
@@ -875,8 +983,12 @@ export async function execPrimaryPrompt(userMessage, options = {}) {
875
983
  }
876
984
 
877
985
  let lastError = null;
986
+ const maxAdaptersToTry = Math.min(
987
+ adaptersToTry.length,
988
+ maxFailoverAttempts + 1,
989
+ );
878
990
 
879
- for (let attempt = 0; attempt < Math.min(adaptersToTry.length, maxFailoverAttempts + 1); attempt++) {
991
+ for (let attempt = 0; attempt < maxAdaptersToTry; attempt++) {
880
992
  const adapterName = adaptersToTry[attempt];
881
993
  const adapter = ADAPTERS[adapterName];
882
994
  if (!adapter) continue;
@@ -950,16 +1062,96 @@ export async function execPrimaryPrompt(userMessage, options = {}) {
950
1062
  });
951
1063
  }
952
1064
  }
1065
+ clearAdapterFailureState(adapterName);
953
1066
  return result;
954
1067
  } catch (err) {
955
1068
  lastError = err;
956
1069
  const isTimeout = err.message?.startsWith("AGENT_TIMEOUT");
1070
+ const isPrimaryAttempt = attempt === 0;
957
1071
  console.error(
958
1072
  `[primary-agent] ${isTimeout ? ":clock: Timeout" : ":close: Error"} with ${adapterName}: ${err.message}`,
959
1073
  );
960
1074
 
1075
+ if (
1076
+ isPrimaryAttempt &&
1077
+ PRIMARY_RECOVERY_RETRY_ATTEMPTS > 0 &&
1078
+ (isSessionScopedAdapterError(err) || isInfrastructureAdapterError(err))
1079
+ ) {
1080
+ for (let retry = 1; retry <= PRIMARY_RECOVERY_RETRY_ATTEMPTS; retry++) {
1081
+ try {
1082
+ console.warn(
1083
+ `[primary-agent] :arrows_counterclockwise: recovering ${adapterName} session (${retry}/${PRIMARY_RECOVERY_RETRY_ATTEMPTS})`,
1084
+ );
1085
+ tracker.recordEvent(sessionId, {
1086
+ role: "system",
1087
+ type: "recovery",
1088
+ content: `:arrows_counterclockwise: Recovering ${adapterName} session (${retry}/${PRIMARY_RECOVERY_RETRY_ATTEMPTS}) before any failover.`,
1089
+ timestamp: new Date().toISOString(),
1090
+ });
1091
+ await recoverAdapterSession(adapter, adapterName);
1092
+ const timeoutAbort = new AbortController();
1093
+ if (options.abortController?.signal) {
1094
+ const callerSignal = options.abortController.signal;
1095
+ if (callerSignal.aborted) {
1096
+ timeoutAbort.abort(callerSignal.reason);
1097
+ } else {
1098
+ callerSignal.addEventListener("abort", () => {
1099
+ timeoutAbort.abort(callerSignal.reason || "user_stop");
1100
+ }, { once: true });
1101
+ }
1102
+ }
1103
+ const retryResult = await withTimeout(
1104
+ adapter.exec(framedMessage, { ...options, sessionId, abortController: timeoutAbort }),
1105
+ timeoutMs,
1106
+ `${adapterName}.exec.retry`,
1107
+ timeoutAbort,
1108
+ );
1109
+ const retryText = typeof retryResult === "string"
1110
+ ? retryResult
1111
+ : retryResult.finalResponse || retryResult.text || retryResult.message || JSON.stringify(retryResult);
1112
+ tracker.recordEvent(sessionId, {
1113
+ role: "assistant",
1114
+ content: retryText,
1115
+ timestamp: new Date().toISOString(),
1116
+ _sessionType: sessionType,
1117
+ });
1118
+ clearAdapterFailureState(adapterName);
1119
+ return retryResult;
1120
+ } catch (retryErr) {
1121
+ lastError = retryErr;
1122
+ console.error(
1123
+ `[primary-agent] :close: recovery attempt ${retry}/${PRIMARY_RECOVERY_RETRY_ATTEMPTS} failed for ${adapterName}: ${retryErr?.message || retryErr}`,
1124
+ );
1125
+ }
1126
+ }
1127
+ }
1128
+
1129
+ const failureState = noteAdapterFailure(adapterName, lastError);
1130
+ const shouldBlockPrimaryFailover =
1131
+ isPrimaryAttempt && !failureState.allowFailover;
1132
+
1133
+ if (shouldBlockPrimaryFailover) {
1134
+ const waitReason = failureState.infrastructure
1135
+ ? `holding failover until ${FAILOVER_CONSECUTIVE_INFRA_ERRORS} consecutive infrastructure failures (${failureState.streak}/${FAILOVER_CONSECUTIVE_INFRA_ERRORS})`
1136
+ : "error classified as session-scoped/non-infrastructure";
1137
+ console.warn(
1138
+ `[primary-agent] failover suppressed for ${adapterName}: ${waitReason}`,
1139
+ );
1140
+ tracker.recordEvent(sessionId, {
1141
+ role: "system",
1142
+ type: "error",
1143
+ content: `:warning: ${adapterName} error: ${lastError?.message || "unknown error"}. Failover suppressed (${waitReason}).`,
1144
+ timestamp: new Date().toISOString(),
1145
+ });
1146
+ return {
1147
+ finalResponse: `:warning: ${adapterName} error: ${lastError?.message || "unknown error"}. Failover suppressed (${waitReason}).`,
1148
+ items: [],
1149
+ usage: null,
1150
+ };
1151
+ }
1152
+
961
1153
  // If this is the last adapter, report to user
962
- if (attempt >= Math.min(adaptersToTry.length, maxFailoverAttempts + 1) - 1) {
1154
+ if (attempt >= maxAdaptersToTry - 1) {
963
1155
  tracker.recordEvent(sessionId, {
964
1156
  role: "system",
965
1157
  type: "error",
@@ -39,6 +39,7 @@ function detectRepoRoot() {
39
39
  return execSync("git rev-parse --show-toplevel", {
40
40
  encoding: "utf8",
41
41
  stdio: ["pipe", "pipe", "ignore"],
42
+ timeout: 1500,
42
43
  }).trim();
43
44
  } catch {
44
45
  return process.cwd();
@@ -144,8 +145,12 @@ function mergeNoOverride(base, extra) {
144
145
  function commandExists(command) {
145
146
  try {
146
147
  const checker = process.platform === "win32" ? "where" : "which";
147
- spawnSync(checker, [command], { stdio: "ignore" });
148
- return true;
148
+ const result = spawnSync(checker, [command], {
149
+ stdio: "ignore",
150
+ timeout: 1500,
151
+ windowsHide: true,
152
+ });
153
+ return result.status === 0;
149
154
  } catch {
150
155
  return false;
151
156
  }
@@ -982,3 +987,5 @@ export function formatWorkspaceHealthReport(result) {
982
987
 
983
988
  return lines.join("\n");
984
989
  }
990
+
991
+
package/config/config.mjs CHANGED
@@ -658,6 +658,12 @@ function detectRepoSlug(repoRoot = "") {
658
658
  }
659
659
 
660
660
  function detectRepoRoot() {
661
+ const gitExecOptions = {
662
+ encoding: "utf8",
663
+ stdio: ["pipe", "pipe", "ignore"],
664
+ timeout: 3000,
665
+ };
666
+
661
667
  // 1. Explicit env var
662
668
  if (process.env.REPO_ROOT) {
663
669
  const envRoot = resolve(process.env.REPO_ROOT);
@@ -667,8 +673,7 @@ function detectRepoRoot() {
667
673
  // 2. Try git from cwd
668
674
  try {
669
675
  const gitRoot = execSync("git rev-parse --show-toplevel", {
670
- encoding: "utf8",
671
- stdio: ["pipe", "pipe", "ignore"],
676
+ ...gitExecOptions,
672
677
  }).trim();
673
678
  if (gitRoot) return gitRoot;
674
679
  } catch {
@@ -678,9 +683,8 @@ function detectRepoRoot() {
678
683
  // 3. Bosun package directory may be inside a repo (common: scripts/bosun/ within a project)
679
684
  try {
680
685
  const gitRoot = execSync("git rev-parse --show-toplevel", {
681
- encoding: "utf8",
682
686
  cwd: __dirname,
683
- stdio: ["pipe", "pipe", "ignore"],
687
+ ...gitExecOptions,
684
688
  }).trim();
685
689
  if (gitRoot) return gitRoot;
686
690
  } catch {
@@ -693,9 +697,8 @@ function detectRepoRoot() {
693
697
  if (moduleRoot && moduleRoot !== process.cwd()) {
694
698
  try {
695
699
  const gitRoot = execSync("git rev-parse --show-toplevel", {
696
- encoding: "utf8",
697
700
  cwd: moduleRoot,
698
- stdio: ["pipe", "pipe", "ignore"],
701
+ ...gitExecOptions,
699
702
  }).trim();
700
703
  if (gitRoot) return gitRoot;
701
704
  } catch {
@@ -1285,20 +1288,27 @@ export function loadConfig(argv = process.argv, options = {}) {
1285
1288
 
1286
1289
  const { reloadEnv = false } = options;
1287
1290
  const cli = parseArgs(argv);
1291
+ const repoRootOverride = cli["repo-root"] || process.env.REPO_ROOT || "";
1292
+ const normalizedRepoRootOverride = repoRootOverride
1293
+ ? resolve(repoRootOverride)
1294
+ : "";
1295
+ let detectedRepoRoot = "";
1296
+ const getFallbackRepoRoot = () => {
1297
+ if (normalizedRepoRootOverride) return normalizedRepoRootOverride;
1298
+ if (!detectedRepoRoot) detectedRepoRoot = detectRepoRoot();
1299
+ return detectedRepoRoot;
1300
+ };
1288
1301
 
1289
- const repoRootForConfig = detectRepoRoot();
1290
1302
  // Determine config directory (where bosun stores its config)
1291
1303
  const configDir =
1292
1304
  cli["config-dir"] ||
1293
1305
  process.env.BOSUN_DIR ||
1294
- resolveConfigDir(repoRootForConfig);
1306
+ resolveConfigDir(normalizedRepoRootOverride);
1295
1307
 
1296
1308
  const configFile = loadConfigFile(configDir);
1297
1309
  let configData = configFile.data || {};
1298
1310
  const configFileHadInvalidJson = configFile.error === "invalid-json";
1299
1311
 
1300
- const repoRootOverride = cli["repo-root"] || process.env.REPO_ROOT || "";
1301
-
1302
1312
  // Load workspace configuration
1303
1313
  const workspacesDir = resolve(configDir, "workspaces");
1304
1314
  const activeWorkspace = cli["workspace"] ||
@@ -1340,7 +1350,8 @@ export function loadConfig(argv = process.argv, options = {}) {
1340
1350
  // over REPO_ROOT (env); REPO_ROOT becomes "developer root" for config only.
1341
1351
  const selectedRepoPath = selectedRepository?.path || "";
1342
1352
  const selectedRepoHasGit = selectedRepoPath && existsSync(resolve(selectedRepoPath, ".git"));
1343
- let repoRoot = (selectedRepoHasGit ? selectedRepoPath : null) || repoRootOverride || detectRepoRoot();
1353
+ let repoRoot =
1354
+ (selectedRepoHasGit ? selectedRepoPath : null) || getFallbackRepoRoot();
1344
1355
 
1345
1356
  // Resolve agent execution root (workspace-aware, separate from developer root)
1346
1357
  const agentRepoRoot = resolveAgentRepoRoot();
@@ -1405,7 +1416,7 @@ export function loadConfig(argv = process.argv, options = {}) {
1405
1416
  {
1406
1417
  const selPath = selectedRepository?.path || "";
1407
1418
  const selHasGit = selPath && existsSync(resolve(selPath, ".git"));
1408
- repoRoot = (selHasGit ? selPath : null) || repoRootOverride || detectRepoRoot();
1419
+ repoRoot = (selHasGit ? selPath : null) || getFallbackRepoRoot();
1409
1420
  }
1410
1421
 
1411
1422
  if (resolve(repoRoot) !== resolve(initialRepoRoot)) {
@@ -14,6 +14,7 @@
14
14
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from "node:fs";
15
15
  import { resolve, dirname } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
+ import { buildSessionInsights } from "../lib/session-insights.mjs";
17
18
 
18
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
20
  const SESSIONS_DIR = resolve(__dirname, "..", "logs", "sessions");
@@ -184,6 +185,7 @@ export class SessionTracker {
184
185
  status: "active",
185
186
  lastActivityAt: Date.now(),
186
187
  metadata: {},
188
+ insights: buildSessionInsights({ messages: [] }),
187
189
  });
188
190
  this.#markDirty(taskId);
189
191
  }
@@ -235,6 +237,7 @@ export class SessionTracker {
235
237
  if (Number.isFinite(maxMessages) && maxMessages > 0) {
236
238
  while (session.messages.length > maxMessages) session.messages.shift();
237
239
  }
240
+ this.#refreshDerivedState(session);
238
241
  this.#markDirty(taskId);
239
242
  emitSessionEvent(session, msg);
240
243
  return;
@@ -266,6 +269,7 @@ export class SessionTracker {
266
269
  if (Number.isFinite(maxMessages) && maxMessages > 0) {
267
270
  while (session.messages.length > maxMessages) session.messages.shift();
268
271
  }
272
+ this.#refreshDerivedState(session);
269
273
  this.#markDirty(taskId);
270
274
  emitSessionEvent(session, msg);
271
275
  return;
@@ -282,6 +286,7 @@ export class SessionTracker {
282
286
  if (Number.isFinite(maxMessages) && maxMessages > 0) {
283
287
  while (session.messages.length > maxMessages) session.messages.shift();
284
288
  }
289
+ this.#refreshDerivedState(session);
285
290
  this.#markDirty(taskId);
286
291
  emitSessionEvent(session, msg);
287
292
  }
@@ -297,6 +302,7 @@ export class SessionTracker {
297
302
 
298
303
  session.endedAt = Date.now();
299
304
  session.status = status;
305
+ this.#refreshDerivedState(session);
300
306
  this.#markDirty(taskId);
301
307
  }
302
308
 
@@ -521,6 +527,7 @@ export class SessionTracker {
521
527
  lastActivityAt: Date.now(),
522
528
  metadata,
523
529
  maxMessages: resolvedMax,
530
+ insights: buildSessionInsights({ messages: [] }),
524
531
  };
525
532
  this.#sessions.set(id, session);
526
533
  this.#markDirty(id);
@@ -549,6 +556,7 @@ export class SessionTracker {
549
556
  lastActiveAt: s.lastActiveAt || new Date(s.lastActivityAt).toISOString(),
550
557
  preview: this.#lastMessagePreview(s),
551
558
  lastMessage: this.#lastMessagePreview(s),
559
+ insights: s.insights || null,
552
560
  });
553
561
  }
554
562
  list.sort((a, b) => (b.lastActiveAt || "").localeCompare(a.lastActiveAt || ""));
@@ -587,6 +595,7 @@ export class SessionTracker {
587
595
  if (status === "completed" || status === "archived") {
588
596
  session.endedAt = Date.now();
589
597
  }
598
+ this.#refreshDerivedState(session);
590
599
  this.#markDirty(sessionId);
591
600
  }
592
601
 
@@ -665,6 +674,7 @@ export class SessionTracker {
665
674
  target.editedAt = new Date().toISOString();
666
675
  session.lastActivityAt = Date.now();
667
676
  session.lastActiveAt = new Date().toISOString();
677
+ this.#refreshDerivedState(session);
668
678
  this.#markDirty(sessionId);
669
679
 
670
680
  return { ok: true, message: { ...target }, index: idx };
@@ -766,6 +776,7 @@ export class SessionTracker {
766
776
  status,
767
777
  lastActivityAt: lastActive || Date.now(),
768
778
  metadata: data.metadata || {},
779
+ insights: data.insights || buildSessionInsights({ messages: data.messages || [] }),
769
780
  });
770
781
  }
771
782
  }
@@ -817,6 +828,7 @@ export class SessionTracker {
817
828
  if (idleMs > this.#idleThresholdMs) {
818
829
  session.status = "completed";
819
830
  session.endedAt = now;
831
+ this.#refreshDerivedState(session);
820
832
  this.#markDirty(id);
821
833
  reaped++;
822
834
  }
@@ -840,6 +852,18 @@ export class SessionTracker {
840
852
  }
841
853
  }
842
854
 
855
+ #refreshDerivedState(session) {
856
+ if (!session) return;
857
+ try {
858
+ session.insights = buildSessionInsights({
859
+ ...session,
860
+ insights: null,
861
+ });
862
+ } catch {
863
+ // Inspector insights are best-effort only.
864
+ }
865
+ }
866
+
843
867
  #ensureDir() {
844
868
  if (this.#persistDir && !existsSync(this.#persistDir)) {
845
869
  mkdirSync(this.#persistDir, { recursive: true });
@@ -872,6 +896,7 @@ export class SessionTracker {
872
896
  turnCount: session.turnCount || 0,
873
897
  messages: session.messages || [],
874
898
  metadata: session.metadata || {},
899
+ insights: session.insights || null,
875
900
  };
876
901
  writeFileSync(filePath, JSON.stringify(data, null, 2));
877
902
  } catch (err) {
@@ -946,6 +971,7 @@ export class SessionTracker {
946
971
  turnCount: data.turnCount || 0,
947
972
  lastActivityAt: lastActive || Date.now(),
948
973
  metadata: data.metadata || {},
974
+ insights: data.insights || buildSessionInsights({ messages: data.messages || [] }),
949
975
  });
950
976
  }
951
977
  } catch {
@@ -744,6 +744,16 @@ class InternalAdapter {
744
744
  ? task.meta.comments
745
745
  : [];
746
746
  const normalizedComments = normalizeCommentList(rawComments);
747
+ const assignee = normalizeTaskStringField(resolveTaskField(task, "assignee"));
748
+ const assignees = normalizeTaskStringList(
749
+ resolveTaskField(task, "assignees", assignee ? [assignee] : []),
750
+ );
751
+ const epicId = normalizeTaskStringField(resolveTaskField(task, "epicId"));
752
+ const storyPoints = normalizeTaskNumericField(resolveTaskField(task, "storyPoints"));
753
+ const parentTaskId = normalizeTaskStringField(
754
+ resolveTaskField(task, "parentTaskId"),
755
+ );
756
+ const dueDate = normalizeTaskStringField(resolveTaskField(task, "dueDate"));
747
757
  const existingAttachments = []
748
758
  .concat(Array.isArray(task.attachments) ? task.attachments : [])
749
759
  .concat(Array.isArray(task.meta?.attachments) ? task.meta.attachments : []);
@@ -757,7 +767,12 @@ class InternalAdapter {
757
767
  title: recoveredTitle || "",
758
768
  description,
759
769
  status: normaliseStatus(task.status || recoveredStatus),
760
- assignee: task.assignee || null,
770
+ assignee: assignee || assignees[0] || null,
771
+ assignees,
772
+ epicId,
773
+ storyPoints,
774
+ parentTaskId,
775
+ dueDate,
761
776
  priority: task.priority || null,
762
777
  tags,
763
778
  draft,
@@ -862,6 +877,20 @@ class InternalAdapter {
862
877
  if (typeof patch.workspace === "string") updates.workspace = patch.workspace;
863
878
  if (typeof patch.repository === "string") updates.repository = patch.repository;
864
879
  if (Array.isArray(patch.repositories)) updates.repositories = patch.repositories;
880
+ const assigneeProvided = hasOwnField(patch, "assignee");
881
+ const assigneesProvided = hasOwnField(patch, "assignees");
882
+ const assignee = normalizeTaskStringField(patch.assignee ?? patch.meta?.assignee);
883
+ const assignees = normalizeTaskStringList(
884
+ assigneesProvided
885
+ ? patch.assignees
886
+ : patch.meta?.assignees ?? (assignee ? [assignee] : []),
887
+ );
888
+ const epicId = normalizeTaskStringField(patch.epicId ?? patch.meta?.epicId);
889
+ const storyPoints = normalizeTaskNumericField(patch.storyPoints ?? patch.meta?.storyPoints);
890
+ const parentTaskId = normalizeTaskStringField(
891
+ patch.parentTaskId ?? patch.meta?.parentTaskId,
892
+ );
893
+ const dueDate = normalizeTaskStringField(patch.dueDate ?? patch.meta?.dueDate);
865
894
  if (Array.isArray(patch.tags) || Array.isArray(patch.labels) || typeof patch.tags === "string") {
866
895
  updates.tags = normalizeTags(patch.tags ?? patch.labels);
867
896
  }
@@ -875,10 +904,32 @@ class InternalAdapter {
875
904
  if (baseBranch) {
876
905
  updates.baseBranch = baseBranch;
877
906
  }
907
+ if (assigneeProvided || assignee || assignees.length > 0) {
908
+ updates.assignee = assignee || assignees[0] || null;
909
+ updates.assignees = assignees.length > 0 ? assignees : assignee ? [assignee] : [];
910
+ }
911
+ if (hasOwnField(patch, "epicId") || epicId) updates.epicId = epicId;
912
+ if (hasOwnField(patch, "storyPoints") || storyPoints != null) {
913
+ updates.storyPoints = storyPoints;
914
+ }
915
+ if (hasOwnField(patch, "parentTaskId") || parentTaskId) {
916
+ updates.parentTaskId = parentTaskId;
917
+ }
918
+ if (hasOwnField(patch, "dueDate") || dueDate) updates.dueDate = dueDate;
878
919
  if (patch.meta && typeof patch.meta === "object") {
879
920
  updates.meta = {
880
921
  ...(current?.meta || {}),
881
922
  ...patch.meta,
923
+ ...((assigneeProvided || assignee || assignees.length > 0)
924
+ ? {
925
+ assignee: assignee || assignees[0] || null,
926
+ assignees: assignees.length > 0 ? assignees : assignee ? [assignee] : [],
927
+ }
928
+ : {}),
929
+ ...((hasOwnField(patch, "epicId") || epicId) ? { epicId } : {}),
930
+ ...((hasOwnField(patch, "storyPoints") || storyPoints != null) ? { storyPoints } : {}),
931
+ ...((hasOwnField(patch, "parentTaskId") || parentTaskId) ? { parentTaskId } : {}),
932
+ ...((hasOwnField(patch, "dueDate") || dueDate) ? { dueDate } : {}),
882
933
  ...(typeof patch.workspace === "string" ? { workspace: patch.workspace } : {}),
883
934
  ...(typeof patch.repository === "string" ? { repository: patch.repository } : {}),
884
935
  ...(Array.isArray(patch.repositories) ? { repositories: patch.repositories } : {}),
@@ -903,12 +954,29 @@ class InternalAdapter {
903
954
  const tags = normalizeTags(taskData.tags || taskData.labels || []);
904
955
  const draft = Boolean(taskData.draft || taskData.status === "draft");
905
956
  const baseBranch = resolveBaseBranchInput(taskData);
957
+ const assignee = normalizeTaskStringField(taskData.assignee ?? taskData.meta?.assignee);
958
+ const assignees = normalizeTaskStringList(
959
+ taskData.assignees ?? taskData.meta?.assignees ?? (assignee ? [assignee] : []),
960
+ );
961
+ const epicId = normalizeTaskStringField(taskData.epicId ?? taskData.meta?.epicId);
962
+ const storyPoints = normalizeTaskNumericField(
963
+ taskData.storyPoints ?? taskData.meta?.storyPoints,
964
+ );
965
+ const parentTaskId = normalizeTaskStringField(
966
+ taskData.parentTaskId ?? taskData.meta?.parentTaskId,
967
+ );
968
+ const dueDate = normalizeTaskStringField(taskData.dueDate ?? taskData.meta?.dueDate);
906
969
  const created = addInternalTask({
907
970
  id,
908
971
  title: taskData.title || "Untitled task",
909
972
  description: taskData.description || "",
910
973
  status: draft ? "draft" : normaliseStatus(taskData.status || "todo"),
911
- assignee: taskData.assignee || null,
974
+ assignee: assignee || assignees[0] || null,
975
+ assignees,
976
+ epicId,
977
+ storyPoints,
978
+ parentTaskId,
979
+ dueDate,
912
980
  priority: taskData.priority || null,
913
981
  tags,
914
982
  draft,
@@ -932,6 +1000,16 @@ class InternalAdapter {
932
1000
  baseBranch,
933
1001
  meta: {
934
1002
  ...(taskData.meta || {}),
1003
+ ...((assignee || assignees.length > 0)
1004
+ ? {
1005
+ assignee: assignee || assignees[0] || null,
1006
+ assignees: assignees.length > 0 ? assignees : assignee ? [assignee] : [],
1007
+ }
1008
+ : {}),
1009
+ ...(epicId ? { epicId } : {}),
1010
+ ...(storyPoints != null ? { storyPoints } : {}),
1011
+ ...(parentTaskId ? { parentTaskId } : {}),
1012
+ ...(dueDate ? { dueDate } : {}),
935
1013
  ...(taskData.workspace ? { workspace: taskData.workspace } : {}),
936
1014
  ...(taskData.repository || taskData.repo
937
1015
  ? { repository: taskData.repository || taskData.repo }
@@ -1004,6 +1082,43 @@ function normalizeTags(raw) {
1004
1082
  return normalizeLabels(raw);
1005
1083
  }
1006
1084
 
1085
+ function hasOwnField(value, key) {
1086
+ return Object.prototype.hasOwnProperty.call(value || {}, key);
1087
+ }
1088
+
1089
+ function normalizeTaskStringField(value) {
1090
+ const normalized = String(value || "").trim();
1091
+ return normalized || null;
1092
+ }
1093
+
1094
+ function normalizeTaskStringList(value) {
1095
+ const values = Array.isArray(value)
1096
+ ? value
1097
+ : typeof value === "string"
1098
+ ? value.split(",")
1099
+ : [];
1100
+ const seen = new Set();
1101
+ const normalized = [];
1102
+ for (const entry of values) {
1103
+ const next = normalizeTaskStringField(entry?.login || entry?.name || entry);
1104
+ if (!next || seen.has(next)) continue;
1105
+ seen.add(next);
1106
+ normalized.push(next);
1107
+ }
1108
+ return normalized;
1109
+ }
1110
+
1111
+ function normalizeTaskNumericField(value) {
1112
+ const numeric = Number(value);
1113
+ return Number.isFinite(numeric) ? numeric : null;
1114
+ }
1115
+
1116
+ function resolveTaskField(task, key, fallback = null) {
1117
+ if (task?.[key] != null) return task[key];
1118
+ if (task?.meta?.[key] != null) return task.meta[key];
1119
+ return fallback;
1120
+ }
1121
+
1007
1122
  function looksLikeKanbanEntity(value) {
1008
1123
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1009
1124
  return (