newo 3.7.3 → 3.7.5

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.
@@ -40,12 +40,15 @@ import type {
40
40
  } from '../../../types.js';
41
41
  import type { LocalProjectData, LocalAgentData, LocalFlowData, LocalSkillData, ApiClientFactory } from './ProjectSyncStrategy.js';
42
42
  import fs from 'fs-extra';
43
+ import path from 'path';
43
44
  import {
44
45
  listProjects,
45
46
  listAgents,
46
47
  listFlowSkills,
47
48
  listFlowEvents,
48
49
  listFlowStates,
50
+ createSkill,
51
+ createSkillParameter,
49
52
  updateSkill,
50
53
  publishFlow,
51
54
  getProjectAttributes,
@@ -70,6 +73,7 @@ import { sha256, saveHashes, loadHashes } from '../../../hash.js';
70
73
  import {
71
74
  v2ImportVersionPath,
72
75
  v2ProjectYamlPath,
76
+ v2AgentDir,
73
77
  v2AgentYamlPath,
74
78
  v2FlowYamlPath,
75
79
  v2SkillScriptPath,
@@ -89,10 +93,10 @@ import {
89
93
  generateV2FlowYaml,
90
94
  generateV2ProjectYaml,
91
95
  generateV2AgentYaml,
96
+ parseV2FlowYaml,
92
97
  buildV2InlineSkill,
93
98
  buildV2FlowEvent,
94
99
  buildV2StateField,
95
- parseV2FlowYaml,
96
100
  type V2InlineSkill,
97
101
  type V2FlowEvent,
98
102
  type V2StateField,
@@ -100,6 +104,15 @@ import {
100
104
  import { isContentDifferent } from '../../../sync/skill-files.js';
101
105
  import yaml from 'js-yaml';
102
106
  import { patchYamlToPyyaml } from '../../../format/yaml-patch.js';
107
+ import type { RunnerType, SkillParameter } from '../../../types.js';
108
+
109
+ interface V2FlowSkillTarget {
110
+ projectIdn: string;
111
+ agentIdn: string;
112
+ flowIdn: string;
113
+ skillIdn: string;
114
+ skillData: SkillMetadata | undefined;
115
+ }
103
116
 
104
117
  /**
105
118
  * V2ProjectSyncStrategy - same API, newo_v2 file layout
@@ -655,9 +668,17 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
655
668
  }
656
669
 
657
670
  const mapData = await fs.readJson(mapFile) as ProjectMap;
671
+ const metadataSync = await this.syncV2FlowYamlDefinitions(client, customer, mapData, newHashes);
672
+ result.created += metadataSync.created;
673
+ result.updated += metadataSync.updated;
674
+ result.errors.push(...metadataSync.errors);
658
675
 
659
676
  for (const change of changes) {
660
677
  try {
678
+ if (metadataSync.syncedPaths.has(change.path)) {
679
+ continue;
680
+ }
681
+
661
682
  if (change.operation === 'modified') {
662
683
  // V2 flow YAML: newo_customers/{cust}/{proj}/agents/{agent}/flows/{flow}/{flow}.yaml
663
684
  // The flow YAML carries title, events, and state_fields inline, so
@@ -672,7 +693,7 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
672
693
  const isLibrary = change.path.includes('/libraries/');
673
694
  const count = isLibrary
674
695
  ? await this.pushV2LibrarySkillUpdate(client, change, mapData, newHashes)
675
- : await this.pushV2SkillUpdate(client, change, mapData, newHashes);
696
+ : await this.pushV2SkillUpdate(client, change, mapData, newHashes, customer.idn);
676
697
  result.updated += count;
677
698
  }
678
699
  } catch (error) {
@@ -682,6 +703,10 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
682
703
  }
683
704
  }
684
705
 
706
+ if (metadataSync.created > 0 || metadataSync.updated > 0) {
707
+ await writeFileSafe(mapFile, JSON.stringify(mapData, null, 2));
708
+ }
709
+
685
710
  await saveHashes(newHashes, customer.idn);
686
711
 
687
712
  if (result.created > 0 || result.updated > 0) {
@@ -800,6 +825,321 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
800
825
  return total;
801
826
  }
802
827
 
828
+ /**
829
+ * Reconcile inline skill definitions from V2 flow YAML before pushing scripts.
830
+ *
831
+ * V2 keeps skill metadata (model, runner_type, parameters) in the flow YAML,
832
+ * not in a separate skill metadata file. The map only contains the remote IDs
833
+ * from a previous pull, so new local skills must be created before their
834
+ * callers can be published.
835
+ */
836
+ private async syncV2FlowYamlDefinitions(
837
+ client: AxiosInstance,
838
+ customer: CustomerConfig,
839
+ mapData: ProjectMap,
840
+ newHashes: HashStore
841
+ ): Promise<{ created: number; updated: number; syncedPaths: Set<string>; errors: string[] }> {
842
+ let created = 0;
843
+ let updated = 0;
844
+ const syncedPaths = new Set<string>();
845
+ const errors: string[] = [];
846
+
847
+ for (const [projectIdn, projectData] of Object.entries(mapData.projects)) {
848
+ for (const [agentIdn, agentData] of Object.entries(projectData.agents)) {
849
+ for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
850
+ const flowYamlPath = v2FlowYamlPath(customer.idn, projectIdn, agentIdn, flowIdn);
851
+ if (!(await fs.pathExists(flowYamlPath))) {
852
+ continue;
853
+ }
854
+
855
+ let flowDef;
856
+ try {
857
+ flowDef = await parseV2FlowYaml(flowYamlPath);
858
+ } catch (error) {
859
+ this.logger.warn(
860
+ `[newo_v2] Failed to parse flow YAML ${flowYamlPath}: ${error instanceof Error ? error.message : String(error)}`
861
+ );
862
+ continue;
863
+ }
864
+
865
+ for (const skill of flowDef.skills || []) {
866
+ const skillLocator = `${projectIdn}/${agentIdn}/${flowIdn}/${skill.idn}`;
867
+ // Per-skill failure isolation: one broken skill must not abort the
868
+ // push of every other project/flow in the workspace.
869
+ try {
870
+ const runnerType = this.normalizeRunnerType(skill.runner_type);
871
+ const scriptPath = await this.resolveV2FlowSkillScriptPath(
872
+ customer.idn,
873
+ projectIdn,
874
+ agentIdn,
875
+ flowIdn,
876
+ skill.idn,
877
+ runnerType,
878
+ skill.prompt_script
879
+ );
880
+
881
+ if (!(await fs.pathExists(scriptPath))) {
882
+ errors.push(
883
+ `[newo_v2] Missing script for skill ${skillLocator}: ${scriptPath}`
884
+ );
885
+ continue;
886
+ }
887
+
888
+ const content = await fs.readFile(scriptPath, 'utf8');
889
+ const localMetadata = this.buildV2SkillMetadataFromYaml(skill, flowDef, runnerType, flowData.skills[skill.idn]);
890
+ const existingSkill = flowData.skills[skill.idn];
891
+
892
+ if (!existingSkill) {
893
+ this.assertSkillModelResolved(localMetadata, skillLocator);
894
+ try {
895
+ const createdSkill = await createSkill(client, flowData.id, {
896
+ idn: localMetadata.idn,
897
+ title: localMetadata.title,
898
+ prompt_script: content,
899
+ runner_type: localMetadata.runner_type,
900
+ model: localMetadata.model,
901
+ parameters: localMetadata.parameters,
902
+ path: localMetadata.path || ''
903
+ });
904
+
905
+ // The create endpoint ignores inline `parameters` (verified
906
+ // against the live platform) — create them explicitly.
907
+ await this.createMissingSkillParameters(
908
+ client,
909
+ { ...localMetadata, id: createdSkill.id, parameters: [] },
910
+ localMetadata
911
+ );
912
+
913
+ flowData.skills[skill.idn] = {
914
+ ...localMetadata,
915
+ id: createdSkill.id
916
+ };
917
+ newHashes[scriptPath] = sha256(content);
918
+ syncedPaths.add(scriptPath);
919
+ created++;
920
+ this.logger.info(`[newo_v2] Created skill: ${flowIdn}/${skill.idn}`);
921
+ } catch (error) {
922
+ if (!this.isAlreadyExistsApiError(error)) {
923
+ throw error;
924
+ }
925
+
926
+ const remoteSkills = await listFlowSkills(client, flowData.id);
927
+ const remoteSkill = remoteSkills.find(s => s.idn === skill.idn);
928
+ if (!remoteSkill) {
929
+ throw error;
930
+ }
931
+
932
+ const remoteMetadata: SkillMetadata = {
933
+ id: remoteSkill.id,
934
+ idn: remoteSkill.idn,
935
+ title: remoteSkill.title,
936
+ runner_type: remoteSkill.runner_type,
937
+ model: remoteSkill.model,
938
+ parameters: this.normalizeParameters(remoteSkill.parameters),
939
+ path: remoteSkill.path
940
+ };
941
+ await this.createMissingSkillParameters(client, remoteMetadata, localMetadata);
942
+ await updateSkill(client, {
943
+ id: remoteSkill.id,
944
+ title: localMetadata.title,
945
+ idn: localMetadata.idn,
946
+ prompt_script: content,
947
+ runner_type: localMetadata.runner_type,
948
+ model: localMetadata.model,
949
+ parameters: localMetadata.parameters,
950
+ path: remoteSkill.path || localMetadata.path
951
+ });
952
+
953
+ flowData.skills[skill.idn] = {
954
+ ...localMetadata,
955
+ id: remoteSkill.id,
956
+ path: remoteSkill.path || localMetadata.path
957
+ };
958
+ newHashes[scriptPath] = sha256(content);
959
+ syncedPaths.add(scriptPath);
960
+ updated++;
961
+ this.logger.info(`[newo_v2] Reused existing skill: ${flowIdn}/${skill.idn}`);
962
+ }
963
+ continue;
964
+ }
965
+
966
+ const createdParameters = await this.createMissingSkillParameters(client, existingSkill, localMetadata);
967
+
968
+ if (createdParameters > 0 || this.skillMetadataDiffers(existingSkill, localMetadata)) {
969
+ this.assertSkillModelResolved(localMetadata, skillLocator);
970
+ await updateSkill(client, {
971
+ id: existingSkill.id,
972
+ title: localMetadata.title,
973
+ idn: localMetadata.idn,
974
+ prompt_script: content,
975
+ runner_type: localMetadata.runner_type,
976
+ model: localMetadata.model,
977
+ parameters: localMetadata.parameters,
978
+ path: localMetadata.path
979
+ });
980
+
981
+ flowData.skills[skill.idn] = {
982
+ ...localMetadata,
983
+ id: existingSkill.id
984
+ };
985
+ newHashes[scriptPath] = sha256(content);
986
+ syncedPaths.add(scriptPath);
987
+ updated++;
988
+ this.logger.info(`[newo_v2] Updated skill metadata: ${flowIdn}/${skill.idn}`);
989
+ }
990
+ } catch (error) {
991
+ errors.push(
992
+ `Failed to sync skill ${skillLocator}: ${error instanceof Error ? error.message : String(error)}`
993
+ );
994
+ }
995
+ }
996
+ }
997
+ }
998
+ }
999
+
1000
+ return { created, updated, syncedPaths, errors };
1001
+ }
1002
+
1003
+ private async createMissingSkillParameters(
1004
+ client: AxiosInstance,
1005
+ existing: SkillMetadata,
1006
+ local: SkillMetadata
1007
+ ): Promise<number> {
1008
+ const existingNames = new Set(this.normalizeParameters(existing.parameters).map(p => p.name));
1009
+ let created = 0;
1010
+
1011
+ for (const parameter of local.parameters) {
1012
+ if (existingNames.has(parameter.name)) {
1013
+ continue;
1014
+ }
1015
+
1016
+ try {
1017
+ await createSkillParameter(client, existing.id, {
1018
+ name: parameter.name,
1019
+ default_value: parameter.default_value ?? ''
1020
+ });
1021
+ created++;
1022
+ this.logger.info(`[newo_v2] Created skill parameter: ${local.idn}/${parameter.name}`);
1023
+ } catch (error) {
1024
+ if (!this.isAlreadyExistsApiError(error)) {
1025
+ throw error;
1026
+ }
1027
+ }
1028
+ existingNames.add(parameter.name);
1029
+ }
1030
+
1031
+ return created;
1032
+ }
1033
+
1034
+ /**
1035
+ * Detect "resource already exists" API errors.
1036
+ *
1037
+ * Matches only on the precise phrases the platform actually returns
1038
+ * ("already exists", "duplicate key"). Loose substrings like "exist"
1039
+ * would otherwise sweep up unrelated "does not exist" / "doesn't exist"
1040
+ * errors and trigger an incorrect reuse fallback.
1041
+ */
1042
+ private isAlreadyExistsApiError(error: unknown): boolean {
1043
+ const response = (error as { response?: { status?: number; data?: unknown } } | null | undefined)?.response;
1044
+ const status = response?.status;
1045
+ if (status !== 400 && status !== 409 && status !== 422) {
1046
+ return false;
1047
+ }
1048
+
1049
+ const haystack = JSON.stringify(
1050
+ response?.data ?? (error instanceof Error ? error.message : String(error))
1051
+ ).toLowerCase();
1052
+
1053
+ return haystack.includes('already exists') || haystack.includes('duplicate key');
1054
+ }
1055
+
1056
+ private normalizeRunnerType(runnerType: string | undefined): RunnerType {
1057
+ return runnerType === 'nsl' ? 'nsl' : 'guidance';
1058
+ }
1059
+
1060
+ private normalizeParameters(parameters: readonly SkillParameter[] | undefined): SkillParameter[] {
1061
+ return (parameters || []).map(p => ({
1062
+ name: p.name,
1063
+ default_value: p.default_value ?? ''
1064
+ }));
1065
+ }
1066
+
1067
+ /**
1068
+ * Fail fast if no model could be resolved for a V2 skill.
1069
+ *
1070
+ * `buildV2SkillMetadataFromYaml` falls back to empty strings when neither
1071
+ * the skill nor the flow declare a model. The platform rejects empty
1072
+ * model_idn/provider_idn at creation/update time, but the error it returns
1073
+ * is generic — we surface a clearer message before issuing the request.
1074
+ */
1075
+ private assertSkillModelResolved(metadata: SkillMetadata, locator: string): void {
1076
+ if (!metadata.model.model_idn || !metadata.model.provider_idn) {
1077
+ throw new Error(
1078
+ `[newo_v2] Cannot resolve model for skill ${locator}: ` +
1079
+ `model_idn="${metadata.model.model_idn}", provider_idn="${metadata.model.provider_idn}". ` +
1080
+ `Set either skill.model.* or flow default_model_idn/default_provider_idn in the flow YAML.`
1081
+ );
1082
+ }
1083
+ }
1084
+
1085
+ private buildV2SkillMetadataFromYaml(
1086
+ skill: V2InlineSkill,
1087
+ flowDef: Awaited<ReturnType<typeof parseV2FlowYaml>>,
1088
+ runnerType: RunnerType,
1089
+ existing?: SkillMetadata
1090
+ ): SkillMetadata {
1091
+ return {
1092
+ id: existing?.id || '',
1093
+ idn: skill.idn,
1094
+ title: skill.title || '',
1095
+ runner_type: runnerType,
1096
+ model: {
1097
+ model_idn: skill.model?.model_idn || flowDef.default_model_idn || '',
1098
+ provider_idn: skill.model?.provider_idn || flowDef.default_provider_idn || ''
1099
+ },
1100
+ parameters: this.normalizeParameters(skill.parameters),
1101
+ path: existing?.path || ''
1102
+ };
1103
+ }
1104
+
1105
+ private skillMetadataDiffers(existing: SkillMetadata, local: SkillMetadata): boolean {
1106
+ // Compare model/parameters field-by-field, never via JSON.stringify of the
1107
+ // raw objects: the map stores model keys in platform API order
1108
+ // (provider_idn first) while YAML-built metadata uses model_idn first, and
1109
+ // a key-order-sensitive comparison flags every skill as changed.
1110
+ const paramsKey = (params: readonly SkillParameter[] | undefined): string =>
1111
+ JSON.stringify(
1112
+ this.normalizeParameters(params).sort((a, b) => a.name.localeCompare(b.name))
1113
+ );
1114
+
1115
+ return (
1116
+ existing.title !== local.title ||
1117
+ existing.runner_type !== local.runner_type ||
1118
+ existing.model.model_idn !== local.model.model_idn ||
1119
+ existing.model.provider_idn !== local.model.provider_idn ||
1120
+ paramsKey(existing.parameters) !== paramsKey(local.parameters)
1121
+ );
1122
+ }
1123
+
1124
+ private async resolveV2FlowSkillScriptPath(
1125
+ customerIdn: string,
1126
+ projectIdn: string,
1127
+ agentIdn: string,
1128
+ flowIdn: string,
1129
+ skillIdn: string,
1130
+ runnerType: RunnerType,
1131
+ promptScript?: string
1132
+ ): Promise<string> {
1133
+ if (promptScript) {
1134
+ const fromPromptScript = `${v2AgentDir(customerIdn, projectIdn, agentIdn)}/${promptScript}`;
1135
+ if (await fs.pathExists(fromPromptScript)) {
1136
+ return fromPromptScript;
1137
+ }
1138
+ }
1139
+
1140
+ return v2SkillScriptPath(customerIdn, projectIdn, agentIdn, flowIdn, skillIdn, runnerType);
1141
+ }
1142
+
803
1143
  /**
804
1144
  * Push a V2 skill update
805
1145
  *
@@ -809,26 +1149,16 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
809
1149
  client: AxiosInstance,
810
1150
  change: ChangeItem<LocalProjectData>,
811
1151
  mapData: ProjectMap,
812
- newHashes: HashStore
1152
+ newHashes: HashStore,
1153
+ customerIdn: string
813
1154
  ): Promise<number> {
814
- // Parse V2 path to extract entity hierarchy
815
- // Path: .../newo_customers/{cust}/{proj}/agents/{agent}/flows/{flow}/skills/{skillFile}
816
- const pathParts = change.path.split('/');
817
- const skillFileName = pathParts[pathParts.length - 1] || '';
818
- const skillIdn = skillFileName.replace(/\.(nsl|nslg|jinja|guidance)$/, '');
819
- // skills/ -> flow/ -> flows/ -> agent/ -> agents/ -> project/
820
- const flowIdn = pathParts[pathParts.length - 3] || '';
821
- const agentIdn = pathParts[pathParts.length - 5] || '';
822
- const projectIdn = pathParts[pathParts.length - 7] || '';
1155
+ const target =
1156
+ await this.resolveV2SkillTargetForScriptPath(customerIdn, change.path, mapData) ||
1157
+ this.resolveV2SkillTargetFromCanonicalPath(change.path, mapData);
1158
+ const skillData = target?.skillData;
823
1159
 
824
- // Look up skill in map
825
- const projectData = mapData.projects[projectIdn];
826
- const agentData = projectData?.agents[agentIdn];
827
- const flowData = agentData?.flows[flowIdn];
828
- const skillData = flowData?.skills[skillIdn];
829
-
830
- if (!skillData) {
831
- throw new Error(`Skill ${skillIdn} not found in project map (path: ${change.path})`);
1160
+ if (!target || !skillData) {
1161
+ throw new Error(`Skill not found in project map (path: ${change.path})`);
832
1162
  }
833
1163
 
834
1164
  // Read updated script content
@@ -847,10 +1177,92 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
847
1177
  });
848
1178
 
849
1179
  newHashes[change.path] = sha256(content);
850
- this.logger.info(`[newo_v2] Pushed: ${skillIdn}`);
1180
+ this.logger.info(`[newo_v2] Pushed: ${target.skillIdn}`);
851
1181
  return 1;
852
1182
  }
853
1183
 
1184
+ private normalizePathForComparison(filePath: string): string {
1185
+ return path.resolve(filePath).replace(/\\/g, '/');
1186
+ }
1187
+
1188
+ private async resolveV2SkillTargetForScriptPath(
1189
+ customerIdn: string,
1190
+ scriptPath: string,
1191
+ mapData: ProjectMap
1192
+ ): Promise<V2FlowSkillTarget | null> {
1193
+ const normalizedScriptPath = this.normalizePathForComparison(scriptPath);
1194
+
1195
+ for (const [projectIdn, projectData] of Object.entries(mapData.projects)) {
1196
+ for (const [agentIdn, agentData] of Object.entries(projectData.agents)) {
1197
+ for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
1198
+ const flowYamlPath = v2FlowYamlPath(customerIdn, projectIdn, agentIdn, flowIdn);
1199
+ if (!(await fs.pathExists(flowYamlPath))) {
1200
+ continue;
1201
+ }
1202
+
1203
+ let flowDef;
1204
+ try {
1205
+ flowDef = await parseV2FlowYaml(flowYamlPath);
1206
+ } catch {
1207
+ continue;
1208
+ }
1209
+
1210
+ for (const skill of flowDef.skills || []) {
1211
+ const runnerType = this.normalizeRunnerType(skill.runner_type || flowData.skills[skill.idn]?.runner_type);
1212
+ const resolvedScriptPath = await this.resolveV2FlowSkillScriptPath(
1213
+ customerIdn,
1214
+ projectIdn,
1215
+ agentIdn,
1216
+ flowIdn,
1217
+ skill.idn,
1218
+ runnerType,
1219
+ skill.prompt_script
1220
+ );
1221
+
1222
+ if (this.normalizePathForComparison(resolvedScriptPath) === normalizedScriptPath) {
1223
+ return {
1224
+ projectIdn,
1225
+ agentIdn,
1226
+ flowIdn,
1227
+ skillIdn: skill.idn,
1228
+ skillData: flowData.skills[skill.idn]
1229
+ };
1230
+ }
1231
+ }
1232
+ }
1233
+ }
1234
+ }
1235
+
1236
+ return null;
1237
+ }
1238
+
1239
+ private resolveV2SkillTargetFromCanonicalPath(
1240
+ scriptPath: string,
1241
+ mapData: ProjectMap
1242
+ ): V2FlowSkillTarget | null {
1243
+ // Parse canonical V2 path:
1244
+ // .../newo_customers/{cust}/{proj}/agents/{agent}/flows/{flow}/skills/{skillFile}
1245
+ const pathParts = scriptPath.split('/');
1246
+ const skillFileName = pathParts[pathParts.length - 1] || '';
1247
+ const skillIdn = skillFileName.replace(/\.(nsl|nslg|jinja|guidance)$/, '');
1248
+ // skills/ -> flow/ -> flows/ -> agent/ -> agents/ -> project/
1249
+ const flowIdn = pathParts[pathParts.length - 3] || '';
1250
+ const agentIdn = pathParts[pathParts.length - 5] || '';
1251
+ const projectIdn = pathParts[pathParts.length - 7] || '';
1252
+
1253
+ const projectData = mapData.projects[projectIdn];
1254
+ const agentData = projectData?.agents[agentIdn];
1255
+ const flowData = agentData?.flows[flowIdn];
1256
+
1257
+ return {
1258
+ projectIdn,
1259
+ agentIdn,
1260
+ flowIdn,
1261
+ skillIdn,
1262
+ skillData: flowData?.skills[skillIdn]
1263
+ };
1264
+ }
1265
+
854
1266
  /**
855
1267
  * Push a V2 library skill update
856
1268
  * Path: .../newo_customers/{cust}/{proj}/libraries/{lib}/skills/{skillFile}
@@ -949,11 +1361,30 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
949
1361
  }
950
1362
 
951
1363
  for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
952
- for (const [skillIdn, skillMeta] of Object.entries(flowData.skills)) {
953
- const scriptPath = v2SkillScriptPath(
954
- customer.idn, projectIdn, agentIdn, flowIdn, skillIdn,
955
- skillMeta.runner_type
956
- );
1364
+ const flowYamlSkills = await this.loadLocalV2FlowSkills(customer.idn, projectIdn, agentIdn, flowIdn);
1365
+ const skillIdns = new Set([
1366
+ ...Object.keys(flowData.skills),
1367
+ ...flowYamlSkills.keys()
1368
+ ]);
1369
+
1370
+ for (const skillIdn of skillIdns) {
1371
+ const yamlSkill = flowYamlSkills.get(skillIdn);
1372
+ const skillMeta = flowData.skills[skillIdn];
1373
+ const runnerType = this.normalizeRunnerType(yamlSkill?.runner_type || skillMeta?.runner_type);
1374
+ const scriptPath = yamlSkill
1375
+ ? await this.resolveV2FlowSkillScriptPath(
1376
+ customer.idn,
1377
+ projectIdn,
1378
+ agentIdn,
1379
+ flowIdn,
1380
+ skillIdn,
1381
+ runnerType,
1382
+ yamlSkill.prompt_script
1383
+ )
1384
+ : v2SkillScriptPath(
1385
+ customer.idn, projectIdn, agentIdn, flowIdn, skillIdn,
1386
+ runnerType
1387
+ );
957
1388
 
958
1389
  if (await fs.pathExists(scriptPath)) {
959
1390
  const content = await fs.readFile(scriptPath, 'utf8');
@@ -1002,6 +1433,25 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
1002
1433
  return changes;
1003
1434
  }
1004
1435
 
1436
+ private async loadLocalV2FlowSkills(
1437
+ customerIdn: string,
1438
+ projectIdn: string,
1439
+ agentIdn: string,
1440
+ flowIdn: string
1441
+ ): Promise<Map<string, V2InlineSkill>> {
1442
+ const flowYamlPath = v2FlowYamlPath(customerIdn, projectIdn, agentIdn, flowIdn);
1443
+ if (!(await fs.pathExists(flowYamlPath))) {
1444
+ return new Map();
1445
+ }
1446
+
1447
+ try {
1448
+ const flowDef = await parseV2FlowYaml(flowYamlPath);
1449
+ return new Map((flowDef.skills || []).map(skill => [skill.idn, skill]));
1450
+ } catch {
1451
+ return new Map();
1452
+ }
1453
+ }
1454
+
1005
1455
  async validate(customer: CustomerConfig, _items: LocalProjectData[]): Promise<ValidationResult> {
1006
1456
  const errors: ValidationError[] = [];
1007
1457
 
@@ -1020,7 +1470,61 @@ export class V2ProjectSyncStrategy implements ISyncStrategy<ProjectMeta, LocalPr
1020
1470
  for (const [projectIdn, projectData] of Object.entries(mapData.projects)) {
1021
1471
  for (const [agentIdn, agentData] of Object.entries(projectData.agents)) {
1022
1472
  for (const [flowIdn, flowData] of Object.entries(agentData.flows)) {
1473
+ const flowYamlPath = v2FlowYamlPath(customer.idn, projectIdn, agentIdn, flowIdn);
1474
+ let localYamlSkills: Map<string, V2InlineSkill> | undefined;
1475
+ if (await fs.pathExists(flowYamlPath)) {
1476
+ try {
1477
+ const flowDef = await parseV2FlowYaml(flowYamlPath);
1478
+ localYamlSkills = new Map((flowDef.skills || []).map(s => [s.idn, s]));
1479
+ const skillIdns = new Set([
1480
+ ...Object.keys(flowData.skills),
1481
+ ...localYamlSkills.keys()
1482
+ ]);
1483
+
1484
+ for (const skillIdn of skillIdns) {
1485
+ const localYamlSkill = localYamlSkills.get(skillIdn);
1486
+ const skillMeta = flowData.skills[skillIdn];
1487
+
1488
+ if (!localYamlSkill) {
1489
+ errors.push({
1490
+ field: `skill.${skillIdn}`,
1491
+ message: `Skill exists in project map but is missing from flow YAML: ${flowYamlPath}`,
1492
+ path: flowYamlPath
1493
+ });
1494
+ continue;
1495
+ }
1496
+
1497
+ const runnerType = this.normalizeRunnerType(
1498
+ localYamlSkill.runner_type || skillMeta?.runner_type
1499
+ );
1500
+ const scriptPath = await this.resolveV2FlowSkillScriptPath(
1501
+ customer.idn,
1502
+ projectIdn,
1503
+ agentIdn,
1504
+ flowIdn,
1505
+ skillIdn,
1506
+ runnerType,
1507
+ localYamlSkill.prompt_script
1508
+ );
1509
+
1510
+ if (!(await fs.pathExists(scriptPath))) {
1511
+ errors.push({
1512
+ field: `skill.${localYamlSkill.idn}`,
1513
+ message: `Script file not found: ${scriptPath}`,
1514
+ path: scriptPath
1515
+ });
1516
+ }
1517
+ }
1518
+ } catch {
1519
+ localYamlSkills = undefined;
1520
+ }
1521
+ }
1522
+
1023
1523
  for (const [skillIdn, skillMeta] of Object.entries(flowData.skills)) {
1524
+ if (localYamlSkills) {
1525
+ continue;
1526
+ }
1527
+
1024
1528
  const scriptPath = v2SkillScriptPath(
1025
1529
  customer.idn, projectIdn, agentIdn, flowIdn, skillIdn,
1026
1530
  skillMeta.runner_type